mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
comments
This commit is contained in:
@@ -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,27 +300,46 @@ 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
149
lib/foundation/comic_source/models.dart
Normal file
149
lib/foundation/comic_source/models.dart
Normal 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);
|
||||
}
|
@@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -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> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
470
lib/pages/comments_page.dart
Normal file
470
lib/pages/comments_page.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user