This commit is contained in:
nyne
2024-10-06 15:31:53 +08:00
parent 3a0fbee7bc
commit 5ccd0af2d8
6 changed files with 763 additions and 235 deletions

View File

@@ -99,11 +99,13 @@ class Button extends StatefulWidget {
static Widget icon(
{Key? key,
required Widget icon,
required VoidCallback onPressed,
double? size,
Color? color,
String? tooltip}) {
required Widget icon,
required VoidCallback onPressed,
double? size,
Color? color,
String? tooltip,
bool isLoading = false,
HitTestBehavior behavior = HitTestBehavior.deferToChild}) {
return _IconButton(
key: key,
icon: icon,
@@ -111,6 +113,8 @@ class Button extends StatefulWidget {
size: size,
color: color,
tooltip: tooltip,
behavior: behavior,
isLoading: isLoading,
);
}
@@ -262,13 +266,16 @@ class _ButtonState extends State<Button> {
}
class _IconButton extends StatefulWidget {
const _IconButton(
{super.key,
required this.icon,
required this.onPressed,
this.size,
this.color,
this.tooltip});
const _IconButton({
super.key,
required this.icon,
required this.onPressed,
this.size,
this.color,
this.tooltip,
this.isLoading = false,
this.behavior = HitTestBehavior.deferToChild,
});
final Widget icon;
@@ -280,6 +287,10 @@ class _IconButton extends StatefulWidget {
final Color? color;
final HitTestBehavior behavior;
final bool isLoading;
@override
State<_IconButton> createState() => _IconButtonState();
}
@@ -289,24 +300,43 @@ class _IconButtonState extends State<_IconButton> {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: widget.onPressed,
mouseCursor: SystemMouseCursors.click,
customBorder: const CircleBorder(),
child: Tooltip(
message: widget.tooltip ?? "",
child: Container(
decoration: BoxDecoration(
color:
isHover ? Theme.of(context).colorScheme.surfaceContainer : null,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(6),
child: IconTheme(
data: IconThemeData(
size: widget.size ?? 24,
color: widget.color ?? context.colorScheme.primary),
child: widget.icon,
var iconSize = widget.size ?? 24;
Widget icon = IconTheme(
data: IconThemeData(
size: iconSize,
color: widget.color ?? context.colorScheme.primary,
),
child: widget.icon,
);
if (widget.isLoading) {
icon = const CircularProgressIndicator(
strokeWidth: 1.5,
).paddingAll(2).fixWidth(iconSize).fixHeight(iconSize);
}
return MouseRegion(
onEnter: (_) => setState(() => isHover = true),
onExit: (_) => setState(() => isHover = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: widget.behavior,
onTap: () {
if (widget.isLoading) return;
widget.onPressed();
},
child: Tooltip(
message: widget.tooltip ?? "",
child: Container(
decoration: BoxDecoration(
color: isHover
? Theme.of(context)
.colorScheme
.outlineVariant
.withOpacity(0.4)
: null,
borderRadius: BorderRadius.circular((iconSize + 12) / 2),
),
padding: const EdgeInsets.all(6),
child: icon,
),
),
),

View File

@@ -17,10 +17,9 @@ import '../js_engine.dart';
import '../log.dart';
part 'category.dart';
part 'favorites.dart';
part 'parser.dart';
part 'models.dart';
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
@@ -43,9 +42,19 @@ typedef GetImageLoadingConfigFunc = Map<String, dynamic> Function(
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?;
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(String comicId, String? next);
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
String comicId, String? next);
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(String comicId, bool isLiking);
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);
/// [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);
class ComicSource {
static final List<ComicSource> _sources = [];
@@ -155,8 +164,6 @@ class ComicSource {
final Map<String, dynamic> Function(String imageKey)?
getThumbnailLoadingConfig;
final String? matchBriefIdReg;
var data = <String, dynamic>{};
bool get isLogged => data["account"] != null;
@@ -175,6 +182,10 @@ class ComicSource {
final LikeOrUnlikeComicFunc? likeOrUnlikeComic;
final VoteCommentFunc? voteCommentFunc;
final LikeCommentFunc? likeCommentFunc;
Future<void> loadData() async {
var file = File("${App.dataPath}/comic_source/$key.data");
if (await file.exists()) {
@@ -228,37 +239,15 @@ class ComicSource {
this.loadComicPages,
this.getImageLoadingConfig,
this.getThumbnailLoadingConfig,
this.matchBriefIdReg,
this.filePath,
this.url,
this.version,
this.commentsLoader,
this.sendCommentFunc,
this.likeOrUnlikeComic)
this.likeOrUnlikeComic,
this.voteCommentFunc,
this.likeCommentFunc,)
: idMatcher = null;
ComicSource.unknown(this.key)
: name = "Unknown",
account = null,
categoryData = null,
categoryComicsData = null,
favoriteData = null,
explorePages = [],
searchPageData = null,
settings = [],
loadComicInfo = null,
loadComicThumbnail = null,
loadComicPages = null,
getImageLoadingConfig = null,
getThumbnailLoadingConfig = null,
matchBriefIdReg = null,
filePath = "",
url = "",
version = "",
commentsLoader = null,
sendCommentFunc = null,
idMatcher = null,
likeOrUnlikeComic = null;
}
class AccountConfig {
@@ -394,131 +383,6 @@ enum SettingType {
input,
}
class Comic {
final String title;
final String cover;
final String id;
final String? subtitle;
final List<String>? 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);
Map<String, dynamic> toJson() {
return {
"title": title,
"cover": cover,
"id": id,
"subTitle": subtitle,
"tags": tags,
"description": description,
"sourceKey": sourceKey,
"maxPage": maxPage,
};
}
Comic.fromJson(Map<String, dynamic> json, this.sourceKey)
: title = json["title"],
subtitle = json["subTitle"] ?? "",
cover = json["cover"],
id = json["id"],
tags = List<String>.from(json["tags"] ?? []),
description = json["description"] ?? "",
maxPage = json["maxPage"];
}
class ComicDetails with HistoryMixin {
@override
final String title;
@override
final String? subTitle;
@override
final String cover;
final String? description;
final Map<String, List<String>> tags;
/// id-name
final Map<String, String>? chapters;
final List<String>? thumbnails;
final List<Comic>? recommend;
final String sourceKey;
final String comicId;
final bool? isFavorite;
final String? subId;
final bool? isLiked;
final int? likesCount;
final int? commentsCount;
final String? uploader;
final String? uploadTime;
final String? updateTime;
static Map<String, List<String>> _generateMap(Map<String, dynamic> map) {
var res = <String, List<String>>{};
map.forEach((key, value) {
res[key] = List<String>.from(value);
});
return res;
}
ComicDetails.fromJson(Map<String, dynamic> json)
: title = json["title"],
subTitle = json["subTitle"],
cover = json["cover"],
description = json["description"],
tags = _generateMap(json["tags"]),
chapters = json["chapters"] == null
? null
: Map<String, String>.from(json["chapters"]),
sourceKey = json["sourceKey"],
comicId = json["comicId"],
thumbnails = ListOrNull.from(json["thumbnails"]),
recommend = (json["recommend"] as List?)
?.map((e) => Comic.fromJson(e, json["sourceKey"]))
.toList(),
isFavorite = json["isFavorite"],
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);
@override
String get id => comicId;
ComicType get comicType => ComicType(sourceKey.hashCode);
}
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
String category, String? param, List<String> options, int page);
@@ -560,14 +424,3 @@ class CategoryComicsOptions {
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
}
class Comment {
final String userName;
final String? avatar;
final String content;
final String? time;
final int? replyCount;
final String? id;
const Comment(this.userName, this.avatar, this.content, this.time,
this.replyCount, this.id);
}

View File

@@ -0,0 +1,149 @@
part of 'comic_source.dart';
class Comment {
final String userName;
final String? avatar;
final String content;
final String? time;
final int? replyCount;
final String? id;
final int? score;
final bool? isLiked;
final int? voteStatus; // 1: upvote, -1: downvote, 0: none
Comment.fromJson(Map<String, dynamic> json)
: userName = json["userName"],
avatar = json["avatar"],
content = json["content"],
time = json["time"],
replyCount = json["replyCount"],
id = json["id"].toString(),
score = json["score"],
isLiked = json["isLiked"],
voteStatus = json["voteStatus"];
}
class Comic {
final String title;
final String cover;
final String id;
final String? subtitle;
final List<String>? 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);
Map<String, dynamic> toJson() {
return {
"title": title,
"cover": cover,
"id": id,
"subTitle": subtitle,
"tags": tags,
"description": description,
"sourceKey": sourceKey,
"maxPage": maxPage,
};
}
Comic.fromJson(Map<String, dynamic> json, this.sourceKey)
: title = json["title"],
subtitle = json["subTitle"] ?? "",
cover = json["cover"],
id = json["id"],
tags = List<String>.from(json["tags"] ?? []),
description = json["description"] ?? "",
maxPage = json["maxPage"];
}
class ComicDetails with HistoryMixin {
@override
final String title;
@override
final String? subTitle;
@override
final String cover;
final String? description;
final Map<String, List<String>> tags;
/// id-name
final Map<String, String>? chapters;
final List<String>? thumbnails;
final List<Comic>? recommend;
final String sourceKey;
final String comicId;
final bool? isFavorite;
final String? subId;
final bool? isLiked;
final int? likesCount;
final int? commentsCount;
final String? uploader;
final String? uploadTime;
final String? updateTime;
static Map<String, List<String>> _generateMap(Map<String, dynamic> map) {
var res = <String, List<String>>{};
map.forEach((key, value) {
res[key] = List<String>.from(value);
});
return res;
}
ComicDetails.fromJson(Map<String, dynamic> json)
: title = json["title"],
subTitle = json["subTitle"],
cover = json["cover"],
description = json["description"],
tags = _generateMap(json["tags"]),
chapters = json["chapters"] == null
? null
: Map<String, String>.from(json["chapters"]),
sourceKey = json["sourceKey"],
comicId = json["comicId"],
thumbnails = ListOrNull.from(json["thumbnails"]),
recommend = (json["recommend"] as List?)
?.map((e) => Comic.fromJson(e, json["sourceKey"]))
.toList(),
isFavorite = json["isFavorite"],
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);
@override
String get id => comicId;
ComicType get comicType => ComicType(sourceKey.hashCode);
}

View File

@@ -103,8 +103,6 @@ class ComicSourceParser {
(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(
@@ -123,43 +121,29 @@ class ComicSourceParser {
ComicSource.sources.$_key = this['temp'];
""");
final account = _loadAccountConfig();
final explorePageData = _loadExploreData();
final categoryPageData = _loadCategoryData();
final categoryComicsData = _loadCategoryComicsData();
final searchData = _loadSearchData();
final loadComicFunc = _parseLoadComicFunc();
final loadComicThumbnailFunc = _parseThumbnailLoader();
final loadComicPagesFunc = _parseLoadComicPagesFunc();
final getImageLoadingConfigFunc = _parseImageLoadingConfigFunc();
final getThumbnailLoadingConfigFunc = _parseThumbnailLoadingConfigFunc();
final favoriteData = _loadFavoriteData();
final commentsLoader = _parseCommentsLoader();
final sendCommentFunc = _parseSendCommentFunc();
final likeFunc = _parseLikeFunc();
var source = ComicSource(
_name!,
key,
account,
categoryPageData,
categoryComicsData,
favoriteData,
explorePageData,
searchData,
_loadAccountConfig(),
_loadCategoryData(),
_loadCategoryComicsData(),
_loadFavoriteData(),
_loadExploreData(),
_loadSearchData(),
[],
loadComicFunc,
loadComicThumbnailFunc,
loadComicPagesFunc,
getImageLoadingConfigFunc,
getThumbnailLoadingConfigFunc,
matchBriefIdRegex,
_parseLoadComicFunc(),
_parseThumbnailLoader(),
_parseLoadComicPagesFunc(),
_parseImageLoadingConfigFunc(),
_parseThumbnailLoadingConfigFunc(),
filePath,
url ?? "",
version ?? "1.0.0",
commentsLoader,
sendCommentFunc,
likeFunc,
_parseCommentsLoader(),
_parseSendCommentFunc(),
_parseLikeFunc(),
_parseVoteCommentFunc(),
_parseLikeCommentFunc(),
);
await source.loadData();
@@ -571,10 +555,7 @@ 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.fromJson(e)).toList(),
subData: res["maxPage"]);
} catch (e, s) {
Log.error("Network", "$e\n$s");
@@ -673,4 +654,38 @@ class ComicSourceParser {
}
};
}
VoteCommentFunc? _parseVoteCommentFunc() {
if (!_checkExists("comic.voteComment")) {
return null;
}
return (id, subId, commentId, isUp, isCancel) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.comic.voteComment(${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(commentId)}, ${jsonEncode(isUp)}, ${jsonEncode(isCancel)})
""");
return Res(res is num ? res.toInt() : 0);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
};
}
LikeCommentFunc? _parseLikeCommentFunc() {
if (!_checkExists("comic.likeComment")) {
return null;
}
return (id, subId, commentId, isLiking) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.comic.likeComment(${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(commentId)}, ${jsonEncode(isLiking)})
""");
return Res(res is num ? res.toInt() : 0);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
};
}
}

View File

@@ -12,6 +12,8 @@ import 'package:venera/pages/favorites/favorite_actions.dart';
import 'package:venera/utils/translations.dart';
import 'dart:math' as math;
import 'comments_page.dart';
class ComicPage extends StatefulWidget {
const ComicPage({super.key, required this.id, required this.sourceKey});
@@ -466,7 +468,15 @@ abstract mixin class _ComicPageActions {
void showMoreActions() {}
void showComments() {}
void showComments() {
showSideBar(
App.rootContext,
CommentsPage(
data: comic,
source: comicSource,
),
);
}
}
class _ActionButton extends StatelessWidget {
@@ -1107,3 +1117,4 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
}
}
}

View File

@@ -0,0 +1,470 @@
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/image_provider/cached_image.dart';
import 'package:venera/utils/translations.dart';
class CommentsPage extends StatefulWidget {
const CommentsPage(
{super.key, required this.data, required this.source, this.replyId});
final ComicDetails data;
final ComicSource source;
final String? replyId;
@override
State<CommentsPage> createState() => _CommentsPageState();
}
class _CommentsPageState extends State<CommentsPage> {
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.commentsLoader!(
widget.data.comicId, widget.data.subId, 1, widget.replyId);
if (res.error) {
setState(() {
_error = res.errorMessage;
_loading = false;
});
} else {
setState(() {
_comments = res.data;
_loading = false;
maxPage = res.subData;
});
}
}
void loadMore() async {
var res = await widget.source.commentsLoader!(
widget.data.comicId, widget.data.subId, _page + 1, widget.replyId);
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(
appBar: Appbar(
title: Text("Comments".tl),
),
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 {
return Column(
children: [
Expanded(
child: ListView.builder(
primary: false,
padding: EdgeInsets.zero,
itemCount: _comments!.length + 1,
itemBuilder: (context, index) {
if (index == _comments!.length) {
if (_page < (maxPage ?? _page + 1)) {
loadMore();
return const ListLoadingIndicator();
} else {
return const SizedBox();
}
}
return _CommentTile(
comment: _comments![index],
source: widget.source,
comic: widget.data,
);
},
),
),
buildBottom(context)
],
);
}
}
Widget buildBottom(BuildContext context) {
if (widget.source.sendCommentFunc == null) {
return const SizedBox(
height: 0,
);
}
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
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.5),
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.sendCommentFunc!(
widget.data.comicId,
widget.data.subId,
controller.text,
widget.replyId);
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,
),
)
],
).paddingVertical(2).paddingLeft(16).paddingRight(4),
),
);
}
}
class _CommentTile extends StatefulWidget {
const _CommentTile({
required this.comment,
required this.source,
required this.comic,
});
final Comment comment;
final ComicSource source;
final ComicDetails comic;
@override
State<_CommentTile> createState() => _CommentTileState();
}
class _CommentTileState extends State<_CommentTile> {
@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(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.comment.avatar != null)
Container(
width: 40,
height: 40,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.secondaryContainer),
child: AnimatedImage(
image: CachedImageProvider(
widget.comment.avatar!,
sourceKey: widget.source.key,
),
),
).paddingRight(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.comment.userName),
if (widget.comment.time != null)
Text(widget.comment.time!, style: ts.s12),
const SizedBox(height: 4),
Text(widget.comment.content),
buildActions(),
],
),
)
],
).paddingAll(16),
);
}
Widget buildActions() {
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(),
if (widget.comment.replyCount != 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: () {
showSideBar(
context,
CommentsPage(
data: widget.comic,
source: widget.source,
replyId: widget.comment.id,
),
);
},
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.comic.comicId,
widget.comic.subId,
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)
const Icon(Icons.favorite, size: 16)
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 isVoteUp = false;
bool isVoteDown = false;
void vote(bool isUp) async {
if (isVoteUp || isVoteDown) return;
setState(() {
if (isUp) {
isVoteUp = true;
} else {
isVoteDown = true;
}
});
var res = await widget.source.voteCommentFunc!(
widget.comic.comicId,
widget.comic.subId,
widget.comment.id!,
isUp,
(isUp && voteStatus == 1) || (!isUp && voteStatus == -1),
);
if (res.success) {
if (isUp) {
voteStatus = 1;
} else {
voteStatus = -1;
}
} else {
context.showMessage(message: res.errorMessage ?? "Error");
}
setState(() {
isVoteUp = false;
isVoteDown = 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: isVoteUp,
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: isVoteDown,
icon: const Icon(Icons.arrow_downward),
size: 18,
color: downColor,
onPressed: () => vote(false),
),
],
),
);
}
}