feat(library): 收藏与观看历史支持分页

This commit is contained in:
Zhou Weidong committed 2026-10-22 12:34:27 +08:00
1 parent 63d3b0f7ca
commit 235b50598a
24 files changed
+1015 -105

No files matched your search

+102 -8
View File
@@ -10,13 +10,22 @@ import '../repositories/favorite_repository.dart';
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
/// "点了没反应"或"看起来收藏了其实没有"。
class FavoriteController extends ChangeNotifier {
FavoriteController(this._repository);
FavoriteController(this._repository, {this.pageSize = defaultPageSize});
static const int defaultPageSize = 20;
final FavoriteRepository _repository;
final int pageSize;
final Set<String> _saved = {};
List<Drama> cards = const [];
int total = 0;
bool loading = false;
bool loadingMore = false;
bool hasMore = false;
String? error;
String? loadMoreError;
int _pageIndex = 0;
int _requestSeq = 0;
bool _disposed = false;
Set<String> get savedIds => Set.unmodifiable(_saved);
@@ -29,20 +38,66 @@ class FavoriteController extends ChangeNotifier {
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
Future<void> load() async {
final request = ++_requestSeq;
loading = true;
loadingMore = false;
error = null;
loadMoreError = null;
notify();
try {
final list = await _repository.list();
final page = await _repository.page(pageIndex: 1, pageSize: pageSize);
if (_disposed || request != _requestSeq) return;
final list = _uniqueCards(page.list);
cards = list;
_saved
..clear()
..addAll(list.map((drama) => drama.id));
total = page.total;
_pageIndex = 1;
hasMore = page.list.isNotEmpty && cards.length < total;
} catch (_) {
error = 'favoritesError';
if (!_disposed && request == _requestSeq) {
error = 'favoritesError';
}
} finally {
loading = false;
notify();
if (!_disposed && request == _requestSeq) {
loading = false;
notify();
}
}
}
/// 追加下一页;正在刷新、已在加载或无更多时直接忽略。
Future<void> loadMore() async {
if (loading || loadingMore || !hasMore) return;
final request = ++_requestSeq;
loadingMore = true;
loadMoreError = null;
notify();
try {
final page = await _repository.page(
pageIndex: _pageIndex + 1,
pageSize: pageSize,
);
if (_disposed || request != _requestSeq) return;
final known = cards.map((drama) => drama.id).toSet();
final appended = _uniqueCards(
page.list,
).where((drama) => known.add(drama.id)).toList(growable: false);
cards = [...cards, ...appended];
_saved.addAll(appended.map((drama) => drama.id));
total = page.total;
_pageIndex += 1;
hasMore = page.list.isNotEmpty && cards.length < total;
} catch (_) {
if (!_disposed && request == _requestSeq) {
loadMoreError = 'favoritesError';
}
} finally {
if (!_disposed && request == _requestSeq) {
loadingMore = false;
notify();
}
}
}
@@ -52,10 +107,17 @@ class FavoriteController extends ChangeNotifier {
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
Future<bool> toggle(String dramaId, {Drama? card}) async {
final wasSaved = _saved.contains(dramaId);
final previousCards = cards;
final previousTotal = total;
final previousHasMore = hasMore;
// 乐观更新
if (wasSaved) {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
if (cards.any((drama) => drama.id == dramaId)) {
cards = cards.where((drama) => drama.id != dramaId).toList();
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
}
} else {
_saved.add(dramaId);
}
@@ -65,17 +127,30 @@ class FavoriteController extends ChangeNotifier {
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
if (saved) {
_saved.add(dramaId);
if (card != null && !cards.any((drama) => drama.id == dramaId)) {
if (wasSaved) {
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
} else if (card != null && !cards.any((drama) => drama.id == dramaId)) {
cards = [card, ...cards];
total += 1;
hasMore = cards.length < total;
}
} else {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
if (cards.any((drama) => drama.id == dramaId)) {
cards = cards.where((drama) => drama.id != dramaId).toList();
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
}
}
notify();
return true;
} catch (_) {
// 回滚
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
if (wasSaved) {
_saved.add(dramaId);
} else {
@@ -94,6 +169,8 @@ class FavoriteController extends ChangeNotifier {
_saved.remove(dramaId);
final next = [...cards]..removeAt(index);
cards = next;
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
notify();
try {
await _repository.remove(dramaId);
@@ -106,6 +183,8 @@ class FavoriteController extends ChangeNotifier {
);
cards = restored;
_saved.add(dramaId);
total += 1;
hasMore = cards.length < total;
notify();
return false;
}
@@ -113,12 +192,27 @@ class FavoriteController extends ChangeNotifier {
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
void clear() {
_requestSeq += 1;
_saved.clear();
cards = const [];
total = 0;
loading = false;
hasMore = false;
loadingMore = false;
loadMoreError = null;
error = null;
_pageIndex = 0;
notify();
}
List<Drama> _uniqueCards(List<Drama> source) {
final seen = <String>{};
return [
for (final drama in source)
if (seen.add(drama.id)) drama,
];
}
@override
void dispose() {
_disposed = true;
@@ -11,41 +11,53 @@ import '../repositories/watch_history_repository.dart';
/// 独立观看记录页状态。
///
/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。
/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。
class WatchRecordsController extends ChangeNotifier {
factory WatchRecordsController({
required WatchHistoryRepository history,
required CatalogRepository catalog,
int limit = defaultLimit,
}) => WatchRecordsController._(history, catalog, limit);
int pageSize = defaultPageSize,
}) => WatchRecordsController._(history, catalog, pageSize);
WatchRecordsController._(this._history, this._catalog, this.limit);
WatchRecordsController._(this._history, this._catalog, this.pageSize);
static const int defaultLimit = 20;
static const int defaultPageSize = 20;
static const int detailConcurrency = 4;
final WatchHistoryRepository _history;
final CatalogRepository _catalog;
final int limit;
final int pageSize;
final Map<String, Drama> _detailCache = {};
List<WatchRecord> records = const [];
int total = 0;
bool loading = false;
bool loadingMore = false;
bool hasMore = false;
bool clearing = false;
String? error;
String? loadMoreError;
String? actionError;
int _pageIndex = 0;
int _requestSeq = 0;
bool _disposed = false;
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
Future<void> load() async {
final request = ++_requestSeq;
loading = true;
loadingMore = false;
error = null;
loadMoreError = null;
actionError = null;
_notify();
try {
final recent = await _history.recent(limit: limit);
if (_disposed) return;
records = _uniqueRecords(recent);
final page = await _history.recent(pageIndex: 1, pageSize: pageSize);
if (_disposed || request != _requestSeq) return;
records = _uniqueRecords(page.list);
total = page.total;
_pageIndex = 1;
hasMore = page.list.isNotEmpty && records.length < total;
_notify();
await _hydrate(records.map((record) => record.dramaId).toList());
} catch (failure, stackTrace) {
@@ -57,17 +69,54 @@ class WatchRecordsController extends ChangeNotifier {
stackTrace: stackTrace,
label: '[WatchRecordsController] recent failed',
);
if (!_disposed) {
if (!_disposed && request == _requestSeq) {
error = failure is ApiException ? failure.message : 'progressError';
}
} finally {
if (!_disposed) {
if (!_disposed && request == _requestSeq) {
loading = false;
_notify();
}
}
}
/// 追加下一页;刷新、加载中或无更多时忽略。
Future<void> loadMore() async {
if (loading || loadingMore || !hasMore) return;
final request = ++_requestSeq;
loadingMore = true;
loadMoreError = null;
_notify();
try {
final page = await _history.recent(
pageIndex: _pageIndex + 1,
pageSize: pageSize,
);
if (_disposed || request != _requestSeq) return;
final known = records.map((record) => record.dramaId).toSet();
final appended = _uniqueRecords(
page.list,
).where((record) => known.add(record.dramaId)).toList(growable: false);
records = [...records, ...appended];
total = page.total;
_pageIndex += 1;
hasMore = page.list.isNotEmpty && records.length < total;
_notify();
await _hydrate(appended.map((record) => record.dramaId).toList());
} catch (failure) {
if (!_disposed && request == _requestSeq) {
loadMoreError = failure is ApiException
? failure.message
: 'progressError';
}
} finally {
if (!_disposed && request == _requestSeq) {
loadingMore = false;
_notify();
}
}
}
/// 只重试一条详情失败记录。
Future<void> retryDetail(String dramaId) async {
final index = _recordIndex(dramaId);
@@ -87,6 +136,8 @@ class WatchRecordsController extends ChangeNotifier {
if (index < 0) return true;
final removed = records[index];
records = [...records]..removeAt(index);
total = total > 0 ? total - 1 : 0;
hasMore = records.length < total;
actionError = null;
_notify();
try {
@@ -96,6 +147,8 @@ class WatchRecordsController extends ChangeNotifier {
final next = [...records];
next.insert(index > next.length ? next.length : index, removed);
records = next;
total += 1;
hasMore = records.length < total;
actionError = 'recordDeleteFailed';
return false;
} finally {
@@ -106,8 +159,15 @@ class WatchRecordsController extends ChangeNotifier {
/// 清空全部记录,先更新 UI,失败恢复原列表。
Future<bool> clear() async {
if (records.isEmpty) return true;
_requestSeq += 1;
final previous = records;
final previousTotal = total;
final previousPageIndex = _pageIndex;
records = const [];
total = 0;
hasMore = false;
_pageIndex = 0;
loadingMore = false;
clearing = true;
actionError = null;
_notify();
@@ -116,6 +176,9 @@ class WatchRecordsController extends ChangeNotifier {
return true;
} catch (_) {
records = previous;
total = previousTotal;
_pageIndex = previousPageIndex;
hasMore = records.length < total;
actionError = 'recordClearFailed';
return false;
} finally {