初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_model/banner/comment_top_banner_model.dart';
import 'package:hgdj/hj_model/comment/comment_model.dart';
import 'package:hgdj/hj_model/comment/reply_model.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/comment/cell/reply_comment_item.dart';
import 'package:hgdj/hj_utils/api_service/comment_service.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
import 'package:like_button/like_button.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import '../../home/widget/user_avatar.dart';
import '../../home/widget/user_name_view.dart';
import '../widget/comment_rich_text.dart';
import '../widget/comment_top_banner.dart';
/// 一条评论:头像 + 昵称 + 正文(可带图) + 时间/点赞/回复 + 折叠的回复列表
class CommentItem extends StatefulWidget {
final CommentModel comment;
final String objId;
final List<CommentTopBannerModel>? topBanners; // 置顶评论下方配置 Banner
final Function(CommentModel, {ReplyModel? reply})? replyHandler;
const CommentItem(
this.comment, {
super.key,
required this.objId,
this.replyHandler,
this.topBanners,
});
@override
State<CommentItem> createState() => _CommentItemState();
}
class _CommentItemState extends State<CommentItem> {
CommentModel get comment => widget.comment;
late final isLikedNof = ValueNotifier(comment.isLike ?? false);
bool _isLoadingMore = false;
bool _isExpand = false;
int _page = 1;
@override
void dispose() {
isLikedNof.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {},
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UserAvatar(
size: 40,
model: Publisher()
..uid = comment.userID
..portrait = comment.userPortrait
..superUser = (comment.superUser ?? false),
showVip: false,
), //头像
12.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
UserNameView(
name: comment.userName,
isVip: (comment.vipLevel ?? 0) > 0,
nameColor: Color(0xff9A9A9A),
),
if ((comment.vipLevel ?? 0) > 0) ...[
4.sizeBoxW,
Image.asset("vip_v.webp".commentPath,
width: 16),
]
],
),
8.sizeBoxH,
CommentRichText(model: comment),
if (comment.image?.isNotEmpty == true)
GestureDetector(
onTap: () {
ImageBrowserPage.open([comment.image ?? '']);
},
child: Container(
margin: EdgeInsets.only(top: 6),
alignment: Alignment.centerLeft,
constraints: BoxConstraints(maxWidth: 150),
child: NetworkImageLoader(
imageUrl: comment.image ?? ""),
),
),
// 置顶评论配置 Banner(多图轮播,支持 GIF / 内外链)
if (widget.topBanners?.isNotEmpty == true)
CommentTopBanner(banners: widget.topBanners!),
8.sizeBoxH,
Row(
children: [
Text(
comment.createdAt?.utcToAgo() ?? "",
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 12,
),
),
const Spacer(),
if (!comment.isOfficial &&
!comment.isHideLikeOrComment) ...[
ValueListenableBuilder(
valueListenable: isLikedNof,
builder: (context, value, child) => Row(
children: [
LikeButton(
isLiked: value,
onTap: (_) => _toggleLike(),
size: 20,
likeBuilder: (isLiked) {
return Image.asset(isLiked
? 'like_red.webp'.commentPath
: 'like_white.webp'.commentPath);
},
),
Text(
comment.likeCount?.countOr("0") ?? "0",
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 12,
),
),
],
),
),
12.sizeBoxW,
GestureDetector(
onTap: () =>
widget.replyHandler?.call(comment),
child: Image.asset('msg_icon.png'.commentPath,
width: 20),
),
],
],
),
_replyList(),
// 展开更多评论
if ((comment.replies?.length ?? 0) > 0)
_moreReplyBar(),
],
),
),
],
),
18.sizeBoxH,
const Divider(height: 1, color: Color(0x0fffffff)),
18.sizeBoxH,
],
),
if ((comment.isGodComment ?? false))
Positioned(
right: 8,
top: -10,
child: Transform.rotate(
angle: pi / 60,
child: Image.asset(
'god_comment.webp'.commentPath,
width: 72,
height: 60,
),
),
),
],
));
}
/// 展开 / 收起那一行:短横线 + 文案 + 箭头
Widget _replyBarRow(String text, {bool isUp = false}) {
final chevron = Image.asset("chevron_down.webp".commonImgPath, width: 16);
return Row(
children: [
Container(width: 20, height: 1, color: const Color(0xff989898)),
4.sizeBoxW,
Text(text,
style: const TextStyle(
fontSize: 12,
color: Color(0xff989898),
fontWeight: FontWeight.w500)),
2.sizeBoxW,
isUp ? Transform.rotate(angle: pi, child: chevron) : chevron,
],
);
}
Widget _moreReplyBar() {
final replyCount = comment.replies?.length ?? 0;
if (replyCount > 1 && _isExpand) {
//收起:padding 在点击区里面,跟改动前一致
return GestureDetector(
onTap: () => setState(() => _isExpand = false),
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: _replyBarRow("收起${comment.commCount ?? 0}条回复", isUp: true),
),
);
}
final diff = (comment.commCount ?? 0) - (_isExpand ? replyCount : 1);
if (diff <= 0) return const SizedBox.shrink();
if (_isLoadingMore) {
return const Padding(
padding: EdgeInsets.only(top: 12, left: 6),
child: CupertinoActivityIndicator(color: Colors.white, radius: 8),
);
}
//展开:padding 在点击区外面别算进热区;外面这层 Row 也不能省——
//它给 InkWell 的是无界宽约束,InkWell 才会只包住文字而不是撑满整行
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
InkWell(
enableFeedback: false,
onTap: _expandReplies,
child: _replyBarRow(replyCount > 99 ? '展开更多回复' : '展开$diff条回复'),
),
],
),
);
}
// 展开回复
Future _expandReplies() async {
if (comment.replies?.isNotEmpty != true) {
comment.commCount = 0;
setState(() {});
return;
}
if (_isLoadingMore) return;
_isLoadingMore = true;
setState(() {});
_isExpand = true;
try {
if (comment.hasMoreReply ?? false) {
final curTime = DateTimeUtil.format2utc(DateTime.now());
final fastId = comment.replies!.first.id ?? '';
final result = await CommentService.getReplyList(widget.objId,
comment.id ?? '', curTime, comment.replyPage ?? _page, 5, fastId);
comment.hasMoreReply = result?.hasNext ?? false;
comment.replies?.addAll(result?.list ?? []);
if (result?.hasNext == true) {
_page += 1;
comment.replyPage = (comment.replyPage ?? 0) + 1;
} else {
comment.commCount = comment.replies?.length ?? 0;
}
}
} catch (e) {
debugLog(e);
}
_isLoadingMore = false;
if (mounted) setState(() {});
}
Future<bool> _toggleLike() async {
final isLike = comment.isLike ?? false;
bool result;
if (!isLike) {
result = await CommonService.sendLike(comment.id ?? '', 'COMMENT');
if (result) comment.likeCount = (comment.likeCount ?? 0) + 1;
} else {
result = await CommonService.cancelLike(comment.id ?? '', 'COMMENT');
if (result) comment.likeCount = (comment.likeCount ?? 1) - 1;
}
if (result) comment.isLike = !isLike;
isLikedNof.value = comment.isLike!;
return isLikedNof.value;
}
//没展开时只露第一条
Widget _replyList() {
final replies = comment.replies ?? [];
if (replies.isEmpty) return const SizedBox.shrink();
final length = (!_isExpand && replies.length > 1) ? 1 : replies.length;
return Column(
//stretch 对齐原来 ListView 的行为:子项撑满宽度,不按内容自适应
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: List.generate(
length,
(i) => ReplyCommentItem(
replies[i],
replyHandler: (reply) =>
widget.replyHandler?.call(comment, reply: reply),
),
),
);
}
}
@@ -0,0 +1,175 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
import 'package:like_button/like_button.dart';
import '../../../hj_model/comment/reply_model.dart';
import '../../../hj_utils/api_service/common_service.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import '../../user_center_page/user_center_page.dart';
/// 评论下的一条回复:小头像 + 「A ▸ B」 + 正文(可带图) + 时间/点赞/回复
class ReplyCommentItem extends StatefulWidget {
final ReplyModel reply;
final Function(ReplyModel)? replyHandler;
const ReplyCommentItem(this.reply, {super.key, this.replyHandler});
@override
State<ReplyCommentItem> createState() => _ReplyCommentItemState();
}
class _ReplyCommentItemState extends State<ReplyCommentItem> {
ReplyModel get reply => widget.reply;
late final isLikedNof = ValueNotifier(reply.isLike ?? false);
@override
void dispose() {
isLikedNof.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
16.sizeBoxH,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
if (reply.userID != null) {
Get.to(() => UserCenterPage(uid: reply.userID ?? 0));
}
},
child: NetworkImageLoader(
imageUrl: reply.userPortrait ?? '',
width: 24,
height: 24,
borderRadius: 18,
),
),
12.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
reply.userName ?? '',
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
3.sizeBoxW,
if (reply.toUserName?.isNotEmpty == true) ...[
Image.asset(
"arrow_grey.webp".commentPath,
width: 16,
height: 16,
),
3.sizeBoxW,
Text(
reply.toUserName ?? '',
style:
TextStyle(color: Color(0x73FFFFFF), fontSize: 14),
),
]
],
), // 还有个类似评分的东西
8.sizeBoxH,
Text(
reply.content ?? '',
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 14,
),
),
if (reply.image?.isNotEmpty == true)
GestureDetector(
onTap: () {
ImageBrowserPage.open([reply.image ?? '']);
},
child: Container(
margin: EdgeInsets.only(top: 6),
alignment: Alignment.centerLeft,
constraints: BoxConstraints(maxWidth: 150),
child: NetworkImageLoader(imageUrl: reply.image ?? ""),
),
),
8.sizeBoxH,
Row(
children: [
Text(
reply.createdAt?.utcToAgo() ?? "",
style: const TextStyle(
color: Color(0x73FFFFFF), fontSize: 12),
),
const Spacer(),
ValueListenableBuilder(
valueListenable: isLikedNof,
builder: (context, value, child) => Row(
children: [
LikeButton(
isLiked: value,
onTap: (_) => _toggleLike(),
size: 20,
likeBuilder: (isLiked) {
return Image.asset(isLiked
? 'like_red.webp'.commentPath
: 'like_white.webp'.commentPath);
},
),
Text(
reply.likeCount?.countOr("0") ?? "0",
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 12,
),
),
],
),
),
12.sizeBoxW,
GestureDetector(
onTap: () => widget.replyHandler?.call(reply),
child:
Image.asset('msg_icon.png'.commentPath, width: 20),
),
],
),
],
),
),
],
),
],
);
}
// 评论点赞
Future<bool> _toggleLike() async {
final isLike = reply.isLike ?? false;
bool result;
if (!isLike) {
result = await CommonService.sendLike(reply.id ?? '', 'COMMENT');
if (result) reply.likeCount = (reply.likeCount ?? 0) + 1;
} else {
result = await CommonService.cancelLike(reply.id ?? '', 'COMMENT');
if (result) reply.likeCount = (reply.likeCount ?? 1) - 1;
}
if (result) reply.isLike = !isLike;
isLikedNof.value = reply.isLike!;
return isLikedNof.value;
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import 'comment_views.dart';
Future showCommentDialog(
String objId, {
String objType = "video",
int? commentCount,
}) async {
return Get.bottomSheet(
Container(
alignment: Alignment.topLeft,
height: screen.screenHeight * 0.72,
decoration: BoxDecoration(
color: Color(0xff141414),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
alignment: Alignment.center,
padding: EdgeInsets.only(top: 12, bottom: 12),
child: const SheetHandleBar(color: Color(0x1AFFFFFF)),
),
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 16),
child: Text(
"${commentCount ?? 0}条评论",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w400,
fontSize: 14),
),
),
Expanded(
child: CommentView(objId, objType: objType),
),
],
),
),
elevation: 0.72,
);
}
+178
View File
@@ -0,0 +1,178 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/group_text_filed.dart';
import 'package:image_pickers/image_pickers.dart';
/// 评论输入弹层:点空白关闭,自动聚焦拉起键盘
class CommentInputView extends StatefulWidget {
static const String routeName = 'comment_input';
final String? hint;
final TextEditingController textCtr;
final String? imgPath;
final Function(String?)? onImgChange;
const CommentInputView({
super.key,
this.hint,
required this.textCtr,
this.imgPath,
this.onImgChange,
});
@override
State<CommentInputView> createState() => _CommentInputViewState();
}
class _CommentInputViewState extends State<CommentInputView> {
final focus = FocusNode();
TextEditingController get textCtr => widget.textCtr;
//弹层内自己维护一份,选/删图先本地刷新再回调出去
String? _imgPath;
@override
void initState() {
super.initState();
_imgPath = widget.imgPath;
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
focus.requestFocus();
});
}
@override
void dispose() {
focus.dispose();
super.dispose();
}
void _pickImage() async {
final listMedia = await ImagePickers.pickerPaths(
uiConfig: UIConfig(uiThemeColor: Colors.white),
galleryMode: GalleryMode.image,
selectCount: 1,
showCamera: false,
);
if (listMedia.isNotEmpty) {
_imgPath = listMedia.first.path ?? '';
widget.onImgChange?.call(_imgPath);
}
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
enableFeedback: false,
onTap: () => Get.back(),
child: Container(
alignment: Alignment.bottomCenter,
child: GestureDetector(
onTap: () {},
child: Container(
color: Colors.black,
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (_imgPath?.isNotEmpty == true)
Container(
margin: const EdgeInsets.only(bottom: 12),
width: 60,
height: 60,
child: Stack(
fit: StackFit.expand,
children: [
Image.file(
File(_imgPath!),
width: 60,
height: 60,
fit: BoxFit.cover,
),
Positioned(
top: 0,
right: 6,
child: GestureDetector(
onTap: () {
_imgPath = null;
widget.onImgChange?.call(null);
setState(() {});
},
child: Image.asset(
"close_grey.png".commonImgPath,
width: 12,
height: 12,
),
),
),
],
),
),
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: GroupTextFiled(
focusNode: focus,
controller: textCtr,
height: 32,
padding: EdgeInsets.symmetric(horizontal: 16),
textStyle:
TextStyle(color: Colors.white, fontSize: 14),
onSubmitted: (value) {
Get.back(result: true);
},
placeholder: widget.hint ?? '请在此输入评论...',
placeholderTextStyle: TextStyle(
color: Color(0xff4D4D4D), fontSize: 14),
decoration: BoxDecoration(
color: Color(0xff1A1A1A),
borderRadius: BorderRadius.circular(40)),
),
),
),
12.sizeBoxW,
Padding(
padding: const EdgeInsets.only(
right: 12, top: 4, bottom: 4),
child: GestureDetector(
onTap: _pickImage,
child: Image.asset(
'icon_pic.webp'.commentPath,
width: 30,
height: 30,
),
),
),
GestureDetector(
onTap: () {
if (textCtr.text.isEmpty) {
showToast('请输入想要说的话哦~~');
return;
}
Get.back(result: true);
},
child: Image.asset(
'ic_send.webp'.commentPath,
width: 30,
height: 30,
),
),
],
),
],
),
),
)),
),
);
}
}
+212
View File
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/comment/comment_model.dart';
import 'package:hgdj/hj_model/comment/reply_model.dart';
import 'package:hgdj/hj_page/comment/comment_input_view.dart';
import 'package:hgdj/hj_utils/api_service/comment_service.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../hj_model/banner/comment_top_banner_model.dart';
import '../../hj_model/comment/comment_list_res.dart';
import '../../tools_base/file_upload/file_upload_tool.dart';
import '../../tools_base/file_upload/upload_result_model.dart';
mixin CommentMixin on GetxController {
final comments = <CommentModel>[];
final commentController = TextEditingController();
Function(int)? totalCallback;
CommentModel? replyComment;
ReplyModel? replyCommentModel;
int page = 1;
String? hintText;
Rx<String?> selectedImagePath = Rx<String?>(null);
RefreshController? refreshCtr;
final quickSearchList = <CommentLink>[];
final commentTopBanners = <CommentTopBannerModel>[];
String? id;
String? objType;
RxBool isSendingMsg = false.obs;
@override
void onClose() {
commentController.dispose();
super.onClose();
}
Future sendComment() async {
String content = commentController.text;
if (content.isEmpty) {
showToast('请输入评论内容哦~');
return;
}
isSendingMsg.value = true;
String? sendImagePath;
if (selectedImagePath.value?.isNotEmpty == true) {
try {
ImageUploadResultModel? imgResult =
await FileUploadTool().uploadImage(selectedImagePath.value!);
sendImagePath = imgResult?.coverImg ?? "";
if (sendImagePath.isEmpty) {
isSendingMsg.value = false;
showToast("图片上传失败!");
return;
}
} catch (e) {
debugLog(e);
isSendingMsg.value = false;
showToast("图片上传失败!");
return;
}
}
try {
if (replyComment != null) {
// 回复评论
final res = await CommentService.sendReply(
id,
2,
content,
rid: replyCommentModel?.id,
cid: replyComment?.id,
toUserID: replyCommentModel?.toUserID,
objType: objType,
image: sendImagePath,
);
if (res != null) {
replyComment?.replies ??= [];
replyComment?.replies?.insert(0, res);
replyComment?.commCount = replyComment?.replies?.length ?? 0;
replyComment = null;
replyCommentModel = null;
hintText = null;
showToast('已提交,审核通过后将展示');
}
update();
} else {
// 评论
final res = await CommentService.sendComment(
id,
1,
content,
objType ?? '',
image: sendImagePath,
);
if (res == null) {
hintText = null;
} else {
showToast('已提交,审核通过后将展示');
hintText = null;
//插在第一条普通评论前面,置顶那几条不动
final insertIndex =
comments.indexWhere((e) => !e.isHideLikeOrComment);
comments.insert(
insertIndex == -1 ? comments.length : insertIndex, res);
}
update();
}
} catch (e) {
debugLog(e);
}
selectedImagePath.value = null;
commentController.text = "";
isSendingMsg.value = false;
}
// isSend: true 点击了发送按钮, 如果没有发送内容弹键盘, 有内容直接发送
readyReplyComment(CommentModel? comment,
{ReplyModel? replyModel, bool? isSend}) async {
replyComment = comment;
replyCommentModel = replyModel;
if (replyModel != null) {
hintText = '回复:${replyModel.userName ?? ''}';
}
if (comment != null && hintText == null) {
hintText = '回复:${comment.userName ?? ''}';
}
if (isSend == true && commentController.text.isNotEmpty) {
update();
sendComment();
} else {
final result = await Get.bottomSheet(
CommentInputView(
hint: hintText,
textCtr: commentController,
imgPath: selectedImagePath.value,
onImgChange: (value) => selectedImagePath.value = value,
),
backgroundColor: Colors.transparent,
barrierColor: Colors.transparent,
enableDrag: false,
settings: RouteSettings(name: CommentInputView.routeName),
useRootNavigator: true,
);
if (result == true) {
update();
sendComment();
}
}
}
// 获取评论
Future fetchComments(String id, {bool isRefresh = false}) async {
try {
if (isRefresh) {
page = 1;
}
final currentT = DateTimeUtil.format2utc(DateTime.now()) ?? "";
final result =
await CommentService.getCommentList(id, currentT, page, 20);
totalCallback?.call(result?.total ?? 0);
refreshCtr?.refreshCompleted();
refreshCtr?.loadComplete();
if (!(result?.hasNext ?? false)) {
refreshCtr?.loadNoData();
}
if (page == 1) {
comments.clear();
quickSearchList.clear();
// 首屏并行拉置顶 Banner;失败不影响评论列表
_fetchCommentTopBanners();
}
comments.addAll(result?.list ?? []);
if (page == 1) {
quickSearchList.addAll(result?.quickSearchList ?? []);
}
page += 1;
for (final e in comments) {
e.hasMoreReply = (e.commCount ?? 0) > (e.replies?.length ?? 0);
}
} catch (e) {
refreshCtr?.refreshCompleted();
refreshCtr?.loadComplete();
debugLog(e);
}
update();
}
/// 评论区置顶 Bannerscene=COMMENT_TOP
Future<void> _fetchCommentTopBanners() async {
try {
final list = await CommonService.fetchBannerList(scene: 'COMMENT_TOP');
commentTopBanners
..clear()
..addAll(list);
update();
} catch (e) {
debugLog(e);
}
}
/// 首条置顶评论下标:官方评论或带内外链的置顶样式评论
int get topPinnedCommentIndex {
for (int i = 0; i < comments.length; i++) {
final c = comments[i];
if (c.isOfficial || c.isHideLikeOrComment) return i;
}
return -1;
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_page/comment/cell/comment_item.dart';
import 'package:hgdj/hj_page/comment/comment_mixin.dart';
import 'package:hgdj/hj_page/comment/widget/comment_foot_view.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import '../home/widget/quick_search_view.dart';
class CommentLogic extends GetxController with CommentMixin {
final String objId;
final String commentType;
@override
final Function(int)? totalCallback;
CommentLogic(
this.objId, {
this.commentType = 'video',
this.totalCallback,
}) : super();
bool isLoading = true;
@override
void onInit() {
super.onInit();
id = objId;
objType = commentType;
}
@override
void onReady() {
super.onReady();
isLoading = false;
fetchComments(objId);
}
}
class CommentView extends StatelessWidget {
final bool showInput;
final String objId;
final String objType; // video, cartoon
final Function(int)? totalCallback;
const CommentView(
this.objId, {
super.key,
this.showInput = true,
this.objType = 'video',
this.totalCallback,
});
@override
Widget build(BuildContext context) {
return GetBuilder(
init: CommentLogic(objId,
commentType: objType, totalCallback: totalCallback),
tag: objId,
builder: (logic) => LayoutBuilder(builder: (_, cons) {
if (cons.maxHeight < 58) return SizedBox.shrink();
return Column(
children: [
Expanded(
child: pullYsRefresh(
onInit: (refreshCtr) => logic.refreshCtr = refreshCtr,
onLoading: (_) => logic.fetchComments(objId),
onRefresh: (_) => logic.fetchComments(objId, isRefresh: true),
child: () {
if (logic.isLoading) return LoadingCenterWidget();
if (logic.comments.isEmpty) return CErrorWidget();
return CustomScrollView(
slivers: [
if (logic.quickSearchList.isNotEmpty)
SliverToBoxAdapter(
child: Container(
padding: EdgeInsets.only(bottom: 12),
margin: EdgeInsets.fromLTRB(12, 0, 12, 12),
decoration: BoxDecoration(
border: Border(
bottom:
BorderSide(color: Color(0x08ffffff)))),
child: QuickSearchView(logic.quickSearchList),
),
),
SliverList.builder(
itemCount: logic.comments.length,
itemBuilder: (BuildContext context, int index) {
final topIndex = logic.topPinnedCommentIndex;
final showBanner = index == topIndex &&
logic.commentTopBanners.isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: CommentItem(
key: ValueKey(logic.comments[index].id),
logic.comments[index],
objId: objId,
topBanners:
showBanner ? logic.commentTopBanners : null,
replyHandler: (comment, {reply}) =>
logic.readyReplyComment(comment,
replyModel: reply),
),
);
},
),
],
);
}(),
),
),
if (showInput)
Obx(
() => CommentFootView(
isSending: logic.isSendingMsg.value,
onComment: (isSend) =>
logic.readyReplyComment(null, isSend: isSend),
textCtr: logic.commentController,
imgPath: logic.selectedImagePath.value,
onImgChange: (value) => logic.selectedImagePath.value = value,
),
),
],
);
}),
global: false,
);
}
}
@@ -0,0 +1,191 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/group_text_filed.dart';
import 'package:image_pickers/image_pickers.dart';
import '../../../hj_utils/api_service/mine_service.dart';
import '../../mine/collec_history_buy/col_his_buy_sub_page.dart';
/// 底部评论条:假输入框 + 选图 + 收藏 + 发送
class CommentFootView extends StatefulWidget {
final VideoModel? video;
final Function(bool isSend) onComment; //false 点的输入框(唤起输入弹层),true 点的发送
final bool isSending;
final TextEditingController textCtr;
final String? imgPath;
final Function(String?)? onImgChange;
final bool showCollect;
const CommentFootView({
super.key,
this.video,
required this.onComment,
this.isSending = false,
required this.textCtr,
this.imgPath,
this.onImgChange,
this.showCollect = false,
});
@override
State<CommentFootView> createState() => _CommentFootViewState();
}
class _CommentFootViewState extends State<CommentFootView> {
bool _isCollectLoading = false;
bool get isCollect => widget.video?.vidStatus?.hasCollected ?? false;
void _pickImage() async {
final listMedia = await ImagePickers.pickerPaths(
uiConfig: UIConfig(uiThemeColor: Colors.white),
galleryMode: GalleryMode.image,
selectCount: 1,
showCamera: false,
);
if (listMedia.isNotEmpty)
widget.onImgChange?.call(listMedia.first.path ?? '');
if (mounted) setState(() {});
}
void _onCollect() async {
if (_isCollectLoading) return;
_isCollectLoading = true;
try {
final preCollect = isCollect;
await MineService.postCollect(
widget.video?.id, LoadDataType.post.apiType, !isCollect);
if (!preCollect) {
widget.video?.collectCount = (widget.video?.collectCount ?? 0) + 1;
} else {
widget.video?.collectCount = (widget.video?.collectCount ?? 1) - 1;
}
_isCollectLoading = false;
widget.video?.vidStatus?.hasCollected = !isCollect;
if (!preCollect) {
showToast("收藏成功");
}
setState(() {});
} catch (e) {
_isCollectLoading = false;
}
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black,
padding: EdgeInsets.only(
left: 18.w, right: 18.w, bottom: screen.paddingBottom),
alignment: Alignment.centerLeft,
child: SizedBox(
height: 58,
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => widget.onComment(false),
child: Container(
padding: EdgeInsets.symmetric(vertical: 2),
child: GroupTextFiled(
controller: widget.textCtr,
height: 32,
enabled: false,
padding: EdgeInsets.symmetric(horizontal: 16),
textStyle: TextStyle(color: Colors.white, fontSize: 14),
placeholder: '请在此输入评论...',
placeholderTextStyle:
TextStyle(color: Color(0xff525252), fontSize: 14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(16)),
),
),
),
),
12.sizeBoxW,
Stack(
children: [
Container(
margin: EdgeInsets.fromLTRB(0, 4, 4, 4),
width: 30,
height: 30,
alignment: Alignment.center,
child: GestureDetector(
onTap: _pickImage,
child: widget.imgPath?.isNotEmpty == true
? Image.file(
File(widget.imgPath!),
width: 30,
height: 30,
fit: BoxFit.cover,
)
: Image.asset(
'icon_pic.webp'.commentPath,
width: 24,
height: 24,
),
),
),
if (widget.imgPath?.isNotEmpty == true)
Positioned(
top: 0,
right: 0,
child: GestureDetector(
onTap: () {
widget.onImgChange?.call(null);
setState(() {});
},
child: Image.asset(
"close_grey.png".commonImgPath,
width: 12,
height: 12,
),
),
),
],
),
if (widget.showCollect)
Container(
width: 30,
height: 30,
alignment: Alignment.center,
child: InkWell(
enableFeedback: false,
onTap: _onCollect,
child: Image.asset(
isCollect
? "collect_red.png".commonImgPath
: "collect_path.png".commonImgPath,
width: 24,
),
),
),
SizedBox(
width: 30,
height: 30,
child: GestureDetector(
onTap: () => widget.onComment(true),
child: widget.isSending
? CupertinoActivityIndicator(
color: AppColors.actionRed,
radius: 12,
)
: Image.asset('ic_send.webp'.commentPath),
),
)
],
),
),
);
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_model/comment/comment_model.dart';
import 'package:hgdj/routers/jump_router.dart';
/// 评论正文:命中搜索词时把该词渲染成可点的橙色链接,其余按纯文本
class CommentRichText extends StatelessWidget {
final CommentModel? model;
const CommentRichText({super.key, this.model});
@override
Widget build(BuildContext context) {
const style = TextStyle(color: Color(0xE5FFFFFF), fontSize: 14);
final keyword = model?.searchKeyword ?? "";
//按关键词切开,段与段之间插链接;切不出两段就当纯文本
final parts =
keyword.isEmpty ? <String>[] : (model?.content?.split(keyword) ?? []);
if (model?.linkStr?.isNotEmpty != true ||
keyword.isEmpty ||
parts.length <= 1) {
return Text(model?.content ?? '', style: style);
}
return Text.rich(
TextSpan(
style: style,
children: [
for (int i = 0; i < parts.length; i++) ...[
TextSpan(text: parts[i]),
if (i != parts.length - 1)
WidgetSpan(
child: GestureDetector(
onTap: () => pushToPageByLink(model?.linkStr),
child: Text(
keyword,
style: const TextStyle(
color: Color(0xffF68804), fontSize: 14, height: 1.2),
),
),
),
],
],
),
);
}
}
@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_model/banner/comment_top_banner_model.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/banner/ads_banner_widget.dart';
import 'package:hgdj/tools_base/widget/card_swiper/src/swiper.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
/// 评论区置顶评论下方 Banner:支持多图轮播(含 GIF),按 linkType 跳转内外链
class CommentTopBanner extends StatefulWidget {
final List<CommentTopBannerModel> banners;
final double borderRadius;
/// 宽高比,默认对齐站内 Banner 素材 720×150;勿用固定高度,窄/宽屏 cover 会裁切
final double aspectRatio;
const CommentTopBanner({
super.key,
required this.banners,
this.borderRadius = 12,
this.aspectRatio = 720 / 150,
});
@override
State<CommentTopBanner> createState() => _CommentTopBannerState();
}
class _CommentTopBannerState extends State<CommentTopBanner> {
final ValueNotifier<int> _selectIndex = ValueNotifier(0);
int get _count => widget.banners.length;
@override
void dispose() {
_selectIndex.dispose();
super.dispose();
}
Future<void> _onTap(CommentTopBannerModel item) async {
final type = (item.linkType ?? '').toUpperCase();
final value = item.linkValue?.trim() ?? '';
if (type == 'NONE' || value.isEmpty) return;
if (type == 'EXTERNAL') {
if (value.startsWith('http')) {
await launchUrlToWeb(value);
} else {
await pushToPageByLink(value);
}
return;
}
if (type == 'INTERNAL') {
// 兼容 video://detail?id=xxx
final uri = Uri.tryParse(value);
if (uri != null && uri.scheme == 'video') {
final id = uri.queryParameters['id'];
if (id?.isNotEmpty == true) {
await pushToVideoPage(videoId: id);
return;
}
}
await pushToPageByLink(value);
}
}
@override
Widget build(BuildContext context) {
if (_count == 0) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.only(top: 8),
width: double.infinity,
child: AspectRatio(
aspectRatio: widget.aspectRatio,
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.borderRadius),
child: Stack(
fit: StackFit.expand,
children: [
Swiper(
autoplay: _count > 1,
autoplayDelay: 3000,
loop: _count > 1,
itemCount: _count,
onIndexChanged: (index) => _selectIndex.value = index,
itemBuilder: (context, index) {
final item = widget.banners[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _onTap(item),
child: NetworkImageLoader(
imageUrl: item.imageUrl ?? '',
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
borderRadius: 0,
),
);
},
),
if (_count > 1)
Positioned(
bottom: 8,
left: 0,
right: 0,
child: Center(
child: ValueListenableBuilder<int>(
valueListenable: _selectIndex,
builder: (_, index, __) => CIndicator(
itemCount: _count,
selectIndex: index,
space: 3,
dotSize: 4,
selectWidth: 10,
isBarStyle: true,
color: Colors.white.withValues(alpha: 0.4),
selectColor: Colors.white,
),
),
),
),
],
),
),
),
);
}
}