From a9e76201f3fc3a84abb0566dece7ae513db79d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A7=92=E7=A0=82=E7=B3=96?= <90336521+lings03@users.noreply.github.com> Date: Sat, 1 Nov 2025 00:56:49 +0800 Subject: [PATCH] Chapter comments. --- assets/translation.json | 4 + doc/comic_source.md | 45 ++ lib/foundation/appdata.dart | 10 +- lib/foundation/comic_source/comic_source.dart | 62 +- lib/foundation/comic_source/parser.dart | 65 +- lib/foundation/comic_source/types.dart | 90 ++- lib/pages/reader/chapter_comments.dart | 573 ++++++++++++++++++ lib/pages/reader/reader.dart | 91 ++- lib/pages/reader/scaffold.dart | 60 +- lib/pages/settings/reader.dart | 9 + 10 files changed, 935 insertions(+), 74 deletions(-) create mode 100644 lib/pages/reader/chapter_comments.dart diff --git a/assets/translation.json b/assets/translation.json index b3cb873..62f05e3 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -379,6 +379,8 @@ "Continuous": "连续", "Display mode of comic list": "漫画列表的显示模式", "Show Page Number": "显示页码", + "Show Chapter Comments": "显示章节评论", + "Chapter Comments": "章节评论", "Jump to page": "跳转到页面", "Page": "页面", "Jump": "跳转", @@ -796,6 +798,8 @@ "Continuous": "連續", "Display mode of comic list": "漫畫列表的顯示模式", "Show Page Number": "顯示頁碼", + "Show Chapter Comments": "顯示章節評論", + "Chapter Comments": "章節評論", "Jump to page": "跳轉到頁面", "Page": "頁面", "Jump": "跳轉", diff --git a/doc/comic_source.md b/doc/comic_source.md index db5d6ab..4a1a934 100644 --- a/doc/comic_source.md +++ b/doc/comic_source.md @@ -553,6 +553,51 @@ If `load` function is implemented, `loadNext` function will be ignored. */ sendComment: async (comicId, subId, content, replyTo) => { + }, + /** + * [Optional] load chapter comments + * + * Chapter comments are displayed in the reader. + * Same rich text support as loadComments. + * + * Note: To control reply functionality: + * - If a comment does not support replies, set its `id` to null/undefined + * - Or set its `replyCount` to null/undefined + * - The reply button will only show when both `id` and `replyCount` are present + * + * @param comicId {string} + * @param epId {string} - chapter id + * @param page {number} + * @param replyTo {string?} - commentId to reply, not null when reply to a comment + * @returns {Promise<{comments: Comment[], maxPage: number?}>} + * + * @example + * // Example for comments without reply support: + * return { + * comments: data.list.map(e => ({ + * userName: e.user_name, + * avatar: e.user_avatar, + * content: e.comment, + * time: e.create_at, + * replyCount: null, // or undefined - no reply support + * id: null, // or undefined - no reply support + * })), + * maxPage: Math.ceil(total / 20) + * } + */ + loadChapterComments: async (comicId, epId, page, replyTo) => { + + }, + /** + * [Optional] send a chapter comment, return any value to indicate success + * @param comicId {string} + * @param epId {string} - chapter id + * @param content {string} + * @param replyTo {string?} - commentId to reply, not null when reply to a comment + * @returns {Promise} + */ + sendChapterComment: async (comicId, epId, content, replyTo) => { + }, /** * [Optional] like or unlike a comment diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index a6abb1e..4b6b735 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -194,6 +194,7 @@ class Settings with ChangeNotifier { 'readerScrollSpeed': 1.0, // 0.5 - 3.0 'localFavoritesFirst': true, 'autoCloseFavoritePanel': false, + 'showChapterComments': true, // show chapter comments in reader }; operator [](String key) { @@ -207,7 +208,11 @@ class Settings with ChangeNotifier { } } - void setEnabledComicSpecificSettings(String comicId, String sourceKey, bool enabled) { + void setEnabledComicSpecificSettings( + String comicId, + String sourceKey, + bool enabled, + ) { setReaderSetting(comicId, sourceKey, "enabled", enabled); } @@ -215,7 +220,8 @@ class Settings with ChangeNotifier { if (comicId == null || sourceKey == null) { return false; } - return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] == true; + return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] == + true; } dynamic getReaderSetting(String comicId, String sourceKey, String key) { diff --git a/lib/foundation/comic_source/comic_source.dart b/lib/foundation/comic_source/comic_source.dart index fa914be..e5bf520 100644 --- a/lib/foundation/comic_source/comic_source.dart +++ b/lib/foundation/comic_source/comic_source.dart @@ -61,8 +61,10 @@ class ComicSourceManager with ChangeNotifier, Init { await for (var entity in Directory(path).list()) { if (entity is File && entity.path.endsWith(".js")) { try { - var source = await ComicSourceParser() - .parse(await entity.readAsString(), entity.absolute.path); + var source = await ComicSourceParser().parse( + await entity.readAsString(), + entity.absolute.path, + ); _sources.add(source); } catch (e, s) { Log.error("ComicSource", "$e\n$s"); @@ -154,7 +156,7 @@ class ComicSource { final GetImageLoadingConfigFunc? getImageLoadingConfig; final Map Function(String imageKey)? - getThumbnailLoadingConfig; + getThumbnailLoadingConfig; var data = {}; @@ -170,6 +172,10 @@ class ComicSource { final SendCommentFunc? sendCommentFunc; + final ChapterCommentsLoader? chapterCommentsLoader; + + final SendChapterCommentFunc? sendChapterCommentFunc; + final RegExp? idMatcher; final LikeOrUnlikeComicFunc? likeOrUnlikeComic; @@ -256,6 +262,8 @@ class ComicSource { this.version, this.commentsLoader, this.sendCommentFunc, + this.chapterCommentsLoader, + this.sendChapterCommentFunc, this.likeOrUnlikeComic, this.voteCommentFunc, this.likeCommentFunc, @@ -367,11 +375,19 @@ enum ExplorePageType { override, } -typedef SearchFunction = Future>> Function( - String keyword, int page, List searchOption); +typedef SearchFunction = + Future>> Function( + String keyword, + int page, + List searchOption, + ); -typedef SearchNextFunction = Future>> Function( - String keyword, String? next, List searchOption); +typedef SearchNextFunction = + Future>> Function( + String keyword, + String? next, + List searchOption, + ); class SearchPageData { /// If this is not null, the default value of search options will be first element. @@ -398,11 +414,19 @@ class SearchOptions { String get defaultValue => defaultVal ?? options.keys.firstOrNull ?? ""; } -typedef CategoryComicsLoader = Future>> Function( - String category, String? param, List options, int page); +typedef CategoryComicsLoader = + Future>> Function( + String category, + String? param, + List options, + int page, + ); -typedef CategoryOptionsLoader = Future>> Function( - String category, String? param); +typedef CategoryOptionsLoader = + Future>> Function( + String category, + String? param, + ); class CategoryComicsData { /// options @@ -419,7 +443,12 @@ class CategoryComicsData { final RankingData? rankingData; - const CategoryComicsData({this.options, this.optionsLoader, required this.load, this.rankingData}); + const CategoryComicsData({ + this.options, + this.optionsLoader, + required this.load, + this.rankingData, + }); } class RankingData { @@ -428,7 +457,7 @@ class RankingData { final Future>> Function(String option, int page)? load; final Future>> Function(String option, String? next)? - loadWithNext; + loadWithNext; const RankingData(this.options, this.load, this.loadWithNext); } @@ -447,7 +476,12 @@ class CategoryComicsOptions { final List? showWhen; - const CategoryComicsOptions(this.label, this.options, this.notShowWhen, this.showWhen); + const CategoryComicsOptions( + this.label, + this.options, + this.notShowWhen, + this.showWhen, + ); } class LinkHandler { diff --git a/lib/foundation/comic_source/parser.dart b/lib/foundation/comic_source/parser.dart index c0da172..73274d7 100644 --- a/lib/foundation/comic_source/parser.dart +++ b/lib/foundation/comic_source/parser.dart @@ -151,6 +151,8 @@ class ComicSourceParser { version ?? "1.0.0", _parseCommentsLoader(), _parseSendCommentFunc(), + _parseChapterCommentsLoader(), + _parseSendChapterCommentFunc(), _parseLikeFunc(), _parseVoteCommentFunc(), _parseLikeCommentFunc(), @@ -560,12 +562,16 @@ class ComicSourceParser { res = await res; } if (res is! List) { - return Res.error("Invalid data:\nExpected: List\nGot: ${res.runtimeType}"); + return Res.error( + "Invalid data:\nExpected: List\nGot: ${res.runtimeType}", + ); } var options = []; for (var element in res) { if (element is! Map) { - return Res.error("Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}"); + return Res.error( + "Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}", + ); } LinkedHashMap map = LinkedHashMap(); for (var option in element["options"] ?? []) { @@ -582,13 +588,14 @@ class ComicSourceParser { element["label"] ?? "", map, List.from(element["notShowWhen"] ?? []), - element["showWhen"] == null ? null : List.from(element["showWhen"]), + element["showWhen"] == null + ? null + : List.from(element["showWhen"]), ), ); } return Res(options); - } - catch(e) { + } catch (e) { Log.error("Data Analysis", "Failed to load category options.\n$e"); return Res.error(e.toString()); } @@ -1005,6 +1012,54 @@ class ComicSourceParser { }; } + ChapterCommentsLoader? _parseChapterCommentsLoader() { + if (!_checkExists("comic.loadChapterComments")) return null; + return (comicId, epId, page, replyTo) async { + try { + var res = await JsEngine().runCode(""" + ComicSource.sources.$_key.comic.loadChapterComments( + ${jsonEncode(comicId)}, ${jsonEncode(epId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)}) + """); + return Res( + (res["comments"] as List).map((e) => Comment.fromJson(e)).toList(), + subData: res["maxPage"], + ); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + }; + } + + SendChapterCommentFunc? _parseSendChapterCommentFunc() { + if (!_checkExists("comic.sendChapterComment")) return null; + return (comicId, epId, content, replyTo) async { + Future> func() async { + try { + await JsEngine().runCode(""" + ComicSource.sources.$_key.comic.sendChapterComment( + ${jsonEncode(comicId)}, ${jsonEncode(epId)}, ${jsonEncode(content)}, ${jsonEncode(replyTo)}) + """); + return const Res(true); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + } + + var res = await func(); + 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"); + } else { + return func(); + } + } + return res; + }; + } + GetImageLoadingConfigFunc? _parseImageLoadingConfigFunc() { if (!_checkExists("comic.onImageLoad")) { return null; diff --git a/lib/foundation/comic_source/types.dart b/lib/foundation/comic_source/types.dart index c38f26a..bafd881 100644 --- a/lib/foundation/comic_source/types.dart +++ b/lib/foundation/comic_source/types.dart @@ -4,50 +4,90 @@ part of 'comic_source.dart'; typedef ComicListBuilder = Future>> Function(int page); /// build comic list with next param, [Res.subData] should be next page param or null if there is no next page. -typedef ComicListBuilderWithNext = Future>> Function( - String? next); +typedef ComicListBuilderWithNext = + Future>> Function(String? next); typedef LoginFunction = Future> Function(String, String); typedef LoadComicFunc = Future> Function(String id); -typedef LoadComicPagesFunc = Future>> Function( - String id, String? ep); +typedef LoadComicPagesFunc = + Future>> Function(String id, String? ep); -typedef CommentsLoader = Future>> Function( - String id, String? subId, int page, String? replyTo); +typedef CommentsLoader = + Future>> Function( + String id, + String? subId, + int page, + String? replyTo, + ); -typedef SendCommentFunc = Future> Function( - String id, String? subId, String content, String? replyTo); +typedef ChapterCommentsLoader = + Future>> Function( + String comicId, + String epId, + int page, + String? replyTo, + ); -typedef GetImageLoadingConfigFunc = Future> Function( - String imageKey, String comicId, String epId)?; -typedef GetThumbnailLoadingConfigFunc = Map Function( - String imageKey)?; +typedef SendCommentFunc = + Future> Function( + String id, + String? subId, + String content, + String? replyTo, + ); -typedef ComicThumbnailLoader = Future>> Function( - String comicId, String? next); +typedef SendChapterCommentFunc = + Future> Function( + String comicId, + String epId, + String content, + String? replyTo, + ); -typedef LikeOrUnlikeComicFunc = Future> Function( - String comicId, bool isLiking); +typedef GetImageLoadingConfigFunc = + Future> Function( + String imageKey, + String comicId, + String epId, + )?; +typedef GetThumbnailLoadingConfigFunc = + Map Function(String imageKey)?; + +typedef ComicThumbnailLoader = + Future>> Function(String comicId, String? next); + +typedef LikeOrUnlikeComicFunc = + Future> Function(String comicId, bool isLiking); /// [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 = PageJumpTarget? Function( - String namespace, String tag); +typedef HandleClickTagEvent = + PageJumpTarget? Function(String namespace, String tag); /// Handle tag suggestion selection event. Should return the text to insert /// into the search field. -typedef TagSuggestionSelectFunc = String Function( - String namespace, String tag); +typedef TagSuggestionSelectFunc = String Function(String namespace, String tag); /// [rating] is the rating value, 0-10. 1 represents 0.5 star. -typedef StarRatingFunc = Future> Function(String comicId, int rating); \ No newline at end of file +typedef StarRatingFunc = Future> Function(String comicId, int rating); diff --git a/lib/pages/reader/chapter_comments.dart b/lib/pages/reader/chapter_comments.dart new file mode 100644 index 0000000..27dab10 --- /dev/null +++ b/lib/pages/reader/chapter_comments.dart @@ -0,0 +1,573 @@ +part of 'reader.dart'; + +class ChapterCommentsPage extends StatefulWidget { + const ChapterCommentsPage({ + super.key, + required this.comicId, + required this.epId, + required this.source, + required this.comicTitle, + required this.chapterTitle, + this.replyComment, + }); + + final String comicId; + final String epId; + final ComicSource source; + final String comicTitle; + final String chapterTitle; + final Comment? replyComment; + + @override + State createState() => _ChapterCommentsPageState(); +} + +class _ChapterCommentsPageState extends State { + bool _loading = true; + List? _comments; + String? _error; + int _page = 1; + int? maxPage; + var controller = TextEditingController(); + bool sending = false; + + void firstLoad() async { + var res = await widget.source.chapterCommentsLoader!( + widget.comicId, + widget.epId, + 1, + widget.replyComment?.id, + ); + if (res.error) { + setState(() { + _error = res.errorMessage; + _loading = false; + }); + } else if (mounted) { + setState(() { + _comments = res.data; + _loading = false; + maxPage = res.subData; + }); + } + } + + void loadMore() async { + var res = await widget.source.chapterCommentsLoader!( + widget.comicId, + widget.epId, + _page + 1, + widget.replyComment?.id, + ); + if (res.error) { + context.showMessage(message: res.errorMessage ?? "Unknown Error"); + } else { + setState(() { + _comments!.addAll(res.data); + _page++; + if (maxPage == null && res.data.isEmpty) { + maxPage = _page; + } + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: Appbar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text("Chapter Comments".tl, style: ts.s18), + Text(widget.chapterTitle, style: ts.s12), + ], + ), + style: AppbarStyle.shadow, + ), + body: buildBody(context), + ); + } + + Widget buildBody(BuildContext context) { + if (_loading) { + firstLoad(); + return const Center(child: CircularProgressIndicator()); + } else if (_error != null) { + return NetworkError( + message: _error!, + retry: () { + setState(() { + _loading = true; + }); + }, + withAppbar: false, + ); + } else { + var showAvatar = _comments!.any((e) { + return e.avatar != null; + }); + return Column( + children: [ + Expanded( + child: SmoothScrollProvider( + builder: (context, controller, physics) { + return ListView.builder( + controller: controller, + physics: physics, + primary: false, + padding: EdgeInsets.zero, + itemCount: _comments!.length + 2, + itemBuilder: (context, index) { + if (index == 0) { + if (widget.replyComment != null) { + return Column( + children: [ + _ChapterCommentTile( + comment: widget.replyComment!, + source: widget.source, + comicId: widget.comicId, + epId: widget.epId, + showAvatar: showAvatar, + showActions: false, + ), + const SizedBox(height: 8), + Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: context.colorScheme.outlineVariant, + width: 0.6, + ), + ), + ), + child: Text("Replies".tl, style: ts.s18), + ), + ], + ); + } else { + return const SizedBox(); + } + } + index--; + + if (index == _comments!.length) { + if (_page < (maxPage ?? _page + 1)) { + loadMore(); + return const ListLoadingIndicator(); + } else { + return const SizedBox(); + } + } + + return _ChapterCommentTile( + comment: _comments![index], + source: widget.source, + comicId: widget.comicId, + epId: widget.epId, + showAvatar: showAvatar, + ); + }, + ); + }, + ), + ), + buildBottom(context), + ], + ); + } + } + + Widget buildBottom(BuildContext context) { + if (widget.source.sendChapterCommentFunc == null) { + return const SizedBox(height: 0); + } + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + top: BorderSide( + color: context.colorScheme.outlineVariant, + width: 0.6, + ), + ), + ), + child: Material( + color: context.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(24), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + decoration: InputDecoration( + border: InputBorder.none, + isCollapsed: true, + hintText: "Comment".tl, + ), + minLines: 1, + maxLines: 5, + ), + ), + if (sending) + const Padding( + padding: EdgeInsets.all(8), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + IconButton( + onPressed: () async { + if (controller.text.isEmpty) { + return; + } + setState(() { + sending = true; + }); + var b = await widget.source.sendChapterCommentFunc!( + widget.comicId, + widget.epId, + controller.text, + widget.replyComment?.id, + ); + if (!b.error) { + controller.text = ""; + setState(() { + sending = false; + _loading = true; + _comments?.clear(); + _page = 1; + maxPage = null; + }); + } else { + context.showMessage(message: b.errorMessage ?? "Error"); + setState(() { + sending = false; + }); + } + }, + icon: Icon( + Icons.send, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ], + ).paddingLeft(16).paddingRight(4), + ), + ); + } +} + +class _ChapterCommentTile extends StatefulWidget { + const _ChapterCommentTile({ + required this.comment, + required this.source, + required this.comicId, + required this.epId, + required this.showAvatar, + this.showActions = true, + }); + + final Comment comment; + final ComicSource source; + final String comicId; + final String epId; + final bool showAvatar; + final bool showActions; + + @override + State<_ChapterCommentTile> createState() => _ChapterCommentTileState(); +} + +class _ChapterCommentTileState extends State<_ChapterCommentTile> { + @override + void initState() { + likes = widget.comment.score ?? 0; + isLiked = widget.comment.isLiked ?? false; + voteStatus = widget.comment.voteStatus; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showAvatar) + Container( + width: 36, + height: 36, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: Theme.of(context).colorScheme.secondaryContainer, + ), + child: widget.comment.avatar == null + ? null + : AnimatedImage( + image: CachedImageProvider( + widget.comment.avatar!, + sourceKey: widget.source.key, + ), + ), + ).paddingRight(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.comment.userName, style: ts.bold), + if (widget.comment.time != null) + Text(widget.comment.time!, style: ts.s12), + const SizedBox(height: 4), + _CommentContent(text: widget.comment.content), + buildActions(), + ], + ), + ), + ], + ), + ); + } + + Widget buildActions() { + if (!widget.showActions) { + return const SizedBox(); + } + if (widget.comment.score == null && widget.comment.replyCount == null) { + return const SizedBox(); + } + return SizedBox( + height: 36, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.comment.score != null && + widget.source.voteCommentFunc != null) + buildVote(), + if (widget.comment.score != null && + widget.source.likeCommentFunc != null) + buildLike(), + // Only show reply button if comment has both id and replyCount + if (widget.comment.replyCount != null && widget.comment.id != null) + buildReply(), + ], + ), + ).paddingTop(8); + } + + Widget buildReply() { + return Container( + margin: const EdgeInsets.only(left: 8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, + ), + borderRadius: BorderRadius.circular(16), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + // Get the parent page's widget to access comicTitle and chapterTitle + var parentState = context.findAncestorStateOfType<_ChapterCommentsPageState>(); + showSideBar( + context, + ChapterCommentsPage( + comicId: widget.comicId, + epId: widget.epId, + source: widget.source, + comicTitle: parentState?.widget.comicTitle ?? '', + chapterTitle: parentState?.widget.chapterTitle ?? '', + replyComment: widget.comment, + ), + showBarrier: false, + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.insert_comment_outlined, size: 16), + const SizedBox(width: 8), + Text(widget.comment.replyCount.toString()), + ], + ).padding(const EdgeInsets.symmetric(horizontal: 12, vertical: 4)), + ), + ); + } + + bool isLiking = false; + bool isLiked = false; + var likes = 0; + + Widget buildLike() { + return Container( + margin: const EdgeInsets.only(left: 8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, + ), + borderRadius: BorderRadius.circular(16), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () async { + if (isLiking) return; + setState(() { + isLiking = true; + }); + var res = await widget.source.likeCommentFunc!( + widget.comicId, + widget.epId, + widget.comment.id!, + !isLiked, + ); + if (res.success) { + isLiked = !isLiked; + likes += isLiked ? 1 : -1; + } else { + context.showMessage(message: res.errorMessage ?? "Error"); + } + setState(() { + isLiking = false; + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLiking) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(), + ) + else if (isLiked) + Icon( + Icons.favorite, + size: 16, + color: context.useTextColor(Colors.red), + ) + else + const Icon(Icons.favorite_border, size: 16), + const SizedBox(width: 8), + Text(likes.toString()), + ], + ).padding(const EdgeInsets.symmetric(horizontal: 12, vertical: 4)), + ), + ); + } + + int? voteStatus; + bool isVotingUp = false; + bool isVotingDown = false; + + void vote(bool isUp) async { + if (isVotingUp || isVotingDown) return; + setState(() { + if (isUp) { + isVotingUp = true; + } else { + isVotingDown = true; + } + }); + var isCancel = (isUp && voteStatus == 1) || (!isUp && voteStatus == -1); + var res = await widget.source.voteCommentFunc!( + widget.comicId, + widget.epId, + widget.comment.id!, + isUp, + isCancel, + ); + if (res.success) { + if (isCancel) { + voteStatus = 0; + } else { + if (isUp) { + voteStatus = 1; + } else { + voteStatus = -1; + } + } + widget.comment.voteStatus = voteStatus; + widget.comment.score = res.data ?? widget.comment.score; + } else { + context.showMessage(message: res.errorMessage ?? "Error"); + } + setState(() { + isVotingUp = false; + isVotingDown = false; + }); + } + + Widget buildVote() { + var upColor = context.colorScheme.outline; + if (voteStatus == 1) { + upColor = context.useTextColor(Colors.red); + } + var downColor = context.colorScheme.outline; + if (voteStatus == -1) { + downColor = context.useTextColor(Colors.blue); + } + + return Container( + margin: const EdgeInsets.only(left: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Button.icon( + isLoading: isVotingUp, + icon: const Icon(Icons.arrow_upward), + size: 18, + color: upColor, + onPressed: () => vote(true), + ), + const SizedBox(width: 4), + Text(widget.comment.score.toString()), + const SizedBox(width: 4), + Button.icon( + isLoading: isVotingDown, + icon: const Icon(Icons.arrow_downward), + size: 18, + color: downColor, + onPressed: () => vote(false), + ), + ], + ), + ); + } +} + +class _CommentContent extends StatelessWidget { + const _CommentContent({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + if (!text.contains('<') && !text.contains('http')) { + return SelectableText(text); + } else { + // Use the RichCommentContent from comments_page.dart + // For simplicity, we'll just show plain text here + // In a real implementation, you'd need to import or duplicate the RichCommentContent class + return SelectableText(text); + } + } +} diff --git a/lib/pages/reader/reader.dart b/lib/pages/reader/reader.dart index 0f17b64..ed45093 100644 --- a/lib/pages/reader/reader.dart +++ b/lib/pages/reader/reader.dart @@ -25,6 +25,7 @@ import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/global_state.dart'; import 'package:venera/foundation/history.dart'; +import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/image_provider/reader_image.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; @@ -54,6 +55,8 @@ part 'loading.dart'; part 'chapters.dart'; +part 'chapter_comments.dart'; + extension _ReaderContext on BuildContext { _ReaderState get reader => findAncestorStateOfType<_ReaderState>()!; @@ -165,12 +168,22 @@ class _ReaderState extends State page = widget.initialPage!; } // mode = ReaderMode.fromKey(appdata.settings['readerMode']); - mode = ReaderMode.fromKey(appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerMode')); + mode = ReaderMode.fromKey( + appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerMode'), + ); history = widget.history; - if (!appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSystemStatusBar')) { + if (!appdata.settings.getReaderSetting( + cid, + type.sourceKey, + 'showSystemStatusBar', + )) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } - if (appdata.settings.getReaderSetting(cid, type.sourceKey, 'enableTurnPageByVolumeKey')) { + if (appdata.settings.getReaderSetting( + cid, + type.sourceKey, + 'enableTurnPageByVolumeKey', + )) { handleVolumeEvent(); } setImageCacheSize(); @@ -208,8 +221,10 @@ class _ReaderState extends State } else { maxImageCacheSize = 500 << 20; } - Log.info("Reader", - "Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize"); + Log.info( + "Reader", + "Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize", + ); PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize; } @@ -239,13 +254,15 @@ class _ReaderState extends State onKeyEvent: onKeyEvent, child: Overlay( initialEntries: [ - OverlayEntry(builder: (context) { - return _ReaderScaffold( - child: _ReaderGestureDetector( - child: _ReaderImages(key: Key(chapter.toString())), - ), - ); - }) + OverlayEntry( + builder: (context) { + return _ReaderScaffold( + child: _ReaderGestureDetector( + child: _ReaderImages(key: Key(chapter.toString())), + ), + ); + }, + ), ], ), ); @@ -382,16 +399,29 @@ abstract mixin class _ImagePerPageHandler { } } - bool showSingleImageOnFirstPage() => - appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage'); + bool showSingleImageOnFirstPage() => appdata.settings.getReaderSetting( + cid, + type.sourceKey, + 'showSingleImageOnFirstPage', + ); /// The number of images displayed on one screen int get imagesPerPage { if (mode.isContinuous) return 1; if (isPortrait) { - return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForPortrait') ?? 1; + return appdata.settings.getReaderSetting( + cid, + type.sourceKey, + 'readerScreenPicNumberForPortrait', + ) ?? + 1; } else { - return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForLandscape') ?? 1; + return appdata.settings.getReaderSetting( + cid, + type.sourceKey, + 'readerScreenPicNumberForLandscape', + ) ?? + 1; } } @@ -400,15 +430,22 @@ abstract mixin class _ImagePerPageHandler { int currentImagesPerPage = imagesPerPage; bool currentOrientation = isPortrait; - if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) { - _adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage); + if (_lastImagesPerPage != currentImagesPerPage || + _lastOrientation != currentOrientation) { + _adjustPageForImagesPerPageChange( + _lastImagesPerPage, + currentImagesPerPage, + ); _lastImagesPerPage = currentImagesPerPage; _lastOrientation = currentOrientation; } } /// Adjust the page number when the number of images per page changes - void _adjustPageForImagesPerPageChange(int oldImagesPerPage, int newImagesPerPage) { + void _adjustPageForImagesPerPageChange( + int oldImagesPerPage, + int newImagesPerPage, + ) { int previousImageIndex = 1; if (!showSingleImageOnFirstPage() || oldImagesPerPage == 1) { previousImageIndex = (page - 1) * oldImagesPerPage + 1; @@ -431,7 +468,7 @@ abstract mixin class _ImagePerPageHandler { newPage = previousImageIndex; } - page = newPage>0 ? newPage : 1; + page = newPage > 0 ? newPage : 1; } } @@ -466,10 +503,7 @@ abstract mixin class _VolumeListener { if (volumeListener != null) { volumeListener?.cancel(); } - volumeListener = VolumeListener( - onDown: onDown, - onUp: onUp, - )..listen(); + volumeListener = VolumeListener(onDown: onDown, onUp: onUp)..listen(); } void stopVolumeEvent() { @@ -504,7 +538,8 @@ abstract mixin class _ReaderLocation { void update(); - bool enablePageAnimation(String cid, ComicType type) => appdata.settings.getReaderSetting(cid, type.sourceKey, 'enablePageAnimation'); + bool enablePageAnimation(String cid, ComicType type) => appdata.settings + .getReaderSetting(cid, type.sourceKey, 'enablePageAnimation'); _ImageViewController? _imageViewController; @@ -585,7 +620,11 @@ abstract mixin class _ReaderLocation { autoPageTurningTimer!.cancel(); autoPageTurningTimer = null; } else { - int interval = appdata.settings.getReaderSetting(cid, type.sourceKey, 'autoPageTurningInterval'); + int interval = appdata.settings.getReaderSetting( + cid, + type.sourceKey, + 'autoPageTurningInterval', + ); autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) { if (page == maxPage) { autoPageTurningTimer!.cancel(); diff --git a/lib/pages/reader/scaffold.dart b/lib/pages/reader/scaffold.dart index ee052da..0053b5b 100644 --- a/lib/pages/reader/scaffold.dart +++ b/lib/pages/reader/scaffold.dart @@ -183,6 +183,14 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { ), ), const SizedBox(width: 8), + if (shouldShowChapterComments()) + Tooltip( + message: "Chapter Comments".tl, + child: IconButton( + icon: const Icon(Icons.comment), + onPressed: openChapterComments, + ), + ), Tooltip( message: "Settings".tl, child: IconButton( @@ -605,7 +613,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { } var (imageIndex, data) = result; var fileType = detectFileType(data); - var filename = "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}"; + var filename = + "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}"; saveFile(data: data, filename: filename); } @@ -616,7 +625,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { } var (imageIndex, data) = result; var fileType = detectFileType(data); - var filename = "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}"; + var filename = + "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}"; Share.shareFile(data: data, filename: filename, mime: fileType.mime); } @@ -650,6 +660,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { if (key == "quickCollectImage") { addDragListener(); } + if (key == "showChapterComments") { + update(); + } context.reader.update(); }, ), @@ -657,6 +670,49 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { ); } + bool shouldShowChapterComments() { + // Check if chapters exist + if (context.reader.widget.chapters == null) return false; + + // Check if setting is enabled + var showChapterComments = appdata.settings.getReaderSetting( + context.reader.cid, + context.reader.type.sourceKey, + 'showChapterComments', + ); + if (showChapterComments != true) return false; + + // Check if comic source supports chapter comments + var source = ComicSource.find(context.reader.type.sourceKey); + if (source == null || source.chapterCommentsLoader == null) return false; + + return true; + } + + void openChapterComments() { + var source = ComicSource.find(context.reader.type.sourceKey); + if (source == null) return; + + var chapters = context.reader.widget.chapters; + if (chapters == null) return; + + var chapterIndex = context.reader.chapter - 1; + var epId = chapters.ids.elementAt(chapterIndex); + var chapterTitle = chapters.titles.elementAt(chapterIndex); + + showSideBar( + context, + ChapterCommentsPage( + comicId: context.reader.cid, + epId: epId, + source: source, + comicTitle: context.reader.widget.name, + chapterTitle: chapterTitle, + ), + showBarrier: false, + ); + } + Widget buildEpChangeButton() { if (context.reader.widget.chapters == null) return const SizedBox(); switch (showFloatingButtonValue) { diff --git a/lib/pages/settings/reader.dart b/lib/pages/settings/reader.dart index 6cb739f..9fd8688 100644 --- a/lib/pages/settings/reader.dart +++ b/lib/pages/settings/reader.dart @@ -303,6 +303,15 @@ class _ReaderSettingsState extends State { comicId: isEnabledSpecificSettings ? widget.comicId : null, comicSource: isEnabledSpecificSettings ? widget.comicSource : null, ).toSliver(), + _SwitchSetting( + title: "Show Chapter Comments".tl, + settingKey: "showChapterComments", + onChanged: () { + widget.onChanged?.call("showChapterComments"); + }, + comicId: isEnabledSpecificSettings ? widget.comicId : null, + comicSource: isEnabledSpecificSettings ? widget.comicSource : null, + ).toSliver(), ], ); }