diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index dcfb9bc..b4c5ba6 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -18,6 +18,7 @@ class FavoriteController extends ChangeNotifier { final FavoriteRepository _repository; final int pageSize; final Set _saved = {}; + final Map _favoriteCounts = {}; List cards = const []; int total = 0; bool loading = false; @@ -34,17 +35,53 @@ class FavoriteController extends ChangeNotifier { bool isSaved(String dramaId) => _saved.contains(dramaId); - /// 用统计快照校准收藏状态;已有列表卡片结构不因一次状态同步而改动。 - void applyServerState(String dramaId, {required bool saved}) { - final changed = _saved.contains(dramaId) != saved; + /// 当前剧收藏数;包含本地 toggle 增量。 + int? favoriteCount(String dramaId) => _favoriteCounts[dramaId]; + + /// 首次展示时缓存卡片基数;之后统一由本控制器维护,避免页面各存一份。 + int? favoriteCountFor(Drama drama) { + final current = _favoriteCounts[drama.id]; + if (current != null) return current; + final fallback = drama.favoriteCount; + if (fallback == null) return null; + final normalized = fallback < 0 ? 0 : fallback; + _favoriteCounts[drama.id] = normalized; + return normalized; + } + + /// 用 stats/detail 的服务端快照校准唯一状态和计数。 + void applyServerState( + String dramaId, { + required bool saved, + int? favoriteCount, + }) { + var changed = _saved.contains(dramaId) != saved; if (saved) { _saved.add(dramaId); } else { _saved.remove(dramaId); } + if (favoriteCount != null) { + final normalized = favoriteCount < 0 ? 0 : favoriteCount; + changed = changed || _favoriteCounts[dramaId] != normalized; + _favoriteCounts[dramaId] = normalized; + } if (changed) notify(); } + /// 详情等只有计数、没有当前收藏状态的接口使用。 + void applyServerCount( + String dramaId, + int? favoriteCount, { + bool notify = true, + }) { + if (favoriteCount == null) return; + final normalized = favoriteCount < 0 ? 0 : favoriteCount; + if (_favoriteCounts[dramaId] == normalized) return; + _favoriteCounts[dramaId] = normalized; + if (notify) this.notify(); + } + void notify() { if (!_disposed) notifyListeners(); } @@ -65,6 +102,7 @@ class FavoriteController extends ChangeNotifier { _saved ..clear() ..addAll(list.map((drama) => drama.id)); + _rememberCardCounts(list); total = page.total; _pageIndex = 1; hasMore = page.list.isNotEmpty && cards.length < total; @@ -99,6 +137,7 @@ class FavoriteController extends ChangeNotifier { ).where((drama) => known.add(drama.id)).toList(growable: false); cards = [...cards, ...appended]; _saved.addAll(appended.map((drama) => drama.id)); + _rememberCardCounts(appended); total = page.total; _pageIndex += 1; hasMore = page.list.isNotEmpty && cards.length < total; @@ -121,12 +160,16 @@ class FavoriteController extends ChangeNotifier { Future toggle(String dramaId, {Drama? card}) async { lastErrorCode = null; final wasSaved = _saved.contains(dramaId); + final previousCount = card == null + ? favoriteCount(dramaId) + : favoriteCountFor(card); final previousCards = cards; final previousTotal = total; final previousHasMore = hasMore; // 乐观更新 if (wasSaved) { _saved.remove(dramaId); + _stepCount(dramaId, -1); if (cards.any((drama) => drama.id == dramaId)) { cards = cards.where((drama) => drama.id != dramaId).toList(); total = total > 0 ? total - 1 : 0; @@ -134,6 +177,7 @@ class FavoriteController extends ChangeNotifier { } } else { _saved.add(dramaId); + _stepCount(dramaId, 1); } notify(); try { @@ -142,6 +186,7 @@ class FavoriteController extends ChangeNotifier { if (saved) { _saved.add(dramaId); if (wasSaved) { + _restoreCount(dramaId, previousCount); cards = previousCards; total = previousTotal; hasMore = previousHasMore; @@ -152,6 +197,9 @@ class FavoriteController extends ChangeNotifier { } } else { _saved.remove(dramaId); + if (!wasSaved) { + _restoreCount(dramaId, previousCount); + } if (cards.any((drama) => drama.id == dramaId)) { cards = cards.where((drama) => drama.id != dramaId).toList(); total = total > 0 ? total - 1 : 0; @@ -170,6 +218,7 @@ class FavoriteController extends ChangeNotifier { } else { _saved.remove(dramaId); } + _restoreCount(dramaId, previousCount); notify(); if (failure is ApiException) lastErrorCode = failure.code; return false; @@ -185,9 +234,13 @@ class FavoriteController extends ChangeNotifier { final previousHasMore = hasMore; final removed = cards.where((drama) => ids.contains(drama.id)).toList(); if (removed.isEmpty) return true; + final previousCounts = { + for (final drama in removed) drama.id: _favoriteCounts[drama.id], + }; cards = cards.where((drama) => !ids.contains(drama.id)).toList(); for (final id in ids) { _saved.remove(id); + _stepCount(id, -1); } total = total > removed.length ? total - removed.length : 0; hasMore = cards.length < total; @@ -202,6 +255,9 @@ class FavoriteController extends ChangeNotifier { for (final drama in removed) { _saved.add(drama.id); } + for (final entry in previousCounts.entries) { + _restoreCount(entry.key, entry.value); + } notify(); return false; } @@ -212,7 +268,9 @@ class FavoriteController extends ChangeNotifier { final index = cards.indexWhere((drama) => drama.id == dramaId); if (index < 0) return true; final removed = cards[index]; + final previousCount = _favoriteCounts[dramaId]; _saved.remove(dramaId); + _stepCount(dramaId, -1); final next = [...cards]..removeAt(index); cards = next; total = total > 0 ? total - 1 : 0; @@ -229,6 +287,7 @@ class FavoriteController extends ChangeNotifier { ); cards = restored; _saved.add(dramaId); + _restoreCount(dramaId, previousCount); total += 1; hasMore = cards.length < total; notify(); @@ -259,6 +318,30 @@ class FavoriteController extends ChangeNotifier { ]; } + void _rememberCardCounts(List dramas) { + for (final drama in dramas) { + final count = drama.favoriteCount; + if (count != null && !_favoriteCounts.containsKey(drama.id)) { + _favoriteCounts[drama.id] = count < 0 ? 0 : count; + } + } + } + + void _stepCount(String dramaId, int delta) { + final current = _favoriteCounts[dramaId]; + if (current == null) return; + final next = current + delta; + _favoriteCounts[dramaId] = next < 0 ? 0 : next; + } + + void _restoreCount(String dramaId, int? count) { + if (count == null) { + _favoriteCounts.remove(dramaId); + } else { + _favoriteCounts[dramaId] = count; + } + } + @override void dispose() { _disposed = true; diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index 82d7536..0866e77 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -38,7 +38,6 @@ class DramaDetailLogic extends ChangeNotifier { int? continueIndex; WatchProgress? progress; String? progressError; - int _favoriteCountDelta = 0; bool _disposed = false; void notify() { @@ -47,6 +46,7 @@ class DramaDetailLogic extends ChangeNotifier { /// 列表卡片只带一集可播预览,进详情时补一次完整剧集列表。 Future load(Future Function() fetch) async { + favorites.applyServerCount(drama.id, drama.favoriteCount, notify: false); if (drama.detailLoaded) { await loadContinue(); return; @@ -57,6 +57,7 @@ class DramaDetailLogic extends ChangeNotifier { notify(); try { drama = await fetch(); + favorites.applyServerCount(drama.id, drama.favoriteCount); await loadContinue(); } on ApiException catch (exception) { failed = true; @@ -139,25 +140,12 @@ class DramaDetailLogic extends ChangeNotifier { await loadContinue(); } - /// 详情页展示用的收藏数;没有服务端计数时继续保持缺省展示。 - int? get adjustedFavoriteCount { - final base = drama.favoriteCount; - if (base == null) return null; - final adjusted = base + _favoriteCountDelta; - return adjusted < 0 ? 0 : adjusted; - } + /// 详情页展示用的收藏数,统一从全局 FavoriteController 读取。 + int? get adjustedFavoriteCount => favorites.favoriteCountFor(drama); - /// 收藏切换:乐观状态由控制器负责回滚,这里只在成功后同步计数。 + /// 收藏切换:状态与计数均由全局控制器维护。 Future toggleFavorite() async { - final wasSaved = favorites.isSaved(drama.id); - final ok = await favorites.toggle(drama.id, card: drama); - if (!ok) return false; - final nowSaved = favorites.isSaved(drama.id); - if (nowSaved != wasSaved) { - _favoriteCountDelta += nowSaved ? 1 : -1; - notify(); - } - return true; + return favorites.toggle(drama.id, card: drama); } @override diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 2505d26..aa3e95e 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -119,7 +119,6 @@ class PlayerLogic extends ChangeNotifier { final Map> _episodeStatsInFlight = {}; final Map _episodeStatsRevision = {}; final Map _dramaStatsRevision = {}; - final Map _dramaFavoriteCounts = {}; /// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。 String? unlockingEpisodeId; @@ -185,8 +184,6 @@ class PlayerLogic extends ChangeNotifier { EpisodeStats? episodeStats(String episodeId) => _episodeStats[episodeId]; - int? dramaFavoriteCount(String dramaId) => _dramaFavoriteCounts[dramaId]; - void notify() { if (!_disposed) notifyListeners(); } @@ -621,9 +618,6 @@ class PlayerLogic extends ChangeNotifier { ); _episodeStats[episodeId] = stats; _episodeStatsFetchedAt[episodeId] = _clock(); - if (stats.dramaId.isNotEmpty) { - _dramaFavoriteCounts[stats.dramaId] = stats.favoriteCount; - } final episodeIndex = sessionEpisodes.indexWhere( (episode) => episode.id == episodeId, ); @@ -703,29 +697,13 @@ class PlayerLogic extends ChangeNotifier { notify(); } - /// 收藏成功时同时修正剧级计数和所有已缓存的同剧互动状态。 - void applyDramaFavoriteState( - String dramaId, { - required bool favorited, - int? favoriteCount, - }) { + /// 收藏成功后让旧 stats 请求失效,计数本身只写 FavoriteController。 + void markDramaFavoriteChanged(String dramaId) { _dramaStatsRevision.update( dramaId, (value) => value + 1, ifAbsent: () => 1, ); - if (favoriteCount != null) { - _dramaFavoriteCounts[dramaId] = favoriteCount; - } - for (final entry in _episodeStats.entries.toList()) { - final stats = entry.value; - if (stats.dramaId != dramaId) continue; - _episodeStats[entry.key] = stats.copyWith( - favorited: favorited, - favoriteCount: favoriteCount, - ); - } - notify(); } String _fileUrl(String path) => diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 74198bf..357008f 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -564,6 +564,7 @@ class _PlayerPageState extends State context.read().applyServerState( stats.dramaId, saved: stats.favorited, + favoriteCount: stats.favoriteCount, ); } setState(() { @@ -571,21 +572,6 @@ class _PlayerPageState extends State }); } - /// 收藏状态确认后同步右侧剧级计数。 - void _applyFavoriteDelta(Drama drama, {required bool nowSaved}) { - final current = logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount; - final next = current == null - ? null - : nowSaved - ? current + 1 - : (current > 0 ? current - 1 : 0); - logic.applyDramaFavoriteState( - drama.id, - favorited: nowSaved, - favoriteCount: next, - ); - } - Future _openComments( BuildContext context, Drama drama, @@ -1026,6 +1012,7 @@ class _PlayerPageState extends State InteractionController interactions, ) { final l10n = AppLocalizations.of(context)!; + final favorites = context.watch(); final entry = logic.queue.entryAt(index); final drama = entry.drama; final episode = _resolvedEpisode(index); @@ -1194,13 +1181,12 @@ class _PlayerPageState extends State liked: interactions.isEpisodeLiked(episode.id), likeCount: interactions.episodeLikeCount(episode.id) ?? episode.likeCount, - favoriteCount: - logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount, + favoriteCount: favorites.favoriteCountFor(drama), commentCount: logic.episodeStats(episode.id)?.commentCount ?? _commentCounts[episode.id] ?? episode.commentCount, - saved: context.watch().isSaved(drama.id), + saved: favorites.isSaved(drama.id), onLike: () async { await _toggleLike(context, interactions, episode); }, @@ -1277,6 +1263,7 @@ class _PlayerPageState extends State ) { if (logic.pip || logic.pipPreparing) return const SizedBox.expand(); final l10n = AppLocalizations.of(context)!; + final favorites = context.watch(); final entry = logic.queue.entryAt(index); final drama = entry.drama; final episode = _resolvedEpisode(index); @@ -1306,9 +1293,8 @@ class _PlayerPageState extends State logic.episodeStats(episode.id)?.commentCount ?? _commentCounts[episode.id] ?? episode.commentCount, - favoriteCount: - logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount, - saved: context.watch().isSaved(drama.id), + favoriteCount: favorites.favoriteCountFor(drama), + saved: favorites.isSaved(drama.id), playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, totalEpisodes: logic.visibleIndices.length, diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 2efb5fb..d78f3d0 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -166,7 +166,7 @@ extension _PlayerViewActions on _PlayerPageState { if (ok) { final nowSaved = favorites.isSaved(drama.id); if (nowSaved != wasSaved) { - _applyFavoriteDelta(drama, nowSaved: nowSaved); + logic.markDramaFavoriteChanged(drama.id); } return; } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index fdd4aac..b4c96ec 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -269,8 +269,11 @@ class _HomeFeedOverlayState extends State { liked: widget.liked, favoriteCount: widget.favoriteCount, likeCount: widget.likeCount, + commentCount: widget.commentCount, onSave: widget.onSave, onLike: widget.onLike, + onComments: widget.onComments, + onShare: widget.onShare, onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer, ); } diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 22a7af0..07f08da 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -5,19 +5,16 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; -import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; +import 'interaction_rail.dart'; const double _seriesFont = 19; const double _tagFont = 13; const double _synopsisFont = 13; const double _watchAllHeight = 44; -const double _actionGap = 28; -const double _actionIcon = 32; -const double _actionWidth = 64; const List _textShadows = [ Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), ]; @@ -307,112 +304,71 @@ class HomeFeedActionRail extends StatelessWidget { required this.liked, required this.onSave, required this.onLike, + required this.onComments, + required this.onShare, required this.onEpisodes, this.favoriteCount, this.likeCount, + this.commentCount, }); final bool saved; final bool liked; final int? favoriteCount; final int? likeCount; + final int? commentCount; final VoidCallback onSave; final VoidCallback onLike; + final VoidCallback onComments; + final VoidCallback onShare; final VoidCallback onEpisodes; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return SizedBox( - width: _actionWidth, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _action( - key: const Key('rail-save'), - icon: saved - ? Icons.bookmark_rounded - : Icons.bookmark_border_rounded, - label: favoriteCount == null - ? l10n.favorite - : formatInteractionCount(favoriteCount!), - labelKey: const Key('rail-save-count'), - onTap: onSave, - ), - const SizedBox(height: _actionGap), - _action( - key: const Key('rail-like'), - icon: liked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - iconColor: liked - ? AppColors.doubleTapHeartRed - : AppColors.textPrimary, - label: likeCount == null - ? l10n.like - : formatInteractionCount(likeCount!), - labelKey: const Key('rail-like-count'), - onTap: onLike, - ), - const SizedBox(height: _actionGap), - _action( - key: const Key('rail-episodes'), - icon: Icons.layers_rounded, - label: l10n.episodes, - labelKey: const Key('rail-episodes-label'), - labelFontSize: 11, - onTap: onEpisodes, - ), - ], - ), - ); - } - - Widget _action({ - required Key key, - required IconData icon, - required String label, - required Key labelKey, - required VoidCallback onTap, - Color iconColor = AppColors.textPrimary, - double labelFontSize = 12, - }) { - return GestureDetector( - key: key, - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: _actionIcon, - color: iconColor, - shadows: const [ - Shadow( - color: AppColors.shadowSoft, - blurRadius: 4, - offset: Offset(0, 1), - ), - ], - ), - const SizedBox(height: 2), - Text( - label, - key: labelKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: TextStyle( - color: AppColors.textPrimary, - fontSize: labelFontSize, - fontWeight: FontWeight.w500, - height: 1, - shadows: _textShadows, - ), - ), - ], - ), + return InteractionRail( + items: [ + InteractionRailItem( + key: const Key('rail-save'), + asset: 'assets/icon/rail/rail_save.png', + label: l10n.favorite, + count: favoriteCount, + countKey: const Key('rail-save-count'), + selected: saved, + onTap: onSave, + ), + InteractionRailItem( + key: const Key('rail-comments'), + asset: 'assets/icon/rail/rail_comment.png', + label: l10n.comments, + count: commentCount, + countKey: const Key('rail-comments-count'), + onTap: onComments, + ), + InteractionRailItem( + key: const Key('rail-like'), + asset: 'assets/icon/rail/rail_like.png', + label: l10n.like, + count: likeCount, + countKey: const Key('rail-like-count'), + selected: liked, + selectedColor: AppColors.doubleTapHeartRed, + onTap: onLike, + ), + InteractionRailItem( + key: const Key('rail-share'), + asset: 'assets/icon/rail/rail_share.png', + label: l10n.share, + onTap: onShare, + ), + InteractionRailItem( + key: const Key('rail-episodes'), + icon: Icons.layers_rounded, + label: l10n.episodes, + countKey: const Key('rail-episodes-label'), + onTap: onEpisodes, + ), + ], ); } } diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart index 48f0c98..40af975 100644 --- a/lib/pages/player/widgets/interaction_rail.dart +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -13,17 +13,19 @@ const double _itemGap = 24; class InteractionRailItem { const InteractionRailItem({ required this.key, - required this.asset, required this.label, required this.onTap, + this.asset, + this.icon, this.count, this.countKey, this.selected = false, this.selectedColor = AppColors.blue, - }); + }) : assert((asset == null) != (icon == null)); final Key key; - final String asset; + final String? asset; + final IconData? icon; final String label; final VoidCallback onTap; final int? count; @@ -68,14 +70,24 @@ class _RailButton extends StatelessWidget { child: IconButton( onPressed: item.onTap, padding: EdgeInsets.zero, - icon: Image.asset( - item.asset, - width: _iconSize, - height: _iconSize, - fit: BoxFit.contain, - color: item.selected ? item.selectedColor : AppColors.onPrimary, - colorBlendMode: BlendMode.srcIn, - ), + icon: item.asset != null + ? Image.asset( + item.asset!, + width: _iconSize, + height: _iconSize, + fit: BoxFit.contain, + color: item.selected + ? item.selectedColor + : AppColors.onPrimary, + colorBlendMode: BlendMode.srcIn, + ) + : Icon( + item.icon, + size: _iconSize, + color: item.selected + ? item.selectedColor + : AppColors.onPrimary, + ), ), ), const SizedBox(height: 2), diff --git a/test/drama_detail/drama_detail_logic_test.dart b/test/drama_detail/drama_detail_logic_test.dart index 8249304..a4fa558 100644 --- a/test/drama_detail/drama_detail_logic_test.dart +++ b/test/drama_detail/drama_detail_logic_test.dart @@ -16,20 +16,20 @@ DramaDetailLogic _logic( FakeWatchHistory history, { FavoriteController? favorites, PlaybackPositionCache? positionCache, -}) => - DramaDetailLogic( - drama: drama, - history: history, - playback: FakePlaybackRepository(), - favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()), - positionCache: positionCache ?? PlaybackPositionCache(), - ); +}) => DramaDetailLogic( + drama: drama, + history: history, + playback: FakePlaybackRepository(), + favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()), + positionCache: positionCache ?? PlaybackPositionCache(), +); -Drama _card(String id) => Drama( +Drama _card(String id, {int? favoriteCount}) => Drama( id: id, title: id, synopsis: 'synopsis', posterUrl: '/files/$id.jpg', + favoriteCount: favoriteCount, totalEpisodes: 3, episodes: [ Episode( @@ -42,11 +42,12 @@ Drama _card(String id) => Drama( ], ); -Drama _detail(String id) => Drama( +Drama _detail(String id, {int? favoriteCount}) => Drama( id: id, title: id, synopsis: 'synopsis', posterUrl: '/files/$id.jpg', + favoriteCount: favoriteCount, totalEpisodes: 3, detailLoaded: true, episodes: [ @@ -62,21 +63,24 @@ Drama _detail(String id) => Drama( ); void main() { - test('should_upgrade_to_full_episode_list_when_detail_loads_for_list_card', () async { - var calls = 0; - final logic = _logic(_card('signal'), FakeWatchHistory()); + test( + 'should_upgrade_to_full_episode_list_when_detail_loads_for_list_card', + () async { + var calls = 0; + final logic = _logic(_card('signal'), FakeWatchHistory()); - await logic.load(() async { - calls++; - return _detail('signal'); - }); + await logic.load(() async { + calls++; + return _detail('signal'); + }); - expect(calls, 1); - expect(logic.loading, isFalse); - expect(logic.failed, isFalse); - expect(logic.drama.episodes, hasLength(3)); - logic.dispose(); - }); + expect(calls, 1); + expect(logic.loading, isFalse); + expect(logic.failed, isFalse); + expect(logic.drama.episodes, hasLength(3)); + logic.dispose(); + }, + ); test('should_keep_card_and_report_reason_when_load_fails', () async { final logic = _logic(_card('signal'), FakeWatchHistory()); @@ -107,6 +111,19 @@ void main() { logic.dispose(); }); + test('should_use_detail_favorite_count_as_authoritative', () async { + final favorites = FavoriteController(InMemoryFavoriteRepository()); + final card = _card('signal', favoriteCount: 2); + expect(favorites.favoriteCountFor(card), 2); + final logic = _logic(card, FakeWatchHistory(), favorites: favorites); + + await logic.load(() async => _detail('signal', favoriteCount: 9)); + + expect(favorites.favoriteCount('signal'), 9); + expect(logic.adjustedFavoriteCount, 9); + logic.dispose(); + }); + test('should_prefer_recent_local_position_when_loading_continue', () async { final cache = PlaybackPositionCache(); cache.save( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index ef1c775..3835d74 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -323,19 +323,59 @@ void main() { tester.widget(find.byKey(const Key('rail-save-count'))).data, '3.2万', ); - expect( + final likeImage = tester.widget( find.descendant( of: find.byKey(const Key('rail-like')), - matching: find.byIcon(Icons.favorite_rounded), + matching: find.byType(Image), ), - findsOneWidget, ); - expect( + final saveImage = tester.widget( find.descendant( of: find.byKey(const Key('rail-save')), - matching: find.byIcon(Icons.bookmark_border_rounded), + matching: find.byType(Image), ), - findsOneWidget, + ); + expect( + (likeImage.image as AssetImage).assetName, + 'assets/icon/rail/rail_like.png', + ); + expect(likeImage.color, AppColors.doubleTapHeartRed); + expect( + (saveImage.image as AssetImage).assetName, + 'assets/icon/rail/rail_save.png', + ); + expect(saveImage.color, AppColors.onPrimary); + expect(find.byKey(const Key('rail-comments')), findsOneWidget); + expect(find.byKey(const Key('rail-share')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('rail-comments-count'))).data, + '12', + ); + expect( + (tester + .widget( + find.descendant( + of: find.byKey(const Key('rail-comments')), + matching: find.byType(Image), + ), + ) + .image + as AssetImage) + .assetName, + 'assets/icon/rail/rail_comment.png', + ); + expect( + (tester + .widget( + find.descendant( + of: find.byKey(const Key('rail-share')), + matching: find.byType(Image), + ), + ) + .image + as AssetImage) + .assetName, + 'assets/icon/rail/rail_share.png', ); expect(find.byKey(const Key('rail-episodes')), findsOneWidget); expect( @@ -345,9 +385,6 @@ void main() { ), findsOneWidget, ); - expect(find.byKey(const Key('rail-comments')), findsNothing); - expect(find.byKey(const Key('rail-share')), findsNothing); - expect(find.text('Comments'), findsNothing); expect( tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right, closeTo( @@ -672,7 +709,10 @@ void main() { final previewDecoration = previewContainer.decoration! as BoxDecoration; expect(previewDecoration.color, AppColors.card); expect(previewDecoration.borderRadius, BorderRadius.circular(8)); - expect((previewDecoration.border! as Border).top.color, AppColors.onPrimary); + expect( + (previewDecoration.border! as Border).top.color, + AppColors.onPrimary, + ); await gesture.moveBy(const Offset(-600, 0)); await tester.pump(); expect( diff --git a/test/library/favorite_controller_test.dart b/test/library/favorite_controller_test.dart index 0b56819..1dc8154 100644 --- a/test/library/favorite_controller_test.dart +++ b/test/library/favorite_controller_test.dart @@ -6,11 +6,12 @@ import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; -Drama _card(String id) => Drama( +Drama _card(String id, {int? favoriteCount}) => Drama( id: id, title: 'Drama $id', synopsis: 'synopsis', posterUrl: '/files/$id.png', + favoriteCount: favoriteCount, ); void main() { @@ -37,6 +38,31 @@ void main() { expect(controller.cards.map((drama) => drama.id), ['signal']); }); + test( + 'should_adjust_the_single_favorite_count_when_toggle_succeeds', + () async { + final controller = FavoriteController(InMemoryFavoriteRepository()); + final card = _card('signal', favoriteCount: 12); + + expect(controller.favoriteCountFor(card), 12); + expect(await controller.toggle('signal', card: card), isTrue); + expect(controller.favoriteCount('signal'), 13); + expect(await controller.toggle('signal', card: card), isTrue); + expect(controller.favoriteCount('signal'), 12); + }, + ); + + test('should_apply_server_count_and_roll_back_toggle_failure', () async { + final repository = InMemoryFavoriteRepository()..failToggle = true; + final controller = FavoriteController(repository); + + controller.applyServerState('signal', saved: false, favoriteCount: 20); + expect(controller.favoriteCount('signal'), 20); + expect(await controller.toggle('signal'), isFalse); + expect(controller.isSaved('signal'), isFalse); + expect(controller.favoriteCount('signal'), 20); + }); + test('should_remove_card_and_state_when_toggled_off', () async { final repository = InMemoryFavoriteRepository(saved: {'signal'}); final controller = FavoriteController(repository); diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index ce253a4..40b341e 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/services/interaction_controller.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; @@ -41,13 +42,13 @@ void main() { ); expect(repository.likeTargets, hasLength(1)); expect(haptics.lightImpacts, 1); - expect( + final likeImage = tester.widget( find.descendant( of: find.byKey(const Key('rail-like')), - matching: find.byIcon(Icons.favorite_rounded), + matching: find.byType(Image), ), - findsOneWidget, ); + expect(likeImage.color, AppColors.doubleTapHeartRed); } }); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 9cf0f33..c71fbb6 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -630,6 +630,53 @@ void main() { expect(interactions.likeTargets, ['EPISODE:the-signal-demo-1']); }); + testWidgets('should_keep_favorite_count_when_player_saves_and_returns', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + final initialCount = _railLabel(tester, const Key('rail-save')); + await tester.tap(find.byKey(const Key('home-feed-series-name'))); + await tester.pumpAndSettle(); + expect(_railLabel(tester, const Key('player-rail-save')), initialCount); + await tester.tap(find.byKey(const Key('player-rail-save'))); + await tester.pumpAndSettle(); + + final savedCount = _railLabel(tester, const Key('player-rail-save')); + expect(savedCount, isNot(initialCount)); + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + + expect(find.byType(AuthPage), findsNothing); + expect(_railLabel(tester, const Key('rail-save')), savedCount); + }); + + testWidgets('should_keep_favorite_state_when_recommend_opens_player', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('rail-save'))); + await tester.pumpAndSettle(); + final savedCount = _railLabel(tester, const Key('rail-save')); + + await tester.tap(find.byKey(const Key('home-feed-series-name'))); + await tester.pumpAndSettle(); + + expect(_railLabel(tester, const Key('player-rail-save')), savedCount); + final saveImage = tester.widget( + find.descendant( + of: find.byKey(const Key('player-rail-save')), + matching: find.byType(Image), + ), + ); + expect(saveImage.color, AppColors.blue); + }); + testWidgets('should_keep_video_surface_background_black', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); @@ -646,9 +693,7 @@ void main() { expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); }); - testWidgets('should_open_comments_without_login', ( - tester, - ) async { + testWidgets('should_open_comments_without_login', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); @@ -1914,7 +1959,6 @@ void main() { find.text('Available unlock methods vary by title.'), findsOneWidget, ); - }); testWidgets('should_require_login_before_opening_unlock_flow', (