// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/data/models/comment.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/repositories/comment_repository.dart'; import '../../../core/data/repositories/interaction_repository.dart'; import '../../../core/data/services/comment_controller.dart'; import '../../../core/data/services/auth_controller.dart'; import '../../../core/formatting/error_text.dart'; import '../../../core/platform/haptics.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/auth_gate.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../l10n/app_localizations.dart'; import '../../interaction/widgets/report_dialog.dart'; import 'comment_tile.dart'; const double _minSheetHeight = 280; const double _maxSheetHeight = 520; const int _maxCommentLength = 300; const double _composerRadius = 12; const double _composerHeight = 52; const double _sendButtonSize = 40; const double _sheetRadius = 20; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 class DramaCommentsSheet extends StatefulWidget { const DramaCommentsSheet({super.key, required this.drama}); final Drama drama; @override State createState() => _DramaCommentsSheetState(); } class _DramaCommentsSheetState extends State { final _input = TextEditingController(); final _inputFocus = FocusNode(); late final CommentController _controller; DramaComment? _replyTo; @override void initState() { super.initState(); _controller = CommentController( comments: context.read(), interactions: context.read(), dramaId: widget.drama.id, ); unawaited(_controller.load()); } @override void dispose() { _controller.dispose(); _input.dispose(); _inputFocus.dispose(); super.dispose(); } Future _post() async { if (_controller.submitting) return; if (!await ensureSignedIn(context) || !mounted) return; if (_input.text.trim().isEmpty) return; unawaited(context.read().lightImpact()); final result = await _controller.publish(_input.text, replyTo: _replyTo); if (!mounted) return; if (result.isSuccess) { _input.clear(); setState(() => _replyTo = null); FocusScope.of(context).unfocus(); return; } _showMessage( result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed, ); } void _beginReply(DramaComment comment) { setState(() => _replyTo = comment); _inputFocus.requestFocus(); } void _cancelReply() { if (_replyTo == null) return; setState(() => _replyTo = null); } Future _authenticateForComment() async { _inputFocus.unfocus(); await ensureSignedIn(context); if (!mounted || !context.read().isSignedIn) return; _inputFocus.requestFocus(); } Future _like(DramaComment comment) async { if (!await ensureSignedIn(context) || !mounted) return; unawaited(context.read().lightImpact()); final result = await _controller.toggleLike(comment); if (!result.isSuccess && mounted) { _showMessage( result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed, ); } } Future _confirmDelete(DramaComment comment) async { final l10n = AppLocalizations.of(context)!; final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(l10n.deleteCommentTitle), content: Text(l10n.deleteCommentMessage), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(l10n.cancel), ), FilledButton( key: const Key('comment-delete-confirm'), onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(l10n.delete), ), ], ), ); if (confirmed != true || !mounted) return; final result = await _controller.delete(comment); if (!result.isSuccess && mounted) { _showMessage( result.errorMessage ?? AppLocalizations.of(context)!.commentDeleteFailed, ); } } Future _report(DramaComment comment) async { if (!await ensureSignedIn(context) || !mounted) return; final l10n = AppLocalizations.of(context)!; final draft = await showReportDialog(context, title: l10n.reportComment); if (draft == null || !mounted) return; unawaited(context.read().lightImpact()); final result = await _controller.report( comment, reason: draft.reason, description: draft.description, ); if (!mounted) return; _showMessage( result.isSuccess ? l10n.reportSubmitted : result.errorMessage ?? l10n.reportFailed, ); } void _showMessage(String message) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } @override Widget build(BuildContext context) { final available = MediaQuery.sizeOf(context).height - MediaQuery.viewInsetsOf(context).bottom - 24; return Padding( padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: Container( key: const Key('player-comments-sheet'), decoration: const BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.vertical( top: Radius.circular(_sheetRadius), ), ), child: SizedBox( height: available.clamp(_minSheetHeight, _maxSheetHeight), child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), child: ListenableBuilder( listenable: _controller, builder: (context, _) => _content(context), ), ), ), ), ), ); } Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; final count = _controller.exactCount; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( key: const Key('player-comments-drag-handle'), width: 36, height: 4, decoration: BoxDecoration( color: AppColors.border, borderRadius: BorderRadius.circular(999), ), ), ), const SizedBox(height: 8), Row( children: [ Expanded( child: Text( count == null ? l10n.comments : l10n.commentsWithCount(count), key: count == null ? null : const Key('comment-count-label'), style: const TextStyle( color: AppColors.onSurface, fontSize: 15, fontWeight: FontWeight.w700, ), ), ), IconButton( key: const Key('player-comments-close'), tooltip: l10n.close, onPressed: () => Navigator.pop(context), visualDensity: VisualDensity.compact, icon: const Icon(Icons.close_rounded, size: 20), ), ], ), Text( widget.drama.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, fontSize: 12, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 8), Expanded(child: _list(l10n)), if (_replyTo != null) _replyBanner(l10n, _replyTo!), _composer(l10n), ], ); } Widget _list(AppLocalizations l10n) { if (_controller.loading && _controller.comments.isEmpty) { return const Center( child: CircularProgressIndicator(key: Key('comment-list-loading')), ); } if (_controller.error != null && _controller.comments.isEmpty) { return EmptyState( key: const Key('comment-list-error'), icon: Icons.cloud_off_rounded, title: errorText(l10n, _controller.error, l10n.commentsLoadFailed), actionLabel: l10n.retry, actionKey: const Key('comment-list-retry'), onAction: _controller.load, compact: true, ); } if (_controller.comments.isEmpty) { return EmptyState( key: const Key('comment-list-empty'), icon: Icons.chat_bubble_outline_rounded, title: l10n.noComments, compact: true, ); } return ListView.builder( key: const Key('comment-list'), itemCount: _controller.comments.length + 1, itemBuilder: (context, index) { if (index == _controller.comments.length) return _footer(l10n); return _CommentThread( comment: _controller.comments[index], controller: _controller, handlers: CommentInteractionHandlers( onReply: _beginReply, onLike: _like, onDelete: _confirmDelete, onReport: _report, ), ); }, ); } Widget _footer(AppLocalizations l10n) { if (_controller.loadingMore) { return const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Center(child: CircularProgressIndicator()), ); } if (_controller.hasMore) { return TextButton( key: const Key('comment-list-load-more'), onPressed: _controller.loadMore, child: Text(l10n.loadMore), ); } return const SizedBox(height: 8); } Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => Padding( padding: const EdgeInsets.only(bottom: 6), child: Row( children: [ Expanded( child: Text( l10n.replyingTo( replyTo.nickname.isEmpty ? l10n.anonymousUser : replyTo.nickname, ), style: const TextStyle(color: AppColors.muted, fontSize: 12), ), ), IconButton( key: const Key('comment-cancel-reply'), tooltip: l10n.cancelReply, onPressed: _cancelReply, visualDensity: VisualDensity.compact, icon: const Icon(Icons.close_rounded, size: 18), ), ], ), ); InputDecoration _composerDecoration(String hint) { const radius = BorderRadius.all(Radius.circular(_composerRadius)); const clear = OutlineInputBorder( borderRadius: radius, borderSide: BorderSide.none, ); return InputDecoration( hintText: hint, hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), counterText: '', filled: true, fillColor: AppColors.commentInput, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), isDense: true, border: clear, enabledBorder: clear, focusedBorder: clear, ); } Widget _composer(AppLocalizations l10n) { final signedIn = context.watch().isSignedIn; return Row( children: [ Expanded( child: SizedBox( height: _composerHeight, child: TextField( key: const Key('comment-input'), controller: _input, focusNode: _inputFocus, readOnly: !signedIn, canRequestFocus: signedIn, maxLength: _maxCommentLength, maxLines: 1, textAlignVertical: TextAlignVertical.center, style: const TextStyle(color: AppColors.cream, fontSize: 14), cursorColor: AppColors.blue, decoration: _composerDecoration( signedIn ? l10n.writeComment : l10n.commentLoginHint, ), onTap: signedIn ? null : _authenticateForComment, onSubmitted: (_) => _post(), ), ), ), const SizedBox(width: 8), SizedBox.square( dimension: _sendButtonSize, child: IconButton.filled( key: const Key('comment-post'), tooltip: l10n.postComment, padding: EdgeInsets.zero, iconSize: 18, onPressed: _controller.submitting ? null : _post, icon: _controller.submitting ? const SizedBox.square( dimension: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.send_rounded), ), ), ], ); } } class _CommentThread extends StatelessWidget { const _CommentThread({ required this.comment, required this.controller, required this.handlers, }); final DramaComment comment; final CommentController controller; final CommentInteractionHandlers handlers; @override Widget build(BuildContext context) { final replies = controller.repliesFor(comment.id); final expanded = controller.repliesExpanded(comment.id); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ CommentTile( comment: comment, controller: controller, handlers: handlers, ), if (comment.replyCount > 0 || replies.isNotEmpty) Padding( padding: const EdgeInsets.only(left: 42, bottom: 4), child: TextButton( key: Key('comment-replies-${comment.id}'), onPressed: () => _toggleReplies(context), style: TextButton.styleFrom( foregroundColor: AppColors.blue, textStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, ), padding: const EdgeInsets.symmetric(horizontal: 4), minimumSize: const Size(0, 28), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: Text( expanded ? AppLocalizations.of(context)!.hideReplies : AppLocalizations.of( context, )!.viewReplies(comment.replyCount), ), ), ), if (expanded) Padding( padding: const EdgeInsets.only(left: 28), child: Column( children: [ if (controller.repliesLoading(comment.id)) const Padding( padding: EdgeInsets.symmetric(vertical: 8), child: CircularProgressIndicator(), ), for (final reply in replies) CommentTile( comment: reply, controller: controller, handlers: handlers, isReply: true, ), if (controller.repliesHasMore(comment.id)) TextButton( onPressed: () => controller.loadMoreReplies(comment.id), child: Text(AppLocalizations.of(context)!.loadMore), ), ], ), ), const Padding( padding: EdgeInsets.only(left: 42), child: Divider(height: 1, color: AppColors.border), ), ], ); } Future _toggleReplies(BuildContext context) async { if (controller.repliesExpanded(comment.id)) { controller.hideReplies(comment.id); return; } await controller.loadReplies(comment.id); } }