From bed6bbb75bf552e476256d726de3aa28c6a19b7a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 12:06:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E6=92=AD=E6=94=BE=E9=A1=B5?= =?UTF-8?q?=E4=BA=92=E5=8A=A8=E6=95=B0=E9=87=8F=E4=B8=8E=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=94=B6=E7=B4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/models/drama.dart | 16 +++++ lib/core/formatting/count_format.dart | 9 +++ lib/pages/player/player_view.dart | 19 +++++- lib/pages/player/player_view_actions.dart | 10 ++- lib/pages/player/widgets/comment_tile.dart | 10 ++- .../player/widgets/drama_comments_sheet.dart | 64 +++++++++++-------- lib/pages/player/widgets/player_overlay.dart | 1 + .../player/widgets/player_overlay_parts.dart | 12 +++- test/comment/drama_comments_sheet_test.dart | 48 ++++++++++++++ test/core/count_format_test.dart | 18 ++++++ test/core/json_id_parsing_test.dart | 23 ++++++- test/player/player_fullscreen_test.dart | 41 +++++++++++- test/support/fake_catalog.dart | 53 +++++++++------ 13 files changed, 265 insertions(+), 59 deletions(-) create mode 100644 lib/core/formatting/count_format.dart create mode 100644 test/core/count_format_test.dart diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index d1b41c3..adf6f75 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -65,6 +65,9 @@ class Drama { this.freeEpisodes = 0, this.isHot = false, this.viewCount = 0, + this.likeCount, + this.favoriteCount, + this.commentCount, this.language = '', this.detailLoaded = false, }); @@ -81,6 +84,11 @@ class Drama { final bool isHot; final int viewCount; + /// 详情下发的互动数量;卡片或旧接口没有这些字段时为 null(客户端不显示数字)。 + final int? likeCount; + final int? favoriteCount; + final int? commentCount; + /// 卡片语种(如 zh-CN / id),用于剧场语言筛选。 final String language; @@ -108,6 +116,9 @@ class Drama { isHot: json['isHot'] == true, // viewCount 后端是 Long,按字符串下发(见 json_numbers.dart) viewCount: jsonToInt(json['viewCount']), + likeCount: _optionalInt(json['likeCount']), + favoriteCount: _optionalInt(json['favoriteCount']), + commentCount: _optionalInt(json['commentCount']), language: json['language'] as String? ?? '', ); } @@ -125,6 +136,9 @@ class Drama { freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0, isHot: json['isHot'] == true, viewCount: jsonToInt(json['viewCount']), + likeCount: _optionalInt(json['likeCount']), + favoriteCount: _optionalInt(json['favoriteCount']), + commentCount: _optionalInt(json['commentCount']), language: json['language'] as String? ?? '', detailLoaded: true, episodes: (json['episodes'] as List? ?? const []) @@ -134,3 +148,5 @@ class Drama { ); } } + +int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value); diff --git a/lib/core/formatting/count_format.dart b/lib/core/formatting/count_format.dart new file mode 100644 index 0000000..c360d7e --- /dev/null +++ b/lib/core/formatting/count_format.dart @@ -0,0 +1,9 @@ +// Author: fruit.com.cn + +/// 互动数量展示:不足一万显示原数,一万及以上显示一位小数的「万」。 +/// +/// 例:`3200` → `3200`,`12400` → `1.2万`。 +String formatInteractionCount(int count) { + if (count < 10000) return '$count'; + return '${(count / 10000).toStringAsFixed(1)}万'; +} diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 431461b..3eeb45d 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -83,6 +83,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { bool _chromeVisible = true; double _playbackSpeed = 1.0; + /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 + final Map _favoriteDelta = {}; + @override void initState() { super.initState(); @@ -127,6 +130,14 @@ class _PlayerPageState extends State with WidgetsBindingObserver { setState(() => _chromeVisible = false); } + /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 + void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { + setState(() { + _favoriteDelta[dramaId] = + (_favoriteDelta[dramaId] ?? 0) + (nowSaved ? 1 : -1); + }); + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -377,9 +388,11 @@ class _PlayerPageState extends State with WidgetsBindingObserver { episode: episode, controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), - likeCount: interactions.likeCount(drama.id), - commentCount: null, - favoriteCount: null, + likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, + commentCount: drama.commentCount, + favoriteCount: drama.favoriteCount == null + ? null + : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), saved: context.watch().isSaved(drama.id), playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index ca874c1..3f9fd81 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -100,8 +100,16 @@ extension _PlayerViewActions on _PlayerPageState { final favorites = context.read(); if (!await ensureSignedIn(context) || !context.mounted) return; unawaited(context.read().lightImpact()); + final wasSaved = favorites.isSaved(drama.id); final ok = await favorites.toggle(drama.id, card: drama); - if (ok || !context.mounted) return; + if (!context.mounted) return; + if (ok) { + final nowSaved = favorites.isSaved(drama.id); + if (nowSaved != wasSaved) { + _applyFavoriteDelta(drama.id, nowSaved: nowSaved); + } + return; + } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.favoriteToggleFailed), diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart index 7214bbb..9409a3c 100644 --- a/lib/pages/player/widgets/comment_tile.dart +++ b/lib/pages/player/widgets/comment_tile.dart @@ -107,7 +107,11 @@ class CommentTile extends StatelessWidget { ), Text( comment.content, - style: const TextStyle(color: AppColors.cream, fontSize: 14), + style: const TextStyle( + color: AppColors.cream, + fontSize: 13, + height: 1.2, + ), ), if (comment.isPending) Padding( @@ -153,14 +157,14 @@ class CommentTile extends StatelessWidget { } Widget _avatar() { - final size = isReply ? 24.0 : 32.0; + final size = isReply ? 22.0 : 28.0; if (comment.avatarUrl.isEmpty) { return CircleAvatar( radius: size / 2, backgroundColor: AppColors.raised, child: Icon( Icons.person_rounded, - size: isReply ? 14 : 18, + size: isReply ? 13 : 16, color: AppColors.cream, ), ); diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index b23ee01..9bb397c 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -22,7 +22,9 @@ import 'comment_tile.dart'; const double _minSheetHeight = 280; const double _maxSheetHeight = 520; const int _maxCommentLength = 300; -const double _composerRadius = 22; +const double _composerRadius = 20; +const double _composerHeight = 40; +const double _sendButtonSize = 32; const double _sheetRadius = 20; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 @@ -70,6 +72,7 @@ class _DramaCommentsSheetState extends State { if (result.isSuccess) { _input.clear(); setState(() => _replyTo = null); + FocusScope.of(context).unfocus(); return; } _showMessage( @@ -174,7 +177,7 @@ class _DramaCommentsSheetState extends State { child: SafeArea( top: false, child: Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), child: ListenableBuilder( listenable: _controller, builder: (context, _) => _content(context), @@ -200,7 +203,7 @@ class _DramaCommentsSheetState extends State { key: count == null ? null : const Key('comment-count-label'), style: const TextStyle( color: AppColors.onSurface, - fontSize: 20, + fontSize: 18, fontWeight: FontWeight.w800, ), ), @@ -223,7 +226,7 @@ class _DramaCommentsSheetState extends State { fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), Expanded(child: _list(l10n)), if (_replyTo != null) _replyBanner(l10n, _replyTo!), _composer(l10n), @@ -323,11 +326,12 @@ class _DramaCommentsSheetState extends State { ); return InputDecoration( hintText: hint, - hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), + hintStyle: const TextStyle(color: AppColors.muted, fontSize: 13), counterText: '', filled: true, fillColor: AppColors.raised, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + isDense: true, border: clear, enabledBorder: clear, focusedBorder: clear, @@ -337,29 +341,37 @@ class _DramaCommentsSheetState extends State { Widget _composer(AppLocalizations l10n) => Row( children: [ Expanded( - child: TextField( - key: const Key('comment-input'), - controller: _input, - focusNode: _inputFocus, - maxLength: _maxCommentLength, - maxLines: 1, - style: const TextStyle(color: AppColors.cream, fontSize: 14), - cursorColor: AppColors.blue, - decoration: _composerDecoration(l10n.writeComment), - onSubmitted: (_) => _post(), + child: SizedBox( + height: _composerHeight, + child: TextField( + key: const Key('comment-input'), + controller: _input, + focusNode: _inputFocus, + maxLength: _maxCommentLength, + maxLines: 1, + style: const TextStyle(color: AppColors.cream, fontSize: 13), + cursorColor: AppColors.blue, + decoration: _composerDecoration(l10n.writeComment), + 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), + 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), + ), ), ], ); diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 7f1cf7c..1553013 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; +import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 9d6712b..a849d67 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -138,7 +138,9 @@ class _Rail extends StatelessWidget { _RailButton( key: const Key('player-rail-save'), icon: saved ? Icons.star_rounded : Icons.star_border_rounded, - label: favoriteCount?.toString() ?? l10n.favorite, + label: favoriteCount == null + ? l10n.favorite + : formatInteractionCount(favoriteCount!), active: saved, onTap: onSave, ), @@ -146,14 +148,18 @@ class _Rail extends StatelessWidget { _RailButton( key: const Key('player-rail-comments'), icon: Icons.chat_bubble_outline_rounded, - label: commentCount?.toString() ?? l10n.comments, + label: commentCount == null + ? l10n.comments + : formatInteractionCount(commentCount!), onTap: onComments, ), const SizedBox(height: _railGap), _RailButton( key: const Key('player-rail-like'), icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - label: likeCount?.toString() ?? l10n.like, + label: likeCount == null + ? l10n.like + : formatInteractionCount(likeCount!), active: liked, onTap: onLike, ), diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 933e16e..7fab8df 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -106,9 +106,57 @@ void main() { find.byKey(const Key('comment-input')), ); expect(field.controller?.text, 'blocked text'); + // 发送失败时保留输入与焦点,用户可以直接改完再发。 + expect(field.focusNode?.hasFocus, isTrue); }, ); + testWidgets('should_unfocus_input_after_successful_post', (tester) async { + final repository = FakeCommentRepository( + pages: [const CommentPage()], + publishResult: commentFixture(id: '11', content: 'Nice demo', mine: true), + ); + + await _pumpSheet(tester, repository: repository); + final input = find.byKey(const Key('comment-input')); + await tester.tap(input); + await tester.pump(); + expect(tester.widget(input).focusNode?.hasFocus, isTrue); + await tester.enterText(input, 'Nice demo'); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); + + expect(tester.widget(input).focusNode?.hasFocus, isFalse); + }); + + testWidgets('should_use_compact_comment_sizes', (tester) async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [commentFixture(id: '10', content: 'Great episode')], + ), + ], + ); + + await _pumpSheet(tester, repository: repository); + + expect( + tester.getSize(find.byKey(const Key('comment-post'))), + const Size(32, 32), + ); + expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 40); + final avatar = tester.widget( + find.descendant( + of: find.byKey(const Key('comment-item-10')), + matching: find.byType(CircleAvatar), + ), + ); + expect(avatar.radius, 14); + final body = tester.widget(find.text('Great episode')); + expect(body.style?.fontSize, 13); + expect(body.style?.height, 1.2); + }); + testWidgets('should_show_review_badge_and_server_replaced_content', ( tester, ) async { diff --git a/test/core/count_format_test.dart b/test/core/count_format_test.dart new file mode 100644 index 0000000..e80781b --- /dev/null +++ b/test/core/count_format_test.dart @@ -0,0 +1,18 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/formatting/count_format.dart'; + +void main() { + test('should_keep_raw_number_below_ten_thousand', () { + expect(formatInteractionCount(0), '0'); + expect(formatInteractionCount(96), '96'); + expect(formatInteractionCount(9999), '9999'); + }); + + test('should_use_one_decimal_wan_from_ten_thousand', () { + expect(formatInteractionCount(10000), '1.0万'); + expect(formatInteractionCount(12400), '1.2万'); + expect(formatInteractionCount(123456), '12.3万'); + }); +} diff --git a/test/core/json_id_parsing_test.dart b/test/core/json_id_parsing_test.dart index 2fbaa11..9974d7f 100644 --- a/test/core/json_id_parsing_test.dart +++ b/test/core/json_id_parsing_test.dart @@ -46,10 +46,18 @@ void main() { }); test('should_parse_view_count_when_backend_sends_number', () { - final drama = Drama.fromCardJson({'id': 5, 'title': 'T', 'viewCount': 1200}); + final drama = Drama.fromCardJson({ + 'id': 5, + 'title': 'T', + 'viewCount': 1200, + }); expect(drama.id, '5'); expect(drama.viewCount, 1200); + // 卡片没有互动数量时保持未知,界面才回退到文字标签。 + expect(drama.likeCount, isNull); + expect(drama.favoriteCount, isNull); + expect(drama.commentCount, isNull); }); test('should_parse_detail_payload_when_ids_are_strings', () { @@ -59,14 +67,25 @@ void main() { 'totalEpisodes': 12, 'freeEpisodes': 3, 'viewCount': '99', + 'likeCount': '12400', + 'favoriteCount': '3200', + 'commentCount': '96', 'episodes': [ - {'id': '2111012753262120963', 'number': 1, 'title': 'E1', 'isFree': true}, + { + 'id': '2111012753262120963', + 'number': 1, + 'title': 'E1', + 'isFree': true, + }, ], }); expect(drama.id, '2111012753262120962'); expect(drama.freeEpisodes, 3); expect(drama.viewCount, 99); + expect(drama.likeCount, 12400); + expect(drama.favoriteCount, 3200); + expect(drama.commentCount, 96); expect(drama.episodes.first.id, '2111012753262120963'); expect(drama.episodes.first.number, 1); }); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 5348892..3928ed4 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/interaction.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; @@ -285,6 +286,43 @@ void main() { expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); }); + testWidgets('should_show_rail_counts_from_detail_and_follow_server_results', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', + }); + await _pumpApp( + tester, + interactions: FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 23000), + ), + ); + await _openSeriesPlayer(tester); + + String railLabel(Key key) => tester + .widget( + find.descendant(of: find.byKey(key), matching: find.byType(Text)), + ) + .data!; + expect(railLabel(const Key('player-rail-like')), '1.2万'); + expect(railLabel(const Key('player-rail-save')), '3200'); + expect(railLabel(const Key('player-rail-comments')), '96'); + // 分享没有数量,仍是文字标签。 + expect(railLabel(const Key('player-rail-share')), 'Share'); + + await tester.tap(find.byKey(const Key('player-rail-like'))); + await tester.pumpAndSettle(); + expect(railLabel(const Key('player-rail-like')), '2.3万'); + + await tester.tap(find.byKey(const Key('player-rail-save'))); + await tester.pumpAndSettle(); + expect(railLabel(const Key('player-rail-save')), '3201'); + }); + testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); @@ -784,6 +822,7 @@ Future _pumpApp( Size size = const Size(390, 844), FakeCatalogRepository? catalog, FakePlaybackRepository? playback, + FakeInteractionRepository? interactions, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -799,7 +838,7 @@ Future _pumpApp( favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), playbackRepository: playback ?? FakePlaybackRepository(), commentRepository: FakeCommentRepository(), - interactionRepository: FakeInteractionRepository(), + interactionRepository: interactions ?? FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), haptics: FakeHaptics(), ), diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 4b49b52..799ac78 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -50,27 +50,40 @@ final List demoDramas = [ 'City Lights & Velvet Nights', 'Urban Romance', format: 8, + likeCount: 12400, + favoriteCount: 3200, + commentCount: 96, ), _drama('vengeance-demo', 'Vengeance Rising', 'Action · Thriller', format: 15), ]; -Drama _drama(String id, String title, String genre, {required int format}) => - Drama( - id: id, - title: title, - synopsis: '$title synopsis', - posterUrl: '/files/$id.png', - genre: genre, - language: 'id', - detailLoaded: true, - episodes: [ - for (var index = 0; index < 7; index++) - Episode( - id: '$id-${index + 1}', - number: index + 1, - title: 'Episode ${index + 1}', - isFree: true, - videoUrl: 'https://example.test/$format-$index.mp4', - ), - ], - ); +Drama _drama( + String id, + String title, + String genre, { + required int format, + int? likeCount, + int? favoriteCount, + int? commentCount, +}) => Drama( + id: id, + title: title, + synopsis: '$title synopsis', + posterUrl: '/files/$id.png', + genre: genre, + language: 'id', + detailLoaded: true, + likeCount: likeCount, + favoriteCount: favoriteCount, + commentCount: commentCount, + episodes: [ + for (var index = 0; index < 7; index++) + Episode( + id: '$id-${index + 1}', + number: index + 1, + title: 'Episode ${index + 1}', + isFree: true, + videoUrl: 'https://example.test/$format-$index.mp4', + ), + ], +);