diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart index 3c567f4..3b17b3c 100644 --- a/lib/pages/player/widgets/comment_tile.dart +++ b/lib/pages/player/widgets/comment_tile.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/comment.dart'; -import '../../../core/data/services/comment_controller.dart'; import '../../../core/formatting/localized_date_time.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; @@ -17,13 +16,11 @@ const String _reportAction = 'report'; class CommentInteractionHandlers { const CommentInteractionHandlers({ required this.onReply, - required this.onLike, required this.onDelete, required this.onReport, }); final ValueChanged onReply; - final Future Function(DramaComment) onLike; final Future Function(DramaComment) onDelete; final Future Function(DramaComment) onReport; } @@ -33,13 +30,11 @@ class CommentTile extends StatelessWidget { const CommentTile({ super.key, required this.comment, - required this.controller, required this.handlers, this.isReply = false, }); final DramaComment comment; - final CommentController controller; final CommentInteractionHandlers handlers; final bool isReply; @@ -51,66 +46,68 @@ class CommentTile extends StatelessWidget { : comment.nickname; return Padding( key: Key('comment-item-${comment.id}'), - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.only(top: 12, bottom: 14), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _avatar(), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( name, style: const TextStyle( - color: AppColors.cream, - fontSize: 13, - fontWeight: FontWeight.w700, + color: AppColors.muted, + fontSize: 12, + fontWeight: FontWeight.w400, ), ), ), - if (comment.createdAt != null) - Text( - formatLocalizedDateTime(context, comment.createdAt!), - style: const TextStyle( + SizedBox.square( + dimension: 28, + child: PopupMenuButton( + key: Key('comment-menu-${comment.id}'), + tooltip: l10n.more, + padding: EdgeInsets.zero, + iconSize: 18, + icon: const Icon( + Icons.more_horiz_rounded, color: AppColors.subtle, - fontSize: 11, ), - ), - const SizedBox(width: 4), - PopupMenuButton( - key: Key('comment-menu-${comment.id}'), - tooltip: l10n.more, - onSelected: (value) { - if (value == _deleteAction) { - handlers.onDelete(comment); - } else if (value == _reportAction) { - handlers.onReport(comment); - } - }, - itemBuilder: (_) => [ - if (comment.mine) + onSelected: (value) { + if (value == _deleteAction) { + handlers.onDelete(comment); + } else if (value == _reportAction) { + handlers.onReport(comment); + } + }, + itemBuilder: (_) => [ + if (comment.mine) + PopupMenuItem( + value: _deleteAction, + child: Text(l10n.deleteComment), + ), PopupMenuItem( - value: _deleteAction, - child: Text(l10n.deleteComment), + value: _reportAction, + child: Text(l10n.reportComment), ), - PopupMenuItem( - value: _reportAction, - child: Text(l10n.reportComment), - ), - ], + ], + ), ), ], ), + const SizedBox(height: 2), Text( comment.content, style: const TextStyle( color: AppColors.cream, - fontSize: 14, + fontSize: 16, height: 1.4, ), ), @@ -126,48 +123,11 @@ class CommentTile extends StatelessWidget { ), ), ), - Row( - children: [ - TextButton.icon( - key: Key('comment-like-${comment.id}'), - style: TextButton.styleFrom( - foregroundColor: AppColors.muted, - padding: const EdgeInsets.symmetric(horizontal: 4), - minimumSize: const Size(0, 28), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: controller.likingComment(comment.id) - ? null - : () => handlers.onLike(comment), - icon: Icon( - comment.liked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - size: 16, - ), - label: Text( - '${comment.likeCount}', - style: const TextStyle(fontSize: 11), - ), - ), - if (!isReply) - TextButton( - key: Key('comment-reply-${comment.id}'), - 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, - ), - onPressed: () => handlers.onReply(comment), - child: Text(l10n.reply), - ), - ], - ), + if (comment.createdAt != null || !isReply) + Padding( + padding: const EdgeInsets.only(top: 6), + child: _metadata(context, l10n), + ), ], ), ), @@ -177,14 +137,14 @@ class CommentTile extends StatelessWidget { } Widget _avatar() { - final size = isReply ? 24.0 : 32.0; + final size = isReply ? 28.0 : 40.0; if (comment.avatarUrl.isEmpty) { return CircleAvatar( radius: size / 2, backgroundColor: AppColors.raised, child: Icon( Icons.person_rounded, - size: isReply ? 14 : 18, + size: isReply ? 16 : 22, color: AppColors.cream, ), ); @@ -197,4 +157,40 @@ class CommentTile extends StatelessWidget { ), ); } + + Widget _metadata(BuildContext context, AppLocalizations l10n) { + final createdAt = comment.createdAt; + return Row( + children: [ + if (createdAt != null) + Text( + formatLocalizedDateTime(context, createdAt), + style: const TextStyle(color: AppColors.subtle, fontSize: 12), + ), + if (createdAt != null && !isReply) ...[ + const SizedBox(width: 8), + const Text( + '·', + style: TextStyle(color: AppColors.muted, fontSize: 12), + ), + const SizedBox(width: 8), + ], + if (!isReply) + TextButton( + key: Key('comment-reply-${comment.id}'), + style: TextButton.styleFrom( + foregroundColor: AppColors.muted, + padding: EdgeInsets.zero, + minimumSize: const Size(0, 24), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => handlers.onReply(comment), + child: Text( + l10n.reply, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w400), + ), + ), + ], + ); + } } diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index c3b4c34..51e6ab1 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -23,12 +23,14 @@ 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 _composerRadius = 22; +const double _composerHeight = 44; +const double _sendButtonSize = 38; const double _sheetRadius = 20; +const double _headerHeight = 44; +const double _replyIndent = 52; -/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 +/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、举报和本人删除。 class DramaCommentsSheet extends StatefulWidget { const DramaCommentsSheet({super.key, required this.drama}); @@ -98,17 +100,6 @@ class _DramaCommentsSheetState extends State { _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( @@ -203,48 +194,40 @@ class _DramaCommentsSheetState extends State { 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( + SizedBox( + height: _headerHeight, + width: double.infinity, + child: Stack( + alignment: Alignment.center, + children: [ + Text( count == null ? l10n.comments : l10n.commentsWithCount(count), - key: count == null ? null : const Key('comment-count-label'), + key: const Key('comment-count-label'), + textAlign: TextAlign.center, style: const TextStyle( color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w700, + fontSize: 16, + fontWeight: FontWeight.w600, ), ), - ), - 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, + 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, + ), + ), + ), + ), + ], ), ), const SizedBox(height: 8), @@ -290,7 +273,6 @@ class _DramaCommentsSheetState extends State { controller: _controller, handlers: CommentInteractionHandlers( onReply: _beginReply, - onLike: _like, onDelete: _confirmDelete, onReport: _report, ), @@ -349,9 +331,7 @@ class _DramaCommentsSheetState extends State { hintText: hint, hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), counterText: '', - filled: true, - fillColor: AppColors.commentInput, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), isDense: true, border: clear, enabledBorder: clear, @@ -361,48 +341,56 @@ class _DramaCommentsSheetState extends State { 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, + return Container( + key: const Key('comment-composer'), + height: _composerHeight, + padding: const EdgeInsets.only(right: 3), + decoration: BoxDecoration( + color: AppColors.commentInput, + borderRadius: BorderRadius.circular(_composerRadius), + ), + child: 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(), ), - 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), + 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), + ), ), - ), - ], + ], + ), ); } } @@ -425,39 +413,51 @@ class _CommentThread extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CommentTile( - comment: comment, - controller: controller, - handlers: handlers, - ), + CommentTile(comment: comment, handlers: handlers), if (comment.replyCount > 0 || replies.isNotEmpty) Padding( - padding: const EdgeInsets.only(left: 42, bottom: 4), + padding: const EdgeInsets.only(left: _replyIndent, 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), + foregroundColor: AppColors.muted, + padding: EdgeInsets.zero, + minimumSize: const Size(0, 24), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - child: Text( - expanded - ? AppLocalizations.of(context)!.hideReplies - : AppLocalizations.of( - context, - )!.viewReplies(comment.replyCount), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 16, height: 1, color: AppColors.border), + const SizedBox(width: 8), + Text( + expanded + ? AppLocalizations.of(context)!.hideReplies + : AppLocalizations.of( + context, + )!.viewReplies(comment.replyCount), + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: 4), + Icon( + expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 14, + color: AppColors.muted, + ), + ], ), ), ), if (expanded) Padding( - padding: const EdgeInsets.only(left: 28), + padding: const EdgeInsets.only(left: _replyIndent), child: Column( children: [ if (controller.repliesLoading(comment.id)) @@ -468,7 +468,6 @@ class _CommentThread extends StatelessWidget { for (final reply in replies) CommentTile( comment: reply, - controller: controller, handlers: handlers, isReply: true, ), @@ -480,10 +479,6 @@ class _CommentThread extends StatelessWidget { ], ), ), - const Padding( - padding: EdgeInsets.only(left: 42), - child: Divider(height: 1, color: AppColors.border), - ), ], ); } diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 9e8cff8..4a256dc 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -129,7 +129,10 @@ void main() { await _pumpSheet(tester, repository: repository); expect(find.text('Great episode'), findsOneWidget); expect(find.byKey(const Key('comment-list-load-more')), findsOneWidget); - expect(find.byKey(const Key('comment-count-label')), findsNothing); + expect( + tester.widget(find.byKey(const Key('comment-count-label'))).data, + 'Comments', + ); await tester.tap(find.byKey(const Key('comment-list-load-more'))); await tester.pumpAndSettle(); @@ -209,30 +212,89 @@ void main() { expect( tester.getSize(find.byKey(const Key('comment-post'))), - const Size(40, 40), + const Size(38, 38), ); - expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 52); + expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 44); + expect(find.byKey(const Key('player-comments-drag-handle')), findsNothing); expect( - find.byKey(const Key('player-comments-drag-handle')), + tester.getSize(find.byKey(const Key('player-comments-close'))), + const Size(44, 44), + ); + expect( + find.descendant( + of: find.byKey(const Key('player-comments-close')), + matching: find.byIcon(Icons.keyboard_arrow_down_rounded), + ), findsOneWidget, ); + final title = tester.widget( + find.byKey(const Key('comment-count-label')), + ); + expect(title.style?.fontSize, 16); + expect(title.style?.fontWeight, FontWeight.w600); + expect( + tester.getCenter(find.byKey(const Key('comment-count-label'))).dx, + closeTo( + tester.getCenter(find.byKey(const Key('player-comments-sheet'))).dx, + 0.5, + ), + ); final avatar = tester.widget( find.descendant( of: find.byKey(const Key('comment-item-10')), matching: find.byType(CircleAvatar), ), ); - expect(avatar.radius, 16); + expect(avatar.radius, 20); final body = tester.widget(find.text('Great episode')); - expect(body.style?.fontSize, 14); + expect(body.style?.fontSize, 16); expect(body.style?.height, 1.4); - expect( - tester - .widget(find.byKey(const Key('comment-input'))) - .decoration - ?.fillColor, - AppColors.commentInput, + final composer = tester.widget( + find.byKey(const Key('comment-composer')), ); + final decoration = composer.decoration! as BoxDecoration; + expect(decoration.color, AppColors.commentInput); + expect(decoration.borderRadius, BorderRadius.circular(22)); + expect( + find.descendant( + of: find.byKey(const Key('comment-menu-10')), + matching: find.byIcon(Icons.more_horiz_rounded), + ), + findsOneWidget, + ); + }); + + testWidgets('should_hide_comment_like_controls', (tester) async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [commentFixture(id: '10', content: 'No likes shown')], + ), + ], + ); + + await _pumpSheet(tester, repository: repository); + + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith('comment-like-'), + ), + findsNothing, + ); + expect(find.byIcon(Icons.favorite_rounded), findsNothing); + expect(find.byIcon(Icons.favorite_border_rounded), findsNothing); + }); + + testWidgets('should_close_sheet_from_down_arrow', (tester) async { + final repository = FakeCommentRepository(pages: [const CommentPage()]); + + await _pumpSheet(tester, repository: repository); + await tester.tap(find.byKey(const Key('player-comments-close'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-comments-sheet')), findsNothing); }); testWidgets('should_show_review_badge_and_server_replaced_content', ( @@ -306,34 +368,6 @@ void main() { expect(find.byKey(const Key('comment-list-retry')), findsOneWidget); }); - testWidgets('should_update_comment_like_count_when_server_returns_count', ( - tester, - ) async { - final repository = FakeCommentRepository( - pages: [ - CommentPage( - comments: [commentFixture(id: '10', content: 'Like me')], - ), - ], - ); - final interactions = FakeInteractionRepository() - ..nextLikeResult = const LikeToggleResult(liked: true, count: 7); - final haptics = FakeHaptics(); - - await _pumpSheet( - tester, - repository: repository, - interactions: interactions, - haptics: haptics, - ); - await tester.tap(find.byKey(const Key('comment-like-10'))); - await tester.pumpAndSettle(); - - expect(interactions.likeTargets, ['COMMENT:10']); - expect(haptics.lightImpacts, 1); - expect(find.text('7'), findsOneWidget); - }); - testWidgets('should_report_comment_and_show_success_when_submitted', ( tester, ) async { @@ -480,10 +514,14 @@ Future _pumpSheet( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: const Scaffold( - backgroundColor: AppColors.ink, - body: DramaCommentsSheet(drama: _drama), - ), + initialRoute: '/comments', + routes: { + '/': (_) => const Scaffold(backgroundColor: AppColors.ink), + '/comments': (_) => const Scaffold( + backgroundColor: AppColors.ink, + body: DramaCommentsSheet(drama: _drama), + ), + }, ), ), );