diff --git a/lib/core/data/services/comment_controller.dart b/lib/core/data/services/comment_controller.dart index 55f6785..a81b066 100644 --- a/lib/core/data/services/comment_controller.dart +++ b/lib/core/data/services/comment_controller.dart @@ -36,12 +36,17 @@ class CommentController extends ChangeNotifier { bool loadingMore = false; bool submitting = false; bool hasMore = false; + bool _loaded = false; String? nextCursor; String? error; /// 当前已加载的顶层评论。 List get comments => List.unmodifiable(_comments); + /// 全部分页加载完后返回精确的顶层评论数,否则返回 null。 + int? get exactCount => + _loaded && !loading && !loadingMore && !hasMore ? _comments.length : null; + /// 某一楼层已加载的回复。 List repliesFor(String rootId) => List.unmodifiable(_replies[rootId] ?? const []); @@ -77,6 +82,7 @@ class CommentController extends ChangeNotifier { ..addAll(page.comments); nextCursor = page.nextCursor; hasMore = page.hasMore; + _loaded = true; } catch (failure) { error = _errorMessage(failure) ?? 'commentsLoadFailed'; } finally { diff --git a/lib/core/data/services/interaction_controller.dart b/lib/core/data/services/interaction_controller.dart index 9dd39c9..539ee5d 100644 --- a/lib/core/data/services/interaction_controller.dart +++ b/lib/core/data/services/interaction_controller.dart @@ -8,14 +8,15 @@ import '../repositories/interaction_repository.dart'; /// 剧集点赞与举报的会话状态控制器。 /// -/// 剧集契约暂未下发初始点赞状态与计数,本批只维护当前会话内的 liked 状态, -/// 不展示数字,也不伪造初始值。 +/// 剧集契约暂未下发初始点赞状态与计数,因此仅在点赞接口成功返回后展示 +/// 服务端最新计数,不伪造初始值。 class InteractionController extends ChangeNotifier { InteractionController(this._repository); final InteractionRepository _repository; final Set _likedDramaIds = {}; final Set _busyDramaIds = {}; + final Map _likeCounts = {}; bool _disposed = false; /// 当前会话内该剧是否已点赞。 @@ -24,6 +25,9 @@ class InteractionController extends ChangeNotifier { /// 点赞请求是否进行中,用于防连点。 bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId); + /// 服务端最新点赞数,仅在点赞接口成功返回后可用。 + int? likeCount(String dramaId) => _likeCounts[dramaId]; + /// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。 Future> toggleDramaLike( String dramaId, @@ -49,6 +53,7 @@ class InteractionController extends ChangeNotifier { } else { _likedDramaIds.remove(dramaId); } + _likeCounts[dramaId] = result.count; return OperationResult.success(result); } catch (failure) { if (wasLiked) { @@ -86,9 +91,14 @@ class InteractionController extends ChangeNotifier { /// 登录账号变化时清理会话内状态,避免跨账号串联。 void clear() { - if (_likedDramaIds.isEmpty && _busyDramaIds.isEmpty) return; + if (_likedDramaIds.isEmpty && + _busyDramaIds.isEmpty && + _likeCounts.isEmpty) { + return; + } _likedDramaIds.clear(); _busyDramaIds.clear(); + _likeCounts.clear(); _notify(); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 5e9a109..0528611 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -337,6 +337,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { drama: drama, episode: episode, liked: interactions.isLiked(drama.id), + likeCount: interactions.likeCount(drama.id), saved: context.watch().isSaved(drama.id), onLike: () async { if (await ensureSignedIn(context)) { @@ -388,6 +389,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { episode: episode, controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), + likeCount: interactions.likeCount(drama.id), // 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载) saved: context.watch().isSaved(drama.id), onBack: widget.embedded @@ -506,9 +508,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { final l10n = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - errorText(l10n, logic.unlockError, l10n.unlockFailed), - ), + content: Text(errorText(l10n, logic.unlockError, l10n.unlockFailed)), ), ); } @@ -688,6 +688,7 @@ class _PlaybackOverlay extends StatefulWidget { required this.episode, required this.controller, required this.liked, + this.likeCount, required this.saved, required this.playing, required this.playbackSpeed, @@ -714,6 +715,7 @@ class _PlaybackOverlay extends StatefulWidget { final Episode episode; final VideoPlayerController? controller; final bool liked; + final int? likeCount; final bool saved; final bool playing; final double playbackSpeed; @@ -985,6 +987,12 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { color: widget.liked ? AppColors.blue : AppColors.muted, ), title: Text(widget.l10n.like), + trailing: widget.likeCount == null + ? null + : Text( + '${widget.likeCount}', + key: const Key('player-more-like-count'), + ), onTap: () { Navigator.pop(sheetContext); widget.onLike(); diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index e95d820..d183d1b 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -178,11 +178,13 @@ class _DramaCommentsSheetState extends State { Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final count = _controller.exactCount; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l10n.comments, + count == null ? l10n.comments : '${l10n.comments} · $count', + key: count == null ? null : const Key('comment-count-label'), style: const TextStyle( color: AppColors.cream, fontSize: 18, diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 3fc5c00..81dc343 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -19,6 +19,7 @@ class HomeFeedOverlay extends StatelessWidget { required this.drama, required this.episode, required this.liked, + this.likeCount, required this.saved, required this.onLike, required this.onSave, @@ -35,6 +36,7 @@ class HomeFeedOverlay extends StatelessWidget { final Drama drama; final Episode episode; final bool liked; + final int? likeCount; final bool saved; final VoidCallback onLike; final VoidCallback onSave; @@ -123,7 +125,8 @@ class HomeFeedOverlay extends StatelessWidget { _RailItem( key: const Key('rail-like'), icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - label: l10n.like, + label: likeCount?.toString() ?? l10n.like, + countKey: likeCount == null ? null : const Key('rail-like-count'), selected: liked, onTap: onLike, ), @@ -375,12 +378,14 @@ class _RailItem extends StatelessWidget { required this.label, required this.onTap, this.selected = false, + this.countKey, }); final IconData icon; final String label; final VoidCallback onTap; final bool selected; + final Key? countKey; @override Widget build(BuildContext context) { @@ -404,6 +409,7 @@ class _RailItem extends StatelessWidget { const SizedBox(height: 2), Text( label, + key: countKey, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( diff --git a/test/comment/comment_controller_test.dart b/test/comment/comment_controller_test.dart index b47f97f..30b0868 100644 --- a/test/comment/comment_controller_test.dart +++ b/test/comment/comment_controller_test.dart @@ -42,6 +42,27 @@ void main() { model.dispose(); }); + test('should_expose_exact_count_only_when_last_page_is_loaded', () async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [commentFixture(id: '2')], + nextCursor: '2', + hasMore: true, + ), + CommentPage(comments: [commentFixture(id: '1')]), + ], + ); + final model = controller(repository); + + await model.load(); + expect(model.exactCount, isNull); + + await model.loadMore(); + expect(model.exactCount, 2); + model.dispose(); + }); + test('should_use_server_content_and_pending_status_when_review_is_returned', () async { final repository = FakeCommentRepository( publishResult: commentFixture( diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 4c5d436..413d19e 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -62,11 +62,13 @@ 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); await tester.tap(find.byKey(const Key('comment-list-load-more'))); await tester.pumpAndSettle(); expect(find.text('Second page'), findsOneWidget); + expect(find.text('Comments · 2'), findsOneWidget); expect(repository.pageCalls.last.cursor, '10'); }); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 753de68..82badc6 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -147,6 +147,48 @@ void main() { 'Fallback Drama', ); }); + + testWidgets('should_show_server_like_count_when_count_is_available', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) => Scaffold( + body: HomeFeedOverlay( + l10n: AppLocalizations.of(context)!, + drama: _drama, + episode: const Episode( + id: 'count-episode', + number: 1, + title: 'Count Episode', + isFree: true, + ), + liked: true, + likeCount: 128, + saved: false, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onEpisodes: () {}, + onDownload: () {}, + onClear: () {}, + onSearch: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('rail-like-count')), findsOneWidget); + expect(find.text('128'), findsOneWidget); + }); } const _drama = Drama( diff --git a/test/interaction/interaction_controller_test.dart b/test/interaction/interaction_controller_test.dart index b2690b3..e79b8cc 100644 --- a/test/interaction/interaction_controller_test.dart +++ b/test/interaction/interaction_controller_test.dart @@ -26,6 +26,23 @@ void main() { expect(result.isSuccess, isTrue); expect(controller.isLiked('5'), isFalse); + expect(controller.likeCount('5'), 0); + controller.dispose(); + }); + + test('should_expose_latest_server_count_when_two_toggles_succeed', () async { + final repository = FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 12), + ); + final controller = InteractionController(repository); + + await controller.toggleDramaLike('5'); + expect(controller.likeCount('5'), 12); + + repository.nextLikeResult = const LikeToggleResult(liked: false, count: 11); + await controller.toggleDramaLike('5'); + + expect(controller.likeCount('5'), 11); controller.dispose(); }); @@ -55,6 +72,7 @@ void main() { controller.clear(); expect(controller.isLiked('5'), isFalse); + expect(controller.likeCount('5'), isNull); controller.dispose(); }); }