// Author: fruit.com.cn import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.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/theme/app_colors.dart'; import '../../../core/widgets/app_glow_panel.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../l10n/app_localizations.dart'; import 'comment_composer_sheet.dart'; import 'comment_thread.dart'; import 'comment_tile.dart'; const double _composerRadius = 22; const double _composerHeight = 44; const double _sendButtonSize = 38; const double _sheetRadius = 20; const double _headerHeight = 44; const double _composerTopGap = 8; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复与设备身份发布。 class DramaCommentsSheet extends StatefulWidget { const DramaCommentsSheet({super.key, required this.drama, this.episodeId}); final Drama drama; final String? episodeId; @override State createState() => _DramaCommentsSheetState(); } class _DramaCommentsSheetState extends State { late final CommentRepository _comments; late final CommentController _controller; OverlayEntry? _composerOverlay; OverlayEntry? _successOverlay; Timer? _successOverlayTimer; int? _serverCount; int _countRequest = 0; @override void initState() { super.initState(); _comments = context.read(); _controller = CommentController( comments: _comments, interactions: context.read(), dramaId: widget.drama.id, episodeId: widget.episodeId, ); unawaited(_controller.load()); unawaited(_refreshCount()); } @override void dispose() { _removeComposerOverlay(); _successOverlayTimer?.cancel(); _successOverlay?.remove(); _controller.dispose(); super.dispose(); } void _beginReply(DramaComment comment) { unawaited(_openComposer(replyTo: comment)); } Future _refreshCount() async { final request = ++_countRequest; try { final count = await _comments.count( widget.drama.id, episodeId: widget.episodeId, ); if (!mounted || request != _countRequest) return; setState(() => _serverCount = count); } catch (_) { if (!mounted || request != _countRequest) return; setState(() => _serverCount = null); } } Future _openComposer({DramaComment? replyTo}) async { if (!mounted) return; _removeComposerOverlay(); final entry = OverlayEntry( builder: (_) => CommentComposerSheet( controller: _controller, replyTo: replyTo, onDismiss: _closeComposer, onPosted: _handleComposerPosted, ), ); setState(() => _composerOverlay = entry); Overlay.of(context, rootOverlay: true).insert(entry); } void _handleComposerPosted() { _closeComposer(); _showPostSuccess(); unawaited(_refreshCount()); } void _closeComposer() { if (!mounted) { _removeComposerOverlay(); return; } setState(_removeComposerOverlay); } void _removeComposerOverlay() { final entry = _composerOverlay; _composerOverlay = null; if (entry?.mounted ?? false) entry!.remove(); FocusManager.instance.primaryFocus?.unfocus(); } void _handleDismissLayerTap() { Navigator.pop(context); } void _showPostSuccess() { _successOverlayTimer?.cancel(); _successOverlay?.remove(); final l10n = AppLocalizations.of(context)!; final entry = OverlayEntry( builder: (_) => _CommentPostSuccessOverlay(message: l10n.commentPostSuccess), ); _successOverlay = entry; Overlay.of(context, rootOverlay: true).insert(entry); _successOverlayTimer = Timer(const Duration(milliseconds: 1200), () { if (entry.mounted) entry.remove(); if (identical(_successOverlay, entry)) _successOverlay = null; }); } double _sheetHeight(BuildContext context) { final screenHeight = MediaQuery.sizeOf(context).height; final topSafeArea = MediaQuery.paddingOf(context).top; final halfHeight = (screenHeight * 0.5).roundToDouble(); final maxHeight = math.max(0.0, screenHeight - topSafeArea); return math.min(halfHeight, maxHeight); } @override Widget build(BuildContext context) { final sheetHeight = _sheetHeight(context); final content = Stack( fit: StackFit.expand, children: [ Positioned.fill( child: GestureDetector( key: const Key('player-comments-dismiss-layer'), behavior: HitTestBehavior.opaque, onTap: _handleDismissLayerTap, ), ), Align( alignment: Alignment.bottomCenter, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () {}, child: KeyedSubtree( key: const Key('player-comments-sheet'), child: SizedBox( key: const Key('comment-sheet-constraints'), height: sheetHeight, child: AppGlowPanel( borderRadius: const BorderRadius.vertical( top: Radius.circular(_sheetRadius), ), border: false, child: Container( decoration: const BoxDecoration(color: AppColors.transparent), child: SafeArea( top: false, child: ListenableBuilder( listenable: _controller, builder: (context, _) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: _content(context), ); }, ), ), ), ), ), ), ), ), ], ); return PopScope( canPop: _composerOverlay == null, onPopInvokedWithResult: (didPop, _) { if (!didPop && _composerOverlay != null) { FocusManager.instance.primaryFocus?.unfocus(); unawaited( SystemChannels.textInput.invokeMethod('TextInput.hide'), ); } }, child: content, ); } Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; final count = _serverCount ?? _controller.exactCount; final composerReserve = _composerHeight + _composerTopGap; return Column( key: const Key('comment-sheet-content'), mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: _headerHeight, width: double.infinity, child: Stack( alignment: Alignment.center, children: [ Text( count == null ? l10n.comments : l10n.commentsWithCount(count), key: const Key('comment-count-label'), textAlign: TextAlign.center, style: const TextStyle( color: AppColors.onSurface, fontSize: 16, fontWeight: FontWeight.w400, ), ), Positioned( left: 0, child: SizedBox.square( dimension: 44, child: IconButton( key: const Key('player-comments-close'), tooltip: l10n.close, padding: EdgeInsets.zero, onPressed: () => Navigator.pop(context), icon: const Icon( Icons.keyboard_arrow_down_rounded, size: 24, color: AppColors.cream, ), ), ), ), ], ), ), Expanded(child: _list(l10n, composerReserve: composerReserve)), Padding( key: const Key('comment-composer-area'), padding: const EdgeInsets.only(top: _composerTopGap), child: _fakeComposer(l10n), ), ], ); } Widget _list(AppLocalizations l10n, {required double composerReserve}) { if (_controller.loading && _controller.comments.isEmpty) { return const Padding( padding: EdgeInsets.only(top: 4), child: Center( child: CircularProgressIndicator(key: Key('comment-list-loading')), ), ); } if (_controller.error != null && _controller.comments.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 4), child: 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 Padding( padding: const EdgeInsets.only(top: 4), child: 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'), padding: EdgeInsets.only(top: 4, bottom: composerReserve), 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), ); }, ); } 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), ); } if (_controller.comments.isNotEmpty) { return Padding( key: const Key('paged-list-no-more'), padding: const EdgeInsets.symmetric(vertical: 12), child: Text( l10n.noMoreItems, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.textHint, fontSize: 12), ), ); } return const SizedBox(height: 8); } Widget _fakeComposer(AppLocalizations l10n) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: _openComposer, child: Container( key: const Key('comment-input-placeholder'), height: _composerHeight, padding: const EdgeInsets.only(right: 6), decoration: BoxDecoration( color: AppColors.commentInput, borderRadius: BorderRadius.circular(_composerRadius), ), child: Row( children: [ Expanded( child: Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only(left: 16), child: Text( l10n.writeComment, key: const Key('comment-input-placeholder-label'), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, fontSize: 14, height: 1, ), ), ), ), ), SizedBox.square( dimension: _sendButtonSize, child: IconButton.filled( key: const Key('comment-post'), tooltip: l10n.postComment, padding: EdgeInsets.zero, iconSize: 18, onPressed: _openComposer, icon: const Icon(Icons.send_rounded), ), ), ], ), ), ); } } class _CommentPostSuccessOverlay extends StatelessWidget { const _CommentPostSuccessOverlay({required this.message}); final String message; @override Widget build(BuildContext context) { return Positioned.fill( child: IgnorePointer( child: Align( alignment: const Alignment(0, -0.4), child: Semantics( liveRegion: true, child: Material( type: MaterialType.transparency, child: Container( key: const Key('comment-post-success-snackbar'), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), decoration: BoxDecoration( color: AppColors.mediaBlack.withValues(alpha: 0.82), borderRadius: BorderRadius.circular(8), ), child: Text( message, textAlign: TextAlign.center, style: const TextStyle( color: AppColors.onPrimary, fontSize: 14, decoration: TextDecoration.none, decorationColor: AppColors.transparent, ), ), ), ), ), ), ), ); } }