Chapter comments.

This commit is contained in:
角砂糖
2025-11-01 00:56:49 +08:00
parent 09a1d2821c
commit a9e76201f3
10 changed files with 935 additions and 74 deletions

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>()!;
@@ -165,12 +168,22 @@ class _ReaderState extends State<Reader>
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<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;
}
@@ -239,13 +254,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())),
),
);
},
),
],
),
);
@@ -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();

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

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(),
],
);
}