diff --git a/assets/init.js b/assets/init.js index 3fbc758..33e4dfe 100644 --- a/assets/init.js +++ b/assets/init.js @@ -1,5 +1,35 @@ +/* +Venera JavaScript Library +*/ + /// encode, decode, hash, decrypt let Convert = { + /** + * @param str {string} + * @returns {ArrayBuffer} + */ + encodeUtf8: (str) => { + return sendMessage({ + method: "convert", + type: "utf8", + value: str, + isEncode: true + }); + }, + + /** + * @param value {ArrayBuffer} + * @returns {string} + */ + decodeUtf8: (value) => { + return sendMessage({ + method: "convert", + type: "utf8", + value: value, + isEncode: false + }); + }, + /** * @param {ArrayBuffer} value * @returns {string} @@ -78,6 +108,41 @@ let Convert = { }); }, + /** + * @param key {ArrayBuffer} + * @param value {ArrayBuffer} + * @param hash {string} - md5, sha1, sha256, sha512 + * @returns {ArrayBuffer} + */ + hmac: (key, value, hash) => { + return sendMessage({ + method: "convert", + type: "hmac", + value: value, + key: key, + hash: hash, + isEncode: true + }); + }, + + /** + * @param key {ArrayBuffer} + * @param value {ArrayBuffer} + * @param hash {string} - md5, sha1, sha256, sha512 + * @returns {string} - hex string + */ + hmacString: (key, value, hash) => { + return sendMessage({ + method: "convert", + type: "hmac", + value: value, + key: key, + hash: hash, + isEncode: true, + isString: true + }); + }, + /** * @param {ArrayBuffer} value * @param {ArrayBuffer} key @@ -160,6 +225,21 @@ let Convert = { } } +/** + * create a time-based uuid + * + * Note: the engine will generate a new uuid every time it is called + * + * To get the same uuid, please save it to the local storage + * + * @returns {string} + */ +function createUuid() { + return sendMessage({ + method: "uuid" + }); +} + function randomInt(min, max) { return sendMessage({ method: 'random', @@ -520,6 +600,91 @@ let console = { }, }; +/** + * Create a comic object + * @param id {string} + * @param title {string} + * @param subtitle {string} + * @param cover {string} + * @param tags {string[]} + * @param description {string} + * @param maxPage {number | null} + * @constructor + */ +function Comic({id, title, subtitle, cover, tags, description, maxPage}) { + this.id = id; + this.title = title; + this.subtitle = subtitle; + this.cover = cover; + this.tags = tags; + this.description = description; + this.maxPage = maxPage; +} + +/** + * Create a comic details object + * @param title {string} + * @param cover {string} + * @param description {string | null} + * @param tags {Map | {} | null} + * @param chapters {Map | {} | null} - key: chapter id, value: chapter title + * @param isFavorite {boolean | null} - favorite status. If the comic source supports multiple folders, this field should be null + * @param subId {string | null} - a param which is passed to comments api + * @param thumbnails {string[] | null} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails + * @param recommend {Comic[] | null} - related comics + * @param commentCount {number | null} + * @param likesCount {number | null} + * @param isLiked {boolean | null} + * @param uploader {string | null} + * @param updateTime {string | null} + * @param uploadTime {string | null} + * @param url {string | null} + * @constructor + */ +function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url}) { + this.title = title; + this.cover = cover; + this.description = description; + this.tags = tags; + this.chapters = chapters; + this.isFavorite = isFavorite; + this.subId = subId; + this.thumbnails = thumbnails; + this.recommend = recommend; + this.commentCount = commentCount; + this.likesCount = likesCount; + this.isLiked = isLiked; + this.uploader = uploader; + this.updateTime = updateTime; + this.uploadTime = uploadTime; + this.url = url; +} + +/** + * Create a comment object + * @param userName {string} + * @param avatar {string?} + * @param content {string} + * @param time {string?} + * @param replyCount {number?} + * @param id {string?} + * @param isLiked {boolean?} + * @param score {number?} + * @param voteStatus {number?} - 1: upvote, -1: downvote, 0: none + * @constructor + */ +function Comment({userName, avatar, content, time, replyCount, id, isLiked, score, voteStatus}) { + this.userName = userName; + this.avatar = avatar; + this.content = content; + this.time = time; + this.replyCount = replyCount; + this.id = id; + this.isLiked = isLiked; + this.score = score; + this.voteStatus = voteStatus; +} + class ComicSource { name = "" @@ -544,6 +709,19 @@ class ComicSource { }) } + /** + * load a setting with its key + * @param key {string} + * @returns {any} + */ + loadSetting(key) { + return sendMessage({ + method: 'load_setting', + key: this.key, + setting_key: key + }) + } + /** * save data * @param {string} dataKey @@ -570,6 +748,17 @@ class ComicSource { }) } + /** + * + * @returns {boolean} + */ + get isLogged() { + return sendMessage({ + method: 'isLogged', + key: this.key, + }); + } + init() { } static sources = {} diff --git a/lib/components/loading.dart b/lib/components/loading.dart index b32ff0b..3d6c13c 100644 --- a/lib/components/loading.dart +++ b/lib/components/loading.dart @@ -116,10 +116,10 @@ abstract class LoadingState }); loadData().then((value) async { if (value.success) { + data = value.data; await onDataLoaded(); setState(() { isLoading = false; - data = value.data; }); } else { setState(() { @@ -131,22 +131,10 @@ abstract class LoadingState } Widget buildError() { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - error!, - maxLines: 3, - ), - const SizedBox(height: 12), - Button.text( - onPressed: retry, - child: const Text("Retry"), - ) - ], - ), - ).paddingHorizontal(16); + return NetworkError( + message: error!, + retry: retry, + ); } @override @@ -154,11 +142,12 @@ abstract class LoadingState void initState() { isLoading = true; Future.microtask(() { - loadData().then((value) { + loadData().then((value) async { if (value.success) { + data = value.data; + await onDataLoaded(); setState(() { isLoading = false; - data = value.data; }); } else { setState(() { diff --git a/lib/components/message.dart b/lib/components/message.dart index 8c80003..dc4cb42 100644 --- a/lib/components/message.dart +++ b/lib/components/message.dart @@ -295,20 +295,21 @@ class ContentDialog extends StatelessWidget { } } -void showInputDialog({ +Future showInputDialog({ required BuildContext context, required String title, - required String hintText, + String? hintText, required FutureOr Function(String) onConfirm, String? initialValue, String confirmText = "Confirm", String cancelText = "Cancel", + RegExp? inputValidator, }) { var controller = TextEditingController(text: initialValue); bool isLoading = false; String? error; - showDialog( + return showDialog( context: context, builder: (context) { return StatefulBuilder( @@ -327,6 +328,11 @@ void showInputDialog({ Button.filled( isLoading: isLoading, onPressed: () async { + if (inputValidator != null && + !inputValidator.hasMatch(controller.text)) { + setState(() => error = "Invalid input"); + return; + } var futureOr = onConfirm(controller.text); Object? result; if (futureOr is Future) { diff --git a/lib/components/scroll.dart b/lib/components/scroll.dart index 0e39345..1b7e9b2 100644 --- a/lib/components/scroll.dart +++ b/lib/components/scroll.dart @@ -1,7 +1,8 @@ part of 'components.dart'; class SmoothCustomScrollView extends StatelessWidget { - const SmoothCustomScrollView({super.key, required this.slivers, this.controller}); + const SmoothCustomScrollView( + {super.key, required this.slivers, this.controller}); final ScrollController? controller; @@ -22,9 +23,9 @@ class SmoothCustomScrollView extends StatelessWidget { } } - class SmoothScrollProvider extends StatefulWidget { - const SmoothScrollProvider({super.key, this.controller, required this.builder}); + const SmoothScrollProvider( + {super.key, this.controller, required this.builder}); final ScrollController? controller; @@ -51,7 +52,7 @@ class _SmoothScrollProviderState extends State { @override Widget build(BuildContext context) { - if(App.isMacOS) { + if (App.isMacOS) { return widget.builder( context, _controller, @@ -77,13 +78,15 @@ class _SmoothScrollProviderState extends State { } if (!_isMouseScroll) return; var currentLocation = _controller.position.pixels; + var old = _futurePosition; _futurePosition ??= currentLocation; double k = (_futurePosition! - currentLocation).abs() / 1600 + 1; - _futurePosition = - _futurePosition! + pointerSignal.scrollDelta.dy * k; + _futurePosition = _futurePosition! + pointerSignal.scrollDelta.dy * k; _futurePosition = _futurePosition!.clamp( - _controller.position.minScrollExtent, - _controller.position.maxScrollExtent); + _controller.position.minScrollExtent, + _controller.position.maxScrollExtent, + ); + if(_futurePosition == old) return; _controller.animateTo(_futurePosition!, duration: _fastAnimationDuration, curve: Curves.linear); } diff --git a/lib/foundation/comic_source/comic_source.dart b/lib/foundation/comic_source/comic_source.dart index c640be2..e2ff7a6 100644 --- a/lib/foundation/comic_source/comic_source.dart +++ b/lib/foundation/comic_source/comic_source.dart @@ -17,8 +17,11 @@ import '../js_engine.dart'; import '../log.dart'; part 'category.dart'; + part 'favorites.dart'; + part 'parser.dart'; + part 'models.dart'; /// build comic list, [Res.subData] should be maxPage or null if there is no limit. @@ -50,11 +53,16 @@ typedef LikeOrUnlikeComicFunc = Future> Function( /// [isLiking] is true if the user is liking the comment, false if unliking. /// return the new likes count or null. -typedef LikeCommentFunc = Future> Function(String comicId, String? subId, String commentId, bool isLiking); +typedef LikeCommentFunc = Future> Function( + String comicId, String? subId, String commentId, bool isLiking); /// [isUp] is true if the user is upvoting the comment, false if downvoting. /// return the new vote count or null. -typedef VoteCommentFunc = Future> Function(String comicId, String? subId, String commentId, bool isUp, bool isCancel); +typedef VoteCommentFunc = Future> Function( + String comicId, String? subId, String commentId, bool isUp, bool isCancel); + +typedef HandleClickTagEvent = Map Function( + String namespace, String tag); class ComicSource { static final List _sources = []; @@ -147,9 +155,6 @@ class ComicSource { /// Search page. final SearchPageData? searchPageData; - /// Settings. - final List settings; - /// Load comic info. final LoadComicFunc? loadComicInfo; @@ -186,6 +191,12 @@ class ComicSource { final LikeCommentFunc? likeCommentFunc; + final Map? settings; + + final Map>? translations; + + final HandleClickTagEvent? handleClickTagEvent; + Future loadData() async { var file = File("${App.dataPath}/comic_source/$key.data"); if (await file.exists()) { @@ -225,29 +236,32 @@ class ComicSource { } ComicSource( - this.name, - this.key, - this.account, - this.categoryData, - this.categoryComicsData, - this.favoriteData, - this.explorePages, - this.searchPageData, - this.settings, - this.loadComicInfo, - this.loadComicThumbnail, - this.loadComicPages, - this.getImageLoadingConfig, - this.getThumbnailLoadingConfig, - this.filePath, - this.url, - this.version, - this.commentsLoader, - this.sendCommentFunc, - this.likeOrUnlikeComic, - this.voteCommentFunc, - this.likeCommentFunc,) - : idMatcher = null; + this.name, + this.key, + this.account, + this.categoryData, + this.categoryComicsData, + this.favoriteData, + this.explorePages, + this.searchPageData, + this.settings, + this.loadComicInfo, + this.loadComicThumbnail, + this.loadComicPages, + this.getImageLoadingConfig, + this.getThumbnailLoadingConfig, + this.filePath, + this.url, + this.version, + this.commentsLoader, + this.sendCommentFunc, + this.likeOrUnlikeComic, + this.voteCommentFunc, + this.likeCommentFunc, + this.idMatcher, + this.translations, + this.handleClickTagEvent, + ); } class AccountConfig { @@ -368,21 +382,6 @@ class SearchOptions { String get defaultValue => options.keys.first; } -class SettingItem { - final String name; - final String iconName; - final SettingType type; - final List? options; - - const SettingItem(this.name, this.iconName, this.type, this.options); -} - -enum SettingType { - switcher, - selector, - input, -} - typedef CategoryComicsLoader = Future>> Function( String category, String? param, List options, int page); @@ -423,4 +422,3 @@ class CategoryComicsOptions { const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen); } - diff --git a/lib/foundation/comic_source/models.dart b/lib/foundation/comic_source/models.dart index 90a608e..2d2554b 100644 --- a/lib/foundation/comic_source/models.dart +++ b/lib/foundation/comic_source/models.dart @@ -107,6 +107,8 @@ class ComicDetails with HistoryMixin { final String? updateTime; + final String? url; + static Map> _generateMap(Map map) { var res = >{}; map.forEach((key, value) { @@ -137,7 +139,8 @@ class ComicDetails with HistoryMixin { commentsCount = json["commentsCount"], uploader = json["uploader"], uploadTime = json["uploadTime"], - updateTime = json["updateTime"]; + updateTime = json["updateTime"], + url = json["url"]; Map toJson() { return { @@ -159,6 +162,7 @@ class ComicDetails with HistoryMixin { "uploader": uploader, "uploadTime": uploadTime, "updateTime": updateTime, + "url": url, }; } diff --git a/lib/foundation/comic_source/parser.dart b/lib/foundation/comic_source/parser.dart index 2881bb4..f881c8b 100644 --- a/lib/foundation/comic_source/parser.dart +++ b/lib/foundation/comic_source/parser.dart @@ -130,7 +130,7 @@ class ComicSourceParser { _loadFavoriteData(), _loadExploreData(), _loadSearchData(), - [], + _parseSettings(), _parseLoadComicFunc(), _parseThumbnailLoader(), _parseLoadComicPagesFunc(), @@ -144,6 +144,9 @@ class ComicSourceParser { _parseLikeFunc(), _parseVoteCommentFunc(), _parseLikeCommentFunc(), + _parseIdMatch(), + _parseTranslation(), + _parseClickTagEvent(), ); await source.loadData(); @@ -639,13 +642,13 @@ class ComicSourceParser { } LikeOrUnlikeComicFunc? _parseLikeFunc() { - if (!_checkExists("comic.likeOrUnlikeComic")) { + if (!_checkExists("comic.likeComic")) { return null; } return (id, isLiking) async { try { await JsEngine().runCode(""" - ComicSource.sources.$_key.comic.likeOrUnlikeComic(${jsonEncode(id)}, ${jsonEncode(isLiking)}) + ComicSource.sources.$_key.comic.likeComic(${jsonEncode(id)}, ${jsonEncode(isLiking)}) """); return const Res(true); } catch (e, s) { @@ -688,4 +691,41 @@ class ComicSourceParser { } }; } + + Map _parseSettings() { + return _getValue("settings") ?? {}; + } + + RegExp? _parseIdMatch() { + if (!_checkExists("comic.idMatch")) { + return null; + } + return RegExp(_getValue("comic.idMatch")); + } + + Map>? _parseTranslation() { + if (!_checkExists("translation")) { + return null; + } + var data = _getValue("translation"); + var res = >{}; + for (var e in data.entries) { + res[e.key] = Map.from(e.value); + } + return res; + } + + HandleClickTagEvent? _parseClickTagEvent() { + if (!_checkExists("comic.onClickTag")) { + return null; + } + return (namespace, tag) { + var res = JsEngine().runCode(""" + ComicSource.sources.$_key.comic.onClickTag(${jsonEncode(namespace)}, ${jsonEncode(tag)}) + """); + var r = Map.from(res); + r.removeWhere((key, value) => value == null); + return Map.from(r); + }; + } } diff --git a/lib/foundation/js_engine.dart b/lib/foundation/js_engine.dart index 64f2160..26fea3d 100644 --- a/lib/foundation/js_engine.dart +++ b/lib/foundation/js_engine.dart @@ -19,6 +19,7 @@ import 'package:pointycastle/block/modes/cbc.dart'; import 'package:pointycastle/block/modes/cfb.dart'; import 'package:pointycastle/block/modes/ecb.dart'; import 'package:pointycastle/block/modes/ofb.dart'; +import 'package:uuid/uuid.dart'; import 'package:venera/network/app_dio.dart'; import 'package:venera/network/cookie_jar.dart'; @@ -112,6 +113,9 @@ class JsEngine with _JSEngineApi{ { String key = message["key"]; String dataKey = message["data_key"]; + if(dataKey == 'setting'){ + throw "setting is not allowed to be saved"; + } var data = message["data"]; var source = ComicSource.find(key)!; source.data[dataKey] = data; @@ -145,6 +149,23 @@ class JsEngine with _JSEngineApi{ { return handleCookieCallback(Map.from(message)); } + case "uuid": + { + return const Uuid().v1(); + } + case "load_setting": + { + String key = message["key"]; + String settingKey = message["setting_key"]; + var source = ComicSource.find(key)!; + return source.data["setting"]?[settingKey] + ?? source.settings?[settingKey]['default'] + ?? (throw "Setting not found: $settingKey"); + } + case "isLogged": + { + return ComicSource.find(message["key"])!.isLogged; + } } } } @@ -303,6 +324,10 @@ mixin class _JSEngineApi{ bool isEncode = data["isEncode"]; try { switch (type) { + case "utf8": + return isEncode + ? utf8.encode(value) + : utf8.decode(value); case "base64": if(value is String){ value = utf8.encode(value); @@ -318,6 +343,21 @@ mixin class _JSEngineApi{ return Uint8List.fromList(sha256.convert(value).bytes); case "sha512": return Uint8List.fromList(sha512.convert(value).bytes); + case "hmac": + var key = data["key"]; + var hash = data["hash"]; + var hmac = Hmac(switch(hash) { + "md5" => md5, + "sha1" => sha1, + "sha256" => sha256, + "sha512" => sha512, + _ => throw "Unsupported hash: $hash" + }, key); + if(data['isString'] == true){ + return hmac.convert(value).toString(); + } else { + return Uint8List.fromList(hmac.convert(value).bytes); + } case "aes-ecb": if(!isEncode){ var key = data["key"]; diff --git a/lib/network/app_dio.dart b/lib/network/app_dio.dart index ba4b62e..8fba5e9 100644 --- a/lib/network/app_dio.dart +++ b/lib/network/app_dio.dart @@ -114,7 +114,8 @@ class AppDio with DioMixin { client.connectionTimeout = const Duration(seconds: 5); client.findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy"; client.idleTimeout = const Duration(seconds: 100); - client.badCertificateCallback = (X509Certificate cert, String host, int port) { + client.badCertificateCallback = + (X509Certificate cert, String host, int port) { if (host.contains("cdn")) return true; final ipv4RegExp = RegExp( r'^((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})$'); @@ -129,7 +130,8 @@ class AppDio with DioMixin { static String? proxy; static Future getProxy() async { - if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") return null; + if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") + return null; if (appdata.settings['proxy'] != "system") return appdata.settings['proxy']; String res; @@ -168,7 +170,7 @@ class AppDio with DioMixin { } @override - Future> request ( + Future> request( String path, { Object? data, Map? queryParameters, @@ -178,11 +180,18 @@ class AppDio with DioMixin { ProgressCallback? onReceiveProgress, }) async { proxy = await getProxy(); - if(_proxy != proxy) { + if (_proxy != proxy) { _proxy = proxy; (httpClientAdapter as IOHttpClientAdapter).close(); - httpClientAdapter = IOHttpClientAdapter(createHttpClient: createHttpClient); + httpClientAdapter = + IOHttpClientAdapter(createHttpClient: createHttpClient); } + Log.info( + "Network", + "${options?.method ?? 'GET'} $path\n" + "Headers: ${options?.headers}\n" + "Data: $data\n", + ); return super.request( path, data: data, diff --git a/lib/pages/categories_page.dart b/lib/pages/categories_page.dart index 49d9390..6bf6883 100644 --- a/lib/pages/categories_page.dart +++ b/lib/pages/categories_page.dart @@ -133,7 +133,7 @@ class _CategoryPage extends StatelessWidget { children: [ if (data.enableRankingPage) buildTag("Ranking".tl, (p0, p1) { - context.to(() => RankingPage(sourceKey: findComicSourceKey())); + context.to(() => RankingPage(categoryKey: data.key)); }), for (var buttonData in data.buttons) buildTag(buttonData.label.tl, (p0, p1) => buttonData.onTap()) diff --git a/lib/pages/category_comics_page.dart b/lib/pages/category_comics_page.dart index fb0317e..3085256 100644 --- a/lib/pages/category_comics_page.dart +++ b/lib/pages/category_comics_page.dart @@ -26,6 +26,7 @@ class _CategoryComicsPageState extends State { late final CategoryComicsData data; late final List options; late List optionsValue; + late String sourceKey; void findData() { for (final source in ComicSource.all()) { @@ -40,6 +41,7 @@ class _CategoryComicsPageState extends State { return true; }).toList(); optionsValue = options.map((e) => e.options.keys.first).toList(); + sourceKey = source.key; return; } } @@ -60,7 +62,7 @@ class _CategoryComicsPageState extends State { ), body: ComicList( key: Key(widget.category + optionsValue.toString()), - leadingSliver: buildOptions(), + leadingSliver: buildOptions().toSliver(), loadPage: (i) => data.load( widget.category, widget.param, @@ -74,7 +76,7 @@ class _CategoryComicsPageState extends State { Widget buildOptionItem( String text, String value, int group, BuildContext context) { return OptionChip( - text: text, + text: text.ts(sourceKey), isSelected: value == optionsValue[group], onTap: () { if (value == optionsValue[group]) return; @@ -105,12 +107,10 @@ class _CategoryComicsPageState extends State { children.add(const SizedBox(height: 8)); } } - return SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [...children, const Divider()], - ).paddingLeft(8).paddingRight(8), - ); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [...children, const Divider()], + ).paddingLeft(8).paddingRight(8); } } diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index 6f93fed..eeddbdf 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher_string.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; @@ -10,8 +12,10 @@ import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/network/download.dart'; +import 'package:venera/pages/category_comics_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/reader/reader.dart'; +import 'package:venera/pages/search_result_page.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/translations.dart'; import 'dart:math' as math; @@ -149,8 +153,9 @@ class _ComicPageState extends LoadingState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(comic.title, style: ts.s18), - if (comic.subTitle != null) Text(comic.subTitle!, style: ts.s14), + SelectableText(comic.title, style: ts.s18), + if (comic.subTitle != null) + SelectableText(comic.subTitle!, style: ts.s14), Text( (ComicSource.find(comic.sourceKey)?.name) ?? '', style: ts.s12, @@ -345,7 +350,7 @@ class _ComicPageState extends LoadingState for (var e in comic.tags.entries) buildWrap( children: [ - buildTag(text: e.key, isTitle: true), + buildTag(text: e.key.ts(comicSource.key), isTitle: true), for (var tag in e.value) buildTag(text: tag, onTap: () => onTapTag(tag, e.key)), ], @@ -407,7 +412,6 @@ class _ComicPageState extends LoadingState } } -// TODO: Implement the _ComicPageActions mixin abstract mixin class _ComicPageActions { void update(); @@ -546,9 +550,74 @@ abstract mixin class _ComicPageActions { update(); } - void onTapTag(String tag, String namespace) {} + void onTapTag(String tag, String namespace) { + var config = comicSource.handleClickTagEvent?.call(namespace, tag) ?? + { + 'action': 'search', + 'keyword': tag, + }; + var context = App.mainNavigatorKey!.currentContext!; + if (config['action'] == 'search') { + context.to(() => SearchResultPage( + text: config['keyword'] ?? '', + sourceKey: comicSource.key, + options: const [], + )); + } else if (config['action'] == 'category') { + context.to( + () => CategoryComicsPage( + category: config['keyword'] ?? '', + categoryKey: comicSource.categoryData!.key, + param: config['param'], + ), + ); + } + } - void showMoreActions() {} + void showMoreActions() { + var context = App.rootContext; + showMenuX( + context, + Offset( + context.width - 16, + context.padding.top, + ), + [ + MenuEntry( + icon: Icons.copy, + text: "Copy Title".tl, + onClick: () { + Clipboard.setData(ClipboardData(text: comic.title)); + context.showMessage(message: "Copied".tl); + }, + ), + MenuEntry( + icon: Icons.copy_rounded, + text: "Copy ID".tl, + onClick: () { + Clipboard.setData(ClipboardData(text: comic.id)); + context.showMessage(message: "Copied".tl); + }, + ), + if (comic.url != null) + MenuEntry( + icon: Icons.link, + text: "Copy URL".tl, + onClick: () { + Clipboard.setData(ClipboardData(text: comic.url!)); + context.showMessage(message: "Copied".tl); + }, + ), + if (comic.url != null) + MenuEntry( + icon: Icons.open_in_browser, + text: "Open in Browser".tl, + onClick: () { + launchUrlString(comic.url!); + }, + ), + ]); + } void showComments() { showSideBar( @@ -1217,7 +1286,10 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> { @override Widget build(BuildContext context) { return Scaffold( - appBar: Appbar(title: Text("Download".tl), backgroundColor: context.colorScheme.surfaceContainerLow,), + appBar: Appbar( + title: Text("Download".tl), + backgroundColor: context.colorScheme.surfaceContainerLow, + ), body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1233,14 +1305,14 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> { onChanged: widget.downloadedEps.contains(i) ? null : (v) { - setState(() { - if (selected.contains(i)) { - selected.remove(i); - } else { - selected.add(i); - } - }); - }); + setState(() { + if (selected.contains(i)) { + selected.remove(i); + } else { + selected.add(i); + } + }); + }); }, ), ), diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index 5a24e5b..33e57e4 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -146,12 +146,90 @@ class _BodyState extends State<_Body> { ListTile( title: const Text("Version"), subtitle: Text(source.version), - ) + ), + ...buildSourceSettings(source), ], ), ); } + Iterable buildSourceSettings(ComicSource source) sync* { + if (source.settings == null) { + return; + } else if (source.data['settings'] == null) { + source.data['settings'] = {}; + } + for (var item in source.settings!.entries) { + var key = item.key; + String type = item.value['type']; + if (type == "select") { + var current = source.data['settings'][key]; + if (current == null) { + var d = item.value['default']; + for (var option in item.value['options']) { + if (option['value'] == d) { + current = option['text'] ?? option['value']; + break; + } + } + } + yield ListTile( + title: Text((item.value['title'] as String).ts(source.key)), + trailing: Select( + current: (current as String).ts(source.key), + values: (item.value['options'] as List) + .map( + (e) => ((e['text'] ?? e['value']) as String).ts(source.key)) + .toList(), + onTap: (i) { + source.data['settings'][key] = item.value['options'][i]['value']; + source.saveData(); + setState(() {}); + }, + ), + ); + } else if (type == "switch") { + var current = source.data['settings'][key] ?? item.value['default']; + yield ListTile( + title: Text((item.value['title'] as String).ts(source.key)), + trailing: Switch( + value: current, + onChanged: (v) { + source.data['settings'][key] = v; + source.saveData(); + setState(() {}); + }, + ), + ); + } else if (type == "input") { + var current = + source.data['settings'][key] ?? item.value['default'] ?? ''; + yield ListTile( + title: Text((item.value['title'] as String).ts(source.key)), + trailing: IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + showInputDialog( + context: context, + title: (item.value['title'] as String).ts(source.key), + initialValue: current, + inputValidator: item.value['validator'] == null + ? null + : RegExp(item.value['validator']), + onConfirm: (value) { + source.data['settings'][key] = value; + source.saveData(); + setState(() {}); + return null; + }, + ); + }, + ), + ); + } + } + } + void delete(ComicSource source) { showConfirmDialog( context: App.rootContext, @@ -280,11 +358,12 @@ class _BodyState extends State<_Body> { if (file == null) return; try { var fileName = file.name; - var bytes = file.bytes!; + var bytes = await File(file.path!).readAsBytes(); var content = utf8.decode(bytes); await addSource(content, fileName); - } catch (e) { + } catch (e, s) { App.rootContext.showMessage(message: e.toString()); + Log.error("Add comic source", "$e\n$s"); } } diff --git a/lib/pages/comments_page.dart b/lib/pages/comments_page.dart index be713be..9436868 100644 --- a/lib/pages/comments_page.dart +++ b/lib/pages/comments_page.dart @@ -88,6 +88,9 @@ class _CommentsPageState extends State { withAppbar: false, ); } else { + var showAvatar = _comments!.any((e) { + return e.avatar != null; + }); return Column( children: [ Expanded( @@ -109,6 +112,7 @@ class _CommentsPageState extends State { comment: _comments![index], source: widget.source, comic: widget.data, + showAvatar: showAvatar, ); }, ), @@ -204,6 +208,7 @@ class _CommentTile extends StatefulWidget { required this.comment, required this.source, required this.comic, + required this.showAvatar, }); final Comment comment; @@ -212,6 +217,8 @@ class _CommentTile extends StatefulWidget { final ComicDetails comic; + final bool showAvatar; + @override State<_CommentTile> createState() => _CommentTileState(); } @@ -239,7 +246,7 @@ class _CommentTileState extends State<_CommentTile> { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (widget.comment.avatar != null) + if (widget.showAvatar) Container( width: 40, height: 40, @@ -247,12 +254,14 @@ class _CommentTileState extends State<_CommentTile> { decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), color: Theme.of(context).colorScheme.secondaryContainer), - child: AnimatedImage( - image: CachedImageProvider( - widget.comment.avatar!, - sourceKey: widget.source.key, - ), - ), + child: widget.comment.avatar == null + ? null + : AnimatedImage( + image: CachedImageProvider( + widget.comment.avatar!, + sourceKey: widget.source.key, + ), + ), ).paddingRight(12), Expanded( child: Column( @@ -313,6 +322,7 @@ class _CommentTileState extends State<_CommentTile> { source: widget.source, replyId: widget.comment.id, ), + showBarrier: false, ); }, child: Row( @@ -376,7 +386,11 @@ class _CommentTileState extends State<_CommentTile> { child: CircularProgressIndicator(), ) else if (isLiked) - const Icon(Icons.favorite, size: 16) + Icon( + Icons.favorite, + size: 16, + color: context.useTextColor(Colors.red), + ) else const Icon(Icons.favorite_border, size: 16), const SizedBox(width: 8), diff --git a/lib/pages/favorites/side_bar.dart b/lib/pages/favorites/side_bar.dart index 4cede62..11c45aa 100644 --- a/lib/pages/favorites/side_bar.dart +++ b/lib/pages/favorites/side_bar.dart @@ -27,7 +27,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList { favPage.folderList = this; folders = LocalFavoritesManager().folderNames; networkFolders = ComicSource.all() - .where((e) => e.favoriteData != null) + .where((e) => e.favoriteData != null && e.isLogged) .map((e) => e.favoriteData!.key) .toList(); super.initState(); diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index b1616a8..db7416b 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -316,7 +316,7 @@ class _LocalState extends State<_Local> { Button.outlined( child: Row( children: [ - if(LocalManager().downloadingTasks.first.isPaused) + if (LocalManager().downloadingTasks.first.isPaused) const Icon(Icons.pause_circle_outline, size: 18) else const _AnimatedDownloadingIcon(), @@ -813,11 +813,14 @@ class _AccountsWidgetState extends State<_AccountsWidget> { SizedBox( width: double.infinity, child: Wrap( + runSpacing: 8, + spacing: 8, children: accounts.map((e) { return Container( - margin: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), + horizontal: 8, + vertical: 2, + ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), @@ -825,7 +828,7 @@ class _AccountsWidgetState extends State<_AccountsWidget> { child: Text(e), ); }).toList(), - ), + ).paddingHorizontal(16).paddingBottom(16), ), ], ), diff --git a/lib/pages/ranking_page.dart b/lib/pages/ranking_page.dart index a76e585..f5fda16 100644 --- a/lib/pages/ranking_page.dart +++ b/lib/pages/ranking_page.dart @@ -5,9 +5,9 @@ import "package:venera/foundation/comic_source/comic_source.dart"; import "package:venera/utils/translations.dart"; class RankingPage extends StatefulWidget { - const RankingPage({required this.sourceKey, super.key}); + const RankingPage({required this.categoryKey, super.key}); - final String sourceKey; + final String categoryKey; @override State createState() => _RankingPageState(); @@ -20,14 +20,14 @@ class _RankingPageState extends State { void findData() { for (final source in ComicSource.all()) { - if (source.categoryData?.key == widget.sourceKey) { + if (source.categoryData?.key == widget.categoryKey) { data = source.categoryComicsData!; options = data.rankingData!.options; optionValue = options.keys.first; return; } } - throw "${widget.sourceKey} Not found"; + throw "${widget.categoryKey} Not found"; } @override diff --git a/lib/pages/reader/comic_image.dart b/lib/pages/reader/comic_image.dart index e5c1068..69fd50c 100644 --- a/lib/pages/reader/comic_image.dart +++ b/lib/pages/reader/comic_image.dart @@ -317,6 +317,14 @@ class _ComicImageState extends State with WidgetsBindingObserver { height = constrains.maxHeight; width = height * cacheSize.width / cacheSize.height; } + } else { + if(width == double.infinity) { + width = constrains.maxWidth; + height = 300; + } else if(height == double.infinity) { + height = constrains.maxHeight; + width = 300; + } } if(_imageInfo != null){ @@ -371,6 +379,7 @@ class _ComicImageState extends State with WidgetsBindingObserver { height: 24, child: CircularProgressIndicator( strokeWidth: 3, + backgroundColor: context.colorScheme.surfaceContainerLow, value: (_loadingProgress != null && _loadingProgress!.expectedTotalBytes!=null && _loadingProgress!.expectedTotalBytes! != 0) diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index 3922a27..fccd79f 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -112,6 +112,7 @@ class _SearchPageState extends State { for (int i = 0; i < searchOptions.length; i++) { final option = searchOptions[i]; children.add(ListTile( + contentPadding: EdgeInsets.zero, title: Text(option.label.tl), )); children.add(Wrap( @@ -119,7 +120,7 @@ class _SearchPageState extends State { spacing: 8, children: option.options.entries.map((e) { return OptionChip( - text: e.value.tl, + text: e.value.ts(searchTarget), isSelected: options[i] == e.key, onTap: () { options[i] = e.key; @@ -127,7 +128,7 @@ class _SearchPageState extends State { }, ); }).toList(), - ).paddingHorizontal(16)); + )); } return SliverToBoxAdapter( @@ -136,13 +137,7 @@ class _SearchPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - contentPadding: EdgeInsets.zero, - title: Text("Search Options".tl), - ), - ...children, - ], + children: children, ), ), ); diff --git a/lib/utils/translations.dart b/lib/utils/translations.dart index da54a66..48e700f 100644 --- a/lib/utils/translations.dart +++ b/lib/utils/translations.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/services.dart'; +import 'package:venera/foundation/comic_source/comic_source.dart'; import '../foundation/app.dart'; extension AppTranslation on String { @@ -27,10 +28,27 @@ extension AppTranslation on String { static late final Map> translations; - static Future init() async{ + static Future init() async { var data = await rootBundle.load("assets/translation.json"); var json = jsonDecode(utf8.decode(data.buffer.asUint8List())); - translations = { for (var e in json.entries) e.key : Map.from(e.value) }; + translations = { + for (var e in json.entries) e.key: Map.from(e.value) + }; + } + + /// Translate a string using specified comic source + String ts(String sourceKey) { + var comicSource = ComicSource.find(sourceKey); + if (comicSource == null || comicSource.translations == null) { + return this; + } + var locale = App.locale; + var lc = locale.languageCode; + var cc = locale.countryCode; + var key = "$lc${cc == null ? "" : "_$cc"}"; + return (comicSource.translations![key] ?? + comicSource.translations![lc])?[this] ?? + this; } } diff --git a/pubspec.lock b/pubspec.lock index 541e5d8..b8aeed0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -550,7 +550,7 @@ packages: source: hosted version: "3.1.2" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff diff --git a/pubspec.yaml b/pubspec.yaml index d00547e..2e88fe5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: path: packages/scrollable_positioned_list flutter_reorderable_grid_view: 5.0.1 yaml: any + uuid: ^4.5.1 dev_dependencies: flutter_test: