diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index 29a473a..1ff7519 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -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 { + 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 _saved = {}; final Map _favoriteCounts = {}; - List 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 get cards => items; Set 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> 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 onPageLoaded(List 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 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 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 _uniqueCards(List source) { - final seen = {}; - return [ - for (final drama in source) - if (seen.add(drama.id)) drama, - ]; - } - void _rememberCardCounts(List 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(); - } } diff --git a/lib/core/data/services/notices_controller.dart b/lib/core/data/services/notices_controller.dart index 6bbf71e..2270641 100644 --- a/lib/core/data/services/notices_controller.dart +++ b/lib/core/data/services/notices_controller.dart @@ -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 { + NoticesController(this._repository, {this.pageSize = 20}) + : super(fallbackErrorKey: 'noticesLoadFailed'); final NoticeRepository _repository; final int pageSize; - final List _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 get notices => List.unmodifiable(_notices); + /// 当前已加载公告(服务端顺序)。 + List get notices => items; - /// Whether another page is expected from the latest total. - bool get hasMore => _notices.length < _total; - - /// Loads the first page. - Future 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 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 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 _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 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> 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; } diff --git a/lib/core/data/services/paged_list_controller.dart b/lib/core/data/services/paged_list_controller.dart new file mode 100644 index 0000000..e85ef7f --- /dev/null +++ b/lib/core/data/services/paged_list_controller.dart @@ -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 extends ChangeNotifier { + PagedListController({this.fallbackErrorKey, this.dedupe = true}); + + /// 非 [ApiException] 的失败文案 key(页面用 `errorText` 翻译)。 + final String? fallbackErrorKey; + + /// 是否跨页按 [itemKey] 去重。 + final bool dedupe; + + List _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 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> 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 onPageLoaded(List added) async {} + + /// 子类做局部乐观更新时改写列表。 + @protected + void setItems(List 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 load() => _fetchFirstPage(refresh: false); + + /// 刷新第一页,且不清空当前列表(滚动位置不跳)。 + Future refresh() => _fetchFirstPage(refresh: true); + + /// 第一页请求会抢占在飞的分页请求:被抢占的一方不再复位标志, + /// 所以三个 loading 标志必须在这里统一接管,否则会永远停在 true。 + Future _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 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 _unique(List source) { + if (!dedupe) return source; + final seen = {}; + return [ + for (final item in source) + if (seen.add(itemKey(item))) item, + ]; + } + + /// 追加页去重:既要过滤页内重复,也要过滤与已加载条目重复。 + List _newItems(List 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(); + } +} diff --git a/lib/core/data/services/playlist_controller.dart b/lib/core/data/services/playlist_controller.dart index 4e5935c..0d0fa50 100644 --- a/lib/core/data/services/playlist_controller.dart +++ b/lib/core/data/services/playlist_controller.dart @@ -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 { + PlaylistController(this._repository) + : super(fallbackErrorKey: 'playlistsError', dedupe: false); static const int pageSize = CatalogApi.playlistPageSize; - List 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 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> fetchPage(int pageIndex) { + return _repository.fetchPlaylists(pageIndex: pageIndex, pageSize: pageSize); } - /// 滚到底时追加下一页。没有更多或正在请求时直接返回。 - Future 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 detail( @@ -94,14 +55,4 @@ class PlaylistController extends ChangeNotifier { ); } } - - void _notify() { - if (!_disposed) notifyListeners(); - } - - @override - void dispose() { - _disposed = true; - super.dispose(); - } } diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index 3ff2094..ad3f0c8 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -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 { 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 _detailCache = {}; - List 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 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 get records => items; + + @override + Future> fetchPage(int pageIndex) async { + final page = await _history.recent( + pageIndex: pageIndex, + pageSize: pageSize, + ); + return PageResult(total: page.total, list: _uniqueRecords(page.list)); } - /// 追加下一页;刷新、加载中或无更多时忽略。 - Future 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 onPageLoaded(List added) => + _hydrate(added.map((record) => record.dramaId).toList()); /// 只重试一条详情失败记录。 Future 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 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 _uniqueRecords(List recent) { final seen = {}; return [ @@ -239,7 +180,7 @@ class WatchRecordsController extends ChangeNotifier { ? queue.length : detailConcurrency; Future 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); } } diff --git a/test/core/paged_list_controller_test.dart b/test/core/paged_list_controller_test.dart new file mode 100644 index 0000000..ee77f63 --- /dev/null +++ b/test/core/paged_list_controller_test.dart @@ -0,0 +1,129 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/services/paged_list_controller.dart'; + +/// 用最薄的子类验证基类契约:分页、去重、空页收口与错误映射。 +class _NumberController extends PagedListController { + _NumberController(this.pages, {super.fallbackErrorKey}); + + final List> pages; + final List requested = []; + Object? failure; + + @override + Future> fetchPage(int pageIndex) async { + requested.add(pageIndex); + if (failure != null) throw failure!; + if (pages.isEmpty) return const PageResult(); + return pages.removeAt(0); + } + + @override + String itemKey(int item) => '$item'; + + /// 基类的失效入口是 protected,测试里包一层。 + void invalidate() => invalidateRequests(); +} + +void main() { + test('should_append_only_new_items_when_next_page_overlaps', () async { + final controller = _NumberController([ + const PageResult(total: 4, list: [1, 2]), + const PageResult(total: 4, list: [2, 3]), + ]); + + await controller.load(); + await controller.loadMore(); + + expect(controller.items, [1, 2, 3]); + expect(controller.requested, [1, 2]); + controller.dispose(); + }); + + test('should_stop_paging_when_empty_page_arrives', () async { + final controller = _NumberController([ + const PageResult(total: 99, list: [1]), + const PageResult(total: 99, list: []), + ]); + + await controller.load(); + expect(controller.hasMore, isTrue); + + await controller.loadMore(); + expect(controller.hasMore, isFalse); + await controller.loadMore(); + expect(controller.requested, [1, 2]); + controller.dispose(); + }); + + test('should_expose_backend_message_when_api_fails', () async { + final controller = _NumberController([]) + ..failure = const ApiException(500, '服务暂不可用'); + + await controller.load(); + + expect(controller.error, '服务暂不可用'); + expect(controller.lastErrorCode, 500); + controller.dispose(); + }); + + test('should_fall_back_to_key_when_failure_is_not_api_error', () async { + final controller = _NumberController([], fallbackErrorKey: 'loadFailed') + ..failure = StateError('boom'); + + await controller.load(); + + expect(controller.error, 'loadFailed'); + controller.dispose(); + }); + + test('should_ignore_stale_response_when_invalidated', () async { + final controller = _NumberController([ + const PageResult(total: 1, list: [1]), + ]); + + final pending = controller.load(); + controller.invalidate(); + await pending; + + expect(controller.items, isEmpty); + expect(controller.loaded, isFalse); + controller.dispose(); + }); + + test('should_not_stick_loading_when_refresh_supersedes_load', () async { + final first = Completer>(); + final controller = _DeferredController([ + () => first.future, + () async => const PageResult(total: 1, list: [2]), + ]); + + final loading = controller.load(); + final refreshing = controller.refresh(); + first.complete(const PageResult(total: 1, list: [1])); + await Future.wait([loading, refreshing]); + + expect(controller.loading, isFalse); + expect(controller.refreshing, isFalse); + expect(controller.items, [2]); + controller.dispose(); + }); +} + +/// 让每个请求各自挂起,用来构造「后发请求抢占先发请求」的时序。 +class _DeferredController extends PagedListController { + _DeferredController(this._pages); + + final List> Function()> _pages; + + @override + Future> fetchPage(int pageIndex) => _pages.removeAt(0)(); + + @override + String itemKey(int item) => '$item'; +} diff --git a/test/core/playlist_controller_test.dart b/test/core/playlist_controller_test.dart index 7898e25..04d0ef6 100644 --- a/test/core/playlist_controller_test.dart +++ b/test/core/playlist_controller_test.dart @@ -22,12 +22,25 @@ void main() { controller.dispose(); }); + + test('should_keep_duplicate_ids_when_repository_repeats_items', () async { + // 剧单不去重:服务端分页错位时按原样展示,不能悄悄吞掉条目。 + final repository = _PagedPlaylists(total: 12, repeatLastOfFirstPage: true); + final controller = PlaylistController(repository); + + await controller.load(); + await controller.loadMore(); + + expect(controller.items, hasLength(12)); + controller.dispose(); + }); } class _PagedPlaylists implements PlaylistRepository { - _PagedPlaylists({required this.total}); + _PagedPlaylists({required this.total, this.repeatLastOfFirstPage = false}); final int total; + final bool repeatLastOfFirstPage; @override Future> fetchPlaylists({ @@ -44,7 +57,7 @@ class _PagedPlaylists implements PlaylistRepository { list: [ for (var index = start; index < end; index++) PlaylistDetail( - id: '$index', + id: '${repeatLastOfFirstPage && index == pageSize ? start - 1 : index}', name: '剧单$index', description: '', dramaCount: 0,