From 794dc87083fade3c98aa1009f1234b106530b8a0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 16:31:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E5=81=9A=E6=94=B6?= =?UTF-8?q?=E8=97=8F=E4=B8=8E=E8=A7=82=E7=9C=8B=E5=8E=86=E5=8F=B2=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/watch_records_controller.dart | 10 +- lib/l10n/app_en.arb | 13 + lib/l10n/app_id.arb | 3 + lib/l10n/app_localizations.dart | 18 + lib/l10n/app_localizations_en.dart | 13 + lib/l10n/app_localizations_id.dart | 13 + lib/l10n/app_localizations_zh.dart | 13 + lib/l10n/app_zh.arb | 3 + lib/pages/favorites/favorites_page.dart | 361 +++++++++++++++--- .../watch_records/watch_records_page.dart | 332 +++++----------- .../widgets/watch_record_card.dart | 248 ++++++++++++ test/library/favorites_page_test.dart | 27 ++ test/watch/watch_records_controller_test.dart | 37 ++ test/watch/watch_records_page_test.dart | 36 ++ 14 files changed, 855 insertions(+), 272 deletions(-) create mode 100644 lib/pages/watch_records/widgets/watch_record_card.dart diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index f3b819b..9862db3 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -128,11 +128,19 @@ class WatchRecordsController extends ChangeNotifier { final seen = {}; return [ for (final progress in recent) - if (seen.add(progress.dramaId)) WatchRecord(progress: progress), + if (seen.add(progress.dramaId)) + WatchRecord( + progress: progress, + drama: _detailCache[progress.dramaId], + detailLoading: !_detailCache.containsKey(progress.dramaId), + ), ]; } Future _hydrate(List dramaIds) async { + for (final id in dramaIds) { + if (_detailCache.containsKey(id)) await _loadDetail(id); + } final queue = [ for (final id in dramaIds) if (!_detailCache.containsKey(id)) id, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1dab89c..a926e71 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -500,6 +500,19 @@ "recordDeleteFailed": "Could not delete this record. Please retry.", "recordClearFailed": "Could not clear watch history. Please retry.", "clearAll": "Clear all", + "watchRecordsCount": "{count} records", + "@watchRecordsCount": { + "placeholders": { + "count": {"type": "int"} + } + }, + "favoritesCount": "{count} favorites", + "@favoritesCount": { + "placeholders": { + "count": {"type": "int"} + } + }, + "favoriteLongPressHint": "Long press a card to remove", "clearWatchHistoryTitle": "Clear watch history?", "clearWatchHistoryMessage": "This removes the watch history from your account.", "confirmClear": "Clear", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4b28acb..d79a440 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -474,6 +474,9 @@ "recordDeleteFailed": "Gagal menghapus riwayat ini. Coba lagi.", "recordClearFailed": "Gagal menghapus semua riwayat. Coba lagi.", "clearAll": "Hapus semua", + "watchRecordsCount": "{count} riwayat", + "favoritesCount": "{count} favorit", + "favoriteLongPressHint": "Tekan lama kartu untuk menghapus", "clearWatchHistoryTitle": "Hapus riwayat tontonan?", "clearWatchHistoryMessage": "Semua riwayat akan dihapus dari akun kamu.", "confirmClear": "Hapus", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a66503a..bda0476 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2074,6 +2074,24 @@ abstract class AppLocalizations { /// **'Clear all'** String get clearAll; + /// No description provided for @watchRecordsCount. + /// + /// In en, this message translates to: + /// **'{count} records'** + String watchRecordsCount(int count); + + /// No description provided for @favoritesCount. + /// + /// In en, this message translates to: + /// **'{count} favorites'** + String favoritesCount(int count); + + /// No description provided for @favoriteLongPressHint. + /// + /// In en, this message translates to: + /// **'Long press a card to remove'** + String get favoriteLongPressHint; + /// No description provided for @clearWatchHistoryTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index cc597d8..4e072fd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1068,6 +1068,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get clearAll => 'Clear all'; + @override + String watchRecordsCount(int count) { + return '$count records'; + } + + @override + String favoritesCount(int count) { + return '$count favorites'; + } + + @override + String get favoriteLongPressHint => 'Long press a card to remove'; + @override String get clearWatchHistoryTitle => 'Clear watch history?'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index fb002d5..86b40bb 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1068,6 +1068,19 @@ class AppLocalizationsId extends AppLocalizations { @override String get clearAll => 'Hapus semua'; + @override + String watchRecordsCount(int count) { + return '$count riwayat'; + } + + @override + String favoritesCount(int count) { + return '$count favorit'; + } + + @override + String get favoriteLongPressHint => 'Tekan lama kartu untuk menghapus'; + @override String get clearWatchHistoryTitle => 'Hapus riwayat tontonan?'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6856d4c..334e3d2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1049,6 +1049,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get clearAll => '清空'; + @override + String watchRecordsCount(int count) { + return '共 $count 条'; + } + + @override + String favoritesCount(int count) { + return '共 $count 部'; + } + + @override + String get favoriteLongPressHint => '长按卡片可取消收藏'; + @override String get clearWatchHistoryTitle => '清空观看记录?'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 10e0a0d..b22074d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -436,6 +436,9 @@ "recordDeleteFailed": "删除记录失败,请重试。", "recordClearFailed": "清空观看记录失败,请重试。", "clearAll": "清空", + "watchRecordsCount": "共 {count} 条", + "favoritesCount": "共 {count} 部", + "favoriteLongPressHint": "长按卡片可取消收藏", "clearWatchHistoryTitle": "清空观看记录?", "clearWatchHistoryMessage": "这会从当前账号移除全部观看记录。", "confirmClear": "清空", diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 0294fa0..8392e75 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -8,18 +8,35 @@ 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/count_format.dart'; import '../../core/formatting/error_text.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/library_drama_tile.dart'; -import '../../core/widgets/plain_page_scaffold.dart'; +import '../../core/widgets/remote_image.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; import 'favorites_binding.dart'; +const double _pageHorizontal = 14; +const double _cardRadius = 14; +const double _cardPadding = 10; +const double _coverWidth = 80; +const double _coverHeight = 106; +const double _coverRadius = 10; +const double _removeSize = 28; +const double _removeIconSize = 15; +const double _statsFontSize = 12; +const double _metaFontSize = 11; +const double _titleFontSize = 15; +const int _maxTags = 2; +const double _emptyListHeight = 360; + +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + /// 独立我的收藏页。 class FavoritesPage extends StatefulWidget { const FavoritesPage({super.key}); @@ -53,13 +70,26 @@ class _FavoritesPageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: PlainPageScaffold( - pageKey: const Key('favorites-page'), - backKey: const Key('favorites-back'), - title: l10n.myFavorites, - child: signedIn - ? _FavoritesBody(favorites: context.watch()) - : const AuthGatePanel(), + child: Scaffold( + key: const Key('favorites-page'), + backgroundColor: AppColors.ink, + body: SafeArea( + child: Column( + children: [ + AppSubpageHeader( + title: l10n.myFavorites, + backKey: const Key('favorites-back'), + ), + Expanded( + child: signedIn + ? _FavoritesBody( + favorites: context.watch(), + ) + : const AuthGatePanel(), + ), + ], + ), + ), ), ); } @@ -74,53 +104,110 @@ class _FavoritesBody extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; if (favorites.loading && favorites.cards.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const Center( + child: CircularProgressIndicator(key: Key('favorites-loading')), + ); } if (favorites.error != null && favorites.cards.isEmpty) { - return EmptyState( - key: const Key('favorites-error'), - icon: Icons.cloud_off_rounded, - title: errorText(l10n, favorites.error, l10n.favoritesError), - actionLabel: l10n.retry, - actionKey: const Key('favorites-retry'), - onAction: () => unawaited(favorites.load()), + return _refreshable( + favorites, + children: [ + SizedBox( + height: _emptyListHeight, + child: EmptyState( + key: const Key('favorites-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, favorites.error, l10n.favoritesError), + actionLabel: l10n.retry, + actionKey: const Key('favorites-retry'), + onAction: () => unawaited(favorites.load()), + ), + ), + ], ); } if (favorites.cards.isEmpty) { - return EmptyState( - key: const Key('favorites-empty'), - icon: Icons.favorite_border_rounded, - title: l10n.noFavorites, - subtitle: l10n.emptyHint, + return _refreshable( + favorites, + children: [ + SizedBox( + height: _emptyListHeight, + child: EmptyState( + key: const Key('favorites-empty'), + icon: Icons.favorite_border_rounded, + title: l10n.noFavorites, + subtitle: l10n.emptyHint, + ), + ), + ], ); } - 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}'), + return _refreshable( + favorites, + children: [ + _summary(l10n), + for (final (index, drama) in favorites.cards.indexed) ...[ + if (index > 0) const SizedBox(height: 10), + _FavoriteCard( 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: AppColors.favorite, - ), - ), - ); - }, + onOpen: () => _open(context, drama), + onRemove: () => _remove(context, drama.id), + ), + ], + ], + ); + } + + Widget _refreshable( + FavoriteController favorites, { + required List children, + }) { + return RefreshIndicator( + color: AppColors.blue, + backgroundColor: AppColors.card, + strokeWidth: 2, + onRefresh: favorites.load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 4, + _pageHorizontal, + 28, + ), + children: children, ), ); } + Widget _summary(AppLocalizations l10n) { + return Row( + children: [ + Text( + l10n.favoritesCount(favorites.cards.length), + key: const Key('favorites-count'), + style: const TextStyle( + color: AppColors.muted, + fontSize: _statsFontSize, + ), + ), + const Spacer(), + Flexible( + child: Text( + l10n.favoriteLongPressHint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: const TextStyle( + color: AppColors.subtle, + fontSize: _metaFontSize, + ), + ), + ), + ], + ); + } + Future _open(BuildContext context, Drama drama) async { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), @@ -137,3 +224,189 @@ class _FavoritesBody extends StatelessWidget { ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); } } + +class _FavoriteCard extends StatelessWidget { + const _FavoriteCard({ + required this.drama, + required this.onOpen, + required this.onRemove, + }); + + final Drama drama; + final VoidCallback onOpen; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final tags = _tags(); + return Container( + key: ValueKey('favorite-card-${drama.id}'), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(_cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onOpen, + onLongPress: onRemove, + child: Padding( + padding: const EdgeInsets.all(_cardPadding), + child: Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(_coverRadius), + child: RemoteImage( + drama.posterUrl, + key: ValueKey('favorite-cover-${drama.id}'), + width: _coverWidth, + height: _coverHeight, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + right: _removeSize, + top: 2, + ), + child: SizedBox( + height: _coverHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + key: ValueKey('favorite-title-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: _titleFontSize, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + if (tags.isNotEmpty) + SizedBox( + height: 20, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: + const NeverScrollableScrollPhysics(), + child: Row( + children: [ + for (final tag in tags) + Padding( + padding: const EdgeInsets.only( + right: 5, + ), + child: Container( + padding: + const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.raised, + borderRadius: + BorderRadius.circular(6), + ), + child: Text( + tag, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: _metaFontSize, + ), + ), + ), + ), + ], + ), + ), + ), + const Spacer(), + Row( + children: [ + const Icon( + Icons.local_fire_department_rounded, + color: AppColors.blue, + size: 13, + ), + const SizedBox(width: 3), + Text( + formatInteractionCount(drama.viewCount), + style: const TextStyle( + color: AppColors.muted, + fontSize: _metaFontSize, + ), + ), + const Spacer(), + Text( + l10n.episodeCountLabel(drama.episodeCount), + style: const TextStyle( + color: AppColors.muted, + fontSize: _metaFontSize, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + Positioned( + top: 0, + right: 0, + child: SizedBox.square( + dimension: _removeSize, + child: Material( + color: AppColors.badgePink, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: IconButton( + key: ValueKey('favorite-remove-${drama.id}'), + tooltip: l10n.remove, + padding: EdgeInsets.zero, + onPressed: onRemove, + icon: const Icon( + Icons.favorite_rounded, + color: AppColors.favorite, + size: _removeIconSize, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + List _tags() { + final values = []; + for (final source in [drama.genre, drama.category]) { + for (final part in source.split(_tagSplitter)) { + final value = part.trim(); + if (value.isNotEmpty && !values.contains(value)) values.add(value); + if (values.length == _maxTags) return values; + } + } + return values; + } +} diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index fb3e5b9..b1987f1 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -6,24 +6,25 @@ 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/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 '../../core/widgets/plain_page_scaffold.dart'; -import '../../core/widgets/remote_image.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; import 'watch_records_binding.dart'; +import 'widgets/watch_record_card.dart'; + +const double _pageHorizontal = 14; +const double _statsFontSize = 12; +const double _emptyListHeight = 360; /// 独立观看记录页。 class WatchRecordsPage extends StatelessWidget { @@ -35,11 +36,24 @@ class WatchRecordsPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.light(), - child: PlainPageScaffold( - pageKey: const Key('watch-records-page'), - backKey: const Key('watch-records-back'), - title: l10n.watchHistory, - child: signedIn ? const _WatchRecordsBody() : const AuthGatePanel(), + child: Scaffold( + key: const Key('watch-records-page'), + backgroundColor: AppColors.ink, + body: SafeArea( + child: Column( + children: [ + AppSubpageHeader( + title: l10n.watchHistory, + backKey: const Key('watch-records-back'), + ), + Expanded( + child: signedIn + ? const _WatchRecordsBody() + : const AuthGatePanel(), + ), + ], + ), + ), ), ); } @@ -82,52 +96,97 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; if (controller.loading && controller.records.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const Center( + child: CircularProgressIndicator(key: Key('watch-records-loading')), + ); } if (controller.error != null && controller.records.isEmpty) { - return _FailureState( - message: errorText(l10n, controller.error, l10n.progressError), - onRetry: controller.load, + return _refreshable( + children: [ + SizedBox( + height: _emptyListHeight, + child: _FailureState( + message: errorText(l10n, controller.error, l10n.progressError), + onRetry: controller.load, + ), + ), + ], ); } if (controller.records.isEmpty) { - return EmptyState( - key: const Key('watch-records-empty'), - icon: Icons.history_rounded, - title: l10n.noWatchRecords, - subtitle: l10n.emptyHint, + return _refreshable( + children: [ + SizedBox( + height: _emptyListHeight, + child: EmptyState( + key: const Key('watch-records-empty'), + icon: Icons.history_rounded, + title: l10n.noWatchRecords, + subtitle: l10n.emptyHint, + ), + ), + ], ); } - return Column( + return _refreshable( 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), + _summary(l10n), + for (final (index, record) in controller.records.indexed) ...[ + if (index > 0) const SizedBox(height: 10), + WatchRecordCard( + record: record, + onRetry: () => controller.retryDetail(record.dramaId), + onRemove: () => _remove(record.dramaId), + onOpen: record.drama == null ? null : () => _open(record.drama!), + ), + ], + ], + ); + } + + Widget _refreshable({required List children}) { + return RefreshIndicator( + color: AppColors.blue, + backgroundColor: AppColors.card, + strokeWidth: 2, + onRefresh: controller.load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 4, + _pageHorizontal, + 28, + ), + children: children, + ), + ); + } + + Widget _summary(AppLocalizations l10n) { + return Row( + children: [ + Text( + l10n.watchRecordsCount(controller.records.length), + key: const Key('watch-records-count'), + style: const TextStyle( + color: AppColors.muted, + fontSize: _statsFontSize, ), ), - 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!), - ); - }, - ), + const Spacer(), + TextButton.icon( + key: const Key('watch-records-clear'), + onPressed: controller.clearing ? null : _confirmClear, + style: TextButton.styleFrom( + foregroundColor: AppColors.blue, + minimumSize: const Size(44, 36), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + icon: const Icon(Icons.delete_sweep_outlined, size: 16), + label: Text( + l10n.clearAll, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), ), ), ], @@ -182,181 +241,6 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { } } -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: AppDimens.watchPlaceholderHeight, - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: const Padding( - padding: EdgeInsets.all(AppDimens.innerPadding), - child: 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(AppDimens.innerRadius), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onOpen, - child: Padding( - padding: const EdgeInsets.all(AppDimens.innerPadding), - 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: AppDimens.listTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 5), - Text( - episodeLabel, - key: ValueKey('watch-record-episode-${record.dramaId}'), - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - ), - ), - const SizedBox(height: 7), - ClipRRect( - borderRadius: BorderRadius.circular( - AppDimens.progressRadius, - ), - child: LinearProgressIndicator( - minHeight: AppDimens.progressHeight, - value: record.progress.fraction, - backgroundColor: Colors.white24, - valueColor: const AlwaysStoppedAnimation( - AppColors.blueSoft, - ), - ), - ), - const SizedBox(height: 6), - Text( - l10n.resumeProgress( - _clock(record.progress.position), - _clock(record.progress.duration), - ), - style: const TextStyle( - color: AppColors.blueSoft, - fontSize: AppDimens.caption, - ), - ), - 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.muted, - fontSize: AppDimens.caption, - ), - ), - ], - ), - ), - 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(AppDimens.innerPadding), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - 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}); @@ -375,9 +259,3 @@ class _FailureState extends StatelessWidget { ); } } - -String _clock(Duration value) { - final minutes = value.inMinutes; - final seconds = value.inSeconds.remainder(60); - return '$minutes:${seconds.toString().padLeft(2, '0')}'; -} diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart new file mode 100644 index 0000000..fdedd38 --- /dev/null +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -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')}'; +} diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 3d12478..677cc33 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -10,6 +10,7 @@ import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/empty_state.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/favorites/favorites_page.dart'; @@ -47,9 +48,35 @@ void main() { await _pumpPage(tester, auth: auth, favorites: favorites); + expect(find.byKey(const Key('favorites-page')), findsOneWidget); + expect(find.byKey(const Key('favorites-back')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('favorites-count'))).data, + '2 favorites', + ); + expect(find.text('Long press a card to remove'), findsOneWidget); expect(find.text('Drama signal'), findsOneWidget); expect(find.text('Drama city'), findsOneWidget); + final title = tester.widget( + find.byKey(const Key('favorite-title-signal')), + ); + expect(title.style?.color, AppColors.onSurface); + expect(title.style?.fontSize, 15); + expect( + tester.getSize(find.byKey(const Key('favorite-cover-signal'))), + const Size(80, 106), + ); expect(find.byKey(const Key('favorite-remove-signal')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('favorite-remove-signal'))), + const Size(28, 28), + ); + final refresh = tester.widget( + find.byType(RefreshIndicator), + ); + expect(refresh.color, AppColors.blue); + expect(refresh.backgroundColor, AppColors.card); + expect(refresh.strokeWidth, 2); }); testWidgets('should_keep_card_and_show_failure_when_remove_fails', ( diff --git a/test/watch/watch_records_controller_test.dart b/test/watch/watch_records_controller_test.dart index 2fd55b2..f6a795b 100644 --- a/test/watch/watch_records_controller_test.dart +++ b/test/watch/watch_records_controller_test.dart @@ -72,6 +72,43 @@ void main() { expect(controller.error, isNull); }); + test('should_reuse_cached_details_when_records_are_loaded_again', () async { + final history = ControlledWatchHistory([_progress('1'), _progress('2')]); + final catalog = ScriptedWatchCatalog(); + final controller = WatchRecordsController( + history: history, + catalog: catalog, + ); + + await controller.load(); + await controller.load(); + + expect(catalog.calls, ['1', '2']); + expect( + controller.records.every( + (record) => !record.detailLoading && record.drama != null, + ), + isTrue, + ); + }); + + test('should_keep_existing_records_when_refresh_fails', () async { + final history = ControlledWatchHistory([_progress('1')]); + final controller = WatchRecordsController( + history: history, + catalog: ScriptedWatchCatalog(), + ); + await controller.load(); + + history.failRecent = true; + await controller.load(); + + expect(controller.loading, isFalse); + expect(controller.error, 'progressError'); + expect(controller.records.single.drama?.id, '1'); + expect(controller.records.single.detailLoading, isFalse); + }); + test('should_keep_other_records_when_one_detail_request_fails', () async { final history = ControlledWatchHistory([_progress('1'), _progress('2')]); final catalog = ScriptedWatchCatalog(failedIds: {'1'}); diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index d8c99e3..22eb59a 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -12,6 +12,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/empty_state.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/watch_records/watch_records_page.dart'; @@ -47,11 +48,46 @@ void main() { await _pumpPage(tester, auth: auth, history: history, catalog: catalog); expect(find.byKey(const Key('watch-records-page')), findsOneWidget); + expect(find.byKey(const Key('watch-records-back')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('watch-records-count'))).data, + '1 records', + ); expect(find.text('The Signal'), findsOneWidget); + final title = tester.widget( + find.byKey(const Key('watch-record-title-the-signal-demo')), + ); + expect(title.style?.color, AppColors.onSurface); + expect(title.style?.fontSize, 15); + expect( + tester.getSize( + find.byKey(const Key('watch-record-cover-the-signal-demo')), + ), + const Size(72, 96), + ); + expect( + tester.getSize( + find.byKey(const Key('watch-record-remove-the-signal-demo')), + ), + const Size(28, 28), + ); expect( find.byKey(const Key('watch-record-episode-the-signal-demo')), findsOneWidget, ); + final refresh = tester.widget( + find.byType(RefreshIndicator), + ); + expect(refresh.color, AppColors.blue); + expect(refresh.backgroundColor, AppColors.card); + expect(refresh.strokeWidth, 2); + final list = tester.widget( + find.descendant( + of: find.byType(RefreshIndicator), + matching: find.byType(ListView), + ), + ); + expect(list.physics, isA()); }); testWidgets(