Merge pull request #593 from lings03/comment

Chapter comments.
This commit is contained in:
ynyx631
2025-11-01 12:29:15 +08:00
committed by GitHub
10 changed files with 934 additions and 74 deletions

View File

@@ -383,6 +383,8 @@
"Continuous": "连续",
"Display mode of comic list": "漫画列表的显示模式",
"Show Page Number": "显示页码",
"Show Chapter Comments": "显示章节评论",
"Chapter Comments": "章节评论",
"Jump to page": "跳转到页面",
"Page": "页面",
"Jump": "跳转",
@@ -804,6 +806,8 @@
"Continuous": "連續",
"Display mode of comic list": "漫畫列表的顯示模式",
"Show Page Number": "顯示頁碼",
"Show Chapter Comments": "顯示章節評論",
"Chapter Comments": "章節評論",
"Jump to page": "跳轉到頁面",
"Page": "頁面",
"Jump": "跳轉",

View File

@@ -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<any>}
*/
sendChapterComment: async (comicId, epId, content, replyTo) => {
},
/**
* [Optional] like or unlike a comment

View File

@@ -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) {

View File

@@ -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<String, dynamic> Function(String imageKey)?
getThumbnailLoadingConfig;
getThumbnailLoadingConfig;
var data = <String, dynamic>{};
@@ -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<Res<List<Comic>>> Function(
String keyword, int page, List<String> searchOption);
typedef SearchFunction =
Future<Res<List<Comic>>> Function(
String keyword,
int page,
List<String> searchOption,
);
typedef SearchNextFunction = Future<Res<List<Comic>>> Function(
String keyword, String? next, List<String> searchOption);
typedef SearchNextFunction =
Future<Res<List<Comic>>> Function(
String keyword,
String? next,
List<String> 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<Res<List<Comic>>> Function(
String category, String? param, List<String> options, int page);
typedef CategoryComicsLoader =
Future<Res<List<Comic>>> Function(
String category,
String? param,
List<String> options,
int page,
);
typedef CategoryOptionsLoader = Future<Res<List<CategoryComicsOptions>>> Function(
String category, String? param);
typedef CategoryOptionsLoader =
Future<Res<List<CategoryComicsOptions>>> 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<Res<List<Comic>>> Function(String option, int page)? load;
final Future<Res<List<Comic>>> Function(String option, String? next)?
loadWithNext;
loadWithNext;
const RankingData(this.options, this.load, this.loadWithNext);
}
@@ -447,7 +476,12 @@ class CategoryComicsOptions {
final List<String>? showWhen;
const CategoryComicsOptions(this.label, this.options, this.notShowWhen, this.showWhen);
const CategoryComicsOptions(
this.label,
this.options,
this.notShowWhen,
this.showWhen,
);
}
class LinkHandler {

View File

@@ -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 = <CategoryComicsOptions>[];
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<String, String> map = LinkedHashMap<String, String>();
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<Res<bool>> 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;

View File

@@ -4,50 +4,90 @@ part of 'comic_source.dart';
typedef ComicListBuilder = Future<Res<List<Comic>>> 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<Res<List<Comic>>> Function(
String? next);
typedef ComicListBuilderWithNext =
Future<Res<List<Comic>>> Function(String? next);
typedef LoginFunction = Future<Res<bool>> Function(String, String);
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
String id, String? ep);
typedef LoadComicPagesFunc =
Future<Res<List<String>>> Function(String id, String? ep);
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
String id, String? subId, int page, String? replyTo);
typedef CommentsLoader =
Future<Res<List<Comment>>> Function(
String id,
String? subId,
int page,
String? replyTo,
);
typedef SendCommentFunc = Future<Res<bool>> Function(
String id, String? subId, String content, String? replyTo);
typedef ChapterCommentsLoader =
Future<Res<List<Comment>>> Function(
String comicId,
String epId,
int page,
String? replyTo,
);
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
String imageKey, String comicId, String epId)?;
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?;
typedef SendCommentFunc =
Future<Res<bool>> Function(
String id,
String? subId,
String content,
String? replyTo,
);
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
String comicId, String? next);
typedef SendChapterCommentFunc =
Future<Res<bool>> Function(
String comicId,
String epId,
String content,
String? replyTo,
);
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
String comicId, bool isLiking);
typedef GetImageLoadingConfigFunc =
Future<Map<String, dynamic>> Function(
String imageKey,
String comicId,
String epId,
)?;
typedef GetThumbnailLoadingConfigFunc =
Map<String, dynamic> Function(String imageKey)?;
typedef ComicThumbnailLoader =
Future<Res<List<String>>> Function(String comicId, String? next);
typedef LikeOrUnlikeComicFunc =
Future<Res<bool>> 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<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isLiking);
typedef LikeCommentFunc =
Future<Res<int?>> 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<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
typedef VoteCommentFunc =
Future<Res<int?>> 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<Res<bool>> Function(String comicId, int rating);
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);

View File

@@ -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<ChapterCommentsPage> createState() => _ChapterCommentsPageState();
}
class _ChapterCommentsPageState extends State<ChapterCommentsPage> {
bool _loading = true;
List<Comment>? _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);
}
}
}

View File

@@ -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>()!;
@@ -168,12 +171,22 @@ class _ReaderState extends State<Reader>
}
}
// 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();
@@ -211,8 +224,10 @@ class _ReaderState extends State<Reader>
} 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;
}
@@ -242,13 +257,15 @@ class _ReaderState extends State<Reader>
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())),
),
);
},
),
],
),
);
@@ -385,16 +402,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;
}
}
@@ -403,15 +433,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;
@@ -434,7 +471,7 @@ abstract mixin class _ImagePerPageHandler {
newPage = previousImageIndex;
}
page = newPage>0 ? newPage : 1;
page = newPage > 0 ? newPage : 1;
}
}
@@ -469,10 +506,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() {
@@ -507,7 +541,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;
@@ -588,7 +623,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();

View File

@@ -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,48 @@ 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,
),
);
}
Widget buildEpChangeButton() {
if (context.reader.widget.chapters == null) return const SizedBox();
switch (showFloatingButtonValue) {

View File

@@ -303,6 +303,15 @@ class _ReaderSettingsState extends State<ReaderSettings> {
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(),
],
);
}