diff --git a/lib/app.dart b/lib/app.dart index 7e15ce7..f2aef94 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -9,10 +9,14 @@ import 'core/config/locale_controller.dart'; import 'core/data/remote/api_client.dart'; import 'core/data/remote/auth_api.dart'; import 'core/data/remote/catalog_api.dart'; +import 'core/data/remote/category_api.dart'; +import 'core/data/remote/download_api.dart'; import 'core/data/remote/library_api.dart'; import 'core/data/remote/playback_api.dart'; import 'core/data/remote/watch_api.dart'; import 'core/data/repositories/catalog_repository.dart'; +import 'core/data/repositories/category_repository.dart'; +import 'core/data/repositories/download_repository.dart'; import 'core/data/repositories/engagement_repository.dart'; import 'core/data/repositories/favorite_repository.dart'; import 'core/data/repositories/playback_repository.dart'; @@ -20,6 +24,8 @@ import 'core/data/repositories/watch_history_repository.dart'; import 'core/data/services/auth_controller.dart'; import 'core/data/services/auth_session_store.dart'; import 'core/data/services/catalog_controller.dart'; +import 'core/data/services/category_controller.dart'; +import 'core/data/services/download_controller.dart'; import 'core/data/services/engagement_controller.dart'; import 'core/data/services/favorite_controller.dart'; import 'core/data/services/playback_session.dart'; @@ -32,6 +38,8 @@ class LumaDramaApp extends StatefulWidget { super.key, this.playerFactory = VideoPlayerPort.new, this.catalogRepository, + this.categoryRepository, + this.downloadRepository, this.watchHistoryRepository, this.favoriteRepository, this.playbackRepository, @@ -41,6 +49,8 @@ class LumaDramaApp extends StatefulWidget { /// 测试注入用;生产走 HTTP 仓库。 final CatalogRepository? catalogRepository; + final CategoryRepository? categoryRepository; + final DownloadRepository? downloadRepository; final WatchHistoryRepository? watchHistoryRepository; final FavoriteRepository? favoriteRepository; final PlaybackRepository? playbackRepository; @@ -61,7 +71,17 @@ class _LumaDramaAppState extends State { deviceIdReader: _sessionStore.deviceId, ); - late final AuthController _auth = AuthController(AuthApi(_apiClient), _sessionStore); + late final AuthController _auth = AuthController( + AuthApi(_apiClient), + _sessionStore, + ); + + late final CatalogRepository _catalogRepository = + widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)); + + late final CategoryRepository _categoryRepository = + widget.categoryRepository ?? + HttpCategoryRepository(CategoryApi(_apiClient), _catalogRepository); late final WatchHistoryRepository _watchHistory = widget.watchHistoryRepository ?? @@ -78,9 +98,20 @@ class _LumaDramaAppState extends State { ); late final PlaybackRepository _playback = - widget.playbackRepository ?? HttpPlaybackRepository(PlaybackApi(_apiClient)); + widget.playbackRepository ?? + HttpPlaybackRepository(PlaybackApi(_apiClient)); - late final FavoriteController _favoriteController = FavoriteController(_favorites); + late final FavoriteController _favoriteController = FavoriteController( + _favorites, + ); + + late final DownloadRepository _downloadRepository = + widget.downloadRepository ?? + HttpDownloadRepository(DownloadApi(_apiClient)); + + late final DownloadController _downloadController = DownloadController( + _downloadRepository, + ); @override void initState() { @@ -101,6 +132,7 @@ class _LumaDramaAppState extends State { unawaited(_favoriteController.load()); } else { _favoriteController.clear(); + _downloadController.reset(); } } @@ -108,6 +140,7 @@ class _LumaDramaAppState extends State { void dispose() { _auth.removeListener(_onAuthChanged); _favoriteController.dispose(); + _downloadController.dispose(); _apiClient.close(); super.dispose(); } @@ -122,12 +155,14 @@ class _LumaDramaAppState extends State { Provider.value(value: _watchHistory), Provider.value(value: _favorites), ChangeNotifierProvider( - create: (_) => CatalogController( - widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)), - )..load(), + create: (_) => CatalogController(_catalogRepository)..load(), + ), + ChangeNotifierProvider( + create: (_) => CategoryController(_categoryRepository)..load(), ), ChangeNotifierProvider(create: (_) => LocaleController()..load()), ChangeNotifierProvider.value(value: _favoriteController), + ChangeNotifierProvider.value(value: _downloadController), ChangeNotifierProvider( create: (_) => EngagementController(SharedPreferencesEngagementRepository()) diff --git a/lib/core/data/models/content_category.dart b/lib/core/data/models/content_category.dart new file mode 100644 index 0000000..b93a308 --- /dev/null +++ b/lib/core/data/models/content_category.dart @@ -0,0 +1,17 @@ +// Author: fruit.com.cn + +/// 服务端内容分类。id 按契约保持字符串,避免雪花 ID 精度丢失。 +class ContentCategory { + const ContentCategory({required this.id, required this.name}); + + final String id; + final String name; + + /// 解析 `GET /app/categories` 的单项。 + factory ContentCategory.fromJson(Map json) { + return ContentCategory( + id: (json['id'] ?? '').toString(), + name: json['name'] as String? ?? '', + ); + } +} diff --git a/lib/core/data/models/download_license.dart b/lib/core/data/models/download_license.dart new file mode 100644 index 0000000..213da62 --- /dev/null +++ b/lib/core/data/models/download_license.dart @@ -0,0 +1,67 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 下载授权与任务进度合并视图,对应 `GET /app/downloads` 的列表项。 +class DownloadLicense { + const DownloadLicense({ + required this.licenseId, + required this.dramaId, + required this.dramaTitle, + required this.episodeId, + required this.episodeNo, + required this.deviceId, + required this.quality, + required this.playUrl, + required this.licenseKey, + required this.status, + required this.grantedAt, + required this.expireAt, + required this.taskStatus, + required this.progressPercent, + required this.downloadedBytes, + required this.totalBytes, + required this.errorMsg, + }); + + final String licenseId; + final String dramaId; + final String dramaTitle; + final String episodeId; + final int episodeNo; + final String deviceId; + final String quality; + final String playUrl; + final String licenseKey; + final String status; + final DateTime? grantedAt; + final DateTime? expireAt; + final String taskStatus; + final int progressPercent; + final int downloadedBytes; + final int totalBytes; + final String errorMsg; + + /// 解析授权响应;所有 ID 与 Long 字段兼容字符串下发。 + factory DownloadLicense.fromJson(Map json) { + return DownloadLicense( + licenseId: (json['licenseId'] ?? '').toString(), + dramaId: (json['dramaId'] ?? '').toString(), + dramaTitle: json['dramaTitle'] as String? ?? '', + episodeId: (json['episodeId'] ?? '').toString(), + episodeNo: jsonToInt(json['episodeNo']), + deviceId: json['deviceId'] as String? ?? '', + quality: json['quality'] as String? ?? '', + playUrl: json['playUrl'] as String? ?? '', + licenseKey: json['licenseKey'] as String? ?? '', + status: json['status'] as String? ?? '', + grantedAt: DateTime.tryParse(json['grantedAt'] as String? ?? ''), + expireAt: DateTime.tryParse(json['expireAt'] as String? ?? ''), + taskStatus: json['taskStatus'] as String? ?? '', + progressPercent: jsonToInt(json['progressPercent']), + downloadedBytes: jsonToInt(json['downloadedBytes']), + totalBytes: jsonToInt(json['totalBytes']), + errorMsg: json['errorMsg'] as String? ?? '', + ); + } +} diff --git a/lib/core/data/models/download_policy.dart b/lib/core/data/models/download_policy.dart new file mode 100644 index 0000000..6c8e46d --- /dev/null +++ b/lib/core/data/models/download_policy.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 下载策略与当前设备配额,对应 `GET /app/downloads/policy`。 +class DownloadPolicy { + const DownloadPolicy({ + required this.maxPerDevice, + required this.licenseDays, + required this.wifiOnly, + required this.maxQuality, + required this.maxStorageBytes, + required this.used, + required this.remaining, + required this.expirePolicyText, + }); + + final int maxPerDevice; + final int licenseDays; + final bool wifiOnly; + final String maxQuality; + final int maxStorageBytes; + final int used; + final int remaining; + final String expirePolicyText; + + /// 设置更新期间用于本地乐观展示。 + DownloadPolicy copyWith({bool? wifiOnly, String? maxQuality}) { + return DownloadPolicy( + maxPerDevice: maxPerDevice, + licenseDays: licenseDays, + wifiOnly: wifiOnly ?? this.wifiOnly, + maxQuality: maxQuality ?? this.maxQuality, + maxStorageBytes: maxStorageBytes, + used: used, + remaining: remaining, + expirePolicyText: expirePolicyText, + ); + } + + /// 解析策略响应;Long 字段兼容字符串。 + factory DownloadPolicy.fromJson(Map json) { + return DownloadPolicy( + maxPerDevice: jsonToInt(json['maxPerDevice']), + licenseDays: jsonToInt(json['licenseDays']), + wifiOnly: json['wifiOnly'] == true, + maxQuality: json['maxQuality'] as String? ?? '', + maxStorageBytes: jsonToInt(json['maxStorageBytes']), + used: jsonToInt(json['used']), + remaining: jsonToInt(json['remaining']), + expirePolicyText: json['expirePolicyText'] as String? ?? '', + ); + } +} diff --git a/lib/core/data/models/watch_progress.dart b/lib/core/data/models/watch_progress.dart index 1f2d392..3d655ef 100644 --- a/lib/core/data/models/watch_progress.dart +++ b/lib/core/data/models/watch_progress.dart @@ -14,6 +14,7 @@ class WatchProgress { required this.duration, this.finished = false, this.playCount = 0, + this.updatedAt, }); final String dramaId; @@ -22,6 +23,7 @@ class WatchProgress { final Duration duration; final bool finished; final int playCount; + final DateTime? updatedAt; bool get hasPosition => position > Duration.zero; @@ -39,6 +41,7 @@ class WatchProgress { duration: Duration(milliseconds: jsonToInt(json['durationMs'])), finished: json['finished'] == true, playCount: jsonToInt(json['playCount']), + updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''), ); } @@ -49,6 +52,7 @@ class WatchProgress { 'durationMs': duration.inMilliseconds, 'finished': finished, 'playCount': playCount, + if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(), }; } diff --git a/lib/core/data/models/watch_record.dart b/lib/core/data/models/watch_record.dart new file mode 100644 index 0000000..954b478 --- /dev/null +++ b/lib/core/data/models/watch_record.dart @@ -0,0 +1,44 @@ +// Author: fruit.com.cn + +import 'drama.dart'; +import 'watch_progress.dart'; + +/// 观看记录页的一条数据:进度记录与按 dramaId 补取的详情卡片。 +class WatchRecord { + const WatchRecord({ + required this.progress, + this.drama, + this.detailLoading = true, + this.detailFailed = false, + }); + + final WatchProgress progress; + final Drama? drama; + final bool detailLoading; + final bool detailFailed; + + String get dramaId => progress.dramaId; + + /// 详情中按 episodeId 定位集号;缺失时返回 0。 + int get episodeNumber { + final episodes = drama?.episodes ?? const []; + for (var index = 0; index < episodes.length; index++) { + if (episodes[index].id == progress.episodeId) return index + 1; + } + return 0; + } + + /// 生成卡片加载中、成功或失败后的新状态。 + WatchRecord copyWith({ + Drama? drama, + bool? detailLoading, + bool? detailFailed, + }) { + return WatchRecord( + progress: progress, + drama: drama ?? this.drama, + detailLoading: detailLoading ?? this.detailLoading, + detailFailed: detailFailed ?? this.detailFailed, + ); + } +} diff --git a/lib/core/data/remote/api_client.dart b/lib/core/data/remote/api_client.dart index 15acde2..906a66d 100644 --- a/lib/core/data/remote/api_client.dart +++ b/lib/core/data/remote/api_client.dart @@ -79,6 +79,21 @@ class ApiClient { ); } + Future put( + String path, { + Object? body, + Map? query, + bool skipAuthRetry = false, + }) { + return _request( + 'PUT', + path, + body: body, + query: query, + skipAuthRetry: skipAuthRetry, + ); + } + Future delete( String path, { Object? body, diff --git a/lib/core/data/remote/category_api.dart b/lib/core/data/remote/category_api.dart new file mode 100644 index 0000000..7c2d324 --- /dev/null +++ b/lib/core/data/remote/category_api.dart @@ -0,0 +1,21 @@ +// Author: fruit.com.cn + +import '../models/content_category.dart'; +import 'api_client.dart'; + +/// 内容分类接口,对应契约 `GET /app/categories`。 +class CategoryApi { + const CategoryApi(this._client); + + final ApiClient _client; + + /// 拉取已启用分类,id 全程按字符串解析。 + Future> list() async { + final data = await _client.get('/app/categories'); + if (data is! List) return const []; + return [ + for (final item in data) + if (item is Map) ContentCategory.fromJson(item), + ]; + } +} diff --git a/lib/core/data/remote/download_api.dart b/lib/core/data/remote/download_api.dart new file mode 100644 index 0000000..ae8e744 --- /dev/null +++ b/lib/core/data/remote/download_api.dart @@ -0,0 +1,101 @@ +// Author: fruit.com.cn + +import '../models/download_license.dart'; +import '../models/download_policy.dart'; +import 'api_client.dart'; + +/// 离线下载授权接口,对应契约 §1.6 的全部 8 个接口。 +/// +/// 设备号由 [ApiClient] 统一通过 `X-Device-Id` 发送,本类不另造设备参数。 +class DownloadApi { + const DownloadApi(this._client); + + final ApiClient _client; + + /// 拉取配额和偏好策略。 + Future policy() async { + return DownloadPolicy.fromJson( + _asMap(await _client.get('/app/downloads/policy')), + ); + } + + /// 更新仅 Wi-Fi 与最高清晰度偏好。 + Future updateSettings({bool? wifiOnly, String? maxQuality}) async { + await _client.put( + '/app/downloads/settings', + body: {'wifiOnly': ?wifiOnly, 'maxQuality': ?maxQuality}, + ); + } + + /// 申请下载授权;同集重复申请由服务端保证幂等。 + Future grant(String episodeId, {String? quality}) async { + return DownloadLicense.fromJson( + _asMap( + await _client.post( + '/app/downloads', + body: {'episodeId': episodeId, 'quality': ?quality}, + ), + ), + ); + } + + /// 拉取当前设备的授权列表。 + Future> list() async { + final data = await _client.get('/app/downloads'); + if (data is! List) return const []; + return [ + for (final item in data) + if (item is Map) DownloadLicense.fromJson(item), + ]; + } + + /// 续传前刷新短时效下载地址。 + Future refreshUrl(String episodeId) async { + return DownloadLicense.fromJson( + _asMap(await _client.get('/app/downloads/$episodeId/url')), + ); + } + + /// 上报下载进度;本批只提供方法,不启动文件落盘。 + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async { + return DownloadLicense.fromJson( + _asMap( + await _client.post( + '/app/downloads/$episodeId/progress', + body: { + 'deviceId': ?deviceId, + 'status': ?status, + 'progressPercent': ?progressPercent, + 'downloadedBytes': ?downloadedBytes, + 'totalBytes': ?totalBytes, + 'errorMsg': ?errorMsg, + }, + ), + ), + ); + } + + /// 吊销单集授权。 + Future remove(String episodeId) async { + await _client.delete('/app/downloads/$episodeId'); + } + + /// 清空当前设备授权并返回清除条数。 + Future clear() async { + final data = await _client.delete('/app/downloads'); + return data is int ? data : 0; + } + + Map _asMap(Object? data) { + if (data is Map) return data; + throw const ApiException(-1, ''); + } +} diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart index f09ea90..f57d7fb 100644 --- a/lib/core/data/remote/watch_api.dart +++ b/lib/core/data/remote/watch_api.dart @@ -56,4 +56,9 @@ class WatchApi { Future removeProgress(String dramaId) async { await _client.delete('/app/watch/progress/$dramaId'); } + + /// 清空当前账号的全部观看记录。 + Future clearProgress() async { + await _client.delete('/app/watch/progress'); + } } diff --git a/lib/core/data/repositories/category_repository.dart b/lib/core/data/repositories/category_repository.dart new file mode 100644 index 0000000..39b6a11 --- /dev/null +++ b/lib/core/data/repositories/category_repository.dart @@ -0,0 +1,30 @@ +// Author: fruit.com.cn + +import '../models/content_category.dart'; +import '../models/home_feed.dart'; +import '../remote/category_api.dart'; +import 'catalog_repository.dart'; + +/// 发现页分类数据来源:分类清单与发现 feed。 +abstract interface class CategoryRepository { + /// 拉取服务端分类。 + Future> fetchCategories(); + + /// 拉取发现页分区;分类内容只从这里读取。 + Future fetchDiscoverFeed(); +} + +/// HTTP 实现。发现 feed 复用既有目录仓库,不新增接口。 +class HttpCategoryRepository implements CategoryRepository { + const HttpCategoryRepository(this._categoryApi, this._catalogRepository); + + final CategoryApi _categoryApi; + final CatalogRepository _catalogRepository; + + @override + Future> fetchCategories() => _categoryApi.list(); + + @override + Future fetchDiscoverFeed() => + _catalogRepository.fetchDiscoverFeed(); +} diff --git a/lib/core/data/repositories/download_repository.dart b/lib/core/data/repositories/download_repository.dart new file mode 100644 index 0000000..21263fc --- /dev/null +++ b/lib/core/data/repositories/download_repository.dart @@ -0,0 +1,82 @@ +// Author: fruit.com.cn + +import '../models/download_license.dart'; +import '../models/download_policy.dart'; +import '../remote/download_api.dart'; + +/// 下载授权数据来源,页面只依赖该抽象。 +abstract interface class DownloadRepository { + Future fetchPolicy(); + + Future> list(); + + Future grant(String episodeId, {String? quality}); + + Future updateSettings({bool? wifiOnly, String? maxQuality}); + + Future refreshUrl(String episodeId); + + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }); + + Future remove(String episodeId); + + Future clear(); +} + +/// HTTP 下载授权仓库。 +class HttpDownloadRepository implements DownloadRepository { + const HttpDownloadRepository(this._api); + + final DownloadApi _api; + + @override + Future fetchPolicy() => _api.policy(); + + @override + Future> list() => _api.list(); + + @override + Future grant(String episodeId, {String? quality}) => + _api.grant(episodeId, quality: quality); + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) => + _api.updateSettings(wifiOnly: wifiOnly, maxQuality: maxQuality); + + @override + Future refreshUrl(String episodeId) => + _api.refreshUrl(episodeId); + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) => _api.reportProgress( + episodeId, + deviceId: deviceId, + status: status, + progressPercent: progressPercent, + downloadedBytes: downloadedBytes, + totalBytes: totalBytes, + errorMsg: errorMsg, + ); + + @override + Future remove(String episodeId) => _api.remove(episodeId); + + @override + Future clear() => _api.clear(); +} diff --git a/lib/core/data/repositories/favorite_repository.dart b/lib/core/data/repositories/favorite_repository.dart index 1123505..1329adf 100644 --- a/lib/core/data/repositories/favorite_repository.dart +++ b/lib/core/data/repositories/favorite_repository.dart @@ -47,7 +47,9 @@ class InMemoryFavoriteRepository implements FavoriteRepository { final List _cards; bool failToggle = false; bool failList = false; + bool failRemove = false; int toggleCalls = 0; + int listCalls = 0; @override Future toggle(String dramaId) async { @@ -59,10 +61,14 @@ class InMemoryFavoriteRepository implements FavoriteRepository { @override Future> list() async { + listCalls++; if (failList) throw StateError('favorite unavailable'); return _cards.where((card) => _saved.contains(card.id)).toList(); } @override - Future remove(String dramaId) async => _saved.remove(dramaId); + Future remove(String dramaId) async { + if (failRemove) throw StateError('favorite unavailable'); + _saved.remove(dramaId); + } } diff --git a/lib/core/data/repositories/watch_history_repository.dart b/lib/core/data/repositories/watch_history_repository.dart index 7b5e79b..fb4a9dd 100644 --- a/lib/core/data/repositories/watch_history_repository.dart +++ b/lib/core/data/repositories/watch_history_repository.dart @@ -25,6 +25,9 @@ abstract interface class WatchHistoryRepository { /// 清理某部剧的记录(K2 的记录页会用,这里先备好)。 Future remove(String dramaId); + + /// 清空当前账号的全部观看记录。 + Future clear(); } class HttpWatchHistoryRepository implements WatchHistoryRepository { @@ -70,12 +73,20 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository { if (!_signedIn) return; await _api.removeProgress(dramaId); } + + @override + Future clear() async { + if (!_signedIn) return; + await _api.clearProgress(); + } } /// 内存实现:单测与 widget 测试注入用(不依赖网络与偏好存储)。 class InMemoryWatchHistoryRepository implements WatchHistoryRepository { InMemoryWatchHistoryRepository({List? seed}) - : _records = {for (final item in seed ?? const []) item.dramaId: item}; + : _records = { + for (final item in seed ?? const []) item.dramaId: item, + }; final Map _records; int failReads = 0; @@ -122,6 +133,9 @@ class InMemoryWatchHistoryRepository implements WatchHistoryRepository { @override Future remove(String dramaId) async => _records.remove(dramaId); + + @override + Future clear() async => _records.clear(); } /// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。 diff --git a/lib/core/data/services/category_controller.dart b/lib/core/data/services/category_controller.dart new file mode 100644 index 0000000..efeada2 --- /dev/null +++ b/lib/core/data/services/category_controller.dart @@ -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 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 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 load() async { + loading = true; + error = null; + _notify(); + try { + final results = await Future.wait([ + _repository.fetchCategories(), + _repository.fetchDiscoverFeed(), + ]); + categories = results[0] as List; + 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(); + } +} diff --git a/lib/core/data/services/download_controller.dart b/lib/core/data/services/download_controller.dart new file mode 100644 index 0000000..1e38ef8 --- /dev/null +++ b/lib/core/data/services/download_controller.dart @@ -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 licenses = const []; + bool loading = false; + bool clearing = false; + String? error; + String? actionError; + bool _disposed = false; + + /// 同时拉取策略与当前设备授权列表。 + Future load() async { + loading = true; + error = null; + _notify(); + try { + final results = await Future.wait([ + _repository.fetchPolicy(), + _repository.list(), + ]); + policy = results[0] as DownloadPolicy; + licenses = results[1] as List; + } catch (_) { + error = 'downloadsError'; + } finally { + loading = false; + _notify(); + } + } + + /// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。 + Future 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 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 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 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(); + } +} diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index 64c1ab1..4b9e4b1 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -88,24 +88,23 @@ class FavoriteController extends ChangeNotifier { /// 取消收藏("我的"页列表里删除某条)。 Future 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; diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart new file mode 100644 index 0000000..647d7fa --- /dev/null +++ b/lib/core/data/services/watch_records_controller.dart @@ -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 _detailCache = {}; + + List records = const []; + bool loading = false; + bool clearing = false; + String? error; + String? actionError; + bool _disposed = false; + + /// 加载最近记录并补齐详情;单条详情失败不拖垮整页。 + Future 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 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 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 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 _uniqueRecords(List recent) { + final seen = {}; + return [ + for (final progress in recent) + if (seen.add(progress.dramaId)) WatchRecord(progress: progress), + ]; + } + + Future _hydrate(List dramaIds) async { + final queue = [ + for (final id in dramaIds) + if (!_detailCache.containsKey(id)) id, + ]; + final workerCount = queue.length < detailConcurrency + ? queue.length + : detailConcurrency; + Future worker() async { + while (queue.isNotEmpty && !_disposed) { + await _loadDetail(queue.removeAt(0)); + } + } + + await Future.wait([ + for (var index = 0; index < workerCount; index++) worker(), + ]); + } + + Future _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(); + } +} diff --git a/lib/core/widgets/auth_gate.dart b/lib/core/widgets/auth_gate.dart index 2e07000..6d5e079 100644 --- a/lib/core/widgets/auth_gate.dart +++ b/lib/core/widgets/auth_gate.dart @@ -9,9 +9,7 @@ import '../theme/app_colors.dart'; /// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库) Future openAuthPage(BuildContext context, {bool signUpMode = false}) { return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => AuthPage(signUpMode: signUpMode), - ), + MaterialPageRoute(builder: (_) => AuthPage(signUpMode: signUpMode)), ); } @@ -81,3 +79,44 @@ Future ensureSignedIn(BuildContext context) async { } return false; } + +/// 页面级登录引导,用于记录、收藏和下载等必须登录的独立页面。 +class AuthGatePanel extends StatelessWidget { + const AuthGatePanel({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Center( + key: const Key('auth-gate-panel'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.authGateTitle, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + l10n.authGateMessage, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted, fontSize: 13), + ), + const SizedBox(height: 18), + FilledButton( + onPressed: () => openAuthPage(context), + child: Text(l10n.authGateAction), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/download_action.dart b/lib/core/widgets/download_action.dart new file mode 100644 index 0000000..6c3a356 --- /dev/null +++ b/lib/core/widgets/download_action.dart @@ -0,0 +1,29 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../data/services/download_controller.dart'; +import 'auth_gate.dart'; + +/// 详情页与播放页共用的下载授权入口。 +/// +/// 未登录先走登录闸;业务拒绝时直接展示后端 message。 +Future requestEpisodeDownload( + BuildContext context, + String episodeId, +) async { + if (!await ensureSignedIn(context)) return; + if (!context.mounted) return; + final result = await context.read().grant(episodeId); + if (!context.mounted) return; + final l10n = AppLocalizations.of(context)!; + final hasBackendMessage = result.message?.trim().isNotEmpty ?? false; + final message = result.ok + ? l10n.downloadGranted + : hasBackendMessage + ? result.message! + : l10n.downloadGrantFailed; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); +} diff --git a/lib/core/widgets/library_drama_tile.dart b/lib/core/widgets/library_drama_tile.dart new file mode 100644 index 0000000..b273fc3 --- /dev/null +++ b/lib/core/widgets/library_drama_tile.dart @@ -0,0 +1,73 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../data/models/drama.dart'; +import '../theme/app_colors.dart'; +import 'remote_image.dart'; + +/// 收藏等账号列表复用的剧集卡片,视觉与"我的"页列表保持一致。 +class LibraryDramaTile extends StatelessWidget { + const LibraryDramaTile({ + super.key, + required this.drama, + required this.onTap, + this.trailing, + }); + + final Drama drama; + final VoidCallback? onTap; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xFF1B1C21), + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(9), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: RemoteImage(drama.posterUrl, width: 74, height: 103), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 7), + Text( + drama.genre.isEmpty ? drama.category : drama.genre, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + ), + ), + ], + ), + ), + ?trailing, + ], + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4fa0f67..8b04bf3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -141,6 +141,7 @@ "lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.", "playbackError": "This clip could not be played.", "catalogError": "The catalog could not be loaded.", + "categoriesError": "Categories could not be loaded.", "detailError": "The episode list could not be loaded.", "noMediaNotice": "This episode has no playable media yet.", "noStories": "No stories available yet.", @@ -223,7 +224,7 @@ "authSwitchToSignUp": "New here? Create an account", "authSwitchToSignIn": "Already have an account? Sign in", "authGateTitle": "Sign in to continue", - "authGateMessage": "Favorites and comments need an account.", + "authGateMessage": "Watch history, favorites, comments and downloads need an account.", "authGateAction": "Sign In", "cancel": "Not now", "noEpisodesYet": "No playable episodes yet.", @@ -231,6 +232,82 @@ "progressError": "Your watch position could not be loaded.", "favoritesError": "Your favorites could not be loaded.", "favoriteToggleFailed": "Could not update favorites. Please retry.", + "myFavorites": "My favorites", + "noFavorites": "No favorites yet.", + "downloads": "Downloads", + "downloadsError": "Download settings could not be loaded.", + "noDownloads": "No download authorizations yet.", + "downloadedItems": "Download authorizations", + "downloadQuota": "{remaining} of {max} authorizations available", + "@downloadQuota": { + "placeholders": { + "remaining": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "downloadLicenseDays": "Authorization valid for {days} days", + "@downloadLicenseDays": { + "placeholders": { + "days": { + "type": "int" + } + } + }, + "downloadWifiOnly": "Wi-Fi only", + "downloadQuality": "Maximum quality", + "downloadStatus": "Authorization: {status}", + "@downloadStatus": { + "placeholders": { + "status": {} + } + }, + "downloadTaskStatus": "Task: {status}", + "@downloadTaskStatus": { + "placeholders": { + "status": {} + } + }, + "downloadProgress": "{progress}%", + "@downloadProgress": { + "placeholders": { + "progress": { + "type": "int" + } + } + }, + "downloadExpiresAt": "Expires {time}", + "@downloadExpiresAt": { + "placeholders": { + "time": {} + } + }, + "clearDownloadsTitle": "Clear download authorizations?", + "clearDownloadsMessage": "This revokes every download authorization for this device.", + "downloadSettingsFailed": "Could not update download settings.", + "downloadDeleteFailed": "Could not remove this download authorization.", + "downloadClearFailed": "Could not clear download authorizations.", + "downloadGrantFailed": "Could not request this download.", + "downloadGranted": "Download authorization is ready.", + "noWatchRecords": "No watch history yet.", + "recordDeleteFailed": "Could not delete this record. Please retry.", + "recordClearFailed": "Could not clear watch history. Please retry.", + "clearAll": "Clear all", + "clearWatchHistoryTitle": "Clear watch history?", + "clearWatchHistoryMessage": "This removes the watch history from your account.", + "confirmClear": "Clear", + "detailLoadFailed": "Details failed to load", + "retryDetail": "Retry", + "episodeUnavailable": "Episode unavailable", + "updatedAtLabel": "Updated {time}", + "@updatedAtLabel": { + "placeholders": { + "time": {} + } + }, "remove": "Remove", "unlockComingSoon": "Unlocking with coins is coming soon.", "resumeProgress": "Resume at {position} / {total}", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 023a9e4..1c41600 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -141,6 +141,7 @@ "lockedBody": "Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.", "playbackError": "Klip ini tidak bisa diputar.", "catalogError": "Katalog gagal dimuat.", + "categoriesError": "Kategori gagal dimuat.", "detailError": "Daftar episode gagal dimuat.", "noMediaNotice": "Episode ini belum punya media yang bisa diputar.", "noStories": "Belum ada cerita.", @@ -223,7 +224,7 @@ "authSwitchToSignUp": "Baru di sini? Buat akun", "authSwitchToSignIn": "Sudah punya akun? Masuk", "authGateTitle": "Masuk untuk melanjutkan", - "authGateMessage": "Favorit dan komentar perlu akun.", + "authGateMessage": "Riwayat tontonan, favorit, komentar, dan unduhan perlu akun.", "authGateAction": "Masuk", "cancel": "Nanti saja", "noEpisodesYet": "Belum ada episode yang bisa diputar.", @@ -231,6 +232,82 @@ "progressError": "Posisi tonton gagal dimuat.", "favoritesError": "Daftar favorit gagal dimuat.", "favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.", + "myFavorites": "Favorit saya", + "noFavorites": "Belum ada favorit.", + "downloads": "Unduhan", + "downloadsError": "Pengaturan unduhan gagal dimuat.", + "noDownloads": "Belum ada izin unduhan.", + "downloadedItems": "Izin unduhan", + "downloadQuota": "{remaining} dari {max} izin tersedia", + "@downloadQuota": { + "placeholders": { + "remaining": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "downloadLicenseDays": "Izin berlaku {days} hari", + "@downloadLicenseDays": { + "placeholders": { + "days": { + "type": "int" + } + } + }, + "downloadWifiOnly": "Hanya Wi-Fi", + "downloadQuality": "Kualitas maksimum", + "downloadStatus": "Izin: {status}", + "@downloadStatus": { + "placeholders": { + "status": {} + } + }, + "downloadTaskStatus": "Tugas: {status}", + "@downloadTaskStatus": { + "placeholders": { + "status": {} + } + }, + "downloadProgress": "{progress}%", + "@downloadProgress": { + "placeholders": { + "progress": { + "type": "int" + } + } + }, + "downloadExpiresAt": "Berakhir {time}", + "@downloadExpiresAt": { + "placeholders": { + "time": {} + } + }, + "clearDownloadsTitle": "Hapus izin unduhan?", + "clearDownloadsMessage": "Semua izin unduhan untuk perangkat ini akan dicabut.", + "downloadSettingsFailed": "Gagal memperbarui pengaturan unduhan.", + "downloadDeleteFailed": "Gagal menghapus izin unduhan ini.", + "downloadClearFailed": "Gagal menghapus semua izin unduhan.", + "downloadGrantFailed": "Gagal meminta unduhan ini.", + "downloadGranted": "Izin unduhan sudah siap.", + "noWatchRecords": "Belum ada riwayat tontonan.", + "recordDeleteFailed": "Gagal menghapus riwayat ini. Coba lagi.", + "recordClearFailed": "Gagal menghapus semua riwayat. Coba lagi.", + "clearAll": "Hapus semua", + "clearWatchHistoryTitle": "Hapus riwayat tontonan?", + "clearWatchHistoryMessage": "Semua riwayat akan dihapus dari akun kamu.", + "confirmClear": "Hapus", + "detailLoadFailed": "Detail drama gagal dimuat", + "retryDetail": "Coba lagi", + "episodeUnavailable": "Episode tidak tersedia", + "updatedAtLabel": "Diperbarui {time}", + "@updatedAtLabel": { + "placeholders": { + "time": {} + } + }, "remove": "Hapus", "unlockComingSoon": "Pembukaan dengan koin segera hadir.", "resumeProgress": "Lanjut di {position} / {total}", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 14c2523..54911ab 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1,5 +1,3 @@ -// Author: fruit.com.cn - import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -636,6 +634,12 @@ abstract class AppLocalizations { /// **'The catalog could not be loaded.'** String get catalogError; + /// No description provided for @categoriesError. + /// + /// In en, this message translates to: + /// **'Categories could not be loaded.'** + String get categoriesError; + /// No description provided for @detailError. /// /// In en, this message translates to: @@ -1131,7 +1135,7 @@ abstract class AppLocalizations { /// No description provided for @authGateMessage. /// /// In en, this message translates to: - /// **'Favorites and comments need an account.'** + /// **'Watch history, favorites, comments and downloads need an account.'** String get authGateMessage; /// No description provided for @authGateAction. @@ -1176,6 +1180,198 @@ abstract class AppLocalizations { /// **'Could not update favorites. Please retry.'** String get favoriteToggleFailed; + /// No description provided for @myFavorites. + /// + /// In en, this message translates to: + /// **'My favorites'** + String get myFavorites; + + /// No description provided for @noFavorites. + /// + /// In en, this message translates to: + /// **'No favorites yet.'** + String get noFavorites; + + /// No description provided for @downloads. + /// + /// In en, this message translates to: + /// **'Downloads'** + String get downloads; + + /// No description provided for @downloadsError. + /// + /// In en, this message translates to: + /// **'Download settings could not be loaded.'** + String get downloadsError; + + /// No description provided for @noDownloads. + /// + /// In en, this message translates to: + /// **'No download authorizations yet.'** + String get noDownloads; + + /// No description provided for @downloadedItems. + /// + /// In en, this message translates to: + /// **'Download authorizations'** + String get downloadedItems; + + /// No description provided for @downloadQuota. + /// + /// In en, this message translates to: + /// **'{remaining} of {max} authorizations available'** + String downloadQuota(int remaining, int max); + + /// No description provided for @downloadLicenseDays. + /// + /// In en, this message translates to: + /// **'Authorization valid for {days} days'** + String downloadLicenseDays(int days); + + /// No description provided for @downloadWifiOnly. + /// + /// In en, this message translates to: + /// **'Wi-Fi only'** + String get downloadWifiOnly; + + /// No description provided for @downloadQuality. + /// + /// In en, this message translates to: + /// **'Maximum quality'** + String get downloadQuality; + + /// No description provided for @downloadStatus. + /// + /// In en, this message translates to: + /// **'Authorization: {status}'** + String downloadStatus(Object status); + + /// No description provided for @downloadTaskStatus. + /// + /// In en, this message translates to: + /// **'Task: {status}'** + String downloadTaskStatus(Object status); + + /// No description provided for @downloadProgress. + /// + /// In en, this message translates to: + /// **'{progress}%'** + String downloadProgress(int progress); + + /// No description provided for @downloadExpiresAt. + /// + /// In en, this message translates to: + /// **'Expires {time}'** + String downloadExpiresAt(Object time); + + /// No description provided for @clearDownloadsTitle. + /// + /// In en, this message translates to: + /// **'Clear download authorizations?'** + String get clearDownloadsTitle; + + /// No description provided for @clearDownloadsMessage. + /// + /// In en, this message translates to: + /// **'This revokes every download authorization for this device.'** + String get clearDownloadsMessage; + + /// No description provided for @downloadSettingsFailed. + /// + /// In en, this message translates to: + /// **'Could not update download settings.'** + String get downloadSettingsFailed; + + /// No description provided for @downloadDeleteFailed. + /// + /// In en, this message translates to: + /// **'Could not remove this download authorization.'** + String get downloadDeleteFailed; + + /// No description provided for @downloadClearFailed. + /// + /// In en, this message translates to: + /// **'Could not clear download authorizations.'** + String get downloadClearFailed; + + /// No description provided for @downloadGrantFailed. + /// + /// In en, this message translates to: + /// **'Could not request this download.'** + String get downloadGrantFailed; + + /// No description provided for @downloadGranted. + /// + /// In en, this message translates to: + /// **'Download authorization is ready.'** + String get downloadGranted; + + /// No description provided for @noWatchRecords. + /// + /// In en, this message translates to: + /// **'No watch history yet.'** + String get noWatchRecords; + + /// No description provided for @recordDeleteFailed. + /// + /// In en, this message translates to: + /// **'Could not delete this record. Please retry.'** + String get recordDeleteFailed; + + /// No description provided for @recordClearFailed. + /// + /// In en, this message translates to: + /// **'Could not clear watch history. Please retry.'** + String get recordClearFailed; + + /// No description provided for @clearAll. + /// + /// In en, this message translates to: + /// **'Clear all'** + String get clearAll; + + /// No description provided for @clearWatchHistoryTitle. + /// + /// In en, this message translates to: + /// **'Clear watch history?'** + String get clearWatchHistoryTitle; + + /// No description provided for @clearWatchHistoryMessage. + /// + /// In en, this message translates to: + /// **'This removes the watch history from your account.'** + String get clearWatchHistoryMessage; + + /// No description provided for @confirmClear. + /// + /// In en, this message translates to: + /// **'Clear'** + String get confirmClear; + + /// No description provided for @detailLoadFailed. + /// + /// In en, this message translates to: + /// **'Details failed to load'** + String get detailLoadFailed; + + /// No description provided for @retryDetail. + /// + /// In en, this message translates to: + /// **'Retry'** + String get retryDetail; + + /// No description provided for @episodeUnavailable. + /// + /// In en, this message translates to: + /// **'Episode unavailable'** + String get episodeUnavailable; + + /// No description provided for @updatedAtLabel. + /// + /// In en, this message translates to: + /// **'Updated {time}'** + String updatedAtLabel(Object time); + /// No description provided for @remove. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f7e0baf..b4e2e65 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1,5 +1,3 @@ -// Author: fruit.com.cn - // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -296,6 +294,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get catalogError => 'The catalog could not be loaded.'; + @override + String get categoriesError => 'Categories could not be loaded.'; + @override String get detailError => 'The episode list could not be loaded.'; @@ -546,7 +547,8 @@ class AppLocalizationsEn extends AppLocalizations { String get authGateTitle => 'Sign in to continue'; @override - String get authGateMessage => 'Favorites and comments need an account.'; + String get authGateMessage => + 'Watch history, favorites, comments and downloads need an account.'; @override String get authGateAction => 'Sign In'; @@ -571,6 +573,121 @@ class AppLocalizationsEn extends AppLocalizations { String get favoriteToggleFailed => 'Could not update favorites. Please retry.'; + @override + String get myFavorites => 'My favorites'; + + @override + String get noFavorites => 'No favorites yet.'; + + @override + String get downloads => 'Downloads'; + + @override + String get downloadsError => 'Download settings could not be loaded.'; + + @override + String get noDownloads => 'No download authorizations yet.'; + + @override + String get downloadedItems => 'Download authorizations'; + + @override + String downloadQuota(int remaining, int max) { + return '$remaining of $max authorizations available'; + } + + @override + String downloadLicenseDays(int days) { + return 'Authorization valid for $days days'; + } + + @override + String get downloadWifiOnly => 'Wi-Fi only'; + + @override + String get downloadQuality => 'Maximum quality'; + + @override + String downloadStatus(Object status) { + return 'Authorization: $status'; + } + + @override + String downloadTaskStatus(Object status) { + return 'Task: $status'; + } + + @override + String downloadProgress(int progress) { + return '$progress%'; + } + + @override + String downloadExpiresAt(Object time) { + return 'Expires $time'; + } + + @override + String get clearDownloadsTitle => 'Clear download authorizations?'; + + @override + String get clearDownloadsMessage => + 'This revokes every download authorization for this device.'; + + @override + String get downloadSettingsFailed => 'Could not update download settings.'; + + @override + String get downloadDeleteFailed => + 'Could not remove this download authorization.'; + + @override + String get downloadClearFailed => 'Could not clear download authorizations.'; + + @override + String get downloadGrantFailed => 'Could not request this download.'; + + @override + String get downloadGranted => 'Download authorization is ready.'; + + @override + String get noWatchRecords => 'No watch history yet.'; + + @override + String get recordDeleteFailed => + 'Could not delete this record. Please retry.'; + + @override + String get recordClearFailed => + 'Could not clear watch history. Please retry.'; + + @override + String get clearAll => 'Clear all'; + + @override + String get clearWatchHistoryTitle => 'Clear watch history?'; + + @override + String get clearWatchHistoryMessage => + 'This removes the watch history from your account.'; + + @override + String get confirmClear => 'Clear'; + + @override + String get detailLoadFailed => 'Details failed to load'; + + @override + String get retryDetail => 'Retry'; + + @override + String get episodeUnavailable => 'Episode unavailable'; + + @override + String updatedAtLabel(Object time) { + return 'Updated $time'; + } + @override String get remove => 'Remove'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 12d319c..fe736cc 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -295,6 +295,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get catalogError => 'Katalog gagal dimuat.'; + @override + String get categoriesError => 'Kategori gagal dimuat.'; + @override String get detailError => 'Daftar episode gagal dimuat.'; @@ -546,7 +549,8 @@ class AppLocalizationsId extends AppLocalizations { String get authGateTitle => 'Masuk untuk melanjutkan'; @override - String get authGateMessage => 'Favorit dan komentar perlu akun.'; + String get authGateMessage => + 'Riwayat tontonan, favorit, komentar, dan unduhan perlu akun.'; @override String get authGateAction => 'Masuk'; @@ -570,6 +574,118 @@ class AppLocalizationsId extends AppLocalizations { @override String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.'; + @override + String get myFavorites => 'Favorit saya'; + + @override + String get noFavorites => 'Belum ada favorit.'; + + @override + String get downloads => 'Unduhan'; + + @override + String get downloadsError => 'Pengaturan unduhan gagal dimuat.'; + + @override + String get noDownloads => 'Belum ada izin unduhan.'; + + @override + String get downloadedItems => 'Izin unduhan'; + + @override + String downloadQuota(int remaining, int max) { + return '$remaining dari $max izin tersedia'; + } + + @override + String downloadLicenseDays(int days) { + return 'Izin berlaku $days hari'; + } + + @override + String get downloadWifiOnly => 'Hanya Wi-Fi'; + + @override + String get downloadQuality => 'Kualitas maksimum'; + + @override + String downloadStatus(Object status) { + return 'Izin: $status'; + } + + @override + String downloadTaskStatus(Object status) { + return 'Tugas: $status'; + } + + @override + String downloadProgress(int progress) { + return '$progress%'; + } + + @override + String downloadExpiresAt(Object time) { + return 'Berakhir $time'; + } + + @override + String get clearDownloadsTitle => 'Hapus izin unduhan?'; + + @override + String get clearDownloadsMessage => + 'Semua izin unduhan untuk perangkat ini akan dicabut.'; + + @override + String get downloadSettingsFailed => 'Gagal memperbarui pengaturan unduhan.'; + + @override + String get downloadDeleteFailed => 'Gagal menghapus izin unduhan ini.'; + + @override + String get downloadClearFailed => 'Gagal menghapus semua izin unduhan.'; + + @override + String get downloadGrantFailed => 'Gagal meminta unduhan ini.'; + + @override + String get downloadGranted => 'Izin unduhan sudah siap.'; + + @override + String get noWatchRecords => 'Belum ada riwayat tontonan.'; + + @override + String get recordDeleteFailed => 'Gagal menghapus riwayat ini. Coba lagi.'; + + @override + String get recordClearFailed => 'Gagal menghapus semua riwayat. Coba lagi.'; + + @override + String get clearAll => 'Hapus semua'; + + @override + String get clearWatchHistoryTitle => 'Hapus riwayat tontonan?'; + + @override + String get clearWatchHistoryMessage => + 'Semua riwayat akan dihapus dari akun kamu.'; + + @override + String get confirmClear => 'Hapus'; + + @override + String get detailLoadFailed => 'Detail drama gagal dimuat'; + + @override + String get retryDetail => 'Coba lagi'; + + @override + String get episodeUnavailable => 'Episode tidak tersedia'; + + @override + String updatedAtLabel(Object time) { + return 'Diperbarui $time'; + } + @override String get remove => 'Hapus'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8ba2e40..8a24435 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1,5 +1,3 @@ -// Author: fruit.com.cn - // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -291,6 +289,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get catalogError => '剧目列表加载失败。'; + @override + String get categoriesError => '分类加载失败。'; + @override String get detailError => '剧集信息加载失败。'; @@ -538,7 +539,7 @@ class AppLocalizationsZh extends AppLocalizations { String get authGateTitle => '登录后可继续'; @override - String get authGateMessage => '收藏、评论等操作需要登录。'; + String get authGateMessage => '观看记录、收藏、评论和下载需要登录。'; @override String get authGateAction => '去登录'; @@ -561,6 +562,116 @@ class AppLocalizationsZh extends AppLocalizations { @override String get favoriteToggleFailed => '收藏操作失败,请重试。'; + @override + String get myFavorites => '我的收藏'; + + @override + String get noFavorites => '还没有收藏。'; + + @override + String get downloads => '下载'; + + @override + String get downloadsError => '下载设置加载失败。'; + + @override + String get noDownloads => '还没有下载授权。'; + + @override + String get downloadedItems => '下载授权'; + + @override + String downloadQuota(int remaining, int max) { + return '剩余 $remaining / $max 个授权'; + } + + @override + String downloadLicenseDays(int days) { + return '授权有效期 $days 天'; + } + + @override + String get downloadWifiOnly => '仅 Wi-Fi 下载'; + + @override + String get downloadQuality => '最高清晰度'; + + @override + String downloadStatus(Object status) { + return '授权:$status'; + } + + @override + String downloadTaskStatus(Object status) { + return '任务:$status'; + } + + @override + String downloadProgress(int progress) { + return '$progress%'; + } + + @override + String downloadExpiresAt(Object time) { + return '到期时间 $time'; + } + + @override + String get clearDownloadsTitle => '清空下载授权?'; + + @override + String get clearDownloadsMessage => '这会吊销当前设备的全部下载授权。'; + + @override + String get downloadSettingsFailed => '下载设置更新失败。'; + + @override + String get downloadDeleteFailed => '删除下载授权失败。'; + + @override + String get downloadClearFailed => '清空下载授权失败。'; + + @override + String get downloadGrantFailed => '申请下载失败。'; + + @override + String get downloadGranted => '下载授权已就绪。'; + + @override + String get noWatchRecords => '还没有观看记录。'; + + @override + String get recordDeleteFailed => '删除记录失败,请重试。'; + + @override + String get recordClearFailed => '清空观看记录失败,请重试。'; + + @override + String get clearAll => '清空'; + + @override + String get clearWatchHistoryTitle => '清空观看记录?'; + + @override + String get clearWatchHistoryMessage => '这会从当前账号移除全部观看记录。'; + + @override + String get confirmClear => '清空'; + + @override + String get detailLoadFailed => '剧集信息加载失败'; + + @override + String get retryDetail => '重试'; + + @override + String get episodeUnavailable => '集数未知'; + + @override + String updatedAtLabel(Object time) { + return '更新于 $time'; + } + @override String get remove => '移除'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1739dcc..264e5de 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -103,6 +103,7 @@ "lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。", "playbackError": "此视频暂时无法播放。", "catalogError": "剧目列表加载失败。", + "categoriesError": "分类加载失败。", "detailError": "剧集信息加载失败。", "noMediaNotice": "该剧集还没有可播放的片源。", "noStories": "暂无剧目。", @@ -185,7 +186,7 @@ "authSwitchToSignUp": "还没有账号?去注册", "authSwitchToSignIn": "已有账号?去登录", "authGateTitle": "登录后可继续", - "authGateMessage": "收藏、评论等操作需要登录。", + "authGateMessage": "观看记录、收藏、评论和下载需要登录。", "authGateAction": "去登录", "cancel": "暂不", "noEpisodesYet": "还没有可播放的剧集。", @@ -193,6 +194,82 @@ "progressError": "续看进度加载失败。", "favoritesError": "收藏列表加载失败。", "favoriteToggleFailed": "收藏操作失败,请重试。", + "myFavorites": "我的收藏", + "noFavorites": "还没有收藏。", + "downloads": "下载", + "downloadsError": "下载设置加载失败。", + "noDownloads": "还没有下载授权。", + "downloadedItems": "下载授权", + "downloadQuota": "剩余 {remaining} / {max} 个授权", + "@downloadQuota": { + "placeholders": { + "remaining": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "downloadLicenseDays": "授权有效期 {days} 天", + "@downloadLicenseDays": { + "placeholders": { + "days": { + "type": "int" + } + } + }, + "downloadWifiOnly": "仅 Wi-Fi 下载", + "downloadQuality": "最高清晰度", + "downloadStatus": "授权:{status}", + "@downloadStatus": { + "placeholders": { + "status": {} + } + }, + "downloadTaskStatus": "任务:{status}", + "@downloadTaskStatus": { + "placeholders": { + "status": {} + } + }, + "downloadProgress": "{progress}%", + "@downloadProgress": { + "placeholders": { + "progress": { + "type": "int" + } + } + }, + "downloadExpiresAt": "到期时间 {time}", + "@downloadExpiresAt": { + "placeholders": { + "time": {} + } + }, + "clearDownloadsTitle": "清空下载授权?", + "clearDownloadsMessage": "这会吊销当前设备的全部下载授权。", + "downloadSettingsFailed": "下载设置更新失败。", + "downloadDeleteFailed": "删除下载授权失败。", + "downloadClearFailed": "清空下载授权失败。", + "downloadGrantFailed": "申请下载失败。", + "downloadGranted": "下载授权已就绪。", + "noWatchRecords": "还没有观看记录。", + "recordDeleteFailed": "删除记录失败,请重试。", + "recordClearFailed": "清空观看记录失败,请重试。", + "clearAll": "清空", + "clearWatchHistoryTitle": "清空观看记录?", + "clearWatchHistoryMessage": "这会从当前账号移除全部观看记录。", + "confirmClear": "清空", + "detailLoadFailed": "剧集信息加载失败", + "retryDetail": "重试", + "episodeUnavailable": "集数未知", + "updatedAtLabel": "更新于 {time}", + "@updatedAtLabel": { + "placeholders": { + "time": {} + } + }, "remove": "移除", "unlockComingSoon": "金币解锁功能即将开放。", "resumeProgress": "续看至 {position} / {total}", diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index c8b3b71..3e6aa11 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -19,7 +19,6 @@ class DiscoverLogic extends ChangeNotifier { final TextEditingController search = TextEditingController(); final FocusNode searchFocus = FocusNode(); final ScrollController scroll = ScrollController(); - int category = 0; bool searchOpen = false; bool _disposed = false; @@ -35,11 +34,6 @@ class DiscoverLogic extends ChangeNotifier { .catchError((Object _) => null); } - void selectCategory(int index) { - category = index; - notify(); - } - void onSearchChanged() => notify(); bool get showSearch => searchOpen || search.text.isNotEmpty; @@ -105,7 +99,12 @@ class DiscoverLogic extends ChangeNotifier { } else if (embedded && onDramaSelected != null) { onDramaSelected(selected); } else { - act(context, DiscoverPageAction.detail, embedded: embedded, onAction: onAction); + act( + context, + DiscoverPageAction.detail, + embedded: embedded, + onAction: onAction, + ); } } @@ -123,42 +122,6 @@ class DiscoverLogic extends ChangeNotifier { ).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice))); } - void showFilters(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final labels = [ - l10n.forYou, - l10n.hot, - l10n.newest, - l10n.romance, - l10n.actionGenre, - ]; - showModalBottomSheet( - context: context, - backgroundColor: const Color(0xFF202126), - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile(title: Text(l10n.filter)), - for (var index = 0; index < labels.length; index++) - ListTile( - title: Text(labels[index]), - trailing: index == category - ? const Icon(Icons.check_rounded, color: Color(0xFF2F7BFF)) - : null, - onTap: () { - selectCategory(index); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ); - } - @override void dispose() { _disposed = true; diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 1afca62..42bb636 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/home_feed.dart'; import '../../core/data/repositories/watch_history_repository.dart'; -import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/category_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; @@ -96,14 +96,9 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final catalog = context.watch(); - final allDramas = widget.dramas ?? [logic.drama]; - final cards = _cardsFor( - allDramas, - catalog.sections, - logic.category, - '', - ); + final categories = context.watch(); + final allDramas = [...?widget.dramas, ...categories.visibleDramas]; + final cards = _cardsFor(categories.visibleDramas, ''); final content = SafeArea( bottom: !widget.embedded, child: Column( @@ -118,7 +113,7 @@ class _DiscoverPageState extends State { padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), child: ClipRRect( borderRadius: BorderRadius.circular(18), - child: _carousel(l10n, catalog.banners, allDramas), + child: _carousel(l10n, categories.feed.banners, allDramas), ), ), Padding( @@ -126,31 +121,11 @@ class _DiscoverPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _categories(l10n), + _categories(l10n, categories), const SizedBox(height: 18), _continueHeader(l10n), const SizedBox(height: 12), - if (cards.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text( - l10n.noSearchResults, - style: const TextStyle(color: Color(0xFFE6E1E3)), - ), - ) - else - SizedBox( - height: 196, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - for (final card in cards) ...[ - _continueTile(l10n, card), - const SizedBox(width: 12), - ], - ], - ), - ), + _dramaContent(l10n, categories, cards), ], ), ), @@ -217,7 +192,9 @@ class _DiscoverPageState extends State { List banners, List allDramas, ) { - final items = banners.where((banner) => banner.imageUrl.isNotEmpty).toList(); + final items = banners + .where((banner) => banner.imageUrl.isNotEmpty) + .toList(); if (items.isEmpty) return const SizedBox.shrink(); return CoverCarousel( items: [ @@ -285,8 +262,64 @@ class _DiscoverPageState extends State { ], ); - Widget _continueTile(AppLocalizations l10n, _ShowcaseCard card) { - final drama = logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]); + Widget _dramaContent( + AppLocalizations l10n, + CategoryController categories, + List<_ShowcaseCard> cards, + ) { + if (categories.loading && categories.feed.sections.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + if (categories.error != null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Row( + children: [ + Expanded( + child: Text( + l10n.categoriesError, + style: const TextStyle(color: Color(0xFFE6E1E3)), + ), + ), + TextButton(onPressed: categories.load, child: Text(l10n.retry)), + ], + ), + ); + } + if (cards.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + categories.selectedSectionMissing + ? l10n.noCategoryDramas + : l10n.noSearchResults, + style: const TextStyle(color: Color(0xFFE6E1E3)), + ), + ); + } + return SizedBox( + height: 196, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + for (final card in cards) ...[ + _continueTile(l10n, card, categories.visibleDramas), + const SizedBox(width: 12), + ], + ], + ), + ); + } + + Widget _continueTile( + AppLocalizations l10n, + _ShowcaseCard card, + List source, + ) { + final drama = logic.dramaFor(card.dramaId, source); return SizedBox( width: 132, child: InkWell( @@ -360,24 +393,25 @@ class _DiscoverPageState extends State { ); } - Widget _categories(AppLocalizations l10n) { - final labels = [ - l10n.forYou, - l10n.hot, - l10n.newest, - l10n.romance, - l10n.actionGenre, + Widget _categories(AppLocalizations l10n, CategoryController categories) { + final choices = <({String code, String label})>[ + (code: CategoryController.allCode, label: l10n.forYou), + (code: CategoryController.hotCode, label: l10n.hot), + (code: CategoryController.newCode, label: l10n.newest), + for (final category in categories.categories) + (code: 'category-${category.id}', label: category.name), ]; return SizedBox( height: 32, child: ListView.separated( scrollDirection: Axis.horizontal, - itemCount: labels.length, + itemCount: choices.length, separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (context, index) { - final selected = logic.category == index; + final choice = choices[index]; + final selected = categories.selectedCode == choice.code; return GestureDetector( - onTap: () => logic.selectCategory(index), + onTap: () => categories.selectCode(choice.code), child: AnimatedContainer( duration: const Duration(milliseconds: 180), alignment: Alignment.center, @@ -387,7 +421,7 @@ class _DiscoverPageState extends State { borderRadius: BorderRadius.circular(16), ), child: Text( - labels[index], + choice.label, maxLines: 1, style: TextStyle( color: selected ? Colors.white : AppColors.muted, @@ -498,19 +532,10 @@ class _ShowcaseCard { } /// 分区由服务端下发:0 为全部,其余按分区顺序对应筛选菜单(热门 / 最新 / 各分类)。 -List<_ShowcaseCard> _cardsFor( - List dramas, - List sections, - int category, - String query, -) { - final source = switch (category) { - 0 => dramas, - _ => category < sections.length ? sections[category].dramas : const [], - }; +List<_ShowcaseCard> _cardsFor(List dramas, String query) { final normalized = query.trim().toLowerCase(); return [ - for (final drama in source) + for (final drama in dramas) if (normalized.isEmpty || drama.title.toLowerCase().contains(normalized) || drama.genre.toLowerCase().contains(normalized) || diff --git a/lib/pages/downloads/downloads_binding.dart b/lib/pages/downloads/downloads_binding.dart new file mode 100644 index 0000000..4448e47 --- /dev/null +++ b/lib/pages/downloads/downloads_binding.dart @@ -0,0 +1,9 @@ +// Author: fruit.com.cn + +import '../../core/data/services/download_controller.dart'; + +/// 下载管理页依赖装配。 +class DownloadsBinding { + /// 从服务端刷新策略与授权列表。 + Future load(DownloadController downloads) => downloads.load(); +} diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart new file mode 100644 index 0000000..f472d30 --- /dev/null +++ b/lib/pages/downloads/downloads_page.dart @@ -0,0 +1,409 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/download_license.dart'; +import '../../core/data/models/download_policy.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../l10n/app_localizations.dart'; +import 'downloads_binding.dart'; + +/// 下载授权与状态管理页;本批不落盘,只管理服务端授权。 +class DownloadsPage extends StatefulWidget { + const DownloadsPage({super.key}); + + @override + State createState() => _DownloadsPageState(); +} + +class _DownloadsPageState extends State { + bool _requested = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final signedIn = context.watch().isSignedIn; + if (!signedIn) { + _requested = false; + return; + } + if (_requested) return; + _requested = true; + final downloads = context.read(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(DownloadsBinding().load(downloads)); + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().isSignedIn; + return Scaffold( + key: const Key('downloads-page'), + backgroundColor: const Color(0xFF0B0C10), + appBar: AppBar( + backgroundColor: const Color(0xFF0B0C10), + foregroundColor: Colors.white, + title: Text(l10n.downloads), + ), + body: signedIn + ? _DownloadsBody(downloads: context.watch()) + : const AuthGatePanel(), + ); + } +} + +class _DownloadsBody extends StatelessWidget { + const _DownloadsBody({required this.downloads}); + + final DownloadController downloads; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (downloads.loading && downloads.policy == null) { + return const Center(child: CircularProgressIndicator()); + } + if (downloads.error != null && downloads.policy == null) { + return Center( + key: const Key('downloads-error'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + errorText(l10n, downloads.error, l10n.downloadsError), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 14), + FilledButton( + key: const Key('downloads-retry'), + onPressed: () => unawaited(downloads.load()), + child: Text(l10n.retry), + ), + ], + ), + ), + ); + } + final policy = downloads.policy; + if (policy == null) { + return Center( + key: const Key('downloads-error'), + child: FilledButton( + key: const Key('downloads-retry'), + onPressed: () => unawaited(downloads.load()), + child: Text(l10n.retry), + ), + ); + } + return RefreshIndicator( + onRefresh: downloads.load, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 28), + children: [ + _PolicyPanel(downloads: downloads, policy: policy), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: Text( + l10n.downloadedItems, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + if (downloads.licenses.isNotEmpty) + TextButton.icon( + key: const Key('download-clear'), + onPressed: downloads.clearing + ? null + : () => _confirmClear(context), + icon: const Icon(Icons.delete_sweep_outlined), + label: Text(l10n.clearAll), + ), + ], + ), + if (downloads.licenses.isEmpty) + Padding( + key: const Key('downloads-empty'), + padding: const EdgeInsets.symmetric(vertical: 34), + child: Text( + l10n.noDownloads, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + ) + else + for (final license in downloads.licenses) ...[ + _LicenseTile( + license: license, + onRemove: () => _remove(context, license.episodeId), + ), + const SizedBox(height: 10), + ], + ], + ), + ); + } + + Future _remove(BuildContext context, String episodeId) async { + final ok = await downloads.remove(episodeId); + if (ok || !context.mounted) return; + _showMessage(context, AppLocalizations.of(context)!.downloadDeleteFailed); + } + + Future _confirmClear(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.clearDownloadsTitle), + content: Text(l10n.clearDownloadsMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.confirmClear), + ), + ], + ), + ); + if (confirmed != true) return; + final ok = await downloads.clear(); + if (!ok && context.mounted) { + _showMessage(context, l10n.downloadClearFailed); + } + } + + void _showMessage(BuildContext context, String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +class _PolicyPanel extends StatelessWidget { + const _PolicyPanel({required this.downloads, required this.policy}); + + final DownloadController downloads; + final DownloadPolicy policy; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('download-policy'), + padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF2A2C33)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.downloadQuota(policy.remaining, policy.maxPerDevice), + style: const TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 5), + Text( + l10n.downloadLicenseDays(policy.licenseDays), + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + const SizedBox(height: 6), + Text( + policy.expirePolicyText, + style: const TextStyle(color: Color(0xFFB7B3B8), fontSize: 12), + ), + const Divider(height: 22, color: Color(0xFF2A2C33)), + Row( + children: [ + Expanded( + child: Text( + l10n.downloadWifiOnly, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + ), + Switch( + key: const Key('download-wifi-switch'), + value: policy.wifiOnly, + onChanged: (value) => _update(context, wifiOnly: value), + ), + ], + ), + Row( + children: [ + Expanded( + child: Text( + l10n.downloadQuality, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + ), + DropdownButton( + key: const Key('download-quality'), + value: + _qualityChoices( + policy.maxQuality, + ).contains(policy.maxQuality) + ? policy.maxQuality + : 'AUTO', + underline: const SizedBox.shrink(), + items: [ + for (final quality in _qualityChoices(policy.maxQuality)) + DropdownMenuItem(value: quality, child: Text(quality)), + ], + onChanged: (value) { + if (value == null || value == policy.maxQuality) return; + _update(context, maxQuality: value); + }, + ), + ], + ), + ], + ), + ); + } + + Future _update( + BuildContext context, { + bool? wifiOnly, + String? maxQuality, + }) async { + final ok = await downloads.updateSettings( + wifiOnly: wifiOnly, + maxQuality: maxQuality, + ); + if (ok || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.downloadSettingsFailed), + ), + ); + } +} + +class _LicenseTile extends StatelessWidget { + const _LicenseTile({required this.license, required this.onRemove}); + + final DownloadLicense license; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: ValueKey('download-license-${license.episodeId}'), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + license.dramaTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + Text(l10n.episodeLabel(license.episodeNo)), + IconButton( + key: ValueKey('download-remove-${license.episodeId}'), + tooltip: l10n.remove, + onPressed: onRemove, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + Wrap( + spacing: 12, + runSpacing: 5, + children: [ + Text( + l10n.downloadStatus(license.status), + style: const TextStyle(color: AppColors.blueSoft, fontSize: 12), + ), + Text( + l10n.downloadTaskStatus(license.taskStatus), + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + Text( + l10n.downloadProgress(license.progressPercent), + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + if (license.quality.isNotEmpty) + Text( + license.quality, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + minHeight: 4, + value: (license.progressPercent / 100).clamp(0.0, 1.0), + ), + ), + if (license.expireAt != null) ...[ + const SizedBox(height: 7), + Text( + l10n.downloadExpiresAt(_formatTime(license.expireAt!)), + style: const TextStyle(color: Color(0xFF8D8990), fontSize: 11), + ), + ], + ], + ), + ); + } +} + +List _qualityChoices(String current) { + final choices = ['AUTO']; + if (current.isNotEmpty && current != 'AUTO') choices.add(current); + for (final value in const ['720P', '1080P']) { + if (!choices.contains(value)) choices.add(value); + } + return choices; +} + +String _formatTime(DateTime value) { + final local = value.toLocal(); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '${local.year}-$month-$day $hour:$minute'; +} diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 7d7a999..131298e 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -12,6 +12,7 @@ import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/widgets/remote_image.dart'; import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import 'drama_detail_binding.dart'; import 'drama_detail_logic.dart'; @@ -136,7 +137,10 @@ class _DramaDetailPageState extends State { pinned: true, backgroundColor: const Color(0xFF111111), foregroundColor: Colors.white, - actions: [_favoriteAction(context, l10n)], + actions: [ + _downloadAction(context, l10n), + _favoriteAction(context, l10n), + ], flexibleSpace: FlexibleSpaceBar( background: RemoteImage(drama.posterUrl), ), @@ -270,6 +274,23 @@ class _DramaDetailPageState extends State { return record.fraction; } + /// 下载当前续看集或第一集;业务判定完全交给服务端。 + Widget _downloadAction(BuildContext context, AppLocalizations l10n) { + final episodes = logic.drama.episodes; + final index = logic.continueIndex ?? 0; + final episode = episodes.isEmpty + ? null + : episodes[index.clamp(0, episodes.length - 1)]; + return IconButton( + key: const Key('detail-download'), + tooltip: l10n.downloads, + icon: const Icon(Icons.download_outlined), + onPressed: episode == null + ? null + : () => requestEpisodeDownload(context, episode.id), + ); + } + /// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。 Widget _favoriteAction(BuildContext context, AppLocalizations l10n) { final favorites = context.watch(); diff --git a/lib/pages/favorites/favorites_binding.dart b/lib/pages/favorites/favorites_binding.dart new file mode 100644 index 0000000..720b097 --- /dev/null +++ b/lib/pages/favorites/favorites_binding.dart @@ -0,0 +1,9 @@ +// Author: fruit.com.cn + +import '../../core/data/services/favorite_controller.dart'; + +/// 收藏页依赖装配。 +class FavoritesBinding { + /// 从服务端刷新收藏列表。 + Future load(FavoriteController favorites) => favorites.load(); +} diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart new file mode 100644 index 0000000..b89bf83 --- /dev/null +++ b/lib/pages/favorites/favorites_page.dart @@ -0,0 +1,148 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/library_drama_tile.dart'; +import '../../l10n/app_localizations.dart'; +import '../drama_detail/drama_detail_view.dart'; +import 'favorites_binding.dart'; + +/// 独立我的收藏页。 +class FavoritesPage extends StatefulWidget { + const FavoritesPage({super.key}); + + @override + State createState() => _FavoritesPageState(); +} + +class _FavoritesPageState extends State { + bool _requested = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final signedIn = context.watch().isSignedIn; + if (!signedIn) { + _requested = false; + return; + } + if (_requested) return; + _requested = true; + final favorites = context.read(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(FavoritesBinding().load(favorites)); + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().isSignedIn; + return Scaffold( + backgroundColor: const Color(0xFF0B0C10), + appBar: AppBar( + backgroundColor: const Color(0xFF0B0C10), + foregroundColor: Colors.white, + title: Text(l10n.myFavorites), + ), + body: signedIn + ? _FavoritesBody(favorites: context.watch()) + : const AuthGatePanel(), + ); + } +} + +class _FavoritesBody extends StatelessWidget { + const _FavoritesBody({required this.favorites}); + + final FavoriteController favorites; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (favorites.loading && favorites.cards.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (favorites.error != null && favorites.cards.isEmpty) { + return Center( + key: const Key('favorites-error'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + errorText(l10n, favorites.error, l10n.favoritesError), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 14), + FilledButton( + key: const Key('favorites-retry'), + onPressed: () => unawaited(favorites.load()), + child: Text(l10n.retry), + ), + ], + ), + ), + ); + } + if (favorites.cards.isEmpty) { + return Center( + key: const Key('favorites-empty'), + child: Text( + l10n.noFavorites, + style: const TextStyle(color: Colors.white70), + ), + ); + } + return RefreshIndicator( + onRefresh: favorites.load, + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + itemCount: favorites.cards.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final drama = favorites.cards[index]; + return LibraryDramaTile( + key: ValueKey('favorite-card-${drama.id}'), + drama: drama, + onTap: () => _open(context, drama), + trailing: IconButton( + key: ValueKey('favorite-remove-${drama.id}'), + tooltip: l10n.remove, + onPressed: () => _remove(context, drama.id), + icon: const Icon( + Icons.favorite_rounded, + color: Color(0xFFD74A7D), + ), + ), + ); + }, + ), + ); + } + + Future _open(BuildContext context, Drama drama) async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + ); + } + + Future _remove(BuildContext context, String dramaId) async { + final ok = await favorites.remove(dramaId); + if (ok || !context.mounted) return; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); + } +} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 3571c23..22f8582 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -105,7 +105,6 @@ class _HomeTabsState extends State<_HomeTabs> { ); } - Widget _scaffold(BuildContext context) { final l10n = AppLocalizations.of(context)!; // 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关) @@ -173,7 +172,6 @@ class _HomeTabsState extends State<_HomeTabs> { embedded: true, active: logic.tab == 2, onAction: logic.myAction, - onOpenHistory: () => logic.openLibrary(section: 0), signedIn: signedIn, onSignIn: () => openAuthPage(context), onSignOut: () => context.read().signOut(), diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 11a9f9d..1546d2b 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -17,6 +17,9 @@ import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; +import '../watch_records/watch_records_page.dart'; +import '../favorites/favorites_page.dart'; +import '../downloads/downloads_page.dart'; class MyPage extends StatefulWidget { const MyPage({ @@ -25,7 +28,6 @@ class MyPage extends StatefulWidget { required this.duration, this.initialSection = 0, this.profile = false, - this.onOpenHistory, this.history, this.embedded = false, this.active = true, @@ -42,7 +44,6 @@ class MyPage extends StatefulWidget { final Duration duration; final int initialSection; final bool profile; - final VoidCallback? onOpenHistory; final WatchHistoryRepository? history; final bool embedded; final bool active; @@ -249,8 +250,12 @@ class _MyPageState extends State { Widget _signedInRow(AppLocalizations l10n) { // 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案 final user = context.watch().user; - final name = (user?.nickname.isNotEmpty ?? false) ? user!.nickname : l10n.mockUserName; - final uidText = (user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.mockUserId; + final name = (user?.nickname.isNotEmpty ?? false) + ? user!.nickname + : l10n.mockUserName; + final uidText = (user?.uid.isNotEmpty ?? false) + ? 'ID: ${user!.uid}' + : l10n.mockUserId; return Row( children: [ const CircleAvatar( @@ -414,7 +419,33 @@ class _MyPageState extends State { ), title: Text(l10n.watchHistory), trailing: const Icon(Icons.chevron_right_rounded), - onTap: widget.onOpenHistory, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const WatchRecordsPage()), + ), + ), + const Divider(height: 1, color: Color(0xFF2A2C33)), + ListTile( + leading: const Icon( + Icons.favorite_outline_rounded, + color: AppColors.blueSoft, + ), + title: Text(l10n.myFavorites), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const FavoritesPage()), + ), + ), + const Divider(height: 1, color: Color(0xFF2A2C33)), + ListTile( + leading: const Icon( + Icons.download_outlined, + color: AppColors.blueSoft, + ), + title: Text(l10n.downloads), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const DownloadsPage()), + ), ), const Divider(height: 1, color: Color(0xFF2A2C33)), ListTile( diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 4613d40..c5521db 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -16,6 +16,7 @@ import '../../core/data/services/video_player_port.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; @@ -360,6 +361,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ); } }, + onDownload: () => requestEpisodeDownload(context, episode.id), onComments: () => logic.showComments(context, drama), onShare: () => logic.shareDrama(context, drama), onClear: () => logic.setScreenCleared(true), @@ -523,6 +525,7 @@ class _PlaybackOverlay extends StatelessWidget { required this.onMy, required this.onLike, required this.onSave, + required this.onDownload, required this.onComments, required this.onShare, required this.onClear, @@ -544,6 +547,7 @@ class _PlaybackOverlay extends StatelessWidget { final VoidCallback onMy; final VoidCallback onLike; final VoidCallback onSave; + final VoidCallback onDownload; final VoidCallback onComments; final VoidCallback onShare; final VoidCallback onClear; @@ -665,6 +669,12 @@ class _PlaybackOverlay extends StatelessWidget { selected: saved, onTap: onSave, ), + _RailAction( + key: const Key('rail-download'), + icon: Icons.download_outlined, + label: l10n.downloads, + onTap: onDownload, + ), _RailAction( icon: Icons.grid_view_rounded, label: l10n.episodes, diff --git a/lib/pages/watch_records/watch_records_binding.dart b/lib/pages/watch_records/watch_records_binding.dart new file mode 100644 index 0000000..80ab1ef --- /dev/null +++ b/lib/pages/watch_records/watch_records_binding.dart @@ -0,0 +1,14 @@ +// Author: fruit.com.cn + +import '../../core/data/repositories/catalog_repository.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/watch_records_controller.dart'; + +/// 观看记录页依赖装配。 +class WatchRecordsBinding { + /// 创建独立页面控制器。 + WatchRecordsController create({ + required WatchHistoryRepository history, + required CatalogRepository catalog, + }) => WatchRecordsController(history: history, catalog: catalog); +} diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart new file mode 100644 index 0000000..0476274 --- /dev/null +++ b/lib/pages/watch_records/watch_records_page.dart @@ -0,0 +1,386 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_record.dart'; +import '../../core/data/repositories/catalog_repository.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/watch_records_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/remote_image.dart'; +import '../../l10n/app_localizations.dart'; +import '../drama_detail/drama_detail_view.dart'; +import 'watch_records_binding.dart'; + +/// 独立观看记录页。 +class WatchRecordsPage extends StatelessWidget { + const WatchRecordsPage({super.key}); + + @override + Widget build(BuildContext context) { + final signedIn = context.watch().isSignedIn; + final l10n = AppLocalizations.of(context)!; + return Scaffold( + key: const Key('watch-records-page'), + backgroundColor: const Color(0xFF0B0C10), + appBar: AppBar( + backgroundColor: const Color(0xFF0B0C10), + foregroundColor: Colors.white, + title: Text(l10n.watchHistory), + ), + body: signedIn ? const _WatchRecordsBody() : const AuthGatePanel(), + ); + } +} + +class _WatchRecordsBody extends StatefulWidget { + const _WatchRecordsBody(); + + @override + State<_WatchRecordsBody> createState() => _WatchRecordsBodyState(); +} + +class _WatchRecordsBodyState extends State<_WatchRecordsBody> { + late final WatchRecordsController controller; + + @override + void initState() { + super.initState(); + controller = WatchRecordsBinding().create( + history: context.read(), + catalog: context.read(), + ); + unawaited(controller.load()); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) => _content(context), + ); + } + + Widget _content(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (controller.loading && controller.records.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.error != null && controller.records.isEmpty) { + return _FailureState( + message: errorText(l10n, controller.error, l10n.progressError), + onRetry: controller.load, + ); + } + if (controller.records.isEmpty) { + return Center( + child: Text( + l10n.noWatchRecords, + style: const TextStyle(color: Colors.white70), + ), + ); + } + return Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + key: const Key('watch-records-clear'), + onPressed: controller.clearing ? null : _confirmClear, + icon: const Icon(Icons.delete_sweep_outlined), + label: Text(l10n.clearAll), + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: controller.load, + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 28), + itemCount: controller.records.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final record = controller.records[index]; + return _RecordTile( + record: record, + onRetry: () => controller.retryDetail(record.dramaId), + onRemove: () => _remove(record.dramaId), + onOpen: record.drama == null + ? null + : () => _open(record.drama!), + ); + }, + ), + ), + ), + ], + ); + } + + Future _open(Drama drama) async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + ); + } + + Future _remove(String dramaId) async { + final ok = await controller.remove(dramaId); + if (!ok && mounted) _showFailure('recordDeleteFailed'); + } + + Future _confirmClear() async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.clearWatchHistoryTitle), + content: Text(l10n.clearWatchHistoryMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.confirmClear), + ), + ], + ), + ); + if (confirmed != true) return; + final ok = await controller.clear(); + if (!ok && mounted) _showFailure('recordClearFailed'); + } + + void _showFailure(String key) { + final l10n = AppLocalizations.of(context)!; + final message = key == 'recordDeleteFailed' + ? l10n.recordDeleteFailed + : l10n.recordClearFailed; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +class _RecordTile extends StatelessWidget { + const _RecordTile({ + required this.record, + required this.onRetry, + required this.onRemove, + this.onOpen, + }); + + final WatchRecord record; + final VoidCallback onRetry; + final VoidCallback onRemove; + final VoidCallback? onOpen; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (record.detailFailed) { + return _FailedRecordTile(record: record, onRetry: onRetry); + } + final drama = record.drama; + if (drama == null || record.detailLoading) { + return Container( + key: ValueKey('watch-record-${record.dramaId}'), + height: 112, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + final episodeLabel = record.episodeNumber > 0 + ? l10n.episodeLabel(record.episodeNumber) + : l10n.episodeUnavailable; + return Material( + key: ValueKey('watch-record-${record.dramaId}'), + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.all(9), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: RemoteImage(drama.posterUrl, width: 72, height: 96), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 5), + Text( + episodeLabel, + key: ValueKey('watch-record-episode-${record.dramaId}'), + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + ), + ), + const SizedBox(height: 7), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + minHeight: 4, + value: record.progress.fraction, + ), + ), + const SizedBox(height: 6), + Text( + l10n.resumeProgress( + _clock(record.progress.position), + _clock(record.progress.duration), + ), + style: const TextStyle( + color: AppColors.blueSoft, + fontSize: 11, + ), + ), + if (record.progress.updatedAt != null) + Text( + l10n.updatedAtLabel( + _formatUpdatedAt(record.progress.updatedAt!), + ), + key: ValueKey('watch-record-updated-${record.dramaId}'), + style: const TextStyle( + color: Color(0xFF8D8990), + fontSize: 11, + ), + ), + ], + ), + ), + IconButton( + key: ValueKey('watch-record-remove-${record.dramaId}'), + tooltip: l10n.remove, + onPressed: onRemove, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + ), + ); + } +} + +class _FailedRecordTile extends StatelessWidget { + const _FailedRecordTile({required this.record, required this.onRetry}); + + final WatchRecord record; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: ValueKey('watch-record-failed-${record.dramaId}'), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.broken_image_outlined, color: Colors.white38), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.detailLoadFailed, + style: const TextStyle(color: Colors.white70), + ), + ), + TextButton( + key: ValueKey('watch-record-retry-${record.dramaId}'), + onPressed: onRetry, + child: Text(l10n.retryDetail), + ), + ], + ), + ); + } +} + +class _FailureState extends StatelessWidget { + const _FailureState({required this.message, required this.onRetry}); + + final String message; + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () => unawaited(onRetry()), + child: Text(l10n.retry), + ), + ], + ), + ), + ); + } +} + +String _clock(Duration value) { + final minutes = value.inMinutes; + final seconds = value.inSeconds.remainder(60); + return '$minutes:${seconds.toString().padLeft(2, '0')}'; +} + +String _formatUpdatedAt(DateTime value) { + final local = value.toLocal(); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '${local.year}-$month-$day $hour:$minute'; +} diff --git a/test/category/category_controller_test.dart b/test/category/category_controller_test.dart new file mode 100644 index 0000000..6ade3eb --- /dev/null +++ b/test/category/category_controller_test.dart @@ -0,0 +1,179 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/category_api.dart'; +import 'package:fruitshorts/core/data/repositories/category_repository.dart'; +import 'package:fruitshorts/core/data/services/category_controller.dart'; + +import '../support/fake_http_adapter.dart'; + +Drama _drama(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: 'synopsis', + posterUrl: '/files/$id.png', +); + +void main() { + test( + 'should_keep_snowflake_category_id_as_string_when_api_returns_list', + () async { + const id = '2111012753262120962'; + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': [ + {'id': id, 'name': 'Romance'}, + ], + }), + ), + ); + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + ); + + final categories = await CategoryApi(client).list(); + + expect(categories.single.id, id); + expect(categories.single.name, 'Romance'); + expect(adapter.requests.single.uri.path, '/app/categories'); + }, + ); + + test( + 'should_map_category_id_to_discover_section_when_category_is_selected', + () async { + final repository = ScriptedCategoryRepository( + categories: const [ + ContentCategory(id: '7', name: 'Romance'), + ContentCategory(id: '8', name: 'Action'), + ], + feed: HomeFeed( + sections: [ + FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot-1')]), + FeedSection(code: 'new', title: 'New', dramas: [_drama('new-1')]), + FeedSection( + code: 'category-7', + title: 'Romance', + dramas: [_drama('romance-1')], + ), + ], + ), + ); + final controller = CategoryController(repository); + + await controller.load(); + controller.selectCategory('7'); + + expect(controller.selectedCode, 'category-7'); + expect(controller.visibleDramas.map((drama) => drama.id), ['romance-1']); + expect(controller.selectedSectionMissing, isFalse); + + controller.selectCategory('8'); + expect(controller.selectedCode, 'category-8'); + expect(controller.visibleDramas, isEmpty); + expect(controller.selectedSectionMissing, isTrue); + }, + ); + + test( + 'should_keep_hot_and_new_entries_when_discover_feed_provides_them', + () async { + final repository = ScriptedCategoryRepository( + feed: HomeFeed( + sections: [ + FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot-1')]), + FeedSection(code: 'new', title: 'New', dramas: [_drama('new-1')]), + FeedSection( + code: 'category-7', + title: 'Romance', + dramas: [_drama('romance-1')], + ), + ], + ), + ); + final controller = CategoryController(repository); + + await controller.load(); + controller.selectCode(CategoryController.hotCode); + expect(controller.visibleDramas.map((drama) => drama.id), ['hot-1']); + + controller.selectCode(CategoryController.newCode); + expect(controller.visibleDramas.map((drama) => drama.id), ['new-1']); + }, + ); + + test('should_dedupe_dramas_when_all_categories_are_selected', () async { + final repository = ScriptedCategoryRepository( + feed: HomeFeed( + sections: [ + FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('same')]), + FeedSection( + code: 'category-7', + title: 'Romance', + dramas: [_drama('same'), _drama('other')], + ), + ], + ), + ); + final controller = CategoryController(repository); + + await controller.load(); + + expect(controller.selectedCode, CategoryController.allCode); + expect(controller.visibleDramas.map((drama) => drama.id), [ + 'same', + 'other', + ]); + }); + + test('should_clear_error_and_retry_when_initial_load_fails', () async { + final repository = ScriptedCategoryRepository(fail: true); + final controller = CategoryController(repository); + + await controller.load(); + expect(controller.error, 'categoriesError'); + expect(controller.loading, isFalse); + + repository.fail = false; + await controller.load(); + + expect(controller.error, isNull); + expect(repository.loads, 2); + }); +} + +class ScriptedCategoryRepository implements CategoryRepository { + ScriptedCategoryRepository({ + this.categories = const [], + this.feed = const HomeFeed(), + this.fail = false, + }); + + List categories; + HomeFeed feed; + bool fail; + int loads = 0; + + @override + Future> fetchCategories() async { + loads++; + if (fail) throw StateError('offline'); + return categories; + } + + @override + Future fetchDiscoverFeed() async { + if (fail) throw StateError('offline'); + return feed; + } +} diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart index ba93cc2..55fc790 100644 --- a/test/discover/discover_logic_test.dart +++ b/test/discover/discover_logic_test.dart @@ -34,7 +34,6 @@ void main() { expect(await logic.position, const Duration(seconds: 4)); expect(history.reads, ['signal']); - expect(logic.category, 0); expect(logic.searchOpen, isFalse); logic.dispose(); }); @@ -78,18 +77,6 @@ void main() { logic.dispose(); }); - test('should_store_index_and_notify_once_when_category_is_selected', () { - final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); - var notifications = 0; - logic.addListener(() => notifications++); - - logic.selectCategory(3); - - expect(logic.category, 3); - expect(notifications, 1); - logic.dispose(); - }); - test('should_show_search_when_flag_is_on_or_text_is_typed', () { final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); diff --git a/test/download/download_api_test.dart b/test/download/download_api_test.dart new file mode 100644 index 0000000..1f4339a --- /dev/null +++ b/test/download/download_api_test.dart @@ -0,0 +1,118 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/download_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + String envelope(Object? data) => + jsonEncode({'code': 0, 'message': 'ok', 'data': data}); + + Map licenseJson({int progress = 35}) => { + 'licenseId': '2111012753262120962', + 'dramaId': '2111012753262120963', + 'dramaTitle': 'The Signal', + 'episodeId': '2111012753262120964', + 'episodeNo': 2, + 'deviceId': 'device-9', + 'quality': '720P', + 'playUrl': '/files/episode.mp4?e=1&s=sig', + 'licenseKey': 'license-key', + 'status': 'ACTIVE', + 'grantedAt': '2026-10-17T12:00:00', + 'expireAt': '2026-11-16T12:00:00', + 'taskStatus': 'DOWNLOADING', + 'progressPercent': progress, + 'downloadedBytes': '1024', + 'totalBytes': '4096', + 'errorMsg': '', + }; + + test( + 'should_call_all_download_contracts_and_parse_string_ids_when_api_is_used', + () async { + final adapter = FakeHttpAdapter((options) async { + final path = options.uri.path; + if (path == '/app/downloads/policy') { + return FakeHttpResponse( + envelope({ + 'maxPerDevice': 20, + 'licenseDays': 30, + 'wifiOnly': true, + 'maxQuality': 'AUTO', + 'maxStorageBytes': '0', + 'used': 1, + 'remaining': 19, + 'expirePolicyText': '30 days', + }), + ); + } + if (path == '/app/downloads/settings') { + return FakeHttpResponse(envelope(null)); + } + if (path == '/app/downloads' && options.method == 'DELETE') { + return FakeHttpResponse(envelope(2)); + } + if (path == '/app/downloads') { + return FakeHttpResponse( + envelope(options.method == 'GET' ? [licenseJson()] : licenseJson()), + ); + } + return FakeHttpResponse(envelope(licenseJson(progress: 80))); + }); + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + ); + final api = DownloadApi(client); + + final policy = await api.policy(); + await api.updateSettings(wifiOnly: false, maxQuality: '1080P'); + final granted = await api.grant('2111012753262120964'); + final listed = await api.list(); + final refreshed = await api.refreshUrl('2111012753262120964'); + final progress = await api.reportProgress( + '2111012753262120964', + status: 'DOWNLOADING', + progressPercent: 80, + ); + await api.remove('2111012753262120964'); + final cleared = await api.clear(); + + expect(policy.remaining, 19); + expect(policy.expirePolicyText, '30 days'); + expect(granted.licenseId, '2111012753262120962'); + expect(granted.dramaId, '2111012753262120963'); + expect(granted.episodeId, '2111012753262120964'); + expect(listed.single.taskStatus, 'DOWNLOADING'); + expect(refreshed.taskStatus, 'DOWNLOADING'); + expect(progress.progressPercent, 80); + expect(cleared, 2); + + expect( + adapter.requests.map( + (request) => '${request.method} ${request.uri.path}', + ), + [ + 'GET /app/downloads/policy', + 'PUT /app/downloads/settings', + 'POST /app/downloads', + 'GET /app/downloads', + 'GET /app/downloads/2111012753262120964/url', + 'POST /app/downloads/2111012753262120964/progress', + 'DELETE /app/downloads/2111012753262120964', + 'DELETE /app/downloads', + ], + ); + expect(adapter.requests[2].data, {'episodeId': '2111012753262120964'}); + expect(adapter.requests[1].data, { + 'wifiOnly': false, + 'maxQuality': '1080P', + }); + }, + ); +} diff --git a/test/download/download_controller_test.dart b/test/download/download_controller_test.dart new file mode 100644 index 0000000..ad30d81 --- /dev/null +++ b/test/download/download_controller_test.dart @@ -0,0 +1,178 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/download_license.dart'; +import 'package:fruitshorts/core/data/models/download_policy.dart'; +import 'package:fruitshorts/core/data/remote/api_exception.dart'; +import 'package:fruitshorts/core/data/repositories/download_repository.dart'; +import 'package:fruitshorts/core/data/services/download_controller.dart'; + +DownloadPolicy _policy({bool wifiOnly = true, int remaining = 19}) => + DownloadPolicy( + maxPerDevice: 20, + licenseDays: 30, + wifiOnly: wifiOnly, + maxQuality: 'AUTO', + maxStorageBytes: 0, + used: 20 - remaining, + remaining: remaining, + expirePolicyText: '30 days', + ); + +DownloadLicense _license(String episodeId) => DownloadLicense( + licenseId: 'license-$episodeId', + dramaId: 'drama-1', + dramaTitle: 'The Signal', + episodeId: episodeId, + episodeNo: 1, + deviceId: 'device-9', + quality: '720P', + playUrl: '', + licenseKey: '', + status: 'ACTIVE', + grantedAt: DateTime(2026, 10, 17), + expireAt: DateTime(2026, 11, 16), + taskStatus: 'QUEUED', + progressPercent: 0, + downloadedBytes: 0, + totalBytes: 100, + errorMsg: '', +); + +void main() { + test('should_load_policy_and_licenses_when_page_opens', () async { + final repository = ScriptedDownloadRepository( + policy: _policy(), + licenses: [_license('1')], + ); + final controller = DownloadController(repository); + + await controller.load(); + + expect(controller.loading, isFalse); + expect(controller.error, isNull); + expect(controller.policy?.remaining, 19); + expect(controller.licenses.single.taskStatus, 'QUEUED'); + }); + + test('should_show_backend_message_when_grant_is_rejected', () async { + final repository = ScriptedDownloadRepository( + policy: _policy(), + grantError: const ApiException(200003, 'download quota is full'), + ); + final controller = DownloadController(repository); + + final result = await controller.grant('episode-1'); + + expect(result.ok, isFalse); + expect(result.message, 'download quota is full'); + }); + + test('should_rollback_wifi_setting_when_update_fails', () async { + final repository = ScriptedDownloadRepository( + policy: _policy(wifiOnly: true), + failSettings: true, + ); + final controller = DownloadController(repository); + await controller.load(); + + final ok = await controller.updateSettings(wifiOnly: false); + + expect(ok, isFalse); + expect(controller.policy?.wifiOnly, isTrue); + }); + + test('should_restore_license_when_delete_fails', () async { + final repository = ScriptedDownloadRepository( + policy: _policy(), + licenses: [_license('1'), _license('2')], + failRemove: true, + ); + final controller = DownloadController(repository); + await controller.load(); + + final ok = await controller.remove('1'); + + expect(ok, isFalse); + expect(controller.licenses.map((item) => item.episodeId), ['1', '2']); + }); + + test('should_restore_licenses_when_clear_fails', () async { + final repository = ScriptedDownloadRepository( + policy: _policy(), + licenses: [_license('1'), _license('2')], + failClear: true, + ); + final controller = DownloadController(repository); + await controller.load(); + + final ok = await controller.clear(); + + expect(ok, isFalse); + expect(controller.licenses, hasLength(2)); + }); +} + +class ScriptedDownloadRepository implements DownloadRepository { + ScriptedDownloadRepository({ + required this.policy, + this.licenses = const [], + this.grantError, + this.failSettings = false, + this.failRemove = false, + this.failClear = false, + }); + + DownloadPolicy policy; + List licenses; + ApiException? grantError; + bool failSettings; + bool failRemove; + bool failClear; + + @override + Future fetchPolicy() async => policy; + + @override + Future> list() async => licenses; + + @override + Future grant(String episodeId, {String? quality}) async { + if (grantError != null) throw grantError!; + return _license(episodeId); + } + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) async { + if (failSettings) throw StateError('settings unavailable'); + } + + @override + Future refreshUrl(String episodeId) async => + _license(episodeId); + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async => _license(episodeId); + + @override + Future remove(String episodeId) async { + if (failRemove) throw StateError('remove unavailable'); + licenses = licenses.where((item) => item.episodeId != episodeId).toList(); + } + + @override + Future clear() async { + if (failClear) throw StateError('clear unavailable'); + final count = licenses.length; + licenses = const []; + return count; + } +} diff --git a/test/download/downloads_page_test.dart b/test/download/downloads_page_test.dart new file mode 100644 index 0000000..fe64f1e --- /dev/null +++ b/test/download/downloads_page_test.dart @@ -0,0 +1,284 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/download_license.dart'; +import 'package:fruitshorts/core/data/models/download_policy.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/repositories/download_repository.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/data/services/download_controller.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/downloads/downloads_page.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_http_adapter.dart'; + +DownloadPolicy _policy({ + bool wifiOnly = true, + String maxQuality = 'AUTO', +}) => DownloadPolicy( + maxPerDevice: 20, + licenseDays: 30, + wifiOnly: wifiOnly, + maxQuality: maxQuality, + maxStorageBytes: 0, + used: 1, + remaining: 19, + expirePolicyText: 'Licenses last 30 days', +); + +DownloadLicense _license(String episodeId) => DownloadLicense( + licenseId: 'license-$episodeId', + dramaId: 'drama-1', + dramaTitle: 'The Signal', + episodeId: episodeId, + episodeNo: 2, + deviceId: 'device-9', + quality: '720P', + playUrl: '', + licenseKey: '', + status: 'ACTIVE', + grantedAt: DateTime(2026, 10, 17, 12), + expireAt: DateTime(2026, 11, 16, 12), + taskStatus: 'DOWNLOADING', + progressPercent: 35, + downloadedBytes: 1024, + totalBytes: 4096, + errorMsg: '', +); + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets( + 'should_render_policy_and_backend_status_values_when_page_loads', + (tester) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository( + policy: _policy(), + licenses: [_license('1')], + ); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + + expect(find.byKey(const Key('downloads-page')), findsOneWidget); + expect(find.byKey(const Key('download-policy')), findsOneWidget); + expect(find.text('Licenses last 30 days'), findsOneWidget); + expect(find.byKey(const Key('download-license-1')), findsOneWidget); + expect(find.textContaining('ACTIVE'), findsOneWidget); + expect(find.textContaining('DOWNLOADING'), findsOneWidget); + expect(find.textContaining('35%'), findsOneWidget); + }, + ); + + testWidgets('should_show_empty_state_when_license_list_is_empty', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository(policy: _policy()); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + + expect(find.byKey(const Key('downloads-empty')), findsOneWidget); + }); + + testWidgets('should_show_retry_when_policy_or_list_load_fails', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository( + policy: _policy(), + failLoad: true, + ); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + + expect(find.byKey(const Key('downloads-error')), findsOneWidget); + expect(find.byKey(const Key('downloads-retry')), findsOneWidget); + }); + + testWidgets('should_show_auth_gate_without_loading_when_signed_out', ( + tester, + ) async { + final auth = await _auth(signedIn: false); + final repository = FakeDownloadRepository(policy: _policy()); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + + expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); + expect(repository.loadCalls, 0); + }); + + testWidgets('should_update_wifi_preference_when_switch_is_tapped', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository(policy: _policy()); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + await tester.tap(find.byKey(const Key('download-wifi-switch'))); + await tester.pumpAndSettle(); + + expect(repository.settingsCalls, 1); + expect(repository.lastWifiOnly, isFalse); + }); + + testWidgets('should_clear_authorizations_only_after_confirmation', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository( + policy: _policy(), + licenses: [_license('1')], + ); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + await tester.tap(find.byKey(const Key('download-clear'))); + await tester.pumpAndSettle(); + expect(repository.clearCalls, 0); + + await tester.tap(find.widgetWithText(FilledButton, 'Clear')); + await tester.pumpAndSettle(); + + expect(repository.clearCalls, 1); + expect(find.byKey(const Key('downloads-empty')), findsOneWidget); + }); + + testWidgets('should_render_unique_quality_options_when_policy_uses_a_preset', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository( + policy: _policy(maxQuality: '720P'), + ); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('download-quality')), findsOneWidget); + }); +} + +Future _auth({required bool signedIn}) async { + if (signedIn) { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"1","nickname":"Viewer","avatarUrl":""}', + }); + } + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: FakeHttpAdapter( + (_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'), + ), + ); + final auth = AuthController(AuthApi(client), AuthSessionStore()); + await auth.load(); + return auth; +} + +Future _pumpPage( + WidgetTester tester, { + required AuthController auth, + required DownloadController controller, +}) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: controller), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const DownloadsPage(), + ), + ), + ); + await tester.pumpAndSettle(); +} + +class FakeDownloadRepository implements DownloadRepository { + FakeDownloadRepository({ + required this.policy, + this.licenses = const [], + this.failLoad = false, + }); + + DownloadPolicy policy; + List licenses; + bool failLoad; + int loadCalls = 0; + int settingsCalls = 0; + int clearCalls = 0; + bool? lastWifiOnly; + + @override + Future fetchPolicy() async { + loadCalls++; + if (failLoad) throw StateError('offline'); + return policy; + } + + @override + Future> list() async { + if (failLoad) throw StateError('offline'); + return licenses; + } + + @override + Future grant(String episodeId, {String? quality}) async => + _license(episodeId); + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) async { + settingsCalls++; + lastWifiOnly = wifiOnly; + } + + @override + Future refreshUrl(String episodeId) async => + _license(episodeId); + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async => _license(episodeId); + + @override + Future remove(String episodeId) async { + licenses = licenses.where((item) => item.episodeId != episodeId).toList(); + } + + @override + Future clear() async { + clearCalls++; + final count = licenses.length; + licenses = const []; + return count; + } +} diff --git a/test/library/favorite_controller_test.dart b/test/library/favorite_controller_test.dart index 335b2e0..0e5a96c 100644 --- a/test/library/favorite_controller_test.dart +++ b/test/library/favorite_controller_test.dart @@ -77,18 +77,33 @@ void main() { final repository = InMemoryFavoriteRepository( saved: {'signal'}, cards: [_card('signal')], - ); + )..failRemove = true; final controller = FavoriteController(repository); await controller.load(); final ok = await controller.remove('signal'); - // InMemory 实现的 remove 不会失败,这里断言正常路径:状态与列表一起清掉 - expect(ok, isTrue); - expect(controller.isSaved('signal'), isFalse); - expect(controller.cards, isEmpty); + expect(ok, isFalse); + expect(controller.isSaved('signal'), isTrue); + expect(controller.cards.map((drama) => drama.id), ['signal']); }); + test( + 'should_restore_removed_card_at_original_position_when_remove_fails', + () async { + final repository = InMemoryFavoriteRepository( + saved: {'signal', 'city'}, + cards: [_card('signal'), _card('city')], + )..failRemove = true; + final controller = FavoriteController(repository); + await controller.load(); + + await controller.remove('signal'); + + expect(controller.cards.map((drama) => drama.id), ['signal', 'city']); + }, + ); + test('should_clear_state_when_signing_out', () async { final controller = FavoriteController( InMemoryFavoriteRepository(saved: {'signal'}, cards: [_card('signal')]), diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart new file mode 100644 index 0000000..b49548a --- /dev/null +++ b/test/library/favorites_page_test.dart @@ -0,0 +1,153 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/favorites/favorites_page.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_http_adapter.dart'; + +Drama _card(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: 'synopsis', + posterUrl: '/files/$id.png', + totalEpisodes: 12, +); + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_favorite_cards_and_visible_remove_action', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final favorites = FavoriteController( + InMemoryFavoriteRepository( + saved: {'signal', 'city'}, + cards: [_card('signal'), _card('city')], + ), + ); + + await _pumpPage(tester, auth: auth, favorites: favorites); + + expect(find.text('Drama signal'), findsOneWidget); + expect(find.text('Drama city'), findsOneWidget); + expect(find.byKey(const Key('favorite-remove-signal')), findsOneWidget); + }); + + testWidgets('should_keep_card_and_show_failure_when_remove_fails', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final repository = InMemoryFavoriteRepository( + saved: {'signal'}, + cards: [_card('signal')], + )..failRemove = true; + final favorites = FavoriteController(repository); + + await _pumpPage(tester, auth: auth, favorites: favorites); + await tester.tap(find.byKey(const Key('favorite-remove-signal'))); + await tester.pumpAndSettle(); + + expect(find.text('Drama signal'), findsOneWidget); + expect( + find.text('Could not update favorites. Please retry.'), + findsOneWidget, + ); + }); + + testWidgets('should_show_empty_state_when_favorite_list_is_empty', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final favorites = FavoriteController(InMemoryFavoriteRepository()); + + await _pumpPage(tester, auth: auth, favorites: favorites); + + expect(find.byKey(const Key('favorites-empty')), findsOneWidget); + }); + + testWidgets('should_show_retry_when_favorite_list_fails', (tester) async { + final auth = await _auth(signedIn: true); + final repository = InMemoryFavoriteRepository()..failList = true; + final favorites = FavoriteController(repository); + + await _pumpPage(tester, auth: auth, favorites: favorites); + + expect(find.byKey(const Key('favorites-error')), findsOneWidget); + expect(find.byKey(const Key('favorites-retry')), findsOneWidget); + }); + + testWidgets( + 'should_show_auth_gate_without_loading_favorites_when_signed_out', + (tester) async { + final auth = await _auth(signedIn: false); + final repository = InMemoryFavoriteRepository( + saved: {'signal'}, + cards: [_card('signal')], + ); + final favorites = FavoriteController(repository); + + await _pumpPage(tester, auth: auth, favorites: favorites); + + expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); + expect(repository.listCalls, 0); + }, + ); +} + +Future _auth({required bool signedIn}) async { + if (signedIn) { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"1","nickname":"Viewer","avatarUrl":""}', + }); + } + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: FakeHttpAdapter( + (_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'), + ), + ); + final auth = AuthController(AuthApi(client), AuthSessionStore()); + await auth.load(); + return auth; +} + +Future _pumpPage( + WidgetTester tester, { + required AuthController auth, + required FavoriteController favorites, +}) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: favorites), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const FavoritesPage(), + ), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/support/fake_category.dart b/test/support/fake_category.dart new file mode 100644 index 0000000..b18fbb1 --- /dev/null +++ b/test/support/fake_category.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/category_repository.dart'; + +/// 分类测试仓库:分类清单固定,发现 feed 复用注入的目录仓库。 +class FakeCategoryRepository implements CategoryRepository { + FakeCategoryRepository({ + required this.catalog, + this.categories = const [ContentCategory(id: '7', name: 'Romance')], + }); + + final CatalogRepository catalog; + final List categories; + + @override + Future> fetchCategories() async => categories; + + @override + Future fetchDiscoverFeed() => catalog.fetchDiscoverFeed(); +} diff --git a/test/support/fake_watch_history.dart b/test/support/fake_watch_history.dart index 6568866..6616b7a 100644 --- a/test/support/fake_watch_history.dart +++ b/test/support/fake_watch_history.dart @@ -64,4 +64,10 @@ class FakeWatchHistory implements WatchHistoryRepository { @override Future remove(String dramaId) async => records.remove(dramaId); + + @override + Future clear() async { + records.clear(); + recentRecords = const []; + } } diff --git a/test/watch/watch_records_controller_test.dart b/test/watch/watch_records_controller_test.dart new file mode 100644 index 0000000..c3d787e --- /dev/null +++ b/test/watch/watch_records_controller_test.dart @@ -0,0 +1,225 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/watch_records_controller.dart'; + +WatchProgress _progress( + String dramaId, { + String episodeId = 'episode-1', + int positionMs = 12000, + int durationMs = 60000, +}) => WatchProgress( + dramaId: dramaId, + episodeId: episodeId, + position: Duration(milliseconds: positionMs), + duration: Duration(milliseconds: durationMs), + updatedAt: DateTime(2026, 10, 17, 12), +); + +Drama _detail(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: 'synopsis', + posterUrl: '/files/$id.png', + detailLoaded: true, + episodes: const [ + Episode(id: 'episode-1', number: 1, title: 'Episode 1', isFree: true), + ], +); + +void main() { + test( + 'should_parse_updated_time_and_keep_string_ids_when_recent_json_arrives', + () { + final progress = WatchProgress.fromJson({ + 'dramaId': '2111012753262120962', + 'episodeId': '2111012753262120963', + 'positionMs': '12000', + 'durationMs': '60000', + 'updatedAt': '2026-10-17T12:34:56', + }); + + expect(progress.dramaId, '2111012753262120962'); + expect(progress.episodeId, '2111012753262120963'); + expect(progress.updatedAt, DateTime(2026, 10, 17, 12, 34, 56)); + }, + ); + + test('should_dedupe_detail_requests_and_cap_concurrency_at_four', () async { + final history = ControlledWatchHistory([ + for (var index = 0; index < 6; index++) _progress('$index'), + _progress('0'), + ]); + final catalog = ScriptedWatchCatalog(); + final controller = WatchRecordsController( + history: history, + catalog: catalog, + ); + + await controller.load(); + + expect(controller.records, hasLength(6)); + expect(catalog.calls, hasLength(6)); + expect(catalog.maxActive, 4); + expect(controller.error, isNull); + }); + + test('should_keep_other_records_when_one_detail_request_fails', () async { + final history = ControlledWatchHistory([_progress('1'), _progress('2')]); + final catalog = ScriptedWatchCatalog(failedIds: {'1'}); + final controller = WatchRecordsController( + history: history, + catalog: catalog, + ); + + await controller.load(); + + expect(controller.records, hasLength(2)); + expect(controller.records.first.detailFailed, isTrue); + expect(controller.records.first.drama, isNull); + expect(controller.records.last.drama?.id, '2'); + + catalog.failedIds.clear(); + await controller.retryDetail('1'); + expect(controller.records.first.detailFailed, isFalse); + expect(controller.records.first.drama?.id, '1'); + }); + + test( + 'should_remove_record_optimistically_and_restore_it_when_delete_fails', + () async { + final history = ControlledWatchHistory([_progress('1'), _progress('2')]); + final controller = WatchRecordsController( + history: history, + catalog: ScriptedWatchCatalog(), + ); + await controller.load(); + final gate = Completer(); + history.removeGate = gate; + history.failRemove = true; + + final pending = controller.remove('1'); + await Future.delayed(Duration.zero); + expect(controller.records.map((record) => record.dramaId), ['2']); + + gate.complete(); + expect(await pending, isFalse); + expect(controller.records.map((record) => record.dramaId), ['1', '2']); + expect(controller.actionError, 'recordDeleteFailed'); + }, + ); + + test( + 'should_clear_records_optimistically_and_restore_them_when_clear_fails', + () async { + final history = ControlledWatchHistory([_progress('1'), _progress('2')]); + final controller = WatchRecordsController( + history: history, + catalog: ScriptedWatchCatalog(), + ); + await controller.load(); + final gate = Completer(); + history.clearGate = gate; + history.failClear = true; + + final pending = controller.clear(); + await Future.delayed(Duration.zero); + expect(controller.records, isEmpty); + + gate.complete(); + expect(await pending, isFalse); + expect(controller.records.map((record) => record.dramaId), ['1', '2']); + expect(controller.actionError, 'recordClearFailed'); + }, + ); + + test('should_expose_retryable_error_when_recent_request_fails', () async { + final history = ControlledWatchHistory([])..failRecent = true; + final controller = WatchRecordsController( + history: history, + catalog: ScriptedWatchCatalog(), + ); + + await controller.load(); + + expect(controller.loading, isFalse); + expect(controller.error, 'progressError'); + expect(controller.records, isEmpty); + }); +} + +class ControlledWatchHistory implements WatchHistoryRepository { + ControlledWatchHistory(this.items); + + final List items; + bool failRecent = false; + bool failRemove = false; + bool failClear = false; + Completer? removeGate; + Completer? clearGate; + + @override + Future progressFor(String dramaId) async => null; + + @override + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async => null; + + @override + Future> recent({int limit = 20}) async { + if (failRecent) throw StateError('recent unavailable'); + return items.take(limit).toList(); + } + + @override + Future remove(String dramaId) async { + if (removeGate != null) await removeGate!.future; + if (failRemove) throw StateError('remove unavailable'); + items.removeWhere((item) => item.dramaId == dramaId); + } + + @override + Future clear() async { + if (clearGate != null) await clearGate!.future; + if (failClear) throw StateError('clear unavailable'); + items.clear(); + } +} + +class ScriptedWatchCatalog implements CatalogRepository { + ScriptedWatchCatalog({Set? failedIds}) + : failedIds = failedIds ?? {}; + + final Set failedIds; + final List calls = []; + int _active = 0; + int maxActive = 0; + + @override + Future fetchHomeFeed() async => const HomeFeed(); + + @override + Future fetchDiscoverFeed() async => const HomeFeed(); + + @override + Future fetchDrama(String dramaId) async { + calls.add(dramaId); + _active++; + if (_active > maxActive) maxActive = _active; + await Future.delayed(const Duration(milliseconds: 5)); + _active--; + if (failedIds.contains(dramaId)) throw StateError('detail unavailable'); + return _detail(dramaId); + } +} diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart new file mode 100644 index 0000000..d42ef0e --- /dev/null +++ b/test/watch/watch_records_page_test.dart @@ -0,0 +1,212 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/watch_records/watch_records_page.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_http_adapter.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_real_record_details_when_recent_history_loads', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final catalog = FakeCatalogRepository(); + final history = CountingWatchHistory([ + WatchProgress( + dramaId: 'the-signal-demo', + episodeId: 'the-signal-demo-1', + position: const Duration(seconds: 12), + duration: const Duration(seconds: 60), + updatedAt: DateTime(2026, 10, 17, 12, 34), + ), + ]); + + await _pumpPage(tester, auth: auth, history: history, catalog: catalog); + + expect(find.byKey(const Key('watch-records-page')), findsOneWidget); + expect(find.text('The Signal'), findsOneWidget); + expect( + find.byKey(const Key('watch-record-episode-the-signal-demo')), + findsOneWidget, + ); + }); + + testWidgets( + 'should_show_failed_placeholder_and_keep_other_cards_when_detail_fails', + (tester) async { + final auth = await _auth(signedIn: true); + final history = CountingWatchHistory([ + _progress('broken'), + _progress('the-signal-demo'), + ]); + + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: _FailingWatchCatalog(), + ); + + expect( + find.byKey(const Key('watch-record-failed-broken')), + findsOneWidget, + ); + expect(find.text('The Signal'), findsOneWidget); + expect( + find.byKey(const Key('watch-record-retry-broken')), + findsOneWidget, + ); + }, + ); + + testWidgets('should_show_auth_gate_without_loading_records_when_signed_out', ( + tester, + ) async { + final auth = await _auth(signedIn: false); + final history = CountingWatchHistory([_progress('the-signal-demo')]); + + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: FakeCatalogRepository(), + ); + + expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); + expect(history.recentCalls, 0); + }); + + testWidgets( + 'should_clear_records_only_after_confirmation_when_clear_is_tapped', + (tester) async { + final auth = await _auth(signedIn: true); + final history = CountingWatchHistory([_progress('the-signal-demo')]); + + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: FakeCatalogRepository(), + ); + await tester.tap(find.byKey(const Key('watch-records-clear'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('watch-records-clear')), findsOneWidget); + expect(history.clearCalls, 0); + + await tester.tap(find.widgetWithText(FilledButton, 'Clear')); + await tester.pumpAndSettle(); + + expect(history.clearCalls, 1); + expect(find.text('No watch history yet.'), findsOneWidget); + }, + ); +} + +WatchProgress _progress(String dramaId) => WatchProgress( + dramaId: dramaId, + episodeId: '$dramaId-1', + position: const Duration(seconds: 12), + duration: const Duration(seconds: 60), + updatedAt: DateTime(2026, 10, 17, 12), +); + +Future _auth({required bool signedIn}) async { + if (signedIn) { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"1","nickname":"Viewer","avatarUrl":""}', + }); + } + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: FakeHttpAdapter( + (_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'), + ), + ); + final auth = AuthController(AuthApi(client), AuthSessionStore()); + await auth.load(); + return auth; +} + +Future _pumpPage( + WidgetTester tester, { + required AuthController auth, + required WatchHistoryRepository history, + required CatalogRepository catalog, +}) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: history), + Provider.value(value: catalog), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const WatchRecordsPage(), + ), + ), + ); + await tester.pumpAndSettle(); +} + +class CountingWatchHistory extends InMemoryWatchHistoryRepository { + CountingWatchHistory(this.visibleRecords); + + final List visibleRecords; + int recentCalls = 0; + int clearCalls = 0; + + @override + Future> recent({int limit = 20}) async { + recentCalls++; + return visibleRecords.take(limit).toList(); + } + + @override + Future clear() async { + clearCalls++; + await super.clear(); + } +} + +class _FailingWatchCatalog implements CatalogRepository { + final _delegate = FakeCatalogRepository(); + + @override + Future fetchHomeFeed() => _delegate.fetchHomeFeed(); + + @override + Future fetchDiscoverFeed() => _delegate.fetchDiscoverFeed(); + + @override + Future fetchDrama(String dramaId) async { + if (dramaId == 'broken') throw StateError('missing detail'); + return _delegate.fetchDrama(dramaId); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index e1d60d5..a6ede86 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -9,6 +9,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'support/fake_catalog.dart'; +import 'support/fake_category.dart'; import 'support/widget_player.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; @@ -39,15 +40,19 @@ void main() { Widget appUnderTest({ PlayerFactory? playerFactory, CatalogRepository? catalogRepository, - }) => LumaDramaApp( - playerFactory: playerFactory ?? createWidgetPlayer, - catalogRepository: catalogRepository ?? FakeCatalogRepository(), - watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository( - cards: FakeCatalogRepository().dramas, - ), - playbackRepository: FakePlaybackRepository(), - ); + }) { + final catalog = catalogRepository ?? FakeCatalogRepository(); + return LumaDramaApp( + playerFactory: playerFactory ?? createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository( + cards: FakeCatalogRepository().dramas, + ), + playbackRepository: FakePlaybackRepository(), + ); + } testWidgets( 'should_promote_next_without_recreating_it_or_showing_loading_when_home_is_swiped',