From 07dbf6e6af9435041cc1b8ece13f1fa7c0c1a181 Mon Sep 17 00:00:00 2001 From: nyne Date: Fri, 4 Oct 2024 21:56:15 +0800 Subject: [PATCH] comic page --- lib/components/comic.dart | 8 +- lib/components/components.dart | 3 +- lib/foundation/app.dart | 2 + lib/foundation/comic_source/comic_source.dart | 93 +-- lib/foundation/comic_source/parser.dart | 250 +++--- lib/foundation/context.dart | 8 + lib/pages/comic_page.dart | 729 ++++++++++++++++++ 7 files changed, 915 insertions(+), 178 deletions(-) create mode 100644 lib/pages/comic_page.dart diff --git a/lib/components/comic.dart b/lib/components/comic.dart index c2c8073..6104a2f 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -14,7 +14,10 @@ class ComicTile extends StatelessWidget { final String? badge; - void onTap() {} + void onTap() { + App.mainNavigatorKey?.currentContext + ?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); + } void onLongPress() {} @@ -721,8 +724,7 @@ class _ComicListState extends State { if (widget.leadingSliver != null) widget.leadingSliver!, buildSliverPageSelector(), SliverGridComics(comics: data[page] ?? const []), - if(data[page]!.length > 6) - buildSliverPageSelector(), + if (data[page]!.length > 6) buildSliverPageSelector(), if (widget.trailingSliver != null) widget.trailingSliver!, ], ); diff --git a/lib/components/components.dart b/lib/components/components.dart index b7ae14b..7ec33c2 100644 --- a/lib/components/components.dart +++ b/lib/components/components.dart @@ -2,7 +2,6 @@ library components; import 'dart:async'; import 'dart:collection'; -import 'dart:io'; import 'dart:math' as math; import 'dart:ui'; @@ -23,8 +22,8 @@ import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/state_controller.dart'; +import 'package:venera/pages/comic_page.dart'; import 'package:venera/utils/ext.dart'; -import 'package:venera/utils/io.dart'; import 'package:venera/utils/translations.dart'; part 'image.dart'; diff --git a/lib/foundation/app.dart b/lib/foundation/app.dart index e49fcfb..ad50304 100644 --- a/lib/foundation/app.dart +++ b/lib/foundation/app.dart @@ -64,6 +64,8 @@ class _App { 'green' => Colors.green, 'orange' => Colors.orange, 'blue' => Colors.blue, + 'yellow' => Colors.yellow, + 'cyan' => Colors.cyan, _ => Colors.blue, }; } diff --git a/lib/foundation/comic_source/comic_source.dart b/lib/foundation/comic_source/comic_source.dart index f24ec69..353e485 100644 --- a/lib/foundation/comic_source/comic_source.dart +++ b/lib/foundation/comic_source/comic_source.dart @@ -43,6 +43,8 @@ typedef GetImageLoadingConfigFunc = Map Function( typedef GetThumbnailLoadingConfigFunc = Map Function( String imageKey)?; +typedef ComicThumbnailLoader = Future>> Function(String comicId, String? next); + class ComicSource { static final List _sources = []; @@ -140,6 +142,8 @@ class ComicSource { /// Load comic info. final LoadComicFunc? loadComicInfo; + final ComicThumbnailLoader? loadComicThumbnail; + /// Load comic pages. final LoadComicPagesFunc? loadComicPages; @@ -216,6 +220,7 @@ class ComicSource { this.searchPageData, this.settings, this.loadComicInfo, + this.loadComicThumbnail, this.loadComicPages, this.getImageLoadingConfig, this.getThumbnailLoadingConfig, @@ -237,6 +242,7 @@ class ComicSource { searchPageData = null, settings = [], loadComicInfo = null, + loadComicThumbnail = null, loadComicPages = null, getImageLoadingConfig = null, getThumbnailLoadingConfig = null, @@ -338,8 +344,8 @@ class SearchPageData { /// If this is not null, the default value of search options will be first element. final List? searchOptions; - final Widget Function(BuildContext, List initialValues, void Function(List))? - customOptionsBuilder; + final Widget Function(BuildContext, List initialValues, + void Function(List))? customOptionsBuilder; final Widget Function(String keyword, List options)? overrideSearchResultBuilder; @@ -384,22 +390,23 @@ enum SettingType { class Comic { final String title; - + final String cover; - + final String id; - + final String? subtitle; - + final List? tags; - + final String description; - + final String sourceKey; final int? maxPage; - - const Comic(this.title, this.cover, this.id, this.subtitle, this.tags, this.description, this.sourceKey, this.maxPage); + + const Comic(this.title, this.cover, this.id, this.subtitle, this.tags, + this.description, this.sourceKey, this.maxPage); Map toJson() { return { @@ -443,12 +450,7 @@ class ComicDetails with HistoryMixin { final List? thumbnails; - final Future>> Function(String id, int page)? - thumbnailLoader; - - final int thumbnailMaxPage; - - final List? suggestions; + final List? recommend; final String sourceKey; @@ -458,36 +460,17 @@ class ComicDetails with HistoryMixin { final String? subId; - const ComicDetails( - this.title, - this.subTitle, - this.cover, - this.description, - this.tags, - this.chapters, - this.thumbnails, - this.thumbnailLoader, - this.thumbnailMaxPage, - this.suggestions, - this.sourceKey, - this.comicId, - {this.isFavorite, - this.subId}); + final bool? isLiked; - Map toJson() { - return { - "title": title, - "subTitle": subTitle, - "cover": cover, - "description": description, - "tags": tags, - "chapters": chapters, - "sourceKey": sourceKey, - "comicId": comicId, - "isFavorite": isFavorite, - "subId": subId, - }; - } + final int? likesCount; + + final int? commentsCount; + + final String? uploader; + + final String? uploadTime; + + final String? updateTime; static Map> _generateMap(Map map) { var res = >{}; @@ -503,15 +486,23 @@ class ComicDetails with HistoryMixin { cover = json["cover"], description = json["description"], tags = _generateMap(json["tags"]), - chapters = Map.from(json["chapters"]), + chapters = json["chapters"] == null + ? null + : Map.from(json["chapters"]), sourceKey = json["sourceKey"], comicId = json["comicId"], - thumbnails = null, - thumbnailLoader = null, - thumbnailMaxPage = 0, - suggestions = null, + thumbnails = ListOrNull.from(json["thumbnails"]), + recommend = (json["recommend"] as List?) + ?.map((e) => Comic.fromJson(e, json["sourceKey"])) + .toList(), isFavorite = json["isFavorite"], - subId = json["subId"]; + subId = json["subId"], + likesCount = json["likesCount"], + isLiked = json["isLiked"], + commentsCount = json["commentsCount"], + uploader = json["uploader"], + uploadTime = json["uploadTime"], + updateTime = json["updateTime"]; @override HistoryType get historyType => HistoryType(sourceKey.hashCode); diff --git a/lib/foundation/comic_source/parser.dart b/lib/foundation/comic_source/parser.dart index 6e2a25b..51cc86c 100644 --- a/lib/foundation/comic_source/parser.dart +++ b/lib/foundation/comic_source/parser.dart @@ -55,20 +55,21 @@ class ComicSourceParser { String? _name; - Future createAndParse(String js, String fileName) async{ - if(!fileName.endsWith("js")){ + Future createAndParse(String js, String fileName) async { + if (!fileName.endsWith("js")) { fileName = "$fileName.js"; } var file = File(FilePath.join(App.dataPath, "comic_source", fileName)); - if(file.existsSync()){ + if (file.existsSync()) { int i = 0; - while(file.existsSync()){ - file = File(FilePath.join(App.dataPath, "comic_source", "${fileName.split('.').first}($i).js")); + while (file.existsSync()) { + file = File(FilePath.join(App.dataPath, "comic_source", + "${fileName.split('.').first}($i).js")); i++; } } await file.writeAsString(js); - try{ + try { return await parse(js, file.path); } catch (e) { await file.delete(); @@ -78,9 +79,12 @@ class ComicSourceParser { Future parse(String js, String filePath) async { js = js.replaceAll("\r\n", "\n"); - var line1 = js.split('\n') + var line1 = js + .split('\n') .firstWhereOrNull((element) => element.removeAllBlank.isNotEmpty); - if(line1 == null || !line1.startsWith("class ") || !line1.contains("extends ComicSource")){ + if (line1 == null || + !line1.startsWith("class ") || + !line1.contains("extends ComicSource")) { throw ComicSourceParseException("Invalid Content"); } var className = line1.split("class")[1].split("extends ComicSource").first; @@ -91,22 +95,24 @@ class ComicSourceParser { this['temp'] = new $className() }).call() """); - _name = JsEngine().runCode("this['temp'].name") - ?? (throw ComicSourceParseException('name is required')); - var key = JsEngine().runCode("this['temp'].key") - ?? (throw ComicSourceParseException('key is required')); - var version = JsEngine().runCode("this['temp'].version") - ?? (throw ComicSourceParseException('version is required')); + _name = JsEngine().runCode("this['temp'].name") ?? + (throw ComicSourceParseException('name is required')); + var key = JsEngine().runCode("this['temp'].key") ?? + (throw ComicSourceParseException('key is required')); + var version = JsEngine().runCode("this['temp'].version") ?? + (throw ComicSourceParseException('version is required')); var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion"); var url = JsEngine().runCode("this['temp'].url"); - var matchBriefIdRegex = JsEngine().runCode("this['temp'].comic.matchBriefIdRegex"); - if(minAppVersion != null){ - if(compareSemVer(minAppVersion, App.version.split('-').first)){ - throw ComicSourceParseException("minAppVersion $minAppVersion is required"); + var matchBriefIdRegex = + JsEngine().runCode("this['temp'].comic.matchBriefIdRegex"); + if (minAppVersion != null) { + if (compareSemVer(minAppVersion, App.version.split('-').first)) { + throw ComicSourceParseException( + "minAppVersion $minAppVersion is required"); } } - for(var source in ComicSource.all()){ - if(source.key == key){ + for (var source in ComicSource.all()) { + if (source.key == key) { throw ComicSourceParseException("key($key) already exists"); } } @@ -120,10 +126,10 @@ class ComicSourceParser { final account = _loadAccountConfig(); final explorePageData = _loadExploreData(); final categoryPageData = _loadCategoryData(); - final categoryComicsData = - _loadCategoryComicsData(); + final categoryComicsData = _loadCategoryComicsData(); final searchData = _loadSearchData(); final loadComicFunc = _parseLoadComicFunc(); + final loadComicThumbnailFunc = _parseThumbnailLoader(); final loadComicPagesFunc = _parseLoadComicPagesFunc(); final getImageLoadingConfigFunc = _parseImageLoadingConfigFunc(); final getThumbnailLoadingConfigFunc = _parseThumbnailLoadingConfigFunc(); @@ -131,26 +137,28 @@ class ComicSourceParser { final commentsLoader = _parseCommentsLoader(); final sendCommentFunc = _parseSendCommentFunc(); - var source = ComicSource( - _name!, - key, - account, - categoryPageData, - categoryComicsData, - favoriteData, - explorePageData, - searchData, - [], - loadComicFunc, - loadComicPagesFunc, - getImageLoadingConfigFunc, - getThumbnailLoadingConfigFunc, - matchBriefIdRegex, - filePath, - url ?? "", - version ?? "1.0.0", - commentsLoader, - sendCommentFunc); + var source = ComicSource( + _name!, + key, + account, + categoryPageData, + categoryComicsData, + favoriteData, + explorePageData, + searchData, + [], + loadComicFunc, + loadComicThumbnailFunc, + loadComicPagesFunc, + getImageLoadingConfigFunc, + getThumbnailLoadingConfigFunc, + matchBriefIdRegex, + filePath, + url ?? "", + version ?? "1.0.0", + commentsLoader, + sendCommentFunc, + ); await source.loadData(); @@ -168,7 +176,7 @@ class ComicSourceParser { } } - bool _checkExists(String index){ + bool _checkExists(String index) { return JsEngine().runCode("ComicSource.sources.$_key.$index !== null " "&& ComicSource.sources.$_key.$index !== undefined"); } @@ -198,16 +206,12 @@ class ComicSourceParser { } } - void logout(){ + void logout() { JsEngine().runCode("ComicSource.sources.$_key.account.logout()"); } - return AccountConfig( - login, - _getValue("account.login.website"), - _getValue("account.registerWebsite"), - logout - ); + return AccountConfig(login, _getValue("account.login.website"), + _getValue("account.registerWebsite"), logout); } List _loadExploreData() { @@ -216,7 +220,7 @@ class ComicSourceParser { } var length = JsEngine().runCode("ComicSource.sources.$_key.explore.length"); var pages = []; - for (int i=0; i>> Function()? loadMultiPart; @@ -226,12 +230,13 @@ class ComicSourceParser { try { var res = await JsEngine() .runCode("ComicSource.sources.$_key.explore[$i].load()"); - return Res(List.from(res.keys.map((e) => ExplorePagePart( - e, - (res[e] as List) - .map((e) => Comic.fromJson(e, _key!)) - .toList(), - null)) + return Res(List.from(res.keys + .map((e) => ExplorePagePart( + e, + (res[e] as List) + .map((e) => Comic.fromJson(e, _key!)) + .toList(), + null)) .toList())); } catch (e, s) { Log.error("Data Analysis", "$e\n$s"); @@ -241,11 +246,11 @@ class ComicSourceParser { } else if (type == "multiPageComicList") { loadPage = (int page) async { try { - var res = await JsEngine() - .runCode("ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})"); + var res = await JsEngine().runCode( + "ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})"); return Res( List.generate(res["comics"].length, - (index) => Comic.fromJson(res["comics"][index], _key!)), + (index) => Comic.fromJson(res["comics"][index], _key!)), subData: res["maxPage"]); } catch (e, s) { Log.error("Network", "$e\n$s"); @@ -317,18 +322,16 @@ class ComicSourceParser { var value = split.join("-"); map[key] = value; } - options.add( - CategoryComicsOptions( - map, - List.from(element["notShowWhen"] ?? []), - element["showWhen"] == null ? null : List.from(element["showWhen"]) - )); + options.add(CategoryComicsOptions( + map, + List.from(element["notShowWhen"] ?? []), + element["showWhen"] == null ? null : List.from(element["showWhen"]))); } RankingData? rankingData; - if(_checkExists("categoryComics.ranking")){ + if (_checkExists("categoryComics.ranking")) { var options = {}; - for(var option in _getValue("categoryComics.ranking.options")){ - if(option.isEmpty || !option.contains("-")){ + for (var option in _getValue("categoryComics.ranking.options")) { + if (option.isEmpty || !option.contains("-")) { continue; } var split = option.split("-"); @@ -336,7 +339,7 @@ class ComicSourceParser { var value = split.join("-"); options[key] = value; } - rankingData = RankingData(options, (option, page) async{ + rankingData = RankingData(options, (option, page) async { try { var res = await JsEngine().runCode(""" ComicSource.sources.$_key.categoryComics.ranking.load( @@ -344,7 +347,7 @@ class ComicSourceParser { """); return Res( List.generate(res["comics"].length, - (index) => Comic.fromJson(res["comics"][index], _key!)), + (index) => Comic.fromJson(res["comics"][index], _key!)), subData: res["maxPage"]); } catch (e, s) { Log.error("Network", "$e\n$s"); @@ -412,27 +415,10 @@ class ComicSourceParser { var res = await JsEngine().runCode(""" ComicSource.sources.$_key.comic.loadInfo(${jsonEncode(id)}) """); - var tags = >{}; - (res["tags"] as Map?) - ?.forEach((key, value) => tags[key] = List.from(value ?? const [])); - return Res(ComicDetails( - res["title"], - res["subTitle"], - res["cover"], - res["description"], - tags, - res["chapters"] == null ? null : Map.from(res["chapters"]), - ListOrNull.from(res["thumbnails"]), - // TODO: implement thumbnailLoader - null, - res["thumbnailMaxPage"] ?? 1, - (res["recommend"] as List?) - ?.map((e) => Comic.fromJson(e, _key!)) - .toList(), - _key!, - id, - isFavorite: res["isFavorite"], - subId: res["subId"],)); + if (res is! Map) throw "Invalid data"; + res['comicId'] = id; + res['sourceKey'] = _key; + return Res(ComicDetails.fromJson(res)); } catch (e, s) { Log.error("Network", "$e\n$s"); return Res.error(e.toString()); @@ -459,8 +445,8 @@ class ComicSourceParser { final bool multiFolder = _getValue("favorites.multiFolder"); - Future> retryZone(Future> Function() func) async{ - if(!ComicSource.find(_key!)!.isLogged){ + Future> retryZone(Future> Function() func) async { + if (!ComicSource.find(_key!)!.isLogged) { return const Res.error("Not login"); } var res = await func(); @@ -493,7 +479,7 @@ class ComicSourceParser { } Future>> loadComic(int page, [String? folder]) async { - Future>> func() async{ + Future>> func() async { try { var res = await JsEngine().runCode(""" ComicSource.sources.$_key.favorites.loadComics( @@ -501,13 +487,14 @@ class ComicSourceParser { """); return Res( List.generate(res["comics"].length, - (index) => Comic.fromJson(res["comics"][index], _key!)), + (index) => Comic.fromJson(res["comics"][index], _key!)), subData: res["maxPage"]); } catch (e, s) { Log.error("Network", "$e\n$s"); return Res.error(e.toString()); } } + return retryZone(func); } @@ -517,15 +504,15 @@ class ComicSourceParser { Future> Function(String key)? deleteFolder; - if(multiFolder) { + if (multiFolder) { loadFolders = ([String? comicId]) async { - Future>> func() async{ + Future>> func() async { try { var res = await JsEngine().runCode(""" ComicSource.sources.$_key.favorites.loadFolders(${jsonEncode(comicId)}) """); List? subData; - if(res["favorited"] != null){ + if (res["favorited"] != null) { subData = List.from(res["favorited"]); } return Res(Map.from(res["folders"]), subData: subData); @@ -562,19 +549,19 @@ class ComicSourceParser { } return FavoriteData( - key: _key!, - title: _name!, - multiFolder: multiFolder, - loadComic: loadComic, - loadFolders: loadFolders, - addFolder: addFolder, - deleteFolder: deleteFolder, - addOrDelFavorite: addOrDelFavFunc, + key: _key!, + title: _name!, + multiFolder: multiFolder, + loadComic: loadComic, + loadFolders: loadFolders, + addFolder: addFolder, + deleteFolder: deleteFolder, + addOrDelFavorite: addOrDelFavFunc, ); } - CommentsLoader? _parseCommentsLoader(){ - if(!_checkExists("comic.loadComments")) return null; + CommentsLoader? _parseCommentsLoader() { + if (!_checkExists("comic.loadComments")) return null; return (id, subId, page, replyTo) async { try { var res = await JsEngine().runCode(""" @@ -582,9 +569,10 @@ class ComicSourceParser { ${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)}) """); return Res( - (res["comments"] as List).map((e) => Comment( - e["userName"], e["avatar"], e["content"], e["time"], e["replyCount"], e["id"].toString() - )).toList(), + (res["comments"] as List) + .map((e) => Comment(e["userName"], e["avatar"], e["content"], + e["time"], e["replyCount"], e["id"].toString())) + .toList(), subData: res["maxPage"]); } catch (e, s) { Log.error("Network", "$e\n$s"); @@ -593,10 +581,10 @@ class ComicSourceParser { }; } - SendCommentFunc? _parseSendCommentFunc(){ - if(!_checkExists("comic.sendComment")) return null; + SendCommentFunc? _parseSendCommentFunc() { + if (!_checkExists("comic.sendComment")) return null; return (id, subId, content, replyTo) async { - Future> func() async{ + Future> func() async { try { await JsEngine().runCode(""" ComicSource.sources.$_key.comic.sendComment( @@ -608,8 +596,9 @@ class ComicSourceParser { return Res.error(e.toString()); } } + var res = await func(); - if(res.error && res.errorMessage!.contains("Login expired")){ + if (res.error && res.errorMessage!.contains("Login expired")) { var reLoginRes = await ComicSource.find(_key!)!.reLogin(); if (!reLoginRes) { return const Res.error("Login expired and re-login failed"); @@ -621,8 +610,8 @@ class ComicSourceParser { }; } - GetImageLoadingConfigFunc? _parseImageLoadingConfigFunc(){ - if(!_checkExists("comic.onImageLoad")){ + GetImageLoadingConfigFunc? _parseImageLoadingConfigFunc() { + if (!_checkExists("comic.onImageLoad")) { return null; } return (imageKey, comicId, ep) { @@ -633,19 +622,36 @@ class ComicSourceParser { }; } - GetThumbnailLoadingConfigFunc? _parseThumbnailLoadingConfigFunc(){ - if(!_checkExists("comic.onThumbnailLoad")){ + GetThumbnailLoadingConfigFunc? _parseThumbnailLoadingConfigFunc() { + if (!_checkExists("comic.onThumbnailLoad")) { return null; } return (imageKey) { var res = JsEngine().runCode(""" ComicSource.sources.$_key.comic.onThumbnailLoad(${jsonEncode(imageKey)}) """); - if(res is! Map) { + if (res is! Map) { Log.error("Network", "function onThumbnailLoad return invalid data"); throw "function onThumbnailLoad return invalid data"; } return res as Map; }; } -} \ No newline at end of file + + ComicThumbnailLoader? _parseThumbnailLoader() { + if (!_checkExists("comic.loadThumbnail")) { + return null; + } + return (id, next) async { + try { + var res = await JsEngine().runCode(""" + ComicSource.sources.$_key.comic.loadThumbnail(${jsonEncode(id)}, ${jsonEncode(next)}) + """); + return Res(List.from(res['thumbnails']), subData: res['next']); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + }; + } +} diff --git a/lib/foundation/context.dart b/lib/foundation/context.dart index 41ec81a..ec83cc6 100644 --- a/lib/foundation/context.dart +++ b/lib/foundation/context.dart @@ -34,4 +34,12 @@ extension Navigation on BuildContext { void showMessage({required String message}) { showToast(message: message, context: this); } + + Color useBackgroundColor(MaterialColor color) { + return color[brightness == Brightness.light ? 100 : 800]!; + } + + Color useTextColor(MaterialColor color) { + return color[brightness == Brightness.light ? 800 : 100]!; + } } diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart new file mode 100644 index 0000000..f7fc44e --- /dev/null +++ b/lib/pages/comic_page.dart @@ -0,0 +1,729 @@ +import 'package:flutter/material.dart'; +import 'package:venera/components/components.dart'; +import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/comic_source/comic_source.dart'; +import 'package:venera/foundation/comic_type.dart'; +import 'package:venera/foundation/consts.dart'; +import 'package:venera/foundation/favorites.dart'; +import 'package:venera/foundation/history.dart'; +import 'package:venera/foundation/image_provider/cached_image.dart'; +import 'package:venera/foundation/res.dart'; +import 'package:venera/utils/translations.dart'; +import 'dart:math' as math; + +class ComicPage extends StatefulWidget { + const ComicPage({super.key, required this.id, required this.sourceKey}); + + final String id; + + final String sourceKey; + + @override + State createState() => _ComicPageState(); +} + +class _ComicPageState extends LoadingState + with _ComicPageActions { + bool showAppbarTitle = false; + + var scrollController = ScrollController(); + + @override + void initState() { + scrollController.addListener(onScroll); + super.initState(); + } + + @override + void update() { + setState(() {}); + } + + @override + ComicDetails get comic => data!; + + void onScroll() { + if (scrollController.offset > 100) { + if (!showAppbarTitle) { + setState(() { + showAppbarTitle = true; + }); + } + } else { + if (showAppbarTitle) { + setState(() { + showAppbarTitle = false; + }); + } + } + } + + @override + Widget buildContent(BuildContext context, ComicDetails data) { + return SmoothCustomScrollView( + controller: scrollController, + slivers: [ + ...buildTitle(), + buildActions(), + buildDescription(), + buildInfo(), + buildChapters(), + buildThumbnails(), + buildRecommend(), + ], + ); + } + + @override + Future> loadData() async { + var comicSource = ComicSource.find(widget.sourceKey); + isAddToLocalFav = LocalFavoritesManager().isExist( + widget.id, + ComicType(widget.sourceKey.hashCode), + ); + history = await HistoryManager() + .find(widget.id, ComicType(widget.sourceKey.hashCode)); + return comicSource!.loadComicInfo!(widget.id); + } + + Iterable buildTitle() sync* { + yield SliverAppbar( + title: AnimatedOpacity( + opacity: showAppbarTitle ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: Text(comic.title), + ), + actions: [ + IconButton( + onPressed: showMoreActions, icon: const Icon(Icons.more_horiz)) + ], + ); + + yield Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 16), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + height: 144, + width: 144 * 0.72, + clipBehavior: Clip.antiAlias, + child: AnimatedImage( + image: CachedImageProvider( + comic.cover, + sourceKey: comic.sourceKey, + ), + width: double.infinity, + height: double.infinity, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(comic.title, style: ts.s18), + if (comic.subTitle != null) Text(comic.subTitle!, style: ts.s14), + Text( + (ComicSource.find(comic.sourceKey)?.name) ?? '', + style: ts.s12, + ), + ], + ), + ), + ], + ).toSliver(); + } + + Widget buildActions() { + bool isMobile = context.width < changePoint; + return SliverToBoxAdapter( + child: Column( + children: [ + ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + children: [ + if(history != null && (history!.ep > 1 || history!.page > 1)) + _ActionButton( + icon: const Icon(Icons.menu_book), + text: 'Continue'.tl, + onPressed: continueRead, + iconColor: context.useTextColor(Colors.yellow), + ), + if (!isMobile) + _ActionButton( + icon: const Icon(Icons.play_circle_outline), + text: 'Read'.tl, + onPressed: read, + iconColor: context.useTextColor(Colors.orange), + ), + if (!isMobile) + _ActionButton( + icon: const Icon(Icons.download), + text: 'Download'.tl, + onPressed: download, + iconColor: context.useTextColor(Colors.cyan), + ), + if (data!.isLiked != null) + _ActionButton( + icon: const Icon(Icons.favorite_border), + activeIcon: const Icon(Icons.favorite), + isActive: data!.isLiked, + text: (data!.likesCount ?? + (comic.isLiked! ? 'Liked'.tl : 'Like'.tl)) + .toString(), + isLoading: isLiking, + onPressed: likeOrUnlike, + iconColor: context.useTextColor(Colors.red), + ), + _ActionButton( + icon: const Icon(Icons.bookmark_border), + activeIcon: const Icon(Icons.bookmark), + isActive: (data!.isFavorite ?? false) || isAddToLocalFav, + text: 'Favorite'.tl, + isLoading: isFavoriting, + onPressed: favoriteOrUnfavorite, + iconColor: context.useTextColor(Colors.purple), + ), + if (comicSource.commentsLoader != null) + _ActionButton( + icon: const Icon(Icons.comment), + text: (comic.commentsCount ?? 'Comments'.tl).toString(), + isLoading: isFavoriting, + onPressed: favoriteOrUnfavorite, + iconColor: context.useTextColor(Colors.green), + ), + _ActionButton( + icon: const Icon(Icons.share), + text: 'Share'.tl, + onPressed: share, + iconColor: context.useTextColor(Colors.blue), + ), + ], + ).fixHeight(48), + if (isMobile) + Row( + children: [ + Expanded( + child: FilledButton.tonal( + onPressed: () {}, + child: Text("Download".tl), + ), + ), + const SizedBox(width: 16), + Expanded( + child: FilledButton(onPressed: read, child: Text("Read".tl)), + ) + ], + ).paddingHorizontal(16).paddingVertical(8), + const Divider(), + ], + ).paddingTop(16), + ); + } + + Widget buildDescription() { + if (comic.description == null) { + return const SliverPadding(padding: EdgeInsets.zero); + } + return SliverToBoxAdapter( + child: Column( + children: [ + ListTile( + title: Text("Description".tl), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SelectableText(comic.description!), + ), + const SizedBox(height: 16), + const Divider(), + ], + ), + ); + } + + Widget buildInfo() { + if (comic.tags.isEmpty && + comic.uploader == null && + comic.uploadTime == null && + comic.uploadTime == null) { + return const SliverPadding(padding: EdgeInsets.zero); + } + + int i = 0; + + Widget buildTag({ + required String text, + VoidCallback? onTap, + bool isTitle = false, + }) { + Color color; + if (isTitle) { + const colors = [ + Colors.blue, + Colors.cyan, + Colors.red, + Colors.pink, + Colors.purple, + Colors.indigo, + Colors.teal, + Colors.green, + Colors.lime, + Colors.yellow, + ]; + color = context.useBackgroundColor(colors[(i++) % (colors.length)]); + } else { + color = context.colorScheme.surfaceContainer; + } + + final borderRadius = BorderRadius.circular(12); + + const padding = EdgeInsets.symmetric(horizontal: 16, vertical: 6); + + if (onTap != null) { + return Material( + color: color, + borderRadius: borderRadius, + child: InkWell( + borderRadius: borderRadius, + onTap: onTap, + child: Text(text).padding(padding), + ), + ); + } else { + return Container( + decoration: BoxDecoration( + color: color, + borderRadius: borderRadius, + ), + child: Text(text).padding(padding), + ); + } + } + + Widget buildWrap({required List children}) { + return Wrap( + runSpacing: 8, + spacing: 8, + children: children, + ).paddingHorizontal(16).paddingBottom(8); + } + + return SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + title: Text("Information".tl), + ), + for (var e in comic.tags.entries) + buildWrap( + children: [ + buildTag(text: e.key, isTitle: true), + for (var tag in e.value) + buildTag(text: tag, onTap: () => onTagTap(tag, e.key)), + ], + ), + if (comic.uploader != null) + buildWrap( + children: [ + buildTag(text: 'Uploader'.tl, isTitle: true), + buildTag(text: comic.uploader!), + ], + ), + if (comic.uploadTime != null) + buildWrap( + children: [ + buildTag(text: 'Upload Time'.tl, isTitle: true), + buildTag(text: comic.uploadTime!), + ], + ), + if (comic.uploadTime != null) + buildWrap( + children: [ + buildTag(text: 'Update Time'.tl, isTitle: true), + buildTag(text: comicSource.name), + ], + ), + const SizedBox(height: 12), + const Divider(), + ], + ), + ); + } + + Widget buildChapters() { + if (comic.chapters == null) { + return const SliverPadding(padding: EdgeInsets.zero); + } + return const _ComicChapters(); + } + + Widget buildThumbnails() { + if (comic.thumbnails == null && comicSource.loadComicThumbnail == null) { + return const SliverPadding(padding: EdgeInsets.zero); + } + return const _ComicThumbnails(); + } + + Widget buildRecommend() { + if (comic.recommend == null) { + return const SliverPadding(padding: EdgeInsets.zero); + } + return SliverMainAxisGroup(slivers: [ + SliverToBoxAdapter( + child: ListTile( + title: Text("Related".tl), + ), + ), + SliverGridComics(comics: comic.recommend!), + ]); + } +} + +// TODO: Implement the _ComicPageActions mixin +abstract mixin class _ComicPageActions { + void update(); + + ComicDetails get comic; + + ComicSource get comicSource => ComicSource.find(comic.sourceKey)!; + + History? history; + + bool isLiking = false; + + void likeOrUnlike() {} + + bool isAddToLocalFav = false; + + bool isFavoriting = false; + + void favoriteOrUnfavorite() {} + + void share() {} + + /// read the comic + /// + /// [ep] the episode number, start from 1 + /// + /// [page] the page number, start from 1 + void read([int? ep, int? page]) {} + + void continueRead() {} + + void download() {} + + void onTagTap(String tag, String namespace) {} + + void showMoreActions() {} +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.icon, + required this.text, + required this.onPressed, + this.activeIcon, + this.isActive, + this.isLoading, + this.iconColor, + }); + + final Widget icon; + + final Widget? activeIcon; + + final bool? isActive; + + final String text; + + final void Function() onPressed; + + final bool? isLoading; + + final Color? iconColor; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: context.colorScheme.outlineVariant, + width: 0.6, + ), + ), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(18), + child: IconTheme.merge( + data: IconThemeData(size: 20, color: iconColor), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading ?? false) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 1.8), + ) + else + (isActive ?? false) ? (activeIcon ?? icon) : icon, + const SizedBox(width: 8), + Text(text), + ], + ).paddingHorizontal(16), + ), + ), + ); + } +} + +class _ComicChapters extends StatefulWidget { + const _ComicChapters(); + + @override + State<_ComicChapters> createState() => _ComicChaptersState(); +} + +class _ComicChaptersState extends State<_ComicChapters> { + late _ComicPageState state; + + bool reverse = false; + + bool showAll = false; + + @override + void didChangeDependencies() { + state = context.findAncestorStateOfType<_ComicPageState>()!; + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + final eps = state.comic.chapters!; + + int length = eps.length; + + if (!showAll) { + length = math.min(length, 20); + } + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: ListTile( + title: Text("Chapters".tl), + trailing: Tooltip( + message: "Order".tl, + child: IconButton( + icon: Icon(reverse + ? Icons.vertical_align_top + : Icons.vertical_align_bottom_outlined), + onPressed: () { + setState(() { + reverse = !reverse; + }); + }, + ), + ), + ), + ), + SliverGrid( + delegate: + SliverChildBuilderDelegate(childCount: length, (context, i) { + if (reverse) { + i = eps.length - i - 1; + } + var key = eps.keys.elementAt(i); + var value = eps[key]!; + bool visited = + (state.history?.readEpisode ?? const {}).contains(i + 1); + return Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(16)), + child: Material( + elevation: 5, + color: context.colorScheme.surface, + surfaceTintColor: context.colorScheme.surfaceTint, + borderRadius: const BorderRadius.all(Radius.circular(12)), + shadowColor: Colors.transparent, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Center( + child: Text( + value, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: + visited ? context.colorScheme.outline : null), + ), + ), + ), + ), + onTap: () => state.read(i + 1), + ), + ); + }), + gridDelegate: const SliverGridDelegateWithFixedHeight( + maxCrossAxisExtent: 200, itemHeight: 48), + ), + if (eps.length > 20 && !showAll) + SliverToBoxAdapter( + child: Align( + alignment: Alignment.center, + child: FilledButton.tonal( + style: ButtonStyle( + shape: WidgetStateProperty.all(const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)))), + ), + onPressed: () { + setState(() { + showAll = true; + }); + }, + child: Text("${"Show all".tl} (${eps.length})"), + ).paddingTop(12), + ), + ), + const SliverToBoxAdapter( + child: Divider(), + ), + ], + ); + } +} + +class _ComicThumbnails extends StatefulWidget { + const _ComicThumbnails(); + + @override + State<_ComicThumbnails> createState() => _ComicThumbnailsState(); +} + +class _ComicThumbnailsState extends State<_ComicThumbnails> { + late _ComicPageState state; + + late List thumbnails; + + bool isInitialLoading = false; + + String? next; + + @override + void didChangeDependencies() { + state = context.findAncestorStateOfType<_ComicPageState>()!; + thumbnails = List.from(state.comic.thumbnails ?? []); + super.didChangeDependencies(); + } + + bool isLoading = false; + + void loadNext() async { + if(state.comicSource.loadComicThumbnail == null || isLoading) return; + if(!isInitialLoading && next == null) { + return; + } + setState(() { + isLoading = true; + }); + var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next); + if(res.success) { + thumbnails.addAll(res.data); + next = res.subData; + isInitialLoading = false; + } + setState(() { + isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + if(thumbnails.isEmpty) { + Future.microtask(loadNext); + } + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: ListTile( + title: Text("Preview".tl), + ), + ), + SliverGrid( + delegate: SliverChildBuilderDelegate(childCount: thumbnails.length, + (context, index) { + if (index == thumbnails.length - 1) { + loadNext(); + } + return Padding( + padding: context.width < changePoint + ? const EdgeInsets.all(4) + : const EdgeInsets.all(8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: InkWell( + onTap: () => state.read(null, index + 1), + borderRadius: const BorderRadius.all(Radius.circular(16)), + child: Container( + decoration: BoxDecoration( + borderRadius: + const BorderRadius.all(Radius.circular(16)), + border: Border.all( + color: Theme.of(context).colorScheme.outline, + ), + ), + width: double.infinity, + height: double.infinity, + child: ClipRRect( + borderRadius: + const BorderRadius.all(Radius.circular(16)), + child: AnimatedImage( + image: CachedImageProvider( + thumbnails[index], + sourceKey: state.widget.sourceKey, + ), + fit: BoxFit.contain, + width: double.infinity, + height: double.infinity, + ), + ), + ), + ), + ), + const SizedBox( + height: 4, + ), + Text((index + 1).toString()), + ], + ), + ); + }), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + childAspectRatio: 0.65, + ), + ), + if(isLoading) + const SliverToBoxAdapter( + child: ListLoadingIndicator(), + ), + const SliverToBoxAdapter( + child: Divider(), + ), + ], + ); + } +}