// 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/models/drama.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_date_time.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../player/player_view.dart'; import 'downloads_binding.dart'; /// 下载授权与状态管理页;本批不落盘,只管理服务端授权。 class DownloadsPage extends StatefulWidget { const DownloadsPage({super.key}); @override State createState() => _DownloadsPageState(); } class _DownloadsPageState extends State { bool _requested = false; @override void didChangeDependencies() { super.didChangeDependencies(); final signedIn = context.watch().isSignedIn; if (!signedIn) { _requested = false; return; } if (_requested) return; _requested = true; final downloads = context.read(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(DownloadsBinding().load(downloads)); }); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.dark(), child: PlainPageScaffold( pageKey: const Key('downloads-page'), backKey: const Key('downloads-back'), title: l10n.downloads, child: signedIn ? _DownloadsBody(downloads: context.watch()) : const AuthGatePanel(), ), ); } } class _DownloadsBody extends StatelessWidget { const _DownloadsBody({required this.downloads}); final DownloadController downloads; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; if (downloads.loading && downloads.policy == null) { return const Center(child: CircularProgressIndicator()); } if (downloads.policy == null) { return EmptyState( key: const Key('downloads-error'), icon: Icons.cloud_off_rounded, title: errorText(l10n, downloads.error, l10n.downloadsError), actionLabel: l10n.retry, actionKey: const Key('downloads-retry'), onAction: () => unawaited(downloads.load()), ); } final policy = downloads.policy!; return AppPullToRefresh( onRefresh: downloads.load, child: ListView( physics: AppPullToRefresh.physics, 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: AppColors.onSurface, 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) EmptyState( key: const Key('downloads-empty'), icon: Icons.download_outlined, title: l10n.noDownloads, subtitle: l10n.emptyHint, ) else for (final license in downloads.licenses) ...[ _LicenseTile( license: license, downloads: downloads, onRemove: () => _remove(context, license.episodeId), onPlay: () => _playOffline(context, license), onDownload: () => unawaited( downloads.startDownload(license.episodeId, license: license), ), ), const SizedBox(height: 10), ], ], ), ); } void _playOffline(BuildContext context, DownloadLicense license) { final drama = Drama( id: license.dramaId, title: license.dramaTitle, synopsis: '', posterUrl: '', episodes: [ Episode( id: license.episodeId, number: license.episodeNo, title: 'Episode ${license.episodeNo}', isFree: true, ), ], ); Navigator.of(context).push( MaterialPageRoute( builder: (_) => PlayerPage(drama: drama, initialIndex: 0, downloads: downloads), ), ); } Future _remove(BuildContext context, String episodeId) async { unawaited(context.read().mediumImpact()); final ok = await downloads.remove(episodeId); if (ok || !context.mounted) return; _showMessage(context, AppLocalizations.of(context)!.downloadDeleteFailed); } Future _confirmClear(BuildContext context) async { final l10n = AppLocalizations.of(context)!; final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(l10n.clearDownloadsTitle), content: Text(l10n.clearDownloadsMessage), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(l10n.cancel), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(l10n.confirmClear), ), ], ), ); if (confirmed != true) return; if (context.mounted) { unawaited(context.read().mediumImpact()); } 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(16, 16, 16, 12), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(AppDimens.cardRadius), border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.downloadQuota(policy.remaining, policy.maxPerDevice), style: const TextStyle( color: AppColors.onSurface, 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: AppColors.muted, fontSize: AppDimens.caption, ), ), const Divider(height: 22, color: AppColors.outlineVariantDark), Row( children: [ Expanded( child: Text( l10n.downloadWifiOnly, style: const TextStyle( color: AppColors.onSurface, fontSize: AppDimens.body, ), ), ), 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: AppColors.onSurface, fontSize: AppDimens.body, ), ), ), DropdownButton( key: const Key('download-quality'), value: _qualityChoices( policy.maxQuality, ).contains(policy.maxQuality) ? policy.maxQuality : 'AUTO', underline: const SizedBox.shrink(), items: [ for (final quality in _qualityChoices(policy.maxQuality)) DropdownMenuItem(value: quality, child: Text(quality)), ], onChanged: (value) { if (value == null || value == policy.maxQuality) return; _update(context, maxQuality: value); }, ), ], ), ], ), ); } Future _update( BuildContext context, { bool? wifiOnly, String? maxQuality, }) async { final ok = await downloads.updateSettings( wifiOnly: wifiOnly, maxQuality: maxQuality, ); if (ok || !context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.downloadSettingsFailed), ), ); } } class _LicenseTile extends StatelessWidget { const _LicenseTile({ required this.license, required this.downloads, required this.onRemove, required this.onPlay, required this.onDownload, }); final DownloadLicense license; final DownloadController downloads; final VoidCallback onRemove; final VoidCallback onPlay; final VoidCallback onDownload; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final downloaded = downloads.isDownloaded(license.episodeId); final downloading = downloads.isDownloading(license.episodeId); final progress = downloaded ? 1.0 : downloading ? downloads.progressFor(license.episodeId) : license.progressPercent / 100; final taskStatus = downloaded ? 'COMPLETED' : downloading ? 'DOWNLOADING' : license.taskStatus; return Container( key: ValueKey('download-license-${license.episodeId}'), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(AppDimens.cardRadius), border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( license.dramaTitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 15, fontWeight: FontWeight.w700, ), ), ), Text(l10n.episodeLabel(license.episodeNo)), if (downloaded) IconButton( key: ValueKey('download-play-${license.episodeId}'), tooltip: l10n.play, onPressed: onPlay, icon: const Icon(Icons.play_circle_outline_rounded), ) else if (!downloading) IconButton( key: ValueKey('download-start-${license.episodeId}'), tooltip: l10n.downloads, onPressed: onDownload, icon: const Icon(Icons.download_rounded), ), 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(taskStatus), style: const TextStyle(color: AppColors.muted, fontSize: 12), ), Text( l10n.downloadProgress(license.progressPercent), style: const TextStyle(color: AppColors.muted, fontSize: 12), ), if (license.quality.isNotEmpty) Text( license.quality, style: const TextStyle(color: AppColors.muted, fontSize: 12), ), ], ), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(AppDimens.progressRadius), child: LinearProgressIndicator( minHeight: AppDimens.progressHeight, value: progress.clamp(0.0, 1.0), backgroundColor: AppColors.border, valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), ), ), if (license.expireAt != null) ...[ const SizedBox(height: 7), Text( l10n.downloadExpiresAt( formatLocalizedDateTime(context, license.expireAt!), ), style: const TextStyle( color: AppColors.muted, fontSize: AppDimens.caption, ), ), ], ], ), ); } } List _qualityChoices(String current) { final choices = ['AUTO']; if (current.isNotEmpty && current != 'AUTO') choices.add(current); for (final value in const ['720P', '1080P']) { if (!choices.contains(value)) choices.add(value); } return choices; }