feat(flutter): 重做收藏与观看历史页

This commit is contained in:
Zhou Weidong committed 2026-10-20 16:31:59 +08:00
1 parent 7285a1cd4e
commit 794dc87083
14 files changed
+855 -272

No files matched your search

@@ -0,0 +1,248 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/watch_record.dart';
import '../../../core/formatting/localized_date_time.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
const double _cardRadius = 14;
const double _cardPadding = 10;
const double _coverWidth = 72;
const double _coverHeight = 96;
const double _coverRadius = 10;
const double _removeSize = 28;
const double _removeIconSize = 16;
const double _progressHeight = 4;
const double _progressRadius = 2;
const double _statsFontSize = 12;
const double _metaFontSize = 11;
const double _titleFontSize = 15;
/// 观看记录卡片:覆盖加载、失败与完整记录三种状态。
class WatchRecordCard extends StatelessWidget {
const WatchRecordCard({
super.key,
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) {
if (record.detailFailed) {
return _FailedRecordTile(record: record, onRetry: onRetry);
}
final drama = record.drama;
if (drama == null || record.detailLoading) {
return _loadingCard();
}
final l10n = AppLocalizations.of(context)!;
final episodeLabel = record.episodeNumber > 0
? l10n.episodeLabel(record.episodeNumber)
: l10n.episodeUnavailable;
return _card(
onTap: onOpen,
child: Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(_coverRadius),
child: RemoteImage(
drama.posterUrl,
key: ValueKey('watch-record-cover-${record.dramaId}'),
width: _coverWidth,
height: _coverHeight,
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: _removeSize),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drama.title,
key: ValueKey('watch-record-title-${record.dramaId}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: _titleFontSize,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
episodeLabel,
key: ValueKey('watch-record-episode-${record.dramaId}'),
style: const TextStyle(
color: AppColors.muted,
fontSize: _statsFontSize,
),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(_progressRadius),
child: LinearProgressIndicator(
minHeight: _progressHeight,
value: record.progress.fraction,
backgroundColor: AppColors.border,
valueColor: const AlwaysStoppedAnimation(
AppColors.blue,
),
),
),
const SizedBox(height: 4),
Text(
'${_clock(record.progress.position)} / '
'${_clock(record.progress.duration)}',
style: const TextStyle(
color: AppColors.muted,
fontSize: _metaFontSize,
),
),
if (record.progress.updatedAt != null)
Text(
l10n.updatedAtLabel(
formatLocalizedDateTime(
context,
record.progress.updatedAt!,
),
),
key: ValueKey(
'watch-record-updated-${record.dramaId}',
),
style: const TextStyle(
color: AppColors.subtle,
fontSize: _metaFontSize,
),
),
],
),
),
),
],
),
Positioned(top: 0, right: 0, child: _removeButton(l10n.remove)),
],
),
);
}
Widget _loadingCard() => Container(
key: ValueKey('watch-record-${record.dramaId}'),
padding: const EdgeInsets.all(_cardPadding),
decoration: _cardDecoration(),
child: const SizedBox(
height: _coverHeight,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
Widget _card({required VoidCallback? onTap, required Widget child}) {
return Container(
key: ValueKey('watch-record-${record.dramaId}'),
decoration: _cardDecoration(),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(_cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(_cardPadding),
child: child,
),
),
),
);
}
BoxDecoration _cardDecoration() => BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_cardRadius),
boxShadow: const [AppTheme.cardShadow],
);
Widget _removeButton(String tooltip) {
return SizedBox.square(
dimension: _removeSize,
child: Material(
color: AppColors.raised,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: IconButton(
key: ValueKey('watch-record-remove-${record.dramaId}'),
tooltip: tooltip,
padding: EdgeInsets.zero,
onPressed: onRemove,
icon: const Icon(
Icons.close_rounded,
size: _removeIconSize,
color: AppColors.muted,
),
),
),
);
}
}
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-${record.dramaId}'),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_cardRadius),
boxShadow: const [AppTheme.cardShadow],
),
child: Container(
key: ValueKey('watch-record-failed-${record.dramaId}'),
padding: const EdgeInsets.all(_cardPadding),
child: Row(
children: [
const Icon(Icons.broken_image_outlined, color: AppColors.muted),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.detailLoadFailed,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
),
TextButton(
key: ValueKey('watch-record-retry-${record.dramaId}'),
onPressed: onRetry,
child: Text(l10n.retryDetail),
),
],
),
),
);
}
}
String _clock(Duration value) {
final minutes = value.inMinutes;
final seconds = value.inSeconds.remainder(60);
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}