import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/network/api_exception.dart'; import '../../../core/theme/app_theme.dart'; import '../../../models/comment_item.dart'; import '../../../providers/auth_provider.dart'; import '../../../providers/comment_provider.dart'; import 'feed_overlays.dart'; /// 打开评论弹层(《01-开发文档》2.6 评论:一级 + 楼中楼二级回复) /// [onCountChanged] 发表/删除成功后回调(delta=±1),用于同步 Feed 评论计数 Future showCommentSheet( BuildContext context, { required int videoId, required void Function(int delta) onCountChanged, }) { return showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: AppColors.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(12)), ), builder: (_) => CommentSheet( videoId: videoId, onCountChanged: onCountChanged, ), ); } class CommentSheet extends ConsumerStatefulWidget { const CommentSheet({ super.key, required this.videoId, required this.onCountChanged, }); final int videoId; /// 评论计数变化回调(发表 +1 / 删除 -1) final void Function(int delta) onCountChanged; @override ConsumerState createState() => _CommentSheetState(); } class _CommentSheetState extends ConsumerState { final _inputController = TextEditingController(); final _scrollController = ScrollController(); final _inputFocus = FocusNode(); @override void initState() { super.initState(); _scrollController.addListener(_onScroll); } @override void dispose() { _inputController.dispose(); _scrollController.dispose(); _inputFocus.dispose(); super.dispose(); } void _onScroll() { if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 100) { ref.read(commentProvider(widget.videoId).notifier).loadMore(); } } /// 发表(游客守卫:跳登录页) Future _send() async { if (!ref.read(authProvider).isLoggedIn) { context.go('/login'); return; } final content = _inputController.text.trim(); if (content.isEmpty) return; final notifier = ref.read(commentProvider(widget.videoId).notifier); try { final result = await notifier.post(content); _inputController.clear(); _inputFocus.unfocus(); widget.onCountChanged(1); if (!mounted) return; final progress = result.rewardProgress; if (progress.rewardedThisTime) { showTopToast(context, '评论奖励 +${progress.rewardPoints} 积分'); } } on ApiException catch (e) { if (mounted) showTopToast(context, e.message); } catch (_) { if (mounted) showTopToast(context, '发表失败,请重试'); } } /// 删除确认(本人评论/本人视频下评论) Future _confirmDelete(CommentItem comment, {int parentId = 0}) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: AppColors.surface, title: const Text('删除评论', style: TextStyle(fontSize: 16)), content: const Text('确定删除这条评论吗?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('取消'), ), TextButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('删除', style: TextStyle(color: AppColors.primary)), ), ], ), ); if (ok != true || !mounted) return; try { await ref .read(commentProvider(widget.videoId).notifier) .remove(comment, parentId: parentId); widget.onCountChanged(-1); } on ApiException catch (e) { if (mounted) showTopToast(context, e.message); } catch (_) { if (mounted) showTopToast(context, '删除失败,请重试'); } } @override Widget build(BuildContext context) { final state = ref.watch(commentProvider(widget.videoId)); final notifier = ref.read(commentProvider(widget.videoId).notifier); return SizedBox( height: MediaQuery.of(context).size.height * 0.7, child: Column( children: [ // 头部:标题 + 关闭 SizedBox( height: 48, child: Stack( children: [ Center( child: Text( state.total > 0 ? '评论 ${state.total}' : '评论', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), ), Positioned( right: 4, top: 0, bottom: 0, child: IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), ), ), ], ), ), const Divider(height: 1), // 评论列表 Expanded(child: _buildList(state, notifier)), // 回复目标提示条 if (state.replyTarget != null) Container( width: double.infinity, color: AppColors.background, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: [ Expanded( child: Text( '回复 @${state.replyTarget!.nickname}', style: const TextStyle( color: AppColors.textSecondary, fontSize: 12, ), ), ), GestureDetector( onTap: () => notifier.setReplyTarget(null), child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), ), ], ), ), // 输入区 SafeArea( top: false, child: Padding( padding: EdgeInsets.only( left: 12, right: 12, top: 8, bottom: MediaQuery.of(context).viewInsets.bottom + 8, ), child: Row( children: [ Expanded( child: TextField( controller: _inputController, focusNode: _inputFocus, maxLength: 500, maxLines: 1, style: const TextStyle(fontSize: 14), decoration: InputDecoration( hintText: state.replyTarget == null ? '说点什么...' : '回复 @${state.replyTarget!.nickname}', counterText: '', isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), ), onTap: () { // 游客点击输入框:守卫跳登录 if (!ref.read(authProvider).isLoggedIn) { _inputFocus.unfocus(); context.go('/login'); } }, onSubmitted: (_) => _send(), ), ), const SizedBox(width: 8), TextButton( onPressed: state.posting ? null : _send, child: Text( state.posting ? '发送中' : '发送', style: TextStyle( color: state.posting ? AppColors.textWeak : AppColors.primary, fontWeight: FontWeight.w600, ), ), ), ], ), ), ), ], ), ); } Widget _buildList(CommentState state, CommentNotifier notifier) { if (state.isLoading) { return const Center( child: CircularProgressIndicator(color: AppColors.primary), ); } if (state.loadFailed) { return Center( child: TextButton( onPressed: notifier.load, child: const Text('加载失败,点击重试', style: TextStyle(color: AppColors.primary)), ), ); } if (state.items.isEmpty) { return const Center( child: Text('还没有评论,快来抢沙发', style: TextStyle(color: AppColors.textSecondary)), ); } return ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: state.items.length + (state.isLoadingMore ? 1 : 0), itemBuilder: (context, index) { if (index >= state.items.length) { return const Padding( padding: EdgeInsets.all(12), child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.primary), ), ), ); } final comment = state.items[index]; final replies = state.replies[comment.commentId]; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ CommentTile( comment: comment, canDelete: _canDelete(comment), onReply: () => _replyTo(comment, rootId: comment.commentId), onDelete: () => _confirmDelete(comment), ), // 回复展开入口 if (comment.replyCount > 0) Padding( padding: const EdgeInsets.only(left: 44), child: GestureDetector( onTap: () => notifier.toggleReplies(comment), child: Text( state.repliesLoading.contains(comment.commentId) ? '加载中...' : replies == null ? '展开 ${comment.replyCount} 条回复' : '收起回复', style: const TextStyle( color: AppColors.secondary, fontSize: 12, ), ), ), ), // 二级回复列表 if (replies != null) Padding( padding: const EdgeInsets.only(left: 44, top: 4), child: Column( children: [ for (final reply in replies) CommentTile( comment: reply, compact: true, canDelete: _canDelete(reply), onReply: () => _replyTo( reply, rootId: comment.commentId, ), onDelete: () => _confirmDelete( reply, parentId: comment.commentId, ), ), if (comment.replyCount > replies.length) Text( '共 ${comment.replyCount} 条回复', style: const TextStyle( color: AppColors.textWeak, fontSize: 12, ), ), ], ), ), ], ); }, ); } /// 本人评论可删(本人视频下他人评论的删除依赖视频作者身份,一期仅支持自删) bool _canDelete(CommentItem comment) { final user = ref.read(authProvider).user; return user != null && user.userId == comment.userId; } void _replyTo(CommentItem comment, {required int rootId}) { if (!ref.read(authProvider).isLoggedIn) { context.go('/login'); return; } ref.read(commentProvider(widget.videoId).notifier).setReplyTarget( ReplyTarget( parentId: rootId, replyToUserId: comment.userId, nickname: comment.nickname, ), ); _inputFocus.requestFocus(); } } /// 单条评论(一级/二级通用,compact 为二级样式) class CommentTile extends StatelessWidget { const CommentTile({ super.key, required this.comment, required this.onReply, required this.onDelete, this.canDelete = false, this.compact = false, }); final CommentItem comment; final VoidCallback onReply; final VoidCallback onDelete; /// 是否可删除(本人评论) final bool canDelete; /// 二级回复紧凑样式 final bool compact; @override Widget build(BuildContext context) { return GestureDetector( onLongPress: canDelete ? onDelete : null, child: Padding( padding: EdgeInsets.symmetric(vertical: compact ? 4 : 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 头像 CircleAvatar( radius: compact ? 12 : 16, backgroundColor: AppColors.background, backgroundImage: comment.avatarUrl.isNotEmpty ? CachedNetworkImageProvider(comment.avatarUrl) : null, child: comment.avatarUrl.isEmpty ? Text( comment.nickname.isNotEmpty ? comment.nickname.characters.first : '友', style: TextStyle( color: Colors.white, fontSize: compact ? 10 : 13, ), ) : null, ), const SizedBox(width: 8), // 昵称 + 内容 + 操作 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${comment.nickname} · ${_formatTime(comment.createdAt)}', style: const TextStyle( color: AppColors.textSecondary, fontSize: 12, ), ), const SizedBox(height: 2), Text( comment.content, style: const TextStyle(fontSize: 14), ), const SizedBox(height: 2), GestureDetector( onTap: onReply, child: const Text( '回复', style: TextStyle( color: AppColors.textWeak, fontSize: 12, ), ), ), ], ), ), ], ), ), ); } /// 时间紧凑显示:"2026-10-14 10:00:00" → "10-14 10:00";"刚刚"原样 static String _formatTime(String raw) { if (raw.length >= 16) return raw.substring(5, 16); return raw; } }