refactor(paging): 抽取分页控制器基类收敛四个列表页

收藏、观看记录、剧单、公告各自手写 loading 互斥、请求序号、跨页去重与
错误映射,修一处要同步改四份。统一到 PagedListController 后子类只提供
取页与条目标识;空页按已加载数收口,避免 total 虚大时反复请求。
This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-29 10:44:41 +08:00
1 parent 9c9a277217
commit a4d08e7c1e
7 files changed
+503 -456

No files matched your search

+54 -142
View File
@@ -1,17 +1,18 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../models/page_result.dart';
import '../remote/api_client.dart';
import '../repositories/favorite_repository.dart';
import 'paged_list_controller.dart';
/// 收藏状态:**以服务端为准**。
///
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
/// "点了没反应"或"看起来收藏了其实没有"。
class FavoriteController extends ChangeNotifier {
FavoriteController(this._repository, {this.pageSize = defaultPageSize});
class FavoriteController extends PagedListController<Drama> {
FavoriteController(this._repository, {this.pageSize = defaultPageSize})
: super(fallbackErrorKey: 'favoritesError');
static const int defaultPageSize = 20;
@@ -19,17 +20,9 @@ class FavoriteController extends ChangeNotifier {
final int pageSize;
final Set<String> _saved = {};
final Map<String, int> _favoriteCounts = {};
List<Drama> cards = const [];
int total = 0;
bool loading = false;
bool loadingMore = false;
bool hasMore = false;
String? error;
String? loadMoreError;
int? lastErrorCode;
int _pageIndex = 0;
int _requestSeq = 0;
bool _disposed = false;
/// 收藏列表卡片(按服务端顺序)。
List<Drama> get cards => items;
Set<String> get savedIds => Set.unmodifiable(_saved);
@@ -38,6 +31,33 @@ class FavoriteController extends ChangeNotifier {
/// 当前剧收藏数;包含本地 toggle 增量。
int? favoriteCount(String dramaId) => _favoriteCounts[dramaId];
/// 页面统一把收藏失败翻译成固定 key。
@override
String? errorKeyFor(Object failure) => 'favoritesError';
@override
Future<PageResult<Drama>> fetchPage(int pageIndex) {
return _repository.page(pageIndex: pageIndex, pageSize: pageSize);
}
@override
String itemKey(Drama item) => item.id;
/// 第一页到位后,用服务端列表校准收藏集合与计数。
@override
void onItemsReplaced() {
_saved
..clear()
..addAll(items.map((drama) => drama.id));
_rememberCardCounts(items);
}
@override
Future<void> onPageLoaded(List<Drama> added) async {
_saved.addAll(added.map((drama) => drama.id));
_rememberCardCounts(added);
}
/// 首次展示时缓存卡片基数;之后统一由本控制器维护,避免页面各存一份。
int? favoriteCountFor(Drama drama) {
final current = _favoriteCounts[drama.id];
@@ -82,81 +102,6 @@ class FavoriteController extends ChangeNotifier {
if (notify) this.notify();
}
void notify() {
if (!_disposed) notifyListeners();
}
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
Future<void> load() async {
final request = ++_requestSeq;
loading = true;
loadingMore = false;
error = null;
loadMoreError = null;
lastErrorCode = null;
notify();
try {
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));
_rememberCardCounts(list);
total = page.total;
_pageIndex = 1;
hasMore = page.list.isNotEmpty && cards.length < total;
} catch (failure) {
if (!_disposed && request == _requestSeq) {
error = 'favoritesError';
if (failure is ApiException) lastErrorCode = failure.code;
}
} finally {
if (!_disposed && request == _requestSeq) {
loading = false;
notify();
}
}
}
/// 追加下一页;正在刷新、已在加载或无更多时直接忽略。
Future<void> loadMore() async {
if (loading || loadingMore || !hasMore) return;
final request = ++_requestSeq;
loadingMore = true;
loadMoreError = null;
lastErrorCode = 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));
_rememberCardCounts(appended);
total = page.total;
_pageIndex += 1;
hasMore = page.list.isNotEmpty && cards.length < total;
} catch (failure) {
if (!_disposed && request == _requestSeq) {
loadMoreError = 'favoritesError';
if (failure is ApiException) lastErrorCode = failure.code;
}
} finally {
if (!_disposed && request == _requestSeq) {
loadingMore = false;
notify();
}
}
}
/// 每次切换都以接口状态为准;匿名请求由设备号归属。
///
/// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
@@ -169,15 +114,13 @@ class FavoriteController extends ChangeNotifier {
: 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;
hasMore = cards.length < total;
setItems(cards.where((drama) => drama.id != dramaId).toList());
setTotal(total - 1);
}
} else {
_saved.add(dramaId);
@@ -191,13 +134,11 @@ class FavoriteController extends ChangeNotifier {
_saved.add(dramaId);
if (wasSaved) {
_restoreCount(dramaId, previousCount);
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
setItems(previousCards);
setTotal(previousTotal);
} else if (card != null && !cards.any((drama) => drama.id == dramaId)) {
cards = [card, ...cards];
total += 1;
hasMore = cards.length < total;
setItems([card, ...cards]);
setTotal(total + 1);
}
} else {
_saved.remove(dramaId);
@@ -205,18 +146,16 @@ class FavoriteController extends ChangeNotifier {
_restoreCount(dramaId, previousCount);
}
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;
setItems(cards.where((drama) => drama.id != dramaId).toList());
setTotal(total - 1);
}
}
notify();
return true;
} catch (failure) {
// 回滚
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
setItems(previousCards);
setTotal(previousTotal);
if (wasSaved) {
_saved.add(dramaId);
} else {
@@ -235,27 +174,24 @@ class FavoriteController extends ChangeNotifier {
if (ids.isEmpty) return true;
final previousCards = cards;
final previousTotal = total;
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();
setItems(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;
setTotal(total - removed.length);
notify();
try {
await _repository.removeMany(dramaIds);
return true;
} catch (_) {
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
setItems(previousCards);
setTotal(previousTotal);
for (final drama in removed) {
_saved.add(drama.id);
}
@@ -276,9 +212,8 @@ class FavoriteController extends ChangeNotifier {
_saved.remove(dramaId);
_stepCount(dramaId, -1);
final next = [...cards]..removeAt(index);
cards = next;
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
setItems(next);
setTotal(total - 1);
notify();
try {
await _repository.remove(dramaId);
@@ -289,11 +224,10 @@ class FavoriteController extends ChangeNotifier {
index > restored.length ? restored.length : index,
removed,
);
cards = restored;
setItems(restored);
_saved.add(dramaId);
_restoreCount(dramaId, previousCount);
total += 1;
hasMore = cards.length < total;
setTotal(total + 1);
notify();
return false;
}
@@ -301,27 +235,11 @@ class FavoriteController extends ChangeNotifier {
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
void clear() {
_requestSeq += 1;
resetPaging();
_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,
];
}
void _rememberCardCounts(List<Drama> dramas) {
for (final drama in dramas) {
final count = drama.favoriteCount;
@@ -345,10 +263,4 @@ class FavoriteController extends ChangeNotifier {
_favoriteCounts[dramaId] = count;
}
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+16 -104
View File
@@ -1,118 +1,30 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/notice.dart';
import '../remote/api_client.dart';
import '../models/page_result.dart';
import '../repositories/notice_repository.dart';
import 'paged_list_controller.dart';
/// Pagination and state controller for the anonymous notices page.
class NoticesController extends ChangeNotifier {
NoticesController(this._repository, {this.pageSize = 20});
/// 公告页状态:匿名可看,按页追加。
class NoticesController extends PagedListController<AppNotice> {
NoticesController(this._repository, {this.pageSize = 20})
: super(fallbackErrorKey: 'noticesLoadFailed');
final NoticeRepository _repository;
final int pageSize;
final List<AppNotice> _notices = [];
int _pageIndex = 0;
int _total = 0;
bool _disposed = false;
bool loading = false;
bool loadingMore = false;
bool refreshing = false;
String? error;
String? loadMoreError;
/// Loaded notices in server order.
List<AppNotice> get notices => List.unmodifiable(_notices);
/// 当前已加载公告(服务端顺序)。
List<AppNotice> get notices => items;
/// Whether another page is expected from the latest total.
bool get hasMore => _notices.length < _total;
/// Loads the first page.
Future<void> load() async {
loading = true;
error = null;
loadMoreError = null;
_notify();
await _replaceFirstPage();
loading = false;
_notify();
}
/// Refreshes from page one while keeping the current page visible.
Future<void> refresh() async {
refreshing = true;
error = null;
loadMoreError = null;
_notify();
await _replaceFirstPage();
refreshing = false;
_notify();
}
/// Appends the next page when loaded count is below total.
Future<void> loadMore() async {
if (loading || loadingMore || refreshing || !hasMore) return;
loadingMore = true;
loadMoreError = null;
_notify();
try {
final page = await _repository.page(
pageIndex: _pageIndex + 1,
pageSize: pageSize,
);
_appendUnique(page.notices);
_pageIndex++;
_total = page.total;
} catch (failure, stackTrace) {
debugPrint('[NoticesController] load more failed: $failure');
debugPrintStack(
stackTrace: stackTrace,
label: '[NoticesController] load more failed',
);
loadMoreError = _message(failure) ?? 'noticesLoadFailed';
} finally {
loadingMore = false;
_notify();
}
}
Future<void> _replaceFirstPage() async {
try {
final page = await _repository.page(pageIndex: 1, pageSize: pageSize);
if (_disposed) return;
_notices
..clear()
..addAll(page.notices);
_pageIndex = 1;
_total = page.total;
} catch (failure, stackTrace) {
debugPrint('[NoticesController] load failed: $failure');
debugPrintStack(
stackTrace: stackTrace,
label: '[NoticesController] load failed',
);
if (!_disposed) error = _message(failure) ?? 'noticesLoadFailed';
}
}
void _appendUnique(List<AppNotice> incoming) {
final ids = _notices.map((notice) => notice.id).toSet();
for (final notice in incoming) {
if (ids.add(notice.id)) _notices.add(notice);
}
}
String? _message(Object failure) =>
failure is ApiException ? failure.message : null;
void _notify() {
if (!_disposed) notifyListeners();
@override
Future<PageResult<AppNotice>> fetchPage(int pageIndex) async {
final page = await _repository.page(
pageIndex: pageIndex,
pageSize: pageSize,
);
return PageResult(total: page.total, list: page.notices);
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
String itemKey(AppNotice item) => item.id;
}
@@ -0,0 +1,216 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/page_result.dart';
import '../remote/api_client.dart';
/// 分页列表控制器基类(`pageIndex` + `total` 模式)。
///
/// 收藏、观看记录、剧单、公告四个页面的分页逻辑此前各写一份:请求序号防串台、
/// loading/loadingMore 互斥、跨页去重追加、hasMore 判定 —— 修一处 bug 要同步
/// 抄四遍。这里收口成模板方法,子类只回答两件事:怎么取一页、怎么标识一条。
abstract class PagedListController<T> extends ChangeNotifier {
PagedListController({this.fallbackErrorKey, this.dedupe = true});
/// 非 [ApiException] 的失败文案 key(页面用 `errorText` 翻译)。
final String? fallbackErrorKey;
/// 是否跨页按 [itemKey] 去重。
final bool dedupe;
List<T> _items = const [];
int _pageIndex = 0;
int _total = 0;
int _requestSeq = 0;
bool _disposed = false;
bool loading = false;
bool loadingMore = false;
bool refreshing = false;
bool loaded = false;
String? error;
String? loadMoreError;
int? lastErrorCode;
/// 当前已加载条目(按服务端顺序)。
List<T> get items => _items;
int get total => _total;
int get pageIndex => _pageIndex;
/// 已加载数未达总数即视为还有下一页。
bool get hasMore => _items.length < _total;
@protected
bool get isDisposed => _disposed;
/// 子类实现:取第 [pageIndex] 页(从 1 开始)。
@protected
Future<PageResult<T>> fetchPage(int pageIndex);
/// 子类实现:跨页去重用的稳定标识。
@protected
String itemKey(T item);
/// 失败文案:默认优先用后端消息,其次 [fallbackErrorKey]。
@protected
String? errorKeyFor(Object failure) {
if (failure is ApiException && failure.message.isNotEmpty) {
return failure.message;
}
return fallbackErrorKey;
}
/// 第一页替换完成后的同步钩子(如同步收藏集合)。
@protected
void onItemsReplaced() {}
/// 每页数据写入后的异步钩子(如补取详情)。
@protected
Future<void> onPageLoaded(List<T> added) async {}
/// 子类做局部乐观更新时改写列表。
@protected
void setItems(List<T> next) => _items = next;
/// 子类做局部乐观更新时改写总数。
@protected
void setTotal(int value) => _total = value < 0 ? 0 : value;
/// 子类回滚分页游标。
@protected
void restorePageIndex(int value) => _pageIndex = value;
/// 子类清空/重置分页状态,并让在飞请求失效。
@protected
void resetPaging() {
_requestSeq += 1;
_items = const [];
_total = 0;
_pageIndex = 0;
loading = false;
loadingMore = false;
refreshing = false;
loaded = false;
error = null;
loadMoreError = null;
lastErrorCode = null;
}
/// 让在飞请求失效(切换身份、清空等)。
@protected
void invalidateRequests() => _requestSeq += 1;
/// 重新拉第一页;失败时保留旧数据。
Future<void> load() => _fetchFirstPage(refresh: false);
/// 刷新第一页,且不清空当前列表(滚动位置不跳)。
Future<void> refresh() => _fetchFirstPage(refresh: true);
/// 第一页请求会抢占在飞的分页请求:被抢占的一方不再复位标志,
/// 所以三个 loading 标志必须在这里统一接管,否则会永远停在 true。
Future<void> _fetchFirstPage({required bool refresh}) async {
final request = ++_requestSeq;
loading = !refresh;
refreshing = refresh;
loadingMore = false;
error = null;
loadMoreError = null;
lastErrorCode = null;
notify();
try {
final page = await fetchPage(1);
if (_stale(request)) return;
final list = _unique(page.list);
_items = list;
_total = page.total;
_pageIndex = 1;
loaded = true;
onItemsReplaced();
notify();
await onPageLoaded(list);
} catch (failure) {
if (!_stale(request)) _recordError(failure, more: false);
} finally {
if (!_stale(request)) {
loading = false;
refreshing = false;
notify();
}
}
}
/// 追加下一页;刷新中、加载中或没有更多时忽略。
Future<void> loadMore() async {
if (loading || loadingMore || refreshing || !hasMore) return;
final request = ++_requestSeq;
loadingMore = true;
loadMoreError = null;
lastErrorCode = null;
notify();
try {
final page = await fetchPage(_pageIndex + 1);
if (_stale(request)) return;
// 关掉去重的列表(如剧单)按服务端顺序原样追加,不做跨页过滤。
final appended = dedupe ? _newItems(page.list) : page.list;
_items = [..._items, ...appended];
// 服务端 total 偏大但已翻到空页时,按已加载数收口,避免反复请求空页。
_total = page.list.isEmpty ? _items.length : page.total;
_pageIndex += 1;
notify();
await onPageLoaded(appended);
} catch (failure) {
if (!_stale(request)) _recordError(failure, more: true);
} finally {
if (!_stale(request)) {
loadingMore = false;
notify();
}
}
}
List<T> _unique(List<T> source) {
if (!dedupe) return source;
final seen = <String>{};
return [
for (final item in source)
if (seen.add(itemKey(item))) item,
];
}
/// 追加页去重:既要过滤页内重复,也要过滤与已加载条目重复。
List<T> _newItems(List<T> source) {
final known = _items.map(itemKey).toSet();
return [
for (final item in source)
if (known.add(itemKey(item))) item,
];
}
void _recordError(Object failure, {required bool more}) {
if (kDebugMode) {
final phase = more ? 'load more' : 'load';
debugPrint('[$runtimeType] $phase failed: $failure');
}
final message = errorKeyFor(failure);
if (more) {
loadMoreError = message;
} else {
error = message;
}
if (failure is ApiException) lastErrorCode = failure.code;
}
bool _stale(int request) => _disposed || request != _requestSeq;
@protected
void notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+15 -64
View File
@@ -2,71 +2,32 @@
import 'package:flutter/foundation.dart';
import '../models/page_result.dart';
import '../models/playlist_detail.dart';
import '../remote/catalog_api.dart';
import '../repositories/playlist_repository.dart';
import 'paged_list_controller.dart';
/// 推荐页剧单:已启用剧单及其短剧卡片。
class PlaylistController extends ChangeNotifier {
PlaylistController(this._repository);
final PlaylistRepository _repository;
class PlaylistController extends PagedListController<PlaylistDetail> {
PlaylistController(this._repository)
: super(fallbackErrorKey: 'playlistsError', dedupe: false);
static const int pageSize = CatalogApi.playlistPageSize;
List<PlaylistDetail> items = const [];
bool loading = false;
bool loadingMore = false;
bool loaded = false;
bool hasMore = false;
String? error;
int _pageIndex = 1;
bool _disposed = false;
final PlaylistRepository _repository;
/// 从第一页重新拉取。失败保留错误态,成功后替换本地缓存。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final page = await _repository.fetchPlaylists(pageSize: pageSize);
items = page.list;
_pageIndex = 1;
hasMore = items.length < page.total;
loaded = true;
} catch (exception) {
error = 'playlistsError';
if (kDebugMode) debugPrint('[playlist] $exception');
} finally {
loading = false;
_notify();
}
@override
Future<PageResult<PlaylistDetail>> fetchPage(int pageIndex) {
return _repository.fetchPlaylists(pageIndex: pageIndex, pageSize: pageSize);
}
/// 滚到底时追加下一页。没有更多或正在请求时直接返回。
Future<void> loadMore() async {
if (loading || loadingMore || !hasMore) return;
loadingMore = true;
_notify();
try {
final page = await _repository.fetchPlaylists(
pageIndex: _pageIndex + 1,
pageSize: pageSize,
);
if (page.list.isEmpty) {
hasMore = false;
} else {
items = [...items, ...page.list];
_pageIndex += 1;
hasMore = items.length < page.total;
}
} catch (exception) {
if (kDebugMode) debugPrint('[playlist] more $exception');
} finally {
loadingMore = false;
_notify();
}
}
@override
String itemKey(PlaylistDetail item) => item.id;
/// 剧单页把失败统一收敛成固定 key,由页面翻译成文案。
@override
String? errorKeyFor(Object failure) => 'playlistsError';
/// 打开某一剧单时再取一次详情;失败则回落到列表里已有的那条。
Future<PlaylistDetail> detail(
@@ -94,14 +55,4 @@ class PlaylistController extends ChangeNotifier {
);
}
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -3,23 +3,26 @@
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../models/page_result.dart';
import '../models/watch_progress.dart';
import '../models/watch_record.dart';
import '../remote/api_client.dart';
import '../repositories/catalog_repository.dart';
import '../repositories/watch_history_repository.dart';
import 'paged_list_controller.dart';
/// 独立观看记录页状态。
///
/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。
class WatchRecordsController extends ChangeNotifier {
class WatchRecordsController extends PagedListController<WatchRecord> {
factory WatchRecordsController({
required WatchHistoryRepository history,
required CatalogRepository catalog,
int pageSize = defaultPageSize,
}) => WatchRecordsController._(history, catalog, pageSize);
WatchRecordsController._(this._history, this._catalog, this.pageSize);
WatchRecordsController._(this._history, this._catalog, this.pageSize)
: super(fallbackErrorKey: 'progressError');
static const int defaultPageSize = 20;
static const int detailConcurrency = 4;
@@ -29,98 +32,32 @@ class WatchRecordsController extends ChangeNotifier {
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? lastErrorCode;
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;
lastErrorCode = null;
_notify();
try {
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) {
debugPrint(
'[WatchRecordsController] recent failed '
'${failure.runtimeType}: $failure',
);
debugPrintStack(
stackTrace: stackTrace,
label: '[WatchRecordsController] recent failed',
);
if (!_disposed && request == _requestSeq) {
error = failure is ApiException ? failure.message : 'progressError';
if (failure is ApiException) lastErrorCode = failure.code;
}
} finally {
if (!_disposed && request == _requestSeq) {
loading = false;
_notify();
}
}
/// 当前已加载记录(按服务端顺序)。
List<WatchRecord> get records => items;
@override
Future<PageResult<WatchRecord>> fetchPage(int pageIndex) async {
final page = await _history.recent(
pageIndex: pageIndex,
pageSize: pageSize,
);
return PageResult(total: page.total, list: _uniqueRecords(page.list));
}
/// 追加下一页;刷新、加载中或无更多时忽略。
Future<void> loadMore() async {
if (loading || loadingMore || !hasMore) return;
final request = ++_requestSeq;
loadingMore = true;
loadMoreError = null;
lastErrorCode = 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';
if (failure is ApiException) lastErrorCode = failure.code;
}
} finally {
if (!_disposed && request == _requestSeq) {
loadingMore = false;
_notify();
}
}
}
@override
String itemKey(WatchRecord item) => item.dramaId;
/// 第一页成功替换后清掉上一轮的操作错误(如删除失败提示)。
@override
void onItemsReplaced() => actionError = null;
/// 只补缺标题的详情,单条失败不拖垮整页。
@override
Future<void> onPageLoaded(List<WatchRecord> added) =>
_hydrate(added.map((record) => record.dramaId).toList());
/// 只重试一条详情失败记录。
Future<void> retryDetail(String dramaId) async {
@@ -130,9 +67,9 @@ class WatchRecordsController extends ChangeNotifier {
index,
records[index].copyWith(detailLoading: true, detailFailed: false),
);
_notify();
notify();
await _loadDetail(dramaId);
_notify();
notify();
}
/// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。
@@ -140,7 +77,7 @@ class WatchRecordsController extends ChangeNotifier {
final cached = _detailCache[dramaId];
if (cached != null) return cached;
await _loadDetail(dramaId);
_notify();
notify();
return _detailCache[dramaId];
}
@@ -157,62 +94,66 @@ class WatchRecordsController extends ChangeNotifier {
final index = _recordIndex(dramaId);
if (index < 0) return true;
final removed = records[index];
records = [...records]..removeAt(index);
total = total > 0 ? total - 1 : 0;
hasMore = records.length < total;
setItems([...records]..removeAt(index));
setTotal(total - 1);
actionError = null;
lastErrorCode = null;
_notify();
notify();
try {
await _history.remove(dramaId);
return true;
} catch (failure) {
final next = [...records];
final next = [...items];
next.insert(index > next.length ? next.length : index, removed);
records = next;
total += 1;
hasMore = records.length < total;
setItems(next);
setTotal(total + 1);
actionError = 'recordDeleteFailed';
if (failure is ApiException) lastErrorCode = failure.code;
return false;
} finally {
_notify();
notify();
}
}
/// 清空全部记录,先更新 UI,失败恢复原列表。
Future<bool> clear() async {
if (records.isEmpty) return true;
_requestSeq += 1;
invalidateRequests();
final previous = records;
final previousTotal = total;
final previousPageIndex = _pageIndex;
records = const [];
total = 0;
hasMore = false;
_pageIndex = 0;
final previousPageIndex = pageIndex;
setItems(const []);
setTotal(0);
restorePageIndex(0);
loadingMore = false;
clearing = true;
actionError = null;
lastErrorCode = null;
_notify();
notify();
try {
await _history.clear();
return true;
} catch (failure) {
records = previous;
total = previousTotal;
_pageIndex = previousPageIndex;
hasMore = records.length < total;
setItems(previous);
setTotal(previousTotal);
restorePageIndex(previousPageIndex);
actionError = 'recordClearFailed';
if (failure is ApiException) lastErrorCode = failure.code;
return false;
} finally {
clearing = false;
_notify();
notify();
}
}
/// 切换账号/设备身份时清除内存数据,不删除服务端记录。
void reset() {
resetPaging();
clearing = false;
actionError = null;
notify();
}
List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) {
final seen = <String>{};
return [
@@ -239,7 +180,7 @@ class WatchRecordsController extends ChangeNotifier {
? queue.length
: detailConcurrency;
Future<void> worker() async {
while (queue.isNotEmpty && !_disposed) {
while (queue.isNotEmpty && !isDisposed) {
await _loadDetail(queue.removeAt(0));
}
}
@@ -272,7 +213,7 @@ class WatchRecordsController extends ChangeNotifier {
}
try {
final detail = await _catalog.fetchDrama(dramaId);
if (_disposed) return null;
if (isDisposed) return null;
_detailCache[dramaId] = detail;
final current = _recordIndex(dramaId);
if (current < 0) return detail;
@@ -294,7 +235,7 @@ class WatchRecordsController extends ChangeNotifier {
stackTrace: stackTrace,
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
);
if (_disposed) return null;
if (isDisposed) return null;
final current = _recordIndex(dramaId);
if (current < 0) return null;
_replaceAt(
@@ -315,33 +256,6 @@ class WatchRecordsController extends ChangeNotifier {
void _replaceAt(int index, WatchRecord record) {
final next = [...records];
next[index] = record;
records = next;
}
void _notify() {
if (!_disposed) notifyListeners();
}
/// 切换账号/设备身份时清除内存数据,不删除服务端记录。
void reset() {
_requestSeq += 1;
records = const [];
total = 0;
loading = false;
loadingMore = false;
hasMore = false;
clearing = false;
error = null;
loadMoreError = null;
actionError = null;
lastErrorCode = null;
_pageIndex = 0;
_notify();
}
@override
void dispose() {
_disposed = true;
super.dispose();
setItems(next);
}
}