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

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

No files matched your search

+41 -6
View File
@@ -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<LumaDramaApp> {
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<LumaDramaApp> {
);
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<LumaDramaApp> {
unawaited(_favoriteController.load());
} else {
_favoriteController.clear();
_downloadController.reset();
}
}
@@ -108,6 +140,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
void dispose() {
_auth.removeListener(_onAuthChanged);
_favoriteController.dispose();
_downloadController.dispose();
_apiClient.close();
super.dispose();
}
@@ -122,12 +155,14 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
Provider<WatchHistoryRepository>.value(value: _watchHistory),
Provider<FavoriteRepository>.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())
@@ -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<String, dynamic> json) {
return ContentCategory(
id: (json['id'] ?? '').toString(),
name: json['name'] as String? ?? '',
);
}
}
@@ -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<String, dynamic> 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? ?? '',
);
}
}
+54
View File
@@ -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<String, dynamic> 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? ?? '',
);
}
}
+4
View File
@@ -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(),
};
}
+44
View File
@@ -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 <Episode>[];
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,
);
}
}
+15
View File
@@ -79,6 +79,21 @@ class ApiClient {
);
}
Future<Object?> put(
String path, {
Object? body,
Map<String, String>? query,
bool skipAuthRetry = false,
}) {
return _request(
'PUT',
path,
body: body,
query: query,
skipAuthRetry: skipAuthRetry,
);
}
Future<Object?> delete(
String path, {
Object? body,
+21
View File
@@ -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<ContentCategory>> 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<String, dynamic>) ContentCategory.fromJson(item),
];
}
}
+101
View File
@@ -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<DownloadPolicy> policy() async {
return DownloadPolicy.fromJson(
_asMap(await _client.get('/app/downloads/policy')),
);
}
/// 更新仅 Wi-Fi 与最高清晰度偏好。
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) async {
await _client.put(
'/app/downloads/settings',
body: {'wifiOnly': ?wifiOnly, 'maxQuality': ?maxQuality},
);
}
/// 申请下载授权;同集重复申请由服务端保证幂等。
Future<DownloadLicense> grant(String episodeId, {String? quality}) async {
return DownloadLicense.fromJson(
_asMap(
await _client.post(
'/app/downloads',
body: {'episodeId': episodeId, 'quality': ?quality},
),
),
);
}
/// 拉取当前设备的授权列表。
Future<List<DownloadLicense>> 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<String, dynamic>) DownloadLicense.fromJson(item),
];
}
/// 续传前刷新短时效下载地址。
Future<DownloadLicense> refreshUrl(String episodeId) async {
return DownloadLicense.fromJson(
_asMap(await _client.get('/app/downloads/$episodeId/url')),
);
}
/// 上报下载进度;本批只提供方法,不启动文件落盘。
Future<DownloadLicense> 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<void> remove(String episodeId) async {
await _client.delete('/app/downloads/$episodeId');
}
/// 清空当前设备授权并返回清除条数。
Future<int> clear() async {
final data = await _client.delete('/app/downloads');
return data is int ? data : 0;
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '');
}
}
+5
View File
@@ -56,4 +56,9 @@ class WatchApi {
Future<void> removeProgress(String dramaId) async {
await _client.delete('/app/watch/progress/$dramaId');
}
/// 清空当前账号的全部观看记录。
Future<void> clearProgress() async {
await _client.delete('/app/watch/progress');
}
}
@@ -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<List<ContentCategory>> fetchCategories();
/// 拉取发现页分区;分类内容只从这里读取。
Future<HomeFeed> fetchDiscoverFeed();
}
/// HTTP 实现。发现 feed 复用既有目录仓库,不新增接口。
class HttpCategoryRepository implements CategoryRepository {
const HttpCategoryRepository(this._categoryApi, this._catalogRepository);
final CategoryApi _categoryApi;
final CatalogRepository _catalogRepository;
@override
Future<List<ContentCategory>> fetchCategories() => _categoryApi.list();
@override
Future<HomeFeed> fetchDiscoverFeed() =>
_catalogRepository.fetchDiscoverFeed();
}
@@ -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<DownloadPolicy> fetchPolicy();
Future<List<DownloadLicense>> list();
Future<DownloadLicense> grant(String episodeId, {String? quality});
Future<void> updateSettings({bool? wifiOnly, String? maxQuality});
Future<DownloadLicense> refreshUrl(String episodeId);
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
});
Future<void> remove(String episodeId);
Future<int> clear();
}
/// HTTP 下载授权仓库。
class HttpDownloadRepository implements DownloadRepository {
const HttpDownloadRepository(this._api);
final DownloadApi _api;
@override
Future<DownloadPolicy> fetchPolicy() => _api.policy();
@override
Future<List<DownloadLicense>> list() => _api.list();
@override
Future<DownloadLicense> grant(String episodeId, {String? quality}) =>
_api.grant(episodeId, quality: quality);
@override
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) =>
_api.updateSettings(wifiOnly: wifiOnly, maxQuality: maxQuality);
@override
Future<DownloadLicense> refreshUrl(String episodeId) =>
_api.refreshUrl(episodeId);
@override
Future<DownloadLicense> 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<void> remove(String episodeId) => _api.remove(episodeId);
@override
Future<int> clear() => _api.clear();
}
@@ -47,7 +47,9 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
final List<Drama> _cards;
bool failToggle = false;
bool failList = false;
bool failRemove = false;
int toggleCalls = 0;
int listCalls = 0;
@override
Future<bool> toggle(String dramaId) async {
@@ -59,10 +61,14 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
@override
Future<List<Drama>> list() async {
listCalls++;
if (failList) throw StateError('favorite unavailable');
return _cards.where((card) => _saved.contains(card.id)).toList();
}
@override
Future<void> remove(String dramaId) async => _saved.remove(dramaId);
Future<void> remove(String dramaId) async {
if (failRemove) throw StateError('favorite unavailable');
_saved.remove(dramaId);
}
}
@@ -25,6 +25,9 @@ abstract interface class WatchHistoryRepository {
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
Future<void> remove(String dramaId);
/// 清空当前账号的全部观看记录。
Future<void> clear();
}
class HttpWatchHistoryRepository implements WatchHistoryRepository {
@@ -70,12 +73,20 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository {
if (!_signedIn) return;
await _api.removeProgress(dramaId);
}
@override
Future<void> clear() async {
if (!_signedIn) return;
await _api.clearProgress();
}
}
/// 内存实现:单测与 widget 测试注入用(不依赖网络与偏好存储)。
class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
InMemoryWatchHistoryRepository({List<WatchProgress>? seed})
: _records = {for (final item in seed ?? const <WatchProgress>[]) item.dramaId: item};
: _records = {
for (final item in seed ?? const <WatchProgress>[]) item.dramaId: item,
};
final Map<String, WatchProgress> _records;
int failReads = 0;
@@ -122,6 +133,9 @@ class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
@override
Future<void> remove(String dramaId) async => _records.remove(dramaId);
@override
Future<void> clear() async => _records.clear();
}
/// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。
@@ -0,0 +1,82 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/content_category.dart';
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../repositories/category_repository.dart';
/// 发现页分类状态:服务端分类清单、发现 feed 与当前筛选。
class CategoryController extends ChangeNotifier {
CategoryController(this._repository);
static const String allCode = 'all';
static const String hotCode = 'hot';
static const String newCode = 'new';
final CategoryRepository _repository;
List<ContentCategory> categories = const [];
HomeFeed feed = const HomeFeed();
String selectedCode = allCode;
bool loading = false;
String? error;
bool _disposed = false;
/// 当前筛选对应的分区 code;全部分类为空字符串语义。
bool get selectedSectionMissing =>
selectedCode != allCode && _selectedSection == null;
/// 当前筛选应展示的剧集;全部分类按 feed 顺序去重。
List<Drama> get visibleDramas {
if (selectedCode == allCode) return feed.dramas;
return _selectedSection?.dramas ?? const [];
}
FeedSection? get _selectedSection {
for (final section in feed.sections) {
if (section.code == selectedCode) return section;
}
return null;
}
/// 加载分类与发现 feed;失败保留可重试错误态。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final results = await Future.wait<Object>([
_repository.fetchCategories(),
_repository.fetchDiscoverFeed(),
]);
categories = results[0] as List<ContentCategory>;
feed = results[1] as HomeFeed;
} catch (_) {
error = 'categoriesError';
} finally {
loading = false;
_notify();
}
}
/// 选择服务端分类,内容只匹配 `category-{id}`。
void selectCategory(String id) => selectCode('category-$id');
/// 选择内置或分区 code。
void selectCode(String code) {
selectedCode = code;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,168 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/download_license.dart';
import '../models/download_policy.dart';
import '../remote/api_exception.dart';
import '../repositories/download_repository.dart';
/// 一次下载授权申请的结果;后端业务拒绝时保留原始 message。
class DownloadActionResult {
const DownloadActionResult({required this.ok, this.license, this.message});
final bool ok;
final DownloadLicense? license;
final String? message;
}
/// 下载授权与策略状态。
class DownloadController extends ChangeNotifier {
DownloadController(this._repository);
final DownloadRepository _repository;
DownloadPolicy? policy;
List<DownloadLicense> licenses = const [];
bool loading = false;
bool clearing = false;
String? error;
String? actionError;
bool _disposed = false;
/// 同时拉取策略与当前设备授权列表。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final results = await Future.wait<Object>([
_repository.fetchPolicy(),
_repository.list(),
]);
policy = results[0] as DownloadPolicy;
licenses = results[1] as List<DownloadLicense>;
} catch (_) {
error = 'downloadsError';
} finally {
loading = false;
_notify();
}
}
/// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。
Future<DownloadActionResult> grant(
String episodeId, {
String? quality,
}) async {
try {
final license = await _repository.grant(episodeId, quality: quality);
licenses = [
license,
for (final item in licenses)
if (item.episodeId != license.episodeId) item,
];
actionError = null;
_notify();
return DownloadActionResult(ok: true, license: license);
} on ApiException catch (exception) {
actionError = exception.message;
_notify();
return DownloadActionResult(ok: false, message: exception.message);
} catch (_) {
actionError = 'downloadGrantFailed';
_notify();
return const DownloadActionResult(ok: false);
}
}
/// 更新设置,先本地展示,失败恢复服务端旧值。
Future<bool> updateSettings({bool? wifiOnly, String? maxQuality}) async {
final previous = policy;
if (previous != null) {
policy = previous.copyWith(wifiOnly: wifiOnly, maxQuality: maxQuality);
_notify();
}
try {
await _repository.updateSettings(
wifiOnly: wifiOnly,
maxQuality: maxQuality,
);
actionError = null;
return true;
} catch (_) {
policy = previous;
actionError = 'downloadSettingsFailed';
return false;
} finally {
_notify();
}
}
/// 删除单条授权,失败按原位置恢复。
Future<bool> remove(String episodeId) async {
final index = licenses.indexWhere((item) => item.episodeId == episodeId);
if (index < 0) return true;
final removed = licenses[index];
licenses = [...licenses]..removeAt(index);
actionError = null;
_notify();
try {
await _repository.remove(episodeId);
return true;
} catch (_) {
final restored = [...licenses];
restored.insert(
index > restored.length ? restored.length : index,
removed,
);
licenses = restored;
actionError = 'downloadDeleteFailed';
return false;
} finally {
_notify();
}
}
/// 清空授权,失败恢复原列表。
Future<bool> clear() async {
if (licenses.isEmpty) return true;
final previous = licenses;
licenses = const [];
clearing = true;
actionError = null;
_notify();
try {
await _repository.clear();
return true;
} catch (_) {
licenses = previous;
actionError = 'downloadClearFailed';
return false;
} finally {
clearing = false;
_notify();
}
}
/// 退出登录时清空账号相关状态,避免跨账号串联。
void reset() {
policy = null;
licenses = const [];
loading = false;
clearing = false;
error = null;
actionError = null;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+11 -12
View File
@@ -88,24 +88,23 @@ class FavoriteController extends ChangeNotifier {
/// 取消收藏("我的"页列表里删除某条)。
Future<bool> remove(String dramaId) async {
final removed = cards.firstWhere(
(drama) => drama.id == dramaId,
orElse: () => const Drama(
id: '',
title: '',
synopsis: '',
posterUrl: '',
),
);
final hadCard = removed.id.isNotEmpty;
final index = cards.indexWhere((drama) => drama.id == dramaId);
if (index < 0) return true;
final removed = cards[index];
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
final next = [...cards]..removeAt(index);
cards = next;
notify();
try {
await _repository.remove(dramaId);
return true;
} catch (_) {
if (hadCard) cards = [...cards, removed];
final restored = [...cards];
restored.insert(
index > restored.length ? restored.length : index,
removed,
);
cards = restored;
_saved.add(dramaId);
notify();
return false;
@@ -0,0 +1,205 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../models/watch_progress.dart';
import '../models/watch_record.dart';
import '../repositories/catalog_repository.dart';
import '../repositories/watch_history_repository.dart';
/// 独立观看记录页状态。
///
/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。
class WatchRecordsController extends ChangeNotifier {
factory WatchRecordsController({
required WatchHistoryRepository history,
required CatalogRepository catalog,
int limit = defaultLimit,
}) => WatchRecordsController._(history, catalog, limit);
WatchRecordsController._(this._history, this._catalog, this.limit);
static const int defaultLimit = 20;
static const int detailConcurrency = 4;
final WatchHistoryRepository _history;
final CatalogRepository _catalog;
final int limit;
final Map<String, Drama> _detailCache = {};
List<WatchRecord> records = const [];
bool loading = false;
bool clearing = false;
String? error;
String? actionError;
bool _disposed = false;
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
Future<void> load() async {
loading = true;
error = null;
actionError = null;
_notify();
try {
final recent = await _history.recent(limit: limit);
if (_disposed) return;
records = _uniqueRecords(recent);
_notify();
await _hydrate(records.map((record) => record.dramaId).toList());
} catch (_) {
if (!_disposed) error = 'progressError';
} finally {
if (!_disposed) {
loading = false;
_notify();
}
}
}
/// 只重试一条详情失败记录。
Future<void> retryDetail(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return;
_replaceAt(
index,
records[index].copyWith(detailLoading: true, detailFailed: false),
);
_notify();
await _loadDetail(dramaId);
_notify();
}
/// 删除单条记录,先更新 UI,失败按原位置回滚。
Future<bool> remove(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return true;
final removed = records[index];
records = [...records]..removeAt(index);
actionError = null;
_notify();
try {
await _history.remove(dramaId);
return true;
} catch (_) {
final next = [...records];
next.insert(index > next.length ? next.length : index, removed);
records = next;
actionError = 'recordDeleteFailed';
return false;
} finally {
_notify();
}
}
/// 清空全部记录,先更新 UI,失败恢复原列表。
Future<bool> clear() async {
if (records.isEmpty) return true;
final previous = records;
records = const [];
clearing = true;
actionError = null;
_notify();
try {
await _history.clear();
return true;
} catch (_) {
records = previous;
actionError = 'recordClearFailed';
return false;
} finally {
clearing = false;
_notify();
}
}
List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) {
final seen = <String>{};
return [
for (final progress in recent)
if (seen.add(progress.dramaId)) WatchRecord(progress: progress),
];
}
Future<void> _hydrate(List<String> dramaIds) async {
final queue = [
for (final id in dramaIds)
if (!_detailCache.containsKey(id)) id,
];
final workerCount = queue.length < detailConcurrency
? queue.length
: detailConcurrency;
Future<void> worker() async {
while (queue.isNotEmpty && !_disposed) {
await _loadDetail(queue.removeAt(0));
}
}
await Future.wait([
for (var index = 0; index < workerCount; index++) worker(),
]);
}
Future<void> _loadDetail(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return;
final cached = _detailCache[dramaId];
if (cached != null) {
_replaceAt(
index,
records[index].copyWith(
drama: cached,
detailLoading: false,
detailFailed: false,
),
);
return;
}
try {
final detail = await _catalog.fetchDrama(dramaId);
if (_disposed) return;
_detailCache[dramaId] = detail;
final current = _recordIndex(dramaId);
if (current < 0) return;
_replaceAt(
current,
records[current].copyWith(
drama: detail,
detailLoading: false,
detailFailed: false,
),
);
} catch (_) {
if (_disposed) return;
final current = _recordIndex(dramaId);
if (current < 0) return;
_replaceAt(
current,
records[current].copyWith(detailLoading: false, detailFailed: true),
);
}
}
int _recordIndex(String dramaId) {
for (var index = 0; index < records.length; index++) {
if (records[index].dramaId == dramaId) return index;
}
return -1;
}
void _replaceAt(int index, WatchRecord record) {
final next = [...records];
next[index] = record;
records = next;
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+42 -3
View File
@@ -9,9 +9,7 @@ import '../theme/app_colors.dart';
/// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库)
Future<void> openAuthPage(BuildContext context, {bool signUpMode = false}) {
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AuthPage(signUpMode: signUpMode),
),
MaterialPageRoute<void>(builder: (_) => AuthPage(signUpMode: signUpMode)),
);
}
@@ -81,3 +79,44 @@ Future<bool> 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),
),
],
),
),
);
}
}
+29
View File
@@ -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<void> requestEpisodeDownload(
BuildContext context,
String episodeId,
) async {
if (!await ensureSignedIn(context)) return;
if (!context.mounted) return;
final result = await context.read<DownloadController>().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)));
}
+73
View File
@@ -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,
],
),
),
),
);
}
}
+78 -1
View File
@@ -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}",
+78 -1
View File
@@ -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}",
+199 -3
View File
@@ -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:
+120 -3
View File
@@ -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';
+117 -1
View File
@@ -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';
+114 -3
View File
@@ -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 => '移除';
+78 -1
View File
@@ -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}",
+6 -43
View File
@@ -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<void>(
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;
+82 -57
View File
@@ -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<DiscoverPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final catalog = context.watch<CatalogController>();
final allDramas = widget.dramas ?? [logic.drama];
final cards = _cardsFor(
allDramas,
catalog.sections,
logic.category,
'',
);
final categories = context.watch<CategoryController>();
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<DiscoverPage> {
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<DiscoverPage> {
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<DiscoverPage> {
List<FeedBanner> banners,
List<Drama> 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<DiscoverPage> {
],
);
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<Drama> source,
) {
final drama = logic.dramaFor(card.dramaId, source);
return SizedBox(
width: 132,
child: InkWell(
@@ -360,24 +393,25 @@ class _DiscoverPageState extends State<DiscoverPage> {
);
}
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<DiscoverPage> {
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<Drama> dramas,
List<FeedSection> sections,
int category,
String query,
) {
final source = switch (category) {
0 => dramas,
_ => category < sections.length ? sections[category].dramas : const <Drama>[],
};
List<_ShowcaseCard> _cardsFor(List<Drama> 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) ||
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
import '../../core/data/services/download_controller.dart';
/// 下载管理页依赖装配。
class DownloadsBinding {
/// 从服务端刷新策略与授权列表。
Future<void> load(DownloadController downloads) => downloads.load();
}
+409
View File
@@ -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<DownloadsPage> createState() => _DownloadsPageState();
}
class _DownloadsPageState extends State<DownloadsPage> {
bool _requested = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final signedIn = context.watch<AuthController>().isSignedIn;
if (!signedIn) {
_requested = false;
return;
}
if (_requested) return;
_requested = true;
final downloads = context.read<DownloadController>();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(DownloadsBinding().load(downloads));
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().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<DownloadController>())
: 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<void> _remove(BuildContext context, String episodeId) async {
final ok = await downloads.remove(episodeId);
if (ok || !context.mounted) return;
_showMessage(context, AppLocalizations.of(context)!.downloadDeleteFailed);
}
Future<void> _confirmClear(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
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<String>(
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<void> _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<String> _qualityChoices(String current) {
final choices = <String>['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';
}
+22 -1
View File
@@ -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<DramaDetailPage> {
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<DramaDetailPage> {
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<FavoriteController>();
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
import '../../core/data/services/favorite_controller.dart';
/// 收藏页依赖装配。
class FavoritesBinding {
/// 从服务端刷新收藏列表。
Future<void> load(FavoriteController favorites) => favorites.load();
}
+148
View File
@@ -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<FavoritesPage> createState() => _FavoritesPageState();
}
class _FavoritesPageState extends State<FavoritesPage> {
bool _requested = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final signedIn = context.watch<AuthController>().isSignedIn;
if (!signedIn) {
_requested = false;
return;
}
if (_requested) return;
_requested = true;
final favorites = context.read<FavoriteController>();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(FavoritesBinding().load(favorites));
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().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<FavoriteController>())
: 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<void> _open(BuildContext context, Drama drama) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
);
}
Future<void> _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)));
}
}
-2
View File
@@ -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<AuthController>().signOut(),
+36 -5
View File
@@ -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<MyPage> {
Widget _signedInRow(AppLocalizations l10n) {
// 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案
final user = context.watch<AuthController>().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<MyPage> {
),
title: Text(l10n.watchHistory),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: widget.onOpenHistory,
onTap: () => Navigator.of(context).push<void>(
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<void>(
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<void>(
MaterialPageRoute(builder: (_) => const DownloadsPage()),
),
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
+10
View File
@@ -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<PlayerPage> 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,
@@ -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);
}
@@ -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<AuthController>().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<WatchHistoryRepository>(),
catalog: context.read<CatalogRepository>(),
);
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<void> _open(Drama drama) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
);
}
Future<void> _remove(String dramaId) async {
final ok = await controller.remove(dramaId);
if (!ok && mounted) _showFailure('recordDeleteFailed');
}
Future<void> _confirmClear() async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
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<void> 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';
}