From c14d89ffce20c770ce0b20002ca74e52c5945715 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:18:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E4=B8=BA=E5=8D=95=E5=88=97=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/core/theme/app_dimens.dart | 10 + lib/l10n/app_localizations_zh.dart | 2 +- lib/l10n/app_zh.arb | 2 +- .../following/following_history_card.dart | 228 ++++++++++++++++++ lib/pages/following/following_page.dart | 27 ++- lib/pages/following/following_paged_grid.dart | 21 +- test/following/following_page_test.dart | 35 +++ 8 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 lib/pages/following/following_history_card.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 8819183..1d91fd3 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1235,3 +1235,9 @@ $ flutter test - 追剧页移除预约 tab,改为追剧/播放历史双页横向切换,并保留各自滚动位置。 - 追剧与历史统一三列卡片,新增固定加号卡片,历史进度与编辑删除能力保留。 - 按最新口径调整列距 6、行距 12、封面圆角 6;`flutter analyze`:No issues found。 + +## 2026-10-22 播放历史单列列表 + +- 追剧保持三列网格,播放历史改为左封面右信息的单列列表,行距 16。 +- 历史卡片补充 96×128 封面、观看进度、已看百分比、继续观看和编辑删除。 +- 刷新、分页与各页独立滚动位置保持;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 9ceb1aa..d18a354 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -158,6 +158,16 @@ abstract final class AppDimens { static const double followingSubtitleFont = 12; static const double followingSubtitleGap = 4; static const double followingProgressHeight = 2; + static const double followingHistoryCoverWidth = 96; + static const double followingHistoryCoverHeight = 128; + static const double followingHistoryCoverRadius = 6; + static const double followingHistoryContentGap = 12; + static const double followingHistoryRowGap = 16; + static const double followingHistoryProgressHeight = 3; + static const double followingHistoryProgressRadius = 2; + static const double followingHistoryProgressGap = 8; + static const double followingHistoryLabelGap = 4; + static const double followingHistoryActionHeight = 28; static const Duration followingTabAnimationDuration = Duration( milliseconds: 180, ); diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1feadad..1c49d79 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -284,7 +284,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String watchedPercent(int percent) { - return '已观看 $percent%'; + return '已看 $percent%'; } @override diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8d4623e..bae9656 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -121,7 +121,7 @@ "count": {"type": "int"} } }, - "watchedPercent": "已观看 {percent}%", + "watchedPercent": "已看 {percent}%", "@watchedPercent": { "placeholders": { "percent": { diff --git a/lib/pages/following/following_history_card.dart b/lib/pages/following/following_history_card.dart new file mode 100644 index 0000000..e38f974 --- /dev/null +++ b/lib/pages/following/following_history_card.dart @@ -0,0 +1,228 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/cover_play_count.dart'; +import '../../core/widgets/remote_image.dart'; +import '../../l10n/app_localizations.dart'; + +class FollowingHistoryCard extends StatelessWidget { + const FollowingHistoryCard({ + super.key, + required this.dramaId, + required this.title, + required this.posterUrl, + required this.viewCount, + required this.subtitle, + required this.onTap, + required this.onContinue, + this.progress, + this.progressLabel, + this.editing = false, + this.onRemove, + }); + + final String dramaId; + final String title; + final String posterUrl; + final int viewCount; + final String subtitle; + final VoidCallback onTap; + final VoidCallback onContinue; + final double? progress; + final String? progressLabel; + final bool editing; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return InkWell( + key: Key('following-history-card-$dramaId'), + borderRadius: BorderRadius.circular( + AppDimens.followingHistoryCoverRadius, + ), + onTap: onTap, + child: SizedBox( + height: AppDimens.followingHistoryCoverHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _cover(), + const SizedBox(width: AppDimens.followingHistoryContentGap), + Expanded(child: _details(l10n)), + ], + ), + ), + ); + } + + Widget _cover() { + return SizedBox( + key: Key('following-history-cover-$dramaId'), + width: AppDimens.followingHistoryCoverWidth, + height: AppDimens.followingHistoryCoverHeight, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.followingHistoryCoverRadius, + ), + child: RemoteImage( + posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + ), + ), + const Positioned( + left: 0, + right: 0, + bottom: 0, + child: CoverBottomScrim(), + ), + Positioned( + right: AppDimens.cardPlayCountRight, + bottom: AppDimens.cardPlayCountBottom, + child: CoverPlayCount( + key: Key('following-history-views-$dramaId'), + count: viewCount, + ), + ), + ], + ), + ); + } + + Widget _details(AppLocalizations l10n) { + final hasProgress = progress != null; + return Stack( + children: [ + Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(right: editing ? 28 : 0), + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, + ), + ), + ), + const SizedBox(height: AppDimens.followingSubtitleGap), + Row( + children: [ + Expanded( + child: Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.followingSubtitleFont, + ), + ), + ), + if (!editing && hasProgress) ...[ + const SizedBox(width: AppDimens.spaceSm), + _continueButton(l10n), + ], + ], + ), + if (hasProgress) ...[ + const SizedBox(height: AppDimens.followingHistoryProgressGap), + ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.followingHistoryProgressRadius, + ), + child: LinearProgressIndicator( + key: Key('following-progress-$dramaId'), + value: progress!.clamp(0.0, 1.0).toDouble(), + minHeight: AppDimens.followingHistoryProgressHeight, + backgroundColor: AppColors.inactiveTrack, + valueColor: const AlwaysStoppedAnimation( + AppColors.primary, + ), + ), + ), + if (progressLabel != null) ...[ + const SizedBox(height: AppDimens.followingHistoryLabelGap), + Text( + progressLabel!, + key: Key('following-history-percent-$dramaId'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.followingSubtitleFont, + ), + ), + ], + ], + ], + ), + ), + ), + if (editing) + Positioned( + top: 0, + right: 0, + child: GestureDetector( + key: Key('following-history-remove-$dramaId'), + onTap: onRemove, + child: const CircleAvatar( + radius: 12, + backgroundColor: AppColors.contentChip, + child: Icon( + Icons.remove_rounded, + color: AppColors.textPrimary, + size: 17, + ), + ), + ), + ), + ], + ); + } + + Widget _continueButton(AppLocalizations l10n) { + return SizedBox( + height: AppDimens.followingHistoryActionHeight, + child: OutlinedButton( + key: Key('following-history-continue-$dramaId'), + onPressed: onContinue, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textPrimary, + side: const BorderSide(color: AppColors.textPrimary), + padding: const EdgeInsets.symmetric(horizontal: 10), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: AppDimens.followingSubtitleFont, + fontWeight: FontWeight.w400, + ), + ), + child: Text(l10n.continueWatching), + ), + ); + } +} diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index e148972..e1d99ec 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -19,6 +19,7 @@ import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'following_grid_card.dart'; +import 'following_history_card.dart'; import 'following_paged_grid.dart'; enum FollowingTab { favorites, history } @@ -94,7 +95,7 @@ class _FollowingPageState extends State { onGoHome: widget.onGoHome, onListEmptied: _exitEditingIfEmpty, ), - _HistoryGrid( + _HistoryList( controller: _history, editing: _editing, scrollController: _historyScroll, @@ -301,8 +302,8 @@ class _FavoritesGrid extends StatelessWidget { } } -class _HistoryGrid extends StatelessWidget { - const _HistoryGrid({ +class _HistoryList extends StatelessWidget { + const _HistoryList({ required this.controller, required this.editing, required this.scrollController, @@ -327,6 +328,7 @@ class _HistoryGrid extends StatelessWidget { loadingMore: controller.loadingMore, hasMore: controller.hasMore, hasItems: controller.records.isNotEmpty, + singleColumn: true, errorMessage: controller.error == null ? null : errorText(l10n, controller.error, l10n.progressError), @@ -345,15 +347,28 @@ class _HistoryGrid extends StatelessWidget { itemBuilder: (context, index) { final record = controller.records[index]; final progress = record.progress.fraction; - return FollowingGridCard( + final hasProgress = record.progress.duration > Duration.zero; + final watchedPercent = (progress * 100) + .round() + .clamp(0, 100) + .toInt(); + void openRecord() { + unawaited(_openHistory(context, controller, record)); + } + + return FollowingHistoryCard( dramaId: record.dramaId, title: _historyTitle(l10n, record), posterUrl: record.displayCover, viewCount: record.drama?.viewCount ?? record.progress.playCount, - progress: progress > 0 ? progress : null, + progress: hasProgress ? progress : null, + progressLabel: hasProgress + ? l10n.watchedPercent(watchedPercent) + : null, editing: editing, subtitle: _historySubtitle(l10n, record), - onTap: () => unawaited(_openHistory(context, controller, record)), + onTap: openRecord, + onContinue: openRecord, onRemove: () async { if (await _confirmRemove(context) && await controller.remove(record.dramaId) && diff --git a/lib/pages/following/following_paged_grid.dart b/lib/pages/following/following_paged_grid.dart index 269e9e6..5ea8167 100644 --- a/lib/pages/following/following_paged_grid.dart +++ b/lib/pages/following/following_paged_grid.dart @@ -25,6 +25,7 @@ class FollowingPagedGrid extends StatelessWidget { required this.onRefresh, required this.onLoadMore, this.showInitialLoading = true, + this.singleColumn = false, this.errorMessage, this.loadMoreError, }); @@ -42,6 +43,7 @@ class FollowingPagedGrid extends StatelessWidget { final String? errorMessage; final String? loadMoreError; final bool showInitialLoading; + final bool singleColumn; final Future Function() onRefresh; final Future Function() onLoadMore; @@ -58,6 +60,18 @@ class FollowingPagedGrid extends StatelessWidget { final itemExtent = itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.followingCardTextExtent; + final gridDelegate = singleColumn + ? const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 1, + mainAxisSpacing: AppDimens.followingHistoryRowGap, + mainAxisExtent: AppDimens.followingHistoryCoverHeight, + ) + : SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.followingGridGap, + mainAxisSpacing: AppDimens.followingGridRowGap, + mainAxisExtent: itemExtent, + ); return NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( @@ -106,12 +120,7 @@ class FollowingPagedGrid extends StatelessWidget { AppDimens.spaceSm, ), sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.followingGridGap, - mainAxisSpacing: AppDimens.followingGridRowGap, - mainAxisExtent: itemExtent, - ), + gridDelegate: gridDelegate, delegate: SliverChildBuilderDelegate( itemBuilder, childCount: itemCount, diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index c32429d..d534ea4 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -208,11 +208,46 @@ void main() { expect(find.text('Server title'), findsOneWidget); expect(find.text('Episode 3 / 8 total'), findsOneWidget); + expect(find.text('Watched 25%'), findsOneWidget); + expect( + find.byKey(const Key('following-history-card-the-signal-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('following-history-continue-the-signal-demo')), + findsOneWidget, + ); expect( find.byKey(const Key('following-progress-the-signal-demo')), findsOneWidget, ); + final cover = tester.widget( + find.byKey(const Key('following-history-cover-the-signal-demo')), + ); + expect(cover.width, AppDimens.followingHistoryCoverWidth); + expect(cover.height, AppDimens.followingHistoryCoverHeight); + final historyGrid = tester.widget( + find.descendant( + of: find.byKey(const Key('following-history-grid')), + matching: find.byType(SliverGrid), + ), + ); + final historyDelegate = + historyGrid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(historyDelegate.crossAxisCount, 1); + expect(historyDelegate.mainAxisSpacing, AppDimens.followingHistoryRowGap); expect(catalog.detailFetches, 0); + + await tester.tap(find.byKey(const Key('following-edit'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('following-history-remove-the-signal-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('following-history-continue-the-signal-demo')), + findsNothing, + ); }); testWidgets('should_switch_following_pages_horizontally', (tester) async {