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

@@ -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';
}