Files
ReelShort/lib/pages/downloads/downloads_page.dart
T

470 lines
15 KiB
Dart

// 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/auth_gate.dart';
import '../../core/widgets/empty_state.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<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 Theme(
data: AppTheme.light(),
child: Scaffold(
key: const Key('downloads-page'),
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
foregroundColor: Colors.white,
title: Text(l10n.downloads),
),
body: SafeArea(
top: false,
child: 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.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 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)
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<void>(
builder: (_) =>
PlayerPage(drama: drama, initialIndex: 0, downloads: downloads),
),
);
}
Future<void> _remove(BuildContext context, String episodeId) async {
unawaited(context.read<Haptics>().mediumImpact());
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;
if (context.mounted) {
unawaited(context.read<Haptics>().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.innerRadius),
border: Border.all(color: AppColors.outlineVariantDark),
),
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: AppColors.muted,
fontSize: AppDimens.caption,
),
),
const Divider(height: 22, color: AppColors.outlineVariantDark),
Row(
children: [
Expanded(
child: Text(
l10n.downloadWifiOnly,
style: const TextStyle(
color: Colors.white,
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: Colors.white,
fontSize: AppDimens.body,
),
),
),
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.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.innerRadius),
),
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)),
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: 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(AppDimens.progressRadius),
child: LinearProgressIndicator(
minHeight: AppDimens.progressHeight,
value: progress.clamp(0.0, 1.0),
backgroundColor: Colors.white24,
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<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;
}