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

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

No files matched your search

+6 -43
View File
@@ -19,7 +19,6 @@ class DiscoverLogic extends ChangeNotifier {
final TextEditingController search = TextEditingController();
final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController();
int category = 0;
bool searchOpen = false;
bool _disposed = false;
@@ -35,11 +34,6 @@ class DiscoverLogic extends ChangeNotifier {
.catchError((Object _) => null);
}
void selectCategory(int index) {
category = index;
notify();
}
void onSearchChanged() => notify();
bool get showSearch => searchOpen || search.text.isNotEmpty;
@@ -105,7 +99,12 @@ class DiscoverLogic extends ChangeNotifier {
} else if (embedded && onDramaSelected != null) {
onDramaSelected(selected);
} else {
act(context, DiscoverPageAction.detail, embedded: embedded, onAction: onAction);
act(
context,
DiscoverPageAction.detail,
embedded: embedded,
onAction: onAction,
);
}
}
@@ -123,42 +122,6 @@ class DiscoverLogic extends ChangeNotifier {
).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice)));
}
void showFilters(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final labels = [
l10n.forYou,
l10n.hot,
l10n.newest,
l10n.romance,
l10n.actionGenre,
];
showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF202126),
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text(l10n.filter)),
for (var index = 0; index < labels.length; index++)
ListTile(
title: Text(labels[index]),
trailing: index == category
? const Icon(Icons.check_rounded, color: Color(0xFF2F7BFF))
: null,
onTap: () {
selectCategory(index);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
),
),
);
}
@override
void dispose() {
_disposed = true;
+82 -57
View File
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
@@ -96,14 +96,9 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final catalog = context.watch<CatalogController>();
final allDramas = widget.dramas ?? [logic.drama];
final cards = _cardsFor(
allDramas,
catalog.sections,
logic.category,
'',
);
final categories = context.watch<CategoryController>();
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
final cards = _cardsFor(categories.visibleDramas, '');
final content = SafeArea(
bottom: !widget.embedded,
child: Column(
@@ -118,7 +113,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: _carousel(l10n, catalog.banners, allDramas),
child: _carousel(l10n, categories.feed.banners, allDramas),
),
),
Padding(
@@ -126,31 +121,11 @@ class _DiscoverPageState extends State<DiscoverPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_categories(l10n),
_categories(l10n, categories),
const SizedBox(height: 18),
_continueHeader(l10n),
const SizedBox(height: 12),
if (cards.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
l10n.noSearchResults,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
)
else
SizedBox(
height: 196,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (final card in cards) ...[
_continueTile(l10n, card),
const SizedBox(width: 12),
],
],
),
),
_dramaContent(l10n, categories, cards),
],
),
),
@@ -217,7 +192,9 @@ class _DiscoverPageState extends State<DiscoverPage> {
List<FeedBanner> banners,
List<Drama> allDramas,
) {
final items = banners.where((banner) => banner.imageUrl.isNotEmpty).toList();
final items = banners
.where((banner) => banner.imageUrl.isNotEmpty)
.toList();
if (items.isEmpty) return const SizedBox.shrink();
return CoverCarousel(
items: [
@@ -285,8 +262,64 @@ class _DiscoverPageState extends State<DiscoverPage> {
],
);
Widget _continueTile(AppLocalizations l10n, _ShowcaseCard card) {
final drama = logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]);
Widget _dramaContent(
AppLocalizations l10n,
CategoryController categories,
List<_ShowcaseCard> cards,
) {
if (categories.loading && categories.feed.sections.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (categories.error != null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Row(
children: [
Expanded(
child: Text(
l10n.categoriesError,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
),
TextButton(onPressed: categories.load, child: Text(l10n.retry)),
],
),
);
}
if (cards.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
categories.selectedSectionMissing
? l10n.noCategoryDramas
: l10n.noSearchResults,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
);
}
return SizedBox(
height: 196,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (final card in cards) ...[
_continueTile(l10n, card, categories.visibleDramas),
const SizedBox(width: 12),
],
],
),
);
}
Widget _continueTile(
AppLocalizations l10n,
_ShowcaseCard card,
List<Drama> source,
) {
final drama = logic.dramaFor(card.dramaId, source);
return SizedBox(
width: 132,
child: InkWell(
@@ -360,24 +393,25 @@ class _DiscoverPageState extends State<DiscoverPage> {
);
}
Widget _categories(AppLocalizations l10n) {
final labels = [
l10n.forYou,
l10n.hot,
l10n.newest,
l10n.romance,
l10n.actionGenre,
Widget _categories(AppLocalizations l10n, CategoryController categories) {
final choices = <({String code, String label})>[
(code: CategoryController.allCode, label: l10n.forYou),
(code: CategoryController.hotCode, label: l10n.hot),
(code: CategoryController.newCode, label: l10n.newest),
for (final category in categories.categories)
(code: 'category-${category.id}', label: category.name),
];
return SizedBox(
height: 32,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: labels.length,
itemCount: choices.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final selected = logic.category == index;
final choice = choices[index];
final selected = categories.selectedCode == choice.code;
return GestureDetector(
onTap: () => logic.selectCategory(index),
onTap: () => categories.selectCode(choice.code),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
alignment: Alignment.center,
@@ -387,7 +421,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
borderRadius: BorderRadius.circular(16),
),
child: Text(
labels[index],
choice.label,
maxLines: 1,
style: TextStyle(
color: selected ? Colors.white : AppColors.muted,
@@ -498,19 +532,10 @@ class _ShowcaseCard {
}
/// 分区由服务端下发:0 为全部,其余按分区顺序对应筛选菜单(热门 / 最新 / 各分类)。
List<_ShowcaseCard> _cardsFor(
List<Drama> dramas,
List<FeedSection> sections,
int category,
String query,
) {
final source = switch (category) {
0 => dramas,
_ => category < sections.length ? sections[category].dramas : const <Drama>[],
};
List<_ShowcaseCard> _cardsFor(List<Drama> dramas, String query) {
final normalized = query.trim().toLowerCase();
return [
for (final drama in source)
for (final drama in dramas)
if (normalized.isEmpty ||
drama.title.toLowerCase().contains(normalized) ||
drama.genre.toLowerCase().contains(normalized) ||
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
import '../../core/data/services/download_controller.dart';
/// 下载管理页依赖装配。
class DownloadsBinding {
/// 从服务端刷新策略与授权列表。
Future<void> load(DownloadController downloads) => downloads.load();
}
+409
View File
@@ -0,0 +1,409 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/download_license.dart';
import '../../core/data/models/download_policy.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/auth_gate.dart';
import '../../l10n/app_localizations.dart';
import 'downloads_binding.dart';
/// 下载授权与状态管理页;本批不落盘,只管理服务端授权。
class DownloadsPage extends StatefulWidget {
const DownloadsPage({super.key});
@override
State<DownloadsPage> createState() => _DownloadsPageState();
}
class _DownloadsPageState extends State<DownloadsPage> {
bool _requested = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final signedIn = context.watch<AuthController>().isSignedIn;
if (!signedIn) {
_requested = false;
return;
}
if (_requested) return;
_requested = true;
final downloads = context.read<DownloadController>();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(DownloadsBinding().load(downloads));
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Scaffold(
key: const Key('downloads-page'),
backgroundColor: const Color(0xFF0B0C10),
appBar: AppBar(
backgroundColor: const Color(0xFF0B0C10),
foregroundColor: Colors.white,
title: Text(l10n.downloads),
),
body: signedIn
? _DownloadsBody(downloads: context.watch<DownloadController>())
: const AuthGatePanel(),
);
}
}
class _DownloadsBody extends StatelessWidget {
const _DownloadsBody({required this.downloads});
final DownloadController downloads;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (downloads.loading && downloads.policy == null) {
return const Center(child: CircularProgressIndicator());
}
if (downloads.error != null && downloads.policy == null) {
return Center(
key: const Key('downloads-error'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorText(l10n, downloads.error, l10n.downloadsError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 14),
FilledButton(
key: const Key('downloads-retry'),
onPressed: () => unawaited(downloads.load()),
child: Text(l10n.retry),
),
],
),
),
);
}
final policy = downloads.policy;
if (policy == null) {
return Center(
key: const Key('downloads-error'),
child: FilledButton(
key: const Key('downloads-retry'),
onPressed: () => unawaited(downloads.load()),
child: Text(l10n.retry),
),
);
}
return RefreshIndicator(
onRefresh: downloads.load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 28),
children: [
_PolicyPanel(downloads: downloads, policy: policy),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: Text(
l10n.downloadedItems,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
if (downloads.licenses.isNotEmpty)
TextButton.icon(
key: const Key('download-clear'),
onPressed: downloads.clearing
? null
: () => _confirmClear(context),
icon: const Icon(Icons.delete_sweep_outlined),
label: Text(l10n.clearAll),
),
],
),
if (downloads.licenses.isEmpty)
Padding(
key: const Key('downloads-empty'),
padding: const EdgeInsets.symmetric(vertical: 34),
child: Text(
l10n.noDownloads,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
)
else
for (final license in downloads.licenses) ...[
_LicenseTile(
license: license,
onRemove: () => _remove(context, license.episodeId),
),
const SizedBox(height: 10),
],
],
),
);
}
Future<void> _remove(BuildContext context, String episodeId) async {
final ok = await downloads.remove(episodeId);
if (ok || !context.mounted) return;
_showMessage(context, AppLocalizations.of(context)!.downloadDeleteFailed);
}
Future<void> _confirmClear(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.clearDownloadsTitle),
content: Text(l10n.clearDownloadsMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.confirmClear),
),
],
),
);
if (confirmed != true) return;
final ok = await downloads.clear();
if (!ok && context.mounted) {
_showMessage(context, l10n.downloadClearFailed);
}
}
void _showMessage(BuildContext context, String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
class _PolicyPanel extends StatelessWidget {
const _PolicyPanel({required this.downloads, required this.policy});
final DownloadController downloads;
final DownloadPolicy policy;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: const Key('download-policy'),
padding: const EdgeInsets.fromLTRB(14, 14, 14, 10),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2A2C33)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.downloadQuota(policy.remaining, policy.maxPerDevice),
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 5),
Text(
l10n.downloadLicenseDays(policy.licenseDays),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
const SizedBox(height: 6),
Text(
policy.expirePolicyText,
style: const TextStyle(color: Color(0xFFB7B3B8), fontSize: 12),
),
const Divider(height: 22, color: Color(0xFF2A2C33)),
Row(
children: [
Expanded(
child: Text(
l10n.downloadWifiOnly,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
Switch(
key: const Key('download-wifi-switch'),
value: policy.wifiOnly,
onChanged: (value) => _update(context, wifiOnly: value),
),
],
),
Row(
children: [
Expanded(
child: Text(
l10n.downloadQuality,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
DropdownButton<String>(
key: const Key('download-quality'),
value:
_qualityChoices(
policy.maxQuality,
).contains(policy.maxQuality)
? policy.maxQuality
: 'AUTO',
underline: const SizedBox.shrink(),
items: [
for (final quality in _qualityChoices(policy.maxQuality))
DropdownMenuItem(value: quality, child: Text(quality)),
],
onChanged: (value) {
if (value == null || value == policy.maxQuality) return;
_update(context, maxQuality: value);
},
),
],
),
],
),
);
}
Future<void> _update(
BuildContext context, {
bool? wifiOnly,
String? maxQuality,
}) async {
final ok = await downloads.updateSettings(
wifiOnly: wifiOnly,
maxQuality: maxQuality,
);
if (ok || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.downloadSettingsFailed),
),
);
}
}
class _LicenseTile extends StatelessWidget {
const _LicenseTile({required this.license, required this.onRemove});
final DownloadLicense license;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: ValueKey('download-license-${license.episodeId}'),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
license.dramaTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
Text(l10n.episodeLabel(license.episodeNo)),
IconButton(
key: ValueKey('download-remove-${license.episodeId}'),
tooltip: l10n.remove,
onPressed: onRemove,
icon: const Icon(Icons.close_rounded),
),
],
),
Wrap(
spacing: 12,
runSpacing: 5,
children: [
Text(
l10n.downloadStatus(license.status),
style: const TextStyle(color: AppColors.blueSoft, fontSize: 12),
),
Text(
l10n.downloadTaskStatus(license.taskStatus),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
Text(
l10n.downloadProgress(license.progressPercent),
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
if (license.quality.isNotEmpty)
Text(
license.quality,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
minHeight: 4,
value: (license.progressPercent / 100).clamp(0.0, 1.0),
),
),
if (license.expireAt != null) ...[
const SizedBox(height: 7),
Text(
l10n.downloadExpiresAt(_formatTime(license.expireAt!)),
style: const TextStyle(color: Color(0xFF8D8990), fontSize: 11),
),
],
],
),
);
}
}
List<String> _qualityChoices(String current) {
final choices = <String>['AUTO'];
if (current.isNotEmpty && current != 'AUTO') choices.add(current);
for (final value in const ['720P', '1080P']) {
if (!choices.contains(value)) choices.add(value);
}
return choices;
}
String _formatTime(DateTime value) {
final local = value.toLocal();
final month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
return '${local.year}-$month-$day $hour:$minute';
}
+22 -1
View File
@@ -12,6 +12,7 @@ import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import 'drama_detail_binding.dart';
import 'drama_detail_logic.dart';
@@ -136,7 +137,10 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
pinned: true,
backgroundColor: const Color(0xFF111111),
foregroundColor: Colors.white,
actions: [_favoriteAction(context, l10n)],
actions: [
_downloadAction(context, l10n),
_favoriteAction(context, l10n),
],
flexibleSpace: FlexibleSpaceBar(
background: RemoteImage(drama.posterUrl),
),
@@ -270,6 +274,23 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
return record.fraction;
}
/// 下载当前续看集或第一集;业务判定完全交给服务端。
Widget _downloadAction(BuildContext context, AppLocalizations l10n) {
final episodes = logic.drama.episodes;
final index = logic.continueIndex ?? 0;
final episode = episodes.isEmpty
? null
: episodes[index.clamp(0, episodes.length - 1)];
return IconButton(
key: const Key('detail-download'),
tooltip: l10n.downloads,
icon: const Icon(Icons.download_outlined),
onPressed: episode == null
? null
: () => requestEpisodeDownload(context, episode.id),
);
}
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
final favorites = context.watch<FavoriteController>();
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
import '../../core/data/services/favorite_controller.dart';
/// 收藏页依赖装配。
class FavoritesBinding {
/// 从服务端刷新收藏列表。
Future<void> load(FavoriteController favorites) => favorites.load();
}
+148
View File
@@ -0,0 +1,148 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/library_drama_tile.dart';
import '../../l10n/app_localizations.dart';
import '../drama_detail/drama_detail_view.dart';
import 'favorites_binding.dart';
/// 独立我的收藏页。
class FavoritesPage extends StatefulWidget {
const FavoritesPage({super.key});
@override
State<FavoritesPage> createState() => _FavoritesPageState();
}
class _FavoritesPageState extends State<FavoritesPage> {
bool _requested = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final signedIn = context.watch<AuthController>().isSignedIn;
if (!signedIn) {
_requested = false;
return;
}
if (_requested) return;
_requested = true;
final favorites = context.read<FavoriteController>();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(FavoritesBinding().load(favorites));
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Scaffold(
backgroundColor: const Color(0xFF0B0C10),
appBar: AppBar(
backgroundColor: const Color(0xFF0B0C10),
foregroundColor: Colors.white,
title: Text(l10n.myFavorites),
),
body: signedIn
? _FavoritesBody(favorites: context.watch<FavoriteController>())
: const AuthGatePanel(),
);
}
}
class _FavoritesBody extends StatelessWidget {
const _FavoritesBody({required this.favorites});
final FavoriteController favorites;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (favorites.loading && favorites.cards.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (favorites.error != null && favorites.cards.isEmpty) {
return Center(
key: const Key('favorites-error'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorText(l10n, favorites.error, l10n.favoritesError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 14),
FilledButton(
key: const Key('favorites-retry'),
onPressed: () => unawaited(favorites.load()),
child: Text(l10n.retry),
),
],
),
),
);
}
if (favorites.cards.isEmpty) {
return Center(
key: const Key('favorites-empty'),
child: Text(
l10n.noFavorites,
style: const TextStyle(color: Colors.white70),
),
);
}
return RefreshIndicator(
onRefresh: favorites.load,
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
itemCount: favorites.cards.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final drama = favorites.cards[index];
return LibraryDramaTile(
key: ValueKey('favorite-card-${drama.id}'),
drama: drama,
onTap: () => _open(context, drama),
trailing: IconButton(
key: ValueKey('favorite-remove-${drama.id}'),
tooltip: l10n.remove,
onPressed: () => _remove(context, drama.id),
icon: const Icon(
Icons.favorite_rounded,
color: Color(0xFFD74A7D),
),
),
);
},
),
);
}
Future<void> _open(BuildContext context, Drama drama) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
);
}
Future<void> _remove(BuildContext context, String dramaId) async {
final ok = await favorites.remove(dramaId);
if (ok || !context.mounted) return;
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
}
}
-2
View File
@@ -105,7 +105,6 @@ class _HomeTabsState extends State<_HomeTabs> {
);
}
Widget _scaffold(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关)
@@ -173,7 +172,6 @@ class _HomeTabsState extends State<_HomeTabs> {
embedded: true,
active: logic.tab == 2,
onAction: logic.myAction,
onOpenHistory: () => logic.openLibrary(section: 0),
signedIn: signedIn,
onSignIn: () => openAuthPage(context),
onSignOut: () => context.read<AuthController>().signOut(),
+36 -5
View File
@@ -17,6 +17,9 @@ import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
import 'mine_binding.dart';
import 'mine_logic.dart';
import '../watch_records/watch_records_page.dart';
import '../favorites/favorites_page.dart';
import '../downloads/downloads_page.dart';
class MyPage extends StatefulWidget {
const MyPage({
@@ -25,7 +28,6 @@ class MyPage extends StatefulWidget {
required this.duration,
this.initialSection = 0,
this.profile = false,
this.onOpenHistory,
this.history,
this.embedded = false,
this.active = true,
@@ -42,7 +44,6 @@ class MyPage extends StatefulWidget {
final Duration duration;
final int initialSection;
final bool profile;
final VoidCallback? onOpenHistory;
final WatchHistoryRepository? history;
final bool embedded;
final bool active;
@@ -249,8 +250,12 @@ class _MyPageState extends State<MyPage> {
Widget _signedInRow(AppLocalizations l10n) {
// 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案
final user = context.watch<AuthController>().user;
final name = (user?.nickname.isNotEmpty ?? false) ? user!.nickname : l10n.mockUserName;
final uidText = (user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.mockUserId;
final name = (user?.nickname.isNotEmpty ?? false)
? user!.nickname
: l10n.mockUserName;
final uidText = (user?.uid.isNotEmpty ?? false)
? 'ID: ${user!.uid}'
: l10n.mockUserId;
return Row(
children: [
const CircleAvatar(
@@ -414,7 +419,33 @@ class _MyPageState extends State<MyPage> {
),
title: Text(l10n.watchHistory),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: widget.onOpenHistory,
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const WatchRecordsPage()),
),
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
leading: const Icon(
Icons.favorite_outline_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.myFavorites),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const FavoritesPage()),
),
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
leading: const Icon(
Icons.download_outlined,
color: AppColors.blueSoft,
),
title: Text(l10n.downloads),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const DownloadsPage()),
),
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
+10
View File
@@ -16,6 +16,7 @@ import '../../core/data/services/video_player_port.dart';
import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import 'player_binding.dart';
import 'player_logic.dart';
@@ -360,6 +361,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
);
}
},
onDownload: () => requestEpisodeDownload(context, episode.id),
onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama),
onClear: () => logic.setScreenCleared(true),
@@ -523,6 +525,7 @@ class _PlaybackOverlay extends StatelessWidget {
required this.onMy,
required this.onLike,
required this.onSave,
required this.onDownload,
required this.onComments,
required this.onShare,
required this.onClear,
@@ -544,6 +547,7 @@ class _PlaybackOverlay extends StatelessWidget {
final VoidCallback onMy;
final VoidCallback onLike;
final VoidCallback onSave;
final VoidCallback onDownload;
final VoidCallback onComments;
final VoidCallback onShare;
final VoidCallback onClear;
@@ -665,6 +669,12 @@ class _PlaybackOverlay extends StatelessWidget {
selected: saved,
onTap: onSave,
),
_RailAction(
key: const Key('rail-download'),
icon: Icons.download_outlined,
label: l10n.downloads,
onTap: onDownload,
),
_RailAction(
icon: Icons.grid_view_rounded,
label: l10n.episodes,
@@ -0,0 +1,14 @@
// Author: fruit.com.cn
import '../../core/data/repositories/catalog_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/watch_records_controller.dart';
/// 观看记录页依赖装配。
class WatchRecordsBinding {
/// 创建独立页面控制器。
WatchRecordsController create({
required WatchHistoryRepository history,
required CatalogRepository catalog,
}) => WatchRecordsController(history: history, catalog: catalog);
}
@@ -0,0 +1,386 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_record.dart';
import '../../core/data/repositories/catalog_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/watch_records_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import '../drama_detail/drama_detail_view.dart';
import 'watch_records_binding.dart';
/// 独立观看记录页。
class WatchRecordsPage extends StatelessWidget {
const WatchRecordsPage({super.key});
@override
Widget build(BuildContext context) {
final signedIn = context.watch<AuthController>().isSignedIn;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
key: const Key('watch-records-page'),
backgroundColor: const Color(0xFF0B0C10),
appBar: AppBar(
backgroundColor: const Color(0xFF0B0C10),
foregroundColor: Colors.white,
title: Text(l10n.watchHistory),
),
body: signedIn ? const _WatchRecordsBody() : const AuthGatePanel(),
);
}
}
class _WatchRecordsBody extends StatefulWidget {
const _WatchRecordsBody();
@override
State<_WatchRecordsBody> createState() => _WatchRecordsBodyState();
}
class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
late final WatchRecordsController controller;
@override
void initState() {
super.initState();
controller = WatchRecordsBinding().create(
history: context.read<WatchHistoryRepository>(),
catalog: context.read<CatalogRepository>(),
);
unawaited(controller.load());
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) => _content(context),
);
}
Widget _content(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (controller.loading && controller.records.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (controller.error != null && controller.records.isEmpty) {
return _FailureState(
message: errorText(l10n, controller.error, l10n.progressError),
onRetry: controller.load,
);
}
if (controller.records.isEmpty) {
return Center(
child: Text(
l10n.noWatchRecords,
style: const TextStyle(color: Colors.white70),
),
);
}
return Column(
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
key: const Key('watch-records-clear'),
onPressed: controller.clearing ? null : _confirmClear,
icon: const Icon(Icons.delete_sweep_outlined),
label: Text(l10n.clearAll),
),
),
Expanded(
child: RefreshIndicator(
onRefresh: controller.load,
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 28),
itemCount: controller.records.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final record = controller.records[index];
return _RecordTile(
record: record,
onRetry: () => controller.retryDetail(record.dramaId),
onRemove: () => _remove(record.dramaId),
onOpen: record.drama == null
? null
: () => _open(record.drama!),
);
},
),
),
),
],
);
}
Future<void> _open(Drama drama) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
);
}
Future<void> _remove(String dramaId) async {
final ok = await controller.remove(dramaId);
if (!ok && mounted) _showFailure('recordDeleteFailed');
}
Future<void> _confirmClear() async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.clearWatchHistoryTitle),
content: Text(l10n.clearWatchHistoryMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.confirmClear),
),
],
),
);
if (confirmed != true) return;
final ok = await controller.clear();
if (!ok && mounted) _showFailure('recordClearFailed');
}
void _showFailure(String key) {
final l10n = AppLocalizations.of(context)!;
final message = key == 'recordDeleteFailed'
? l10n.recordDeleteFailed
: l10n.recordClearFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
class _RecordTile extends StatelessWidget {
const _RecordTile({
required this.record,
required this.onRetry,
required this.onRemove,
this.onOpen,
});
final WatchRecord record;
final VoidCallback onRetry;
final VoidCallback onRemove;
final VoidCallback? onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (record.detailFailed) {
return _FailedRecordTile(record: record, onRetry: onRetry);
}
final drama = record.drama;
if (drama == null || record.detailLoading) {
return Container(
key: ValueKey('watch-record-${record.dramaId}'),
height: 112,
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
final episodeLabel = record.episodeNumber > 0
? l10n.episodeLabel(record.episodeNumber)
: l10n.episodeUnavailable;
return Material(
key: ValueKey('watch-record-${record.dramaId}'),
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onOpen,
child: Padding(
padding: const EdgeInsets.all(9),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: RemoteImage(drama.posterUrl, width: 72, height: 96),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drama.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 5),
Text(
episodeLabel,
key: ValueKey('watch-record-episode-${record.dramaId}'),
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
minHeight: 4,
value: record.progress.fraction,
),
),
const SizedBox(height: 6),
Text(
l10n.resumeProgress(
_clock(record.progress.position),
_clock(record.progress.duration),
),
style: const TextStyle(
color: AppColors.blueSoft,
fontSize: 11,
),
),
if (record.progress.updatedAt != null)
Text(
l10n.updatedAtLabel(
_formatUpdatedAt(record.progress.updatedAt!),
),
key: ValueKey('watch-record-updated-${record.dramaId}'),
style: const TextStyle(
color: Color(0xFF8D8990),
fontSize: 11,
),
),
],
),
),
IconButton(
key: ValueKey('watch-record-remove-${record.dramaId}'),
tooltip: l10n.remove,
onPressed: onRemove,
icon: const Icon(Icons.close_rounded),
),
],
),
),
),
);
}
}
class _FailedRecordTile extends StatelessWidget {
const _FailedRecordTile({required this.record, required this.onRetry});
final WatchRecord record;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: ValueKey('watch-record-failed-${record.dramaId}'),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.broken_image_outlined, color: Colors.white38),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.detailLoadFailed,
style: const TextStyle(color: Colors.white70),
),
),
TextButton(
key: ValueKey('watch-record-retry-${record.dramaId}'),
onPressed: onRetry,
child: Text(l10n.retryDetail),
),
],
),
);
}
}
class _FailureState extends StatelessWidget {
const _FailureState({required this.message, required this.onRetry});
final String message;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 14),
FilledButton(
onPressed: () => unawaited(onRetry()),
child: Text(l10n.retry),
),
],
),
),
);
}
}
String _clock(Duration value) {
final minutes = value.inMinutes;
final seconds = value.inSeconds.remainder(60);
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
String _formatUpdatedAt(DateTime value) {
final local = value.toLocal();
final month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
return '${local.year}-$month-$day $hour:$minute';
}