diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart index d4016cb..bfc2719 100644 --- a/lib/pages/player/widgets/comment_tile.dart +++ b/lib/pages/player/widgets/comment_tile.dart @@ -46,7 +46,7 @@ class CommentTile extends StatelessWidget { : comment.nickname; return Padding( key: Key('comment-item-${comment.id}'), - padding: const EdgeInsets.only(top: 12, bottom: 14), + padding: const EdgeInsets.only(top: 10, bottom: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -125,7 +125,7 @@ class CommentTile extends StatelessWidget { ), if (comment.createdAt != null || !isReply) Padding( - padding: const EdgeInsets.only(top: 6), + padding: const EdgeInsets.only(top: 4), child: _metadata(context, l10n), ), ], diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 70d6c0c..33dc0bc 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -29,6 +29,8 @@ const double _sendButtonSize = 38; const double _sheetRadius = 20; const double _headerHeight = 44; const double _replyIndent = 52; +const double _composerTopGap = 8; +const double _replyBannerHeight = 46; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复、举报和本人删除。 class DramaCommentsSheet extends StatefulWidget { @@ -178,114 +180,170 @@ class _DramaCommentsSheetState extends State { @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), + final screenLimit = MediaQuery.sizeOf(context).height - 24; + final maxHeight = screenLimit < _maxSheetHeight + ? screenLimit + : _maxSheetHeight; + return Container( + key: const Key('player-comments-sheet'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + ), + child: SafeArea( + top: false, + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final minHeight = _controller.comments.isEmpty + ? (_minSheetHeight < maxHeight ? _minSheetHeight : maxHeight) + : 0.0; + return ConstrainedBox( + key: const Key('comment-sheet-constraints'), + constraints: BoxConstraints( + minHeight: minHeight, + maxHeight: maxHeight, ), - ), - ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _content(context, maxHeight), + ), + ); + }, ), ), ); } - Widget _content(BuildContext context) { + Widget _content(BuildContext context, double maxHeight) { final l10n = AppLocalizations.of(context)!; final count = _serverCount ?? _controller.exactCount; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + final replyReserve = _replyTo == null ? 0.0 : _replyBannerHeight; + final listMaxHeight = + maxHeight - + _headerHeight - + _composerHeight - + _composerTopGap - + replyReserve; + return Stack( 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.w600, - ), - ), - 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, + 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.w600, ), ), - ), + 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, + ), + ), + ), + ), + ], ), - ], + ), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: listMaxHeight < 0 ? 0 : listMaxHeight, + ), + child: _list(l10n), + ), + if (_replyTo != null) _replyBanner(l10n, _replyTo!), + const SizedBox( + height: _composerHeight + _composerTopGap, + ), + ], + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: AnimatedPadding( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: Padding( + padding: const EdgeInsets.only(top: _composerTopGap), + child: _composer(l10n), + ), ), ), - const SizedBox(height: 8), - Expanded(child: _list(l10n)), - if (_replyTo != null) _replyBanner(l10n, _replyTo!), - _composer(l10n), ], ); } Widget _list(AppLocalizations l10n) { + final keyboardHeight = MediaQuery.viewInsetsOf(context).bottom; if (_controller.loading && _controller.comments.isEmpty) { - return const Center( - child: CircularProgressIndicator(key: Key('comment-list-loading')), + 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 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, + 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 EmptyState( - key: const Key('comment-list-empty'), - icon: Icons.chat_bubble_outline_rounded, - title: l10n.noComments, - compact: true, + 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'), + shrinkWrap: true, + padding: EdgeInsets.only( + top: 4, + bottom: _composerHeight + keyboardHeight, + ), itemCount: _controller.comments.length + 1, itemBuilder: (context, index) { if (index == _controller.comments.length) return _footer(l10n); @@ -319,26 +377,31 @@ class _DramaCommentsSheetState extends State { 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, + Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => SizedBox( + height: _replyBannerHeight, + child: 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), ), - 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), - ), - ], + IconButton( + key: const Key('comment-cancel-reply'), + tooltip: l10n.cancelReply, + onPressed: _cancelReply, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close_rounded, size: 18), + ), + ], + ), ), ); diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index fc8bf32..fb64efc 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -305,6 +305,101 @@ void main() { ); }); + testWidgets('should_size_short_list_to_content', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [ + commentFixture(id: '10', content: 'Short list one'), + commentFixture(id: '11', content: 'Short list two'), + ], + ), + ], + ); + + await _pumpSheet(tester, repository: repository); + + final sheet = find.byKey(const Key('player-comments-sheet')); + expect(tester.getSize(sheet).height, lessThan(520)); + final lastCommentBottom = tester + .getBottomLeft(find.byKey(const Key('comment-item-11'))) + .dy; + final composerTop = tester + .getTopLeft(find.byKey(const Key('comment-composer'))) + .dy; + expect(composerTop - lastCommentBottom, lessThan(80)); + }); + + testWidgets('should_keep_minimum_height_for_empty_state', (tester) async { + final repository = FakeCommentRepository(pages: [const CommentPage()]); + + await _pumpSheet(tester, repository: repository); + + expect( + tester.getSize(find.byKey(const Key('player-comments-sheet'))).height, + greaterThanOrEqualTo(220), + ); + }); + + testWidgets('should_keep_long_comment_list_scrollable', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final repository = FakeCommentRepository( + pages: [CommentPage(comments: _comments(30))], + ); + + await _pumpSheet(tester, repository: repository); + final scrolledComment = find.byKey(const Key('comment-item-12')); + final before = tester.getTopLeft(scrolledComment).dy; + + await tester.drag( + find.byKey(const Key('comment-list')), + const Offset(0, -300), + ); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(scrolledComment).dy, lessThan(before)); + expect( + tester.getSize(find.byKey(const Key('player-comments-sheet'))).height, + 520, + ); + }); + + testWidgets('should_keep_sheet_fixed_and_move_only_composer_for_keyboard', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + final repository = FakeCommentRepository( + pages: [CommentPage(comments: _comments(30))], + ); + + await _pumpSheet(tester, repository: repository); + final sheet = find.byKey(const Key('player-comments-sheet')); + final input = find.byKey(const Key('comment-composer')); + final first = find.byKey(const Key('comment-item-10')); + final sheetTop = tester.getTopLeft(sheet).dy; + final inputTop = tester.getTopLeft(input).dy; + final firstTop = tester.getTopLeft(first).dy; + + tester.view.viewInsets = const FakeViewPadding(bottom: 180); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 160)); + + expect(tester.getTopLeft(sheet).dy, closeTo(sheetTop, 0.1)); + expect(tester.getTopLeft(first).dy, closeTo(firstTop, 0.1)); + expect(tester.getTopLeft(input).dy, lessThan(inputTop - 100)); + }); + testWidgets('should_hide_comment_like_controls', (tester) async { final repository = FakeCommentRepository( pages: [ @@ -560,7 +655,11 @@ Future _pumpSheet( '/': (_) => const Scaffold(backgroundColor: AppColors.ink), '/comments': (_) => const Scaffold( backgroundColor: AppColors.ink, - body: DramaCommentsSheet(drama: _drama), + resizeToAvoidBottomInset: false, + body: Align( + alignment: Alignment.bottomCenter, + child: DramaCommentsSheet(drama: _drama), + ), ), }, ), @@ -602,6 +701,11 @@ Future _auth() async { return auth; } +List _comments(int count) => [ + for (var index = 0; index < count; index++) + commentFixture(id: '${index + 10}', content: 'Comment ${index + 1}'), +]; + class _TestAuthController extends AuthController { _TestAuthController(super.api, super.store, {required this._signedIn});