Files
video/app/lib/screens/feed/widgets/comment_sheet.dart
T
2026-10-15 20:36:59 +08:00

484 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> showCommentSheet(
BuildContext context, {
required int videoId,
required void Function(int delta) onCountChanged,
}) {
return showModalBottomSheet<void>(
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<CommentSheet> createState() => _CommentSheetState();
}
class _CommentSheetState extends ConsumerState<CommentSheet> {
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<void> _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<void> _confirmDelete(CommentItem comment, {int parentId = 0}) async {
final ok = await showDialog<bool>(
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;
}
}