feat(flutter): 接入分类记录收藏与下载授权
This commit is contained in:
1 parent
00280ca0da
commit
cc4d7c5a9d
52 files changed
+4440
-171
No files matched your search
@@ -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