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
+347 -213

No files matched your search

@@ -18,6 +18,7 @@ class FavoriteController extends ChangeNotifier {
final FavoriteRepository _repository;
final int pageSize;
final Set<String> _saved = {};
final Map<String, int> _favoriteCounts = {};
List<Drama> 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<bool> 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<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
void dispose() {
_disposed = true;
+6 -18
View File
@@ -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<void> load(Future<Drama> 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<bool> 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
+2 -24
View File
@@ -119,7 +119,6 @@ class PlayerLogic extends ChangeNotifier {
final Map<String, Future<void>> _episodeStatsInFlight = {};
final Map<String, int> _episodeStatsRevision = {};
final Map<String, int> _dramaStatsRevision = {};
final Map<String, int> _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) =>
+7 -21
View File
@@ -564,6 +564,7 @@ class _PlayerPageState extends State<PlayerPage>
context.read<FavoriteController>().applyServerState(
stats.dramaId,
saved: stats.favorited,
favoriteCount: stats.favoriteCount,
);
}
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(
BuildContext context,
Drama drama,
@@ -1026,6 +1012,7 @@ class _PlayerPageState extends State<PlayerPage>
InteractionController interactions,
) {
final l10n = AppLocalizations.of(context)!;
final favorites = context.watch<FavoriteController>();
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = _resolvedEpisode(index);
@@ -1194,13 +1181,12 @@ class _PlayerPageState extends State<PlayerPage>
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<FavoriteController>().isSaved(drama.id),
saved: favorites.isSaved(drama.id),
onLike: () async {
await _toggleLike(context, interactions, episode);
},
@@ -1277,6 +1263,7 @@ class _PlayerPageState extends State<PlayerPage>
) {
if (logic.pip || logic.pipPreparing) return const SizedBox.expand();
final l10n = AppLocalizations.of(context)!;
final favorites = context.watch<FavoriteController>();
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = _resolvedEpisode(index);
@@ -1306,9 +1293,8 @@ class _PlayerPageState extends State<PlayerPage>
logic.episodeStats(episode.id)?.commentCount ??
_commentCounts[episode.id] ??
episode.commentCount,
favoriteCount:
logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount,
saved: context.watch<FavoriteController>().isSaved(drama.id),
favoriteCount: favorites.favoriteCountFor(drama),
saved: favorites.isSaved(drama.id),
playing: logic.session.userWantsPlay,
playbackSpeed: _playbackSpeed,
totalEpisodes: logic.visibleIndices.length,
+1 -1
View File
@@ -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;
}
@@ -269,8 +269,11 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
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,
);
}
@@ -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<Shadow> _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,
),
],
);
}
}
+23 -11
View File
@@ -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),