fix(favorite): 统一收藏状态与互动图标

This commit is contained in:
Zhou Weidong committed 2026-10-27 09:31:48 +08:00
1 parent 4b97f89c0b
commit d3dbc9f8dc
13 files changed
+313 -179

No files matched your search

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