feat(flutter): 接入分类记录收藏与下载授权
This commit is contained in:
1 parent
00280ca0da
commit
cc4d7c5a9d
52 files changed
+4440
-171
No files matched your search
+41
-6
@@ -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? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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, '');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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}",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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}",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user