// 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/formatting/error_text.dart'; import '../../../core/platform/haptics.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/auth_gate.dart'; import '../../../l10n/app_localizations.dart'; import '../../interaction/widgets/report_dialog.dart'; import 'comment_tile.dart'; const double _minSheetHeight = 240; const double _maxSheetHeight = 460; const int _maxCommentLength = 300; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 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 (_input.text.trim().isEmpty) return; if (!await ensureSignedIn(context) || !mounted) 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); 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 _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: SizedBox( height: available.clamp(_minSheetHeight, _maxSheetHeight), child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: ListenableBuilder( listenable: _controller, builder: (context, _) => _content(context), ), ), ), ), ); } Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.comments, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), ), 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 Center( key: const Key('comment-list-error'), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( errorText(l10n, _controller.error, l10n.commentsLoadFailed), textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted), ), const SizedBox(height: 8), TextButton( key: const Key('comment-list-retry'), onPressed: _controller.load, child: Text(l10n.retry), ), ], ), ); } if (_controller.comments.isEmpty) { return Center( key: const Key('comment-list-empty'), child: Text( l10n.noComments, style: const TextStyle(color: AppColors.muted), ), ); } 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.blueSoft, 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), ), ], ), ); Widget _composer(AppLocalizations l10n) => Row( children: [ Expanded( child: TextField( key: const Key('comment-input'), controller: _input, focusNode: _inputFocus, maxLength: _maxCommentLength, maxLines: 1, decoration: InputDecoration( hintText: l10n.writeComment, counterText: '', filled: true, fillColor: const Color(0xFF35353A), border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none, ), ), onSubmitted: (_) => _post(), ), ), const SizedBox(width: 8), IconButton.filled( key: const Key('comment-post'), tooltip: l10n.postComment, onPressed: _controller.submitting ? null : _post, icon: _controller.submitting ? const SizedBox.square( dimension: 18, 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: 44, bottom: 8), child: TextButton( key: Key('comment-replies-${comment.id}'), onPressed: () => _toggleReplies(context), 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 Divider(height: 1), ], ); } Future _toggleReplies(BuildContext context) async { if (controller.repliesExpanded(comment.id)) { controller.hideReplies(comment.id); return; } await controller.loadReplies(comment.id); } }