484 lines
16 KiB
Dart
484 lines
16 KiB
Dart
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;
|
||
}
|
||
}
|