feat(flutter): 接入分类记录收藏与下载授权

This commit is contained in:
Zhou Weidong committed 2026-10-17 14:49:16 +08:00
1 parent 00280ca0da
commit cc4d7c5a9d
52 files changed
+4440 -171

No files matched your search

@@ -0,0 +1,82 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/content_category.dart';
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../repositories/category_repository.dart';
/// 发现页分类状态:服务端分类清单、发现 feed 与当前筛选。
class CategoryController extends ChangeNotifier {
CategoryController(this._repository);
static const String allCode = 'all';
static const String hotCode = 'hot';
static const String newCode = 'new';
final CategoryRepository _repository;
List<ContentCategory> categories = const [];
HomeFeed feed = const HomeFeed();
String selectedCode = allCode;
bool loading = false;
String? error;
bool _disposed = false;
/// 当前筛选对应的分区 code;全部分类为空字符串语义。
bool get selectedSectionMissing =>
selectedCode != allCode && _selectedSection == null;
/// 当前筛选应展示的剧集;全部分类按 feed 顺序去重。
List<Drama> get visibleDramas {
if (selectedCode == allCode) return feed.dramas;
return _selectedSection?.dramas ?? const [];
}
FeedSection? get _selectedSection {
for (final section in feed.sections) {
if (section.code == selectedCode) return section;
}
return null;
}
/// 加载分类与发现 feed;失败保留可重试错误态。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final results = await Future.wait<Object>([
_repository.fetchCategories(),
_repository.fetchDiscoverFeed(),
]);
categories = results[0] as List<ContentCategory>;
feed = results[1] as HomeFeed;
} catch (_) {
error = 'categoriesError';
} finally {
loading = false;
_notify();
}
}
/// 选择服务端分类,内容只匹配 `category-{id}`。
void selectCategory(String id) => selectCode('category-$id');
/// 选择内置或分区 code。
void selectCode(String code) {
selectedCode = code;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,168 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/download_license.dart';
import '../models/download_policy.dart';
import '../remote/api_exception.dart';
import '../repositories/download_repository.dart';
/// 一次下载授权申请的结果;后端业务拒绝时保留原始 message。
class DownloadActionResult {
const DownloadActionResult({required this.ok, this.license, this.message});
final bool ok;
final DownloadLicense? license;
final String? message;
}
/// 下载授权与策略状态。
class DownloadController extends ChangeNotifier {
DownloadController(this._repository);
final DownloadRepository _repository;
DownloadPolicy? policy;
List<DownloadLicense> licenses = const [];
bool loading = false;
bool clearing = false;
String? error;
String? actionError;
bool _disposed = false;
/// 同时拉取策略与当前设备授权列表。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final results = await Future.wait<Object>([
_repository.fetchPolicy(),
_repository.list(),
]);
policy = results[0] as DownloadPolicy;
licenses = results[1] as List<DownloadLicense>;
} catch (_) {
error = 'downloadsError';
} finally {
loading = false;
_notify();
}
}
/// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。
Future<DownloadActionResult> grant(
String episodeId, {
String? quality,
}) async {
try {
final license = await _repository.grant(episodeId, quality: quality);
licenses = [
license,
for (final item in licenses)
if (item.episodeId != license.episodeId) item,
];
actionError = null;
_notify();
return DownloadActionResult(ok: true, license: license);
} on ApiException catch (exception) {
actionError = exception.message;
_notify();
return DownloadActionResult(ok: false, message: exception.message);
} catch (_) {
actionError = 'downloadGrantFailed';
_notify();
return const DownloadActionResult(ok: false);
}
}
/// 更新设置,先本地展示,失败恢复服务端旧值。
Future<bool> updateSettings({bool? wifiOnly, String? maxQuality}) async {
final previous = policy;
if (previous != null) {
policy = previous.copyWith(wifiOnly: wifiOnly, maxQuality: maxQuality);
_notify();
}
try {
await _repository.updateSettings(
wifiOnly: wifiOnly,
maxQuality: maxQuality,
);
actionError = null;
return true;
} catch (_) {
policy = previous;
actionError = 'downloadSettingsFailed';
return false;
} finally {
_notify();
}
}
/// 删除单条授权,失败按原位置恢复。
Future<bool> remove(String episodeId) async {
final index = licenses.indexWhere((item) => item.episodeId == episodeId);
if (index < 0) return true;
final removed = licenses[index];
licenses = [...licenses]..removeAt(index);
actionError = null;
_notify();
try {
await _repository.remove(episodeId);
return true;
} catch (_) {
final restored = [...licenses];
restored.insert(
index > restored.length ? restored.length : index,
removed,
);
licenses = restored;
actionError = 'downloadDeleteFailed';
return false;
} finally {
_notify();
}
}
/// 清空授权,失败恢复原列表。
Future<bool> clear() async {
if (licenses.isEmpty) return true;
final previous = licenses;
licenses = const [];
clearing = true;
actionError = null;
_notify();
try {
await _repository.clear();
return true;
} catch (_) {
licenses = previous;
actionError = 'downloadClearFailed';
return false;
} finally {
clearing = false;
_notify();
}
}
/// 退出登录时清空账号相关状态,避免跨账号串联。
void reset() {
policy = null;
licenses = const [];
loading = false;
clearing = false;
error = null;
actionError = null;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+11 -12
View File
@@ -88,24 +88,23 @@ class FavoriteController extends ChangeNotifier {
/// 取消收藏("我的"页列表里删除某条)。
Future<bool> remove(String dramaId) async {
final removed = cards.firstWhere(
(drama) => drama.id == dramaId,
orElse: () => const Drama(
id: '',
title: '',
synopsis: '',
posterUrl: '',
),
);
final hadCard = removed.id.isNotEmpty;
final index = cards.indexWhere((drama) => drama.id == dramaId);
if (index < 0) return true;
final removed = cards[index];
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
final next = [...cards]..removeAt(index);
cards = next;
notify();
try {
await _repository.remove(dramaId);
return true;
} catch (_) {
if (hadCard) cards = [...cards, removed];
final restored = [...cards];
restored.insert(
index > restored.length ? restored.length : index,
removed,
);
cards = restored;
_saved.add(dramaId);
notify();
return false;
@@ -0,0 +1,205 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../models/watch_progress.dart';
import '../models/watch_record.dart';
import '../repositories/catalog_repository.dart';
import '../repositories/watch_history_repository.dart';
/// 独立观看记录页状态。
///
/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。
class WatchRecordsController extends ChangeNotifier {
factory WatchRecordsController({
required WatchHistoryRepository history,
required CatalogRepository catalog,
int limit = defaultLimit,
}) => WatchRecordsController._(history, catalog, limit);
WatchRecordsController._(this._history, this._catalog, this.limit);
static const int defaultLimit = 20;
static const int detailConcurrency = 4;
final WatchHistoryRepository _history;
final CatalogRepository _catalog;
final int limit;
final Map<String, Drama> _detailCache = {};
List<WatchRecord> records = const [];
bool loading = false;
bool clearing = false;
String? error;
String? actionError;
bool _disposed = false;
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
Future<void> load() async {
loading = true;
error = null;
actionError = null;
_notify();
try {
final recent = await _history.recent(limit: limit);
if (_disposed) return;
records = _uniqueRecords(recent);
_notify();
await _hydrate(records.map((record) => record.dramaId).toList());
} catch (_) {
if (!_disposed) error = 'progressError';
} finally {
if (!_disposed) {
loading = false;
_notify();
}
}
}
/// 只重试一条详情失败记录。
Future<void> retryDetail(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return;
_replaceAt(
index,
records[index].copyWith(detailLoading: true, detailFailed: false),
);
_notify();
await _loadDetail(dramaId);
_notify();
}
/// 删除单条记录,先更新 UI,失败按原位置回滚。
Future<bool> remove(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return true;
final removed = records[index];
records = [...records]..removeAt(index);
actionError = null;
_notify();
try {
await _history.remove(dramaId);
return true;
} catch (_) {
final next = [...records];
next.insert(index > next.length ? next.length : index, removed);
records = next;
actionError = 'recordDeleteFailed';
return false;
} finally {
_notify();
}
}
/// 清空全部记录,先更新 UI,失败恢复原列表。
Future<bool> clear() async {
if (records.isEmpty) return true;
final previous = records;
records = const [];
clearing = true;
actionError = null;
_notify();
try {
await _history.clear();
return true;
} catch (_) {
records = previous;
actionError = 'recordClearFailed';
return false;
} finally {
clearing = false;
_notify();
}
}
List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) {
final seen = <String>{};
return [
for (final progress in recent)
if (seen.add(progress.dramaId)) WatchRecord(progress: progress),
];
}
Future<void> _hydrate(List<String> dramaIds) async {
final queue = [
for (final id in dramaIds)
if (!_detailCache.containsKey(id)) id,
];
final workerCount = queue.length < detailConcurrency
? queue.length
: detailConcurrency;
Future<void> worker() async {
while (queue.isNotEmpty && !_disposed) {
await _loadDetail(queue.removeAt(0));
}
}
await Future.wait([
for (var index = 0; index < workerCount; index++) worker(),
]);
}
Future<void> _loadDetail(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return;
final cached = _detailCache[dramaId];
if (cached != null) {
_replaceAt(
index,
records[index].copyWith(
drama: cached,
detailLoading: false,
detailFailed: false,
),
);
return;
}
try {
final detail = await _catalog.fetchDrama(dramaId);
if (_disposed) return;
_detailCache[dramaId] = detail;
final current = _recordIndex(dramaId);
if (current < 0) return;
_replaceAt(
current,
records[current].copyWith(
drama: detail,
detailLoading: false,
detailFailed: false,
),
);
} catch (_) {
if (_disposed) return;
final current = _recordIndex(dramaId);
if (current < 0) return;
_replaceAt(
current,
records[current].copyWith(detailLoading: false, detailFailed: true),
);
}
}
int _recordIndex(String dramaId) {
for (var index = 0; index < records.length; index++) {
if (records[index].dramaId == dramaId) return index;
}
return -1;
}
void _replaceAt(int index, WatchRecord record) {
final next = [...records];
next[index] = record;
records = next;
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}