refactor(paging): 抽取分页控制器基类收敛四个列表页
收藏、观看记录、剧单、公告各自手写 loading 互斥、请求序号、跨页去重与 错误映射,修一处要同步改四份。统一到 PagedListController 后子类只提供 取页与条目标识;空页按已加载数收口,避免 total 虚大时反复请求。
This commit is contained in:
1 parent
9c9a277217
commit
a4d08e7c1e
7 files changed
+503
-456
No files matched your search
@@ -1,17 +1,18 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
import '../models/drama.dart';
|
import '../models/drama.dart';
|
||||||
|
import '../models/page_result.dart';
|
||||||
import '../remote/api_client.dart';
|
import '../remote/api_client.dart';
|
||||||
import '../repositories/favorite_repository.dart';
|
import '../repositories/favorite_repository.dart';
|
||||||
|
import 'paged_list_controller.dart';
|
||||||
|
|
||||||
/// 收藏状态:**以服务端为准**。
|
/// 收藏状态:**以服务端为准**。
|
||||||
///
|
///
|
||||||
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
|
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
|
||||||
/// "点了没反应"或"看起来收藏了其实没有"。
|
/// "点了没反应"或"看起来收藏了其实没有"。
|
||||||
class FavoriteController extends ChangeNotifier {
|
class FavoriteController extends PagedListController<Drama> {
|
||||||
FavoriteController(this._repository, {this.pageSize = defaultPageSize});
|
FavoriteController(this._repository, {this.pageSize = defaultPageSize})
|
||||||
|
: super(fallbackErrorKey: 'favoritesError');
|
||||||
|
|
||||||
static const int defaultPageSize = 20;
|
static const int defaultPageSize = 20;
|
||||||
|
|
||||||
@@ -19,17 +20,9 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
final int pageSize;
|
final int pageSize;
|
||||||
final Set<String> _saved = {};
|
final Set<String> _saved = {};
|
||||||
final Map<String, int> _favoriteCounts = {};
|
final Map<String, int> _favoriteCounts = {};
|
||||||
List<Drama> cards = const [];
|
|
||||||
int total = 0;
|
/// 收藏列表卡片(按服务端顺序)。
|
||||||
bool loading = false;
|
List<Drama> get cards => items;
|
||||||
bool loadingMore = false;
|
|
||||||
bool hasMore = false;
|
|
||||||
String? error;
|
|
||||||
String? loadMoreError;
|
|
||||||
int? lastErrorCode;
|
|
||||||
int _pageIndex = 0;
|
|
||||||
int _requestSeq = 0;
|
|
||||||
bool _disposed = false;
|
|
||||||
|
|
||||||
Set<String> get savedIds => Set.unmodifiable(_saved);
|
Set<String> get savedIds => Set.unmodifiable(_saved);
|
||||||
|
|
||||||
@@ -38,6 +31,33 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
/// 当前剧收藏数;包含本地 toggle 增量。
|
/// 当前剧收藏数;包含本地 toggle 增量。
|
||||||
int? favoriteCount(String dramaId) => _favoriteCounts[dramaId];
|
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) {
|
int? favoriteCountFor(Drama drama) {
|
||||||
final current = _favoriteCounts[drama.id];
|
final current = _favoriteCounts[drama.id];
|
||||||
@@ -82,81 +102,6 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
if (notify) this.notify();
|
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] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
|
/// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
|
||||||
@@ -169,15 +114,13 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
: favoriteCountFor(card);
|
: favoriteCountFor(card);
|
||||||
final previousCards = cards;
|
final previousCards = cards;
|
||||||
final previousTotal = total;
|
final previousTotal = total;
|
||||||
final previousHasMore = hasMore;
|
|
||||||
// 乐观更新
|
// 乐观更新
|
||||||
if (wasSaved) {
|
if (wasSaved) {
|
||||||
_saved.remove(dramaId);
|
_saved.remove(dramaId);
|
||||||
_stepCount(dramaId, -1);
|
_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();
|
setItems(cards.where((drama) => drama.id != dramaId).toList());
|
||||||
total = total > 0 ? total - 1 : 0;
|
setTotal(total - 1);
|
||||||
hasMore = cards.length < total;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_saved.add(dramaId);
|
_saved.add(dramaId);
|
||||||
@@ -191,13 +134,11 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
_saved.add(dramaId);
|
_saved.add(dramaId);
|
||||||
if (wasSaved) {
|
if (wasSaved) {
|
||||||
_restoreCount(dramaId, previousCount);
|
_restoreCount(dramaId, previousCount);
|
||||||
cards = previousCards;
|
setItems(previousCards);
|
||||||
total = previousTotal;
|
setTotal(previousTotal);
|
||||||
hasMore = previousHasMore;
|
|
||||||
} else if (card != null && !cards.any((drama) => drama.id == dramaId)) {
|
} else if (card != null && !cards.any((drama) => drama.id == dramaId)) {
|
||||||
cards = [card, ...cards];
|
setItems([card, ...cards]);
|
||||||
total += 1;
|
setTotal(total + 1);
|
||||||
hasMore = cards.length < total;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_saved.remove(dramaId);
|
_saved.remove(dramaId);
|
||||||
@@ -205,18 +146,16 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
_restoreCount(dramaId, previousCount);
|
_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();
|
setItems(cards.where((drama) => drama.id != dramaId).toList());
|
||||||
total = total > 0 ? total - 1 : 0;
|
setTotal(total - 1);
|
||||||
hasMore = cards.length < total;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notify();
|
notify();
|
||||||
return true;
|
return true;
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
// 回滚
|
// 回滚
|
||||||
cards = previousCards;
|
setItems(previousCards);
|
||||||
total = previousTotal;
|
setTotal(previousTotal);
|
||||||
hasMore = previousHasMore;
|
|
||||||
if (wasSaved) {
|
if (wasSaved) {
|
||||||
_saved.add(dramaId);
|
_saved.add(dramaId);
|
||||||
} else {
|
} else {
|
||||||
@@ -235,27 +174,24 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
if (ids.isEmpty) return true;
|
if (ids.isEmpty) return true;
|
||||||
final previousCards = cards;
|
final previousCards = cards;
|
||||||
final previousTotal = total;
|
final previousTotal = total;
|
||||||
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 = {
|
final previousCounts = {
|
||||||
for (final drama in removed) drama.id: _favoriteCounts[drama.id],
|
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) {
|
for (final id in ids) {
|
||||||
_saved.remove(id);
|
_saved.remove(id);
|
||||||
_stepCount(id, -1);
|
_stepCount(id, -1);
|
||||||
}
|
}
|
||||||
total = total > removed.length ? total - removed.length : 0;
|
setTotal(total - removed.length);
|
||||||
hasMore = cards.length < total;
|
|
||||||
notify();
|
notify();
|
||||||
try {
|
try {
|
||||||
await _repository.removeMany(dramaIds);
|
await _repository.removeMany(dramaIds);
|
||||||
return true;
|
return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
cards = previousCards;
|
setItems(previousCards);
|
||||||
total = previousTotal;
|
setTotal(previousTotal);
|
||||||
hasMore = previousHasMore;
|
|
||||||
for (final drama in removed) {
|
for (final drama in removed) {
|
||||||
_saved.add(drama.id);
|
_saved.add(drama.id);
|
||||||
}
|
}
|
||||||
@@ -276,9 +212,8 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
_saved.remove(dramaId);
|
_saved.remove(dramaId);
|
||||||
_stepCount(dramaId, -1);
|
_stepCount(dramaId, -1);
|
||||||
final next = [...cards]..removeAt(index);
|
final next = [...cards]..removeAt(index);
|
||||||
cards = next;
|
setItems(next);
|
||||||
total = total > 0 ? total - 1 : 0;
|
setTotal(total - 1);
|
||||||
hasMore = cards.length < total;
|
|
||||||
notify();
|
notify();
|
||||||
try {
|
try {
|
||||||
await _repository.remove(dramaId);
|
await _repository.remove(dramaId);
|
||||||
@@ -289,11 +224,10 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
index > restored.length ? restored.length : index,
|
index > restored.length ? restored.length : index,
|
||||||
removed,
|
removed,
|
||||||
);
|
);
|
||||||
cards = restored;
|
setItems(restored);
|
||||||
_saved.add(dramaId);
|
_saved.add(dramaId);
|
||||||
_restoreCount(dramaId, previousCount);
|
_restoreCount(dramaId, previousCount);
|
||||||
total += 1;
|
setTotal(total + 1);
|
||||||
hasMore = cards.length < total;
|
|
||||||
notify();
|
notify();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -301,27 +235,11 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
|
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
|
||||||
void clear() {
|
void clear() {
|
||||||
_requestSeq += 1;
|
resetPaging();
|
||||||
_saved.clear();
|
_saved.clear();
|
||||||
cards = const [];
|
|
||||||
total = 0;
|
|
||||||
loading = false;
|
|
||||||
hasMore = false;
|
|
||||||
loadingMore = false;
|
|
||||||
loadMoreError = null;
|
|
||||||
error = null;
|
|
||||||
_pageIndex = 0;
|
|
||||||
notify();
|
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) {
|
void _rememberCardCounts(List<Drama> dramas) {
|
||||||
for (final drama in dramas) {
|
for (final drama in dramas) {
|
||||||
final count = drama.favoriteCount;
|
final count = drama.favoriteCount;
|
||||||
@@ -345,10 +263,4 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
_favoriteCounts[dramaId] = count;
|
_favoriteCounts[dramaId] = count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_disposed = true;
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,118 +1,30 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
import '../models/notice.dart';
|
import '../models/notice.dart';
|
||||||
import '../remote/api_client.dart';
|
import '../models/page_result.dart';
|
||||||
import '../repositories/notice_repository.dart';
|
import '../repositories/notice_repository.dart';
|
||||||
|
import 'paged_list_controller.dart';
|
||||||
|
|
||||||
/// Pagination and state controller for the anonymous notices page.
|
/// 公告页状态:匿名可看,按页追加。
|
||||||
class NoticesController extends ChangeNotifier {
|
class NoticesController extends PagedListController<AppNotice> {
|
||||||
NoticesController(this._repository, {this.pageSize = 20});
|
NoticesController(this._repository, {this.pageSize = 20})
|
||||||
|
: super(fallbackErrorKey: 'noticesLoadFailed');
|
||||||
|
|
||||||
final NoticeRepository _repository;
|
final NoticeRepository _repository;
|
||||||
final int pageSize;
|
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.
|
@override
|
||||||
bool get hasMore => _notices.length < _total;
|
Future<PageResult<AppNotice>> fetchPage(int pageIndex) async {
|
||||||
|
final page = await _repository.page(
|
||||||
/// Loads the first page.
|
pageIndex: pageIndex,
|
||||||
Future<void> load() async {
|
pageSize: pageSize,
|
||||||
loading = true;
|
);
|
||||||
error = null;
|
return PageResult(total: page.total, list: page.notices);
|
||||||
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
|
@override
|
||||||
void dispose() {
|
String itemKey(AppNotice item) => item.id;
|
||||||
_disposed = true;
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,71 +2,32 @@
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/page_result.dart';
|
||||||
import '../models/playlist_detail.dart';
|
import '../models/playlist_detail.dart';
|
||||||
import '../remote/catalog_api.dart';
|
import '../remote/catalog_api.dart';
|
||||||
import '../repositories/playlist_repository.dart';
|
import '../repositories/playlist_repository.dart';
|
||||||
|
import 'paged_list_controller.dart';
|
||||||
|
|
||||||
/// 推荐页剧单:已启用剧单及其短剧卡片。
|
/// 推荐页剧单:已启用剧单及其短剧卡片。
|
||||||
class PlaylistController extends ChangeNotifier {
|
class PlaylistController extends PagedListController<PlaylistDetail> {
|
||||||
PlaylistController(this._repository);
|
PlaylistController(this._repository)
|
||||||
|
: super(fallbackErrorKey: 'playlistsError', dedupe: false);
|
||||||
final PlaylistRepository _repository;
|
|
||||||
|
|
||||||
static const int pageSize = CatalogApi.playlistPageSize;
|
static const int pageSize = CatalogApi.playlistPageSize;
|
||||||
|
|
||||||
List<PlaylistDetail> items = const [];
|
final PlaylistRepository _repository;
|
||||||
bool loading = false;
|
|
||||||
bool loadingMore = false;
|
|
||||||
bool loaded = false;
|
|
||||||
bool hasMore = false;
|
|
||||||
String? error;
|
|
||||||
int _pageIndex = 1;
|
|
||||||
bool _disposed = false;
|
|
||||||
|
|
||||||
/// 从第一页重新拉取。失败保留错误态,成功后替换本地缓存。
|
@override
|
||||||
Future<void> load() async {
|
Future<PageResult<PlaylistDetail>> fetchPage(int pageIndex) {
|
||||||
loading = true;
|
return _repository.fetchPlaylists(pageIndex: pageIndex, pageSize: pageSize);
|
||||||
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<void> loadMore() async {
|
String itemKey(PlaylistDetail item) => item.id;
|
||||||
if (loading || loadingMore || !hasMore) return;
|
|
||||||
loadingMore = true;
|
/// 剧单页把失败统一收敛成固定 key,由页面翻译成文案。
|
||||||
_notify();
|
@override
|
||||||
try {
|
String? errorKeyFor(Object failure) => 'playlistsError';
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 打开某一剧单时再取一次详情;失败则回落到列表里已有的那条。
|
/// 打开某一剧单时再取一次详情;失败则回落到列表里已有的那条。
|
||||||
Future<PlaylistDetail> detail(
|
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 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../models/drama.dart';
|
import '../models/drama.dart';
|
||||||
|
import '../models/page_result.dart';
|
||||||
import '../models/watch_progress.dart';
|
import '../models/watch_progress.dart';
|
||||||
import '../models/watch_record.dart';
|
import '../models/watch_record.dart';
|
||||||
import '../remote/api_client.dart';
|
import '../remote/api_client.dart';
|
||||||
import '../repositories/catalog_repository.dart';
|
import '../repositories/catalog_repository.dart';
|
||||||
import '../repositories/watch_history_repository.dart';
|
import '../repositories/watch_history_repository.dart';
|
||||||
|
import 'paged_list_controller.dart';
|
||||||
|
|
||||||
/// 独立观看记录页状态。
|
/// 独立观看记录页状态。
|
||||||
///
|
///
|
||||||
/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。
|
/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。
|
||||||
class WatchRecordsController extends ChangeNotifier {
|
class WatchRecordsController extends PagedListController<WatchRecord> {
|
||||||
factory WatchRecordsController({
|
factory WatchRecordsController({
|
||||||
required WatchHistoryRepository history,
|
required WatchHistoryRepository history,
|
||||||
required CatalogRepository catalog,
|
required CatalogRepository catalog,
|
||||||
int pageSize = defaultPageSize,
|
int pageSize = defaultPageSize,
|
||||||
}) => WatchRecordsController._(history, catalog, pageSize);
|
}) => 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 defaultPageSize = 20;
|
||||||
static const int detailConcurrency = 4;
|
static const int detailConcurrency = 4;
|
||||||
@@ -29,98 +32,32 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
final int pageSize;
|
final int pageSize;
|
||||||
final Map<String, Drama> _detailCache = {};
|
final Map<String, Drama> _detailCache = {};
|
||||||
|
|
||||||
List<WatchRecord> records = const [];
|
|
||||||
int total = 0;
|
|
||||||
bool loading = false;
|
|
||||||
bool loadingMore = false;
|
|
||||||
bool hasMore = false;
|
|
||||||
bool clearing = false;
|
bool clearing = false;
|
||||||
String? error;
|
|
||||||
String? loadMoreError;
|
|
||||||
String? actionError;
|
String? actionError;
|
||||||
int? lastErrorCode;
|
|
||||||
int _pageIndex = 0;
|
|
||||||
int _requestSeq = 0;
|
|
||||||
bool _disposed = false;
|
|
||||||
|
|
||||||
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
|
/// 当前已加载记录(按服务端顺序)。
|
||||||
Future<void> load() async {
|
List<WatchRecord> get records => items;
|
||||||
final request = ++_requestSeq;
|
|
||||||
loading = true;
|
@override
|
||||||
loadingMore = false;
|
Future<PageResult<WatchRecord>> fetchPage(int pageIndex) async {
|
||||||
error = null;
|
final page = await _history.recent(
|
||||||
loadMoreError = null;
|
pageIndex: pageIndex,
|
||||||
actionError = null;
|
pageSize: pageSize,
|
||||||
lastErrorCode = null;
|
);
|
||||||
_notify();
|
return PageResult(total: page.total, list: _uniqueRecords(page.list));
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 追加下一页;刷新、加载中或无更多时忽略。
|
@override
|
||||||
Future<void> loadMore() async {
|
String itemKey(WatchRecord item) => item.dramaId;
|
||||||
if (loading || loadingMore || !hasMore) return;
|
|
||||||
final request = ++_requestSeq;
|
/// 第一页成功替换后清掉上一轮的操作错误(如删除失败提示)。
|
||||||
loadingMore = true;
|
@override
|
||||||
loadMoreError = null;
|
void onItemsReplaced() => actionError = null;
|
||||||
lastErrorCode = null;
|
|
||||||
_notify();
|
/// 只补缺标题的详情,单条失败不拖垮整页。
|
||||||
try {
|
@override
|
||||||
final page = await _history.recent(
|
Future<void> onPageLoaded(List<WatchRecord> added) =>
|
||||||
pageIndex: _pageIndex + 1,
|
_hydrate(added.map((record) => record.dramaId).toList());
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 只重试一条详情失败记录。
|
/// 只重试一条详情失败记录。
|
||||||
Future<void> retryDetail(String dramaId) async {
|
Future<void> retryDetail(String dramaId) async {
|
||||||
@@ -130,9 +67,9 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
index,
|
index,
|
||||||
records[index].copyWith(detailLoading: true, detailFailed: false),
|
records[index].copyWith(detailLoading: true, detailFailed: false),
|
||||||
);
|
);
|
||||||
_notify();
|
notify();
|
||||||
await _loadDetail(dramaId);
|
await _loadDetail(dramaId);
|
||||||
_notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。
|
/// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。
|
||||||
@@ -140,7 +77,7 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
final cached = _detailCache[dramaId];
|
final cached = _detailCache[dramaId];
|
||||||
if (cached != null) return cached;
|
if (cached != null) return cached;
|
||||||
await _loadDetail(dramaId);
|
await _loadDetail(dramaId);
|
||||||
_notify();
|
notify();
|
||||||
return _detailCache[dramaId];
|
return _detailCache[dramaId];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,62 +94,66 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
final index = _recordIndex(dramaId);
|
final index = _recordIndex(dramaId);
|
||||||
if (index < 0) return true;
|
if (index < 0) return true;
|
||||||
final removed = records[index];
|
final removed = records[index];
|
||||||
records = [...records]..removeAt(index);
|
setItems([...records]..removeAt(index));
|
||||||
total = total > 0 ? total - 1 : 0;
|
setTotal(total - 1);
|
||||||
hasMore = records.length < total;
|
|
||||||
actionError = null;
|
actionError = null;
|
||||||
lastErrorCode = null;
|
lastErrorCode = null;
|
||||||
_notify();
|
notify();
|
||||||
try {
|
try {
|
||||||
await _history.remove(dramaId);
|
await _history.remove(dramaId);
|
||||||
return true;
|
return true;
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
final next = [...records];
|
final next = [...items];
|
||||||
next.insert(index > next.length ? next.length : index, removed);
|
next.insert(index > next.length ? next.length : index, removed);
|
||||||
records = next;
|
setItems(next);
|
||||||
total += 1;
|
setTotal(total + 1);
|
||||||
hasMore = records.length < total;
|
|
||||||
actionError = 'recordDeleteFailed';
|
actionError = 'recordDeleteFailed';
|
||||||
if (failure is ApiException) lastErrorCode = failure.code;
|
if (failure is ApiException) lastErrorCode = failure.code;
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
_notify();
|
notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清空全部记录,先更新 UI,失败恢复原列表。
|
/// 清空全部记录,先更新 UI,失败恢复原列表。
|
||||||
Future<bool> clear() async {
|
Future<bool> clear() async {
|
||||||
if (records.isEmpty) return true;
|
if (records.isEmpty) return true;
|
||||||
_requestSeq += 1;
|
invalidateRequests();
|
||||||
final previous = records;
|
final previous = records;
|
||||||
final previousTotal = total;
|
final previousTotal = total;
|
||||||
final previousPageIndex = _pageIndex;
|
final previousPageIndex = pageIndex;
|
||||||
records = const [];
|
setItems(const []);
|
||||||
total = 0;
|
setTotal(0);
|
||||||
hasMore = false;
|
restorePageIndex(0);
|
||||||
_pageIndex = 0;
|
|
||||||
loadingMore = false;
|
loadingMore = false;
|
||||||
clearing = true;
|
clearing = true;
|
||||||
actionError = null;
|
actionError = null;
|
||||||
lastErrorCode = null;
|
lastErrorCode = null;
|
||||||
_notify();
|
notify();
|
||||||
try {
|
try {
|
||||||
await _history.clear();
|
await _history.clear();
|
||||||
return true;
|
return true;
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
records = previous;
|
setItems(previous);
|
||||||
total = previousTotal;
|
setTotal(previousTotal);
|
||||||
_pageIndex = previousPageIndex;
|
restorePageIndex(previousPageIndex);
|
||||||
hasMore = records.length < total;
|
|
||||||
actionError = 'recordClearFailed';
|
actionError = 'recordClearFailed';
|
||||||
if (failure is ApiException) lastErrorCode = failure.code;
|
if (failure is ApiException) lastErrorCode = failure.code;
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
clearing = false;
|
clearing = false;
|
||||||
_notify();
|
notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 切换账号/设备身份时清除内存数据,不删除服务端记录。
|
||||||
|
void reset() {
|
||||||
|
resetPaging();
|
||||||
|
clearing = false;
|
||||||
|
actionError = null;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) {
|
List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) {
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
return [
|
return [
|
||||||
@@ -239,7 +180,7 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
? queue.length
|
? queue.length
|
||||||
: detailConcurrency;
|
: detailConcurrency;
|
||||||
Future<void> worker() async {
|
Future<void> worker() async {
|
||||||
while (queue.isNotEmpty && !_disposed) {
|
while (queue.isNotEmpty && !isDisposed) {
|
||||||
await _loadDetail(queue.removeAt(0));
|
await _loadDetail(queue.removeAt(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,7 +213,7 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final detail = await _catalog.fetchDrama(dramaId);
|
final detail = await _catalog.fetchDrama(dramaId);
|
||||||
if (_disposed) return null;
|
if (isDisposed) return null;
|
||||||
_detailCache[dramaId] = detail;
|
_detailCache[dramaId] = detail;
|
||||||
final current = _recordIndex(dramaId);
|
final current = _recordIndex(dramaId);
|
||||||
if (current < 0) return detail;
|
if (current < 0) return detail;
|
||||||
@@ -294,7 +235,7 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||||
);
|
);
|
||||||
if (_disposed) return null;
|
if (isDisposed) return null;
|
||||||
final current = _recordIndex(dramaId);
|
final current = _recordIndex(dramaId);
|
||||||
if (current < 0) return null;
|
if (current < 0) return null;
|
||||||
_replaceAt(
|
_replaceAt(
|
||||||
@@ -315,33 +256,6 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
void _replaceAt(int index, WatchRecord record) {
|
void _replaceAt(int index, WatchRecord record) {
|
||||||
final next = [...records];
|
final next = [...records];
|
||||||
next[index] = record;
|
next[index] = record;
|
||||||
records = next;
|
setItems(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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<int> {
|
||||||
|
_NumberController(this.pages, {super.fallbackErrorKey});
|
||||||
|
|
||||||
|
final List<PageResult<int>> pages;
|
||||||
|
final List<int> requested = [];
|
||||||
|
Object? failure;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PageResult<int>> 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<PageResult<int>>();
|
||||||
|
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<int> {
|
||||||
|
_DeferredController(this._pages);
|
||||||
|
|
||||||
|
final List<Future<PageResult<int>> Function()> _pages;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PageResult<int>> fetchPage(int pageIndex) => _pages.removeAt(0)();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String itemKey(int item) => '$item';
|
||||||
|
}
|
||||||
@@ -22,12 +22,25 @@ void main() {
|
|||||||
|
|
||||||
controller.dispose();
|
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 {
|
class _PagedPlaylists implements PlaylistRepository {
|
||||||
_PagedPlaylists({required this.total});
|
_PagedPlaylists({required this.total, this.repeatLastOfFirstPage = false});
|
||||||
|
|
||||||
final int total;
|
final int total;
|
||||||
|
final bool repeatLastOfFirstPage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PageResult<PlaylistDetail>> fetchPlaylists({
|
Future<PageResult<PlaylistDetail>> fetchPlaylists({
|
||||||
@@ -44,7 +57,7 @@ class _PagedPlaylists implements PlaylistRepository {
|
|||||||
list: [
|
list: [
|
||||||
for (var index = start; index < end; index++)
|
for (var index = start; index < end; index++)
|
||||||
PlaylistDetail(
|
PlaylistDetail(
|
||||||
id: '$index',
|
id: '${repeatLastOfFirstPage && index == pageSize ? start - 1 : index}',
|
||||||
name: '剧单$index',
|
name: '剧单$index',
|
||||||
description: '',
|
description: '',
|
||||||
dramaCount: 0,
|
dramaCount: 0,
|
||||||
|
|||||||
Reference in new issue
Block a user