From 2fac46f2488650a1704258ff69edc401f96e422b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:14:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B9=E9=80=A0=E8=BF=BD=E5=89=A7?= =?UTF-8?q?=E9=A1=B5=E5=8F=8C=E5=88=97=E8=A1=A8=E4=B8=8E=E4=B8=89=E5=88=97?= =?UTF-8?q?=E5=8D=A1=E7=89=87?= 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 | 19 +- lib/l10n/app_localizations_zh.dart | 2 +- lib/l10n/app_zh.arb | 2 +- lib/pages/following/following_grid_card.dart | 104 +++--- lib/pages/following/following_page.dart | 343 +++++++----------- lib/pages/following/following_paged_grid.dart | 152 ++++++++ test/following/following_page_test.dart | 83 +++-- 8 files changed, 435 insertions(+), 276 deletions(-) create mode 100644 lib/pages/following/following_paged_grid.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 57a957d..8819183 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1229,3 +1229,9 @@ $ flutter test - 删除不再使用的筛选回调、尺寸 token、颜色和 CustomPainter;全部短剧页筛选按钮保持不变。 - `flutter analyze`:No issues found。 - 按后续更正恢复首页透明三横线按钮(26、无背景、`#7A7A7E`),删除全部短剧第 4 行圆形按钮及回调链路。 + +## 2026-10-22 追剧页三列改造 + +- 追剧页移除预约 tab,改为追剧/播放历史双页横向切换,并保留各自滚动位置。 +- 追剧与历史统一三列卡片,新增固定加号卡片,历史进度与编辑删除能力保留。 +- 按最新口径调整列距 6、行距 12、封面圆角 6;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index e8ce8a1..9ceb1aa 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -147,8 +147,23 @@ abstract final class AppDimens { static const double allDramasGridGap = 6; static const double allDramasGridRowGap = 12; static const double allDramasFilterToGridGap = 12; - static const double followingTabFont = 16; - static const double followingGridAspectRatio = 0.5; + static const double followingHeaderHeight = 52; + static const double followingTabFont = 18; + static const double followingTabGap = 24; + static const double followingEditIconSize = 24; + static const double followingAddIconSize = 28; + static const double followingGridGap = 6; + static const double followingGridRowGap = 12; + static const double followingCardTextExtent = 66; + static const double followingSubtitleFont = 12; + static const double followingSubtitleGap = 4; + static const double followingProgressHeight = 2; + static const Duration followingTabAnimationDuration = Duration( + milliseconds: 180, + ); + static const Duration followingPageAnimationDuration = Duration( + milliseconds: 220, + ); /// 「我的」页中性视觉。 static const double mineCardRadius = 16; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index b99bb6e..1feadad 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -851,7 +851,7 @@ class AppLocalizationsZh extends AppLocalizations { String get freeBonus => '领免费奖励'; @override - String get watchHistory => '观看历史'; + String get watchHistory => '播放历史'; @override String get settings => '设置'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e98372e..8d4623e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -339,7 +339,7 @@ "joinVip": "开通会员", "earnRewards": "赚奖励", "freeBonus": "领免费奖励", - "watchHistory": "观看历史", + "watchHistory": "播放历史", "settings": "设置", "serverAddress": "服务器地址", "serverSettings": "服务器设置", diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index c1a433a..a3506e0 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -14,7 +14,6 @@ class FollowingGridCard extends StatelessWidget { required this.title, required this.posterUrl, required this.viewCount, - required this.tags, required this.subtitle, required this.onTap, this.progress, @@ -26,7 +25,6 @@ class FollowingGridCard extends StatelessWidget { final String title; final String posterUrl; final int viewCount; - final List tags; final String subtitle; final VoidCallback onTap; final double? progress; @@ -37,22 +35,21 @@ class FollowingGridCard extends StatelessWidget { Widget build(BuildContext context) { return InkWell( key: Key('following-card-$dramaId'), - borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, children: [ AspectRatio( key: Key('following-cover-$dramaId'), aspectRatio: AppDimens.cardCoverAspectRatio, child: Stack( - clipBehavior: Clip.none, + clipBehavior: Clip.hardEdge, children: [ Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular( - AppDimens.cardCoverRadius, + AppDimens.homeCardRadius, ), child: RemoteImage( posterUrl, @@ -64,18 +61,18 @@ class FollowingGridCard extends StatelessWidget { ), if (editing) Positioned( - top: -6, - right: -6, + top: -2, + right: -2, child: GestureDetector( key: Key('following-remove-$dramaId'), onTap: onRemove, child: const CircleAvatar( - radius: 13, + radius: 12, backgroundColor: AppColors.contentChip, child: Icon( Icons.remove_rounded, color: AppColors.textPrimary, - size: 18, + size: 17, ), ), ), @@ -88,12 +85,29 @@ class FollowingGridCard extends StatelessWidget { ), Positioned( right: AppDimens.cardPlayCountRight, - bottom: AppDimens.cardPlayCountBottom, + bottom: progress == null + ? AppDimens.cardPlayCountBottom + : AppDimens.followingProgressHeight + 4, child: CoverPlayCount( key: Key('following-views-$dramaId'), count: viewCount, ), ), + if (progress != null) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LinearProgressIndicator( + key: Key('following-progress-$dramaId'), + value: progress!.clamp(0.0, 1.0).toDouble(), + minHeight: AppDimens.followingProgressHeight, + backgroundColor: AppColors.inactiveTrack, + valueColor: const AlwaysStoppedAnimation( + AppColors.primary, + ), + ), + ), ], ), ), @@ -110,51 +124,55 @@ class FollowingGridCard extends StatelessWidget { height: AppDimens.cardTitleLineHeight, ), ), - if (tags.isNotEmpty) ...[ - const SizedBox(height: AppDimens.cardTitleTagGap), - _tags(), - ], - const SizedBox(height: AppDimens.spaceXs), + const SizedBox(height: AppDimens.followingSubtitleGap), Text( subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, style: const TextStyle( color: AppColors.textTertiary, - fontSize: AppDimens.cardViewsFont, + fontSize: AppDimens.followingSubtitleFont, ), ), - if (progress != null) ...[ - const SizedBox(height: AppDimens.spaceXs), - ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.progressRadius), - child: LinearProgressIndicator( - key: Key('following-progress-$dramaId'), - value: progress!.clamp(0.0, 1.0).toDouble(), - minHeight: 2, - backgroundColor: AppColors.inactiveTrack, - valueColor: const AlwaysStoppedAnimation( - AppColors.primary, - ), - ), - ), - ], ], ), ); } +} - Widget _tags() { - final visible = tags.take(3).toList(growable: false); - return Text( - visible.join(' | '), - key: Key('following-tags-$dramaId'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.cardTagFont, +class FollowingAddCard extends StatelessWidget { + const FollowingAddCard({super.key, required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + key: const Key('following-add-card'), + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: AppDimens.cardCoverAspectRatio, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + ), + child: const Center( + child: Icon( + Icons.add, + key: Key('following-add-icon'), + size: AppDimens.followingAddIconSize, + color: AppColors.textPrimary, + ), + ), + ), + ), + ], ), ); } diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 5be30a2..e148972 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -5,7 +5,6 @@ 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'; @@ -15,17 +14,14 @@ import '../../core/data/services/watch_records_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; -import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/paged_list_footer.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'following_grid_card.dart'; +import 'following_paged_grid.dart'; -final RegExp _tagSplitter = RegExp(r'[,/、·]'); - -enum FollowingTab { favorites, history, reserve } +enum FollowingTab { favorites, history } class FollowingPage extends StatefulWidget { const FollowingPage({ @@ -42,17 +38,16 @@ class FollowingPage extends StatefulWidget { } class _FollowingPageState extends State { + final PageController _pageController = PageController(); + final ScrollController _favoriteScroll = ScrollController(); + final ScrollController _historyScroll = ScrollController(); late final WatchRecordsController _history; - late final ScrollController _favoriteScroll; - late final ScrollController _historyScroll; FollowingTab _tab = FollowingTab.favorites; bool _editing = false; @override void initState() { super.initState(); - _favoriteScroll = ScrollController(); - _historyScroll = ScrollController(); _history = WatchRecordsController( history: context.read(), catalog: context.read(), @@ -65,6 +60,7 @@ class _FollowingPageState extends State { @override void dispose() { + _pageController.dispose(); _favoriteScroll.dispose(); _historyScroll.dispose(); _history.dispose(); @@ -86,13 +82,16 @@ class _FollowingPageState extends State { Expanded( child: !signedIn ? const AuthGatePanel() - : IndexedStack( - index: _tab.index, + : PageView( + key: const Key('following-tab-pages'), + controller: _pageController, + onPageChanged: _onPageChanged, children: [ _FavoritesGrid( controller: widget.favoriteController, editing: _editing, scrollController: _favoriteScroll, + onGoHome: widget.onGoHome, onListEmptied: _exitEditingIfEmpty, ), _HistoryGrid( @@ -101,7 +100,6 @@ class _FollowingPageState extends State { scrollController: _historyScroll, onListEmptied: _exitEditingIfEmpty, ), - _reserveEmpty(l10n), ], ), ), @@ -112,79 +110,119 @@ class _FollowingPageState extends State { } Widget _header(AppLocalizations l10n) { - final tabs = [ - (FollowingTab.favorites, l10n.followingTab, 'favorites'), - (FollowingTab.history, l10n.watchHistory, 'history'), - (FollowingTab.reserve, l10n.reserveTitle, 'reserve'), - ]; return SizedBox( key: const Key('following-header'), - height: 52, + height: AppDimens.followingHeaderHeight, child: Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), child: Row( children: [ - for (final tab in tabs) - Expanded( + _tabButton(FollowingTab.favorites, l10n.followingTab, 'favorites'), + const SizedBox(width: AppDimens.followingTabGap), + _tabButton(FollowingTab.history, l10n.watchHistory, 'history'), + const Spacer(), + if (_editing) + SizedBox( + width: 44, + height: 44, child: InkWell( - key: Key('following-tab-${tab.$3}'), - onTap: () => setState(() { - _tab = tab.$1; - _editing = false; - }), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - tab.$2, - maxLines: 1, - style: TextStyle( - color: _tab == tab.$1 - ? AppColors.textPrimary - : AppColors.textTertiary, - fontSize: AppDimens.followingTabFont, - fontWeight: _tab == tab.$1 - ? FontWeight.w700 - : FontWeight.w400, + key: const Key('following-edit'), + onTap: _toggleEditing, + child: Align( + alignment: Alignment.centerRight, + child: Text( + l10n.done, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.followingTabFont, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ) + else + Semantics( + button: true, + label: l10n.edit, + child: SizedBox( + width: 44, + height: 44, + child: InkWell( + key: const Key('following-edit'), + onTap: _toggleEditing, + child: const Align( + alignment: Alignment.centerRight, + child: SizedBox.square( + dimension: AppDimens.followingEditIconSize, + child: Icon( + Icons.edit_outlined, + color: AppColors.textPrimary, ), ), - const SizedBox(height: 3), - Container( - width: _tab == tab.$1 ? 20 : 0, - height: 2, - color: AppColors.textPrimary, - ), - ], + ), ), ), ), - IconButton( - key: const Key('following-edit'), - tooltip: _editing ? l10n.done : l10n.edit, - onPressed: () => setState(() => _editing = !_editing), - color: AppColors.textPrimary, - iconSize: 24, - icon: Icon(_editing ? Icons.check_rounded : Icons.edit_outlined), - ), ], ), ), ); } - Widget _reserveEmpty(AppLocalizations l10n) { - return EmptyState( - key: const Key('following-reserve-empty'), - icon: Icons.notifications_none_rounded, - title: l10n.reserveEmptyTitle, - subtitle: l10n.reserveEmptyHint, - actionLabel: l10n.homeTab, - onAction: widget.onGoHome, + Widget _tabButton(FollowingTab tab, String label, String keyName) { + final selected = _tab == tab; + return InkWell( + key: Key('following-tab-$keyName'), + onTap: () => _selectTab(tab), + child: SizedBox( + height: AppDimens.followingHeaderHeight, + child: Center( + child: AnimatedDefaultTextStyle( + duration: AppDimens.followingTabAnimationDuration, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textTabInactive, + fontSize: AppDimens.followingTabFont, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + child: Text(label, maxLines: 1), + ), + ), + ), ); } + void _selectTab(FollowingTab tab) { + if (_tab != tab || _editing) { + setState(() { + _tab = tab; + _editing = false; + }); + } + _pageController.animateToPage( + tab.index, + duration: AppDimens.followingPageAnimationDuration, + curve: Curves.easeOutCubic, + ); + } + + void _onPageChanged(int index) { + final tab = FollowingTab.values[index]; + if (_tab == tab && !_editing) return; + setState(() { + _tab = tab; + _editing = false; + }); + } + + void _toggleEditing() { + setState(() => _editing = !_editing); + } + void _exitEditingIfEmpty() { if (_editing && mounted) setState(() => _editing = false); } @@ -195,12 +233,14 @@ class _FavoritesGrid extends StatelessWidget { required this.controller, required this.editing, required this.scrollController, + required this.onGoHome, required this.onListEmptied, }); final FavoriteController controller; final bool editing; final ScrollController scrollController; + final VoidCallback onGoHome; final VoidCallback onListEmptied; @override @@ -209,13 +249,14 @@ class _FavoritesGrid extends StatelessWidget { listenable: controller, builder: (context, _) { final l10n = AppLocalizations.of(context)!; - return _PagedGrid( + return FollowingPagedGrid( key: const Key('following-favorites-grid'), scrollController: scrollController, loading: controller.loading, loadingMore: controller.loadingMore, hasMore: controller.hasMore, hasItems: controller.cards.isNotEmpty, + showInitialLoading: false, errorMessage: controller.error == null ? null : errorText(l10n, controller.error, l10n.favoritesError), @@ -224,15 +265,13 @@ class _FavoritesGrid extends StatelessWidget { : errorText(l10n, controller.loadMoreError, l10n.favoritesError), onRefresh: controller.load, onLoadMore: controller.loadMore, - itemCount: controller.cards.length, - empty: EmptyState( - key: const Key('following-favorites-empty'), - icon: Icons.bookmark_border_rounded, - title: l10n.noFavorites, - subtitle: l10n.emptyHint, - ), + itemCount: controller.cards.length + 1, + empty: const SizedBox.shrink(), itemBuilder: (context, index) { - final drama = controller.cards[index]; + if (index == 0) { + return FollowingAddCard(onTap: onGoHome); + } + final drama = controller.cards[index - 1]; final episode = drama.episodes.isEmpty ? 1 : drama.episodes.first.number; @@ -241,7 +280,6 @@ class _FavoritesGrid extends StatelessWidget { title: drama.title, posterUrl: drama.posterUrl, viewCount: drama.viewCount, - tags: _dramaTags(drama), editing: editing, subtitle: l10n.favoriteEpisodeProgress( episode, @@ -249,9 +287,10 @@ class _FavoritesGrid extends StatelessWidget { ), onTap: () => unawaited(playDramaFromCatalog(context, drama)), onRemove: () async { - if (await _confirmRemove(context)) { - await controller.remove(drama.id); - if (controller.cards.isEmpty) onListEmptied(); + if (await _confirmRemove(context) && + await controller.remove(drama.id) && + controller.cards.isEmpty) { + onListEmptied(); } }, ); @@ -281,7 +320,7 @@ class _HistoryGrid extends StatelessWidget { listenable: controller, builder: (context, _) { final l10n = AppLocalizations.of(context)!; - return _PagedGrid( + return FollowingPagedGrid( key: const Key('following-history-grid'), scrollController: scrollController, loading: controller.loading, @@ -305,23 +344,21 @@ class _HistoryGrid extends StatelessWidget { ), itemBuilder: (context, index) { final record = controller.records[index]; - final title = record.displayTitle.isEmpty - ? l10n.episodeLabel(record.episodeNumber) - : record.displayTitle; + final progress = record.progress.fraction; return FollowingGridCard( dramaId: record.dramaId, - title: title, + title: _historyTitle(l10n, record), posterUrl: record.displayCover, - viewCount: record.drama?.viewCount ?? 0, - tags: _dramaTags(record.drama), - progress: record.progress.fraction, + viewCount: record.drama?.viewCount ?? record.progress.playCount, + progress: progress > 0 ? progress : null, editing: editing, - subtitle: l10n.watchedToEpisode(record.episodeNumber), + subtitle: _historySubtitle(l10n, record), onTap: () => unawaited(_openHistory(context, controller, record)), onRemove: () async { - if (await _confirmRemove(context)) { - await controller.remove(record.dramaId); - if (controller.records.isEmpty) onListEmptied(); + if (await _confirmRemove(context) && + await controller.remove(record.dramaId) && + controller.records.isEmpty) { + onListEmptied(); } }, ); @@ -332,15 +369,19 @@ class _HistoryGrid extends StatelessWidget { } } -List _dramaTags(Drama? drama) { - if (drama == null) return const []; - final seen = {}; - return [drama.genre, drama.category] - .expand((value) => value.split(_tagSplitter)) - .map((value) => value.trim()) - .where((value) => value.isNotEmpty && seen.add(value)) - .take(3) - .toList(growable: false); +String _historyTitle(AppLocalizations l10n, WatchRecord record) { + return record.displayTitle.isEmpty + ? l10n.episodeLabel(record.episodeNumber) + : record.displayTitle; +} + +String _historySubtitle(AppLocalizations l10n, WatchRecord record) { + final total = + record.progress.totalEpisodes ?? record.drama?.episodeCount ?? 0; + if (record.episodeNumber <= 0 || total <= 0) { + return l10n.watchedToEpisode(record.episodeNumber); + } + return l10n.favoriteEpisodeProgress(record.episodeNumber, total); } Future _openHistory( @@ -377,111 +418,3 @@ Future _confirmRemove(BuildContext context) async { ) ?? false; } - -class _PagedGrid extends StatelessWidget { - const _PagedGrid({ - super.key, - required this.scrollController, - required this.loading, - required this.loadingMore, - required this.hasMore, - required this.hasItems, - required this.itemCount, - required this.itemBuilder, - required this.empty, - required this.onRefresh, - required this.onLoadMore, - this.errorMessage, - this.loadMoreError, - }); - - static const double _loadMoreThreshold = 240; - - final ScrollController scrollController; - final bool loading; - final bool loadingMore; - final bool hasMore; - final bool hasItems; - final int itemCount; - final IndexedWidgetBuilder itemBuilder; - final Widget empty; - final String? errorMessage; - final String? loadMoreError; - final Future Function() onRefresh; - final Future Function() onLoadMore; - - @override - Widget build(BuildContext context) { - if (loading && !hasItems) { - return const Center(child: CircularProgressIndicator()); - } - final emptyContent = errorMessage == null - ? empty - : EmptyState( - icon: Icons.cloud_off_rounded, - title: errorMessage!, - actionLabel: AppLocalizations.of(context)!.retry, - onAction: () => unawaited(onRefresh()), - ); - return NotificationListener( - onNotification: _onScroll, - child: AppPullToRefresh( - onRefresh: onRefresh, - child: CustomScrollView( - key: ValueKey('following-scroll-$runtimeType'), - controller: scrollController, - physics: AppPullToRefresh.physics, - slivers: [ - if (!hasItems) - SliverFillRemaining(hasScrollBody: false, child: emptyContent) - else ...[ - SliverPadding( - padding: const EdgeInsets.fromLTRB( - AppDimens.feedPageHorizontal, - AppDimens.spaceMd, - AppDimens.feedPageHorizontal, - AppDimens.spaceSm, - ), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, - childAspectRatio: AppDimens.followingGridAspectRatio, - ), - delegate: SliverChildBuilderDelegate( - itemBuilder, - childCount: itemCount, - ), - ), - ), - SliverToBoxAdapter( - child: PagedListFooter( - loading: loadingMore, - hasMore: hasMore, - hasItems: hasItems, - errorMessage: loadMoreError, - onRetry: onLoadMore, - ), - ), - const SliverToBoxAdapter( - child: SizedBox(height: AppDimens.spaceXl), - ), - ], - ], - ), - ), - ); - } - - bool _onScroll(ScrollNotification notification) { - if (notification.depth != 0) return false; - if (notification.metrics.extentAfter > _loadMoreThreshold) return false; - if (notification is ScrollUpdateNotification || - notification is ScrollEndNotification || - notification is OverscrollNotification) { - unawaited(onLoadMore()); - } - return false; - } -} diff --git a/lib/pages/following/following_paged_grid.dart b/lib/pages/following/following_paged_grid.dart new file mode 100644 index 0000000..269e9e6 --- /dev/null +++ b/lib/pages/following/following_paged_grid.dart @@ -0,0 +1,152 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/paged_list_footer.dart'; +import '../../l10n/app_localizations.dart'; + +class FollowingPagedGrid extends StatelessWidget { + const FollowingPagedGrid({ + super.key, + required this.scrollController, + required this.loading, + required this.loadingMore, + required this.hasMore, + required this.hasItems, + required this.itemCount, + required this.itemBuilder, + required this.empty, + required this.onRefresh, + required this.onLoadMore, + this.showInitialLoading = true, + this.errorMessage, + this.loadMoreError, + }); + + static const double _loadMoreThreshold = 240; + + final ScrollController scrollController; + final bool loading; + final bool loadingMore; + final bool hasMore; + final bool hasItems; + final int itemCount; + final IndexedWidgetBuilder itemBuilder; + final Widget empty; + final String? errorMessage; + final String? loadMoreError; + final bool showInitialLoading; + final Future Function() onRefresh; + final Future Function() onLoadMore; + + @override + Widget build(BuildContext context) { + if (showInitialLoading && loading && !hasItems) { + return const Center(child: CircularProgressIndicator()); + } + return LayoutBuilder( + builder: (context, constraints) { + final gridWidth = + constraints.maxWidth - AppDimens.feedPageHorizontal * 2; + final itemWidth = (gridWidth - AppDimens.followingGridGap * 2) / 3; + final itemExtent = + itemWidth / AppDimens.cardCoverAspectRatio + + AppDimens.followingCardTextExtent; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + onRefresh: onRefresh, + child: CustomScrollView( + key: ValueKey('following-scroll-$runtimeType'), + controller: scrollController, + physics: AppPullToRefresh.physics, + slivers: [ + if (itemCount == 0) + SliverFillRemaining( + hasScrollBody: false, + child: errorMessage == null + ? empty + : EmptyState( + icon: Icons.cloud_off_rounded, + title: errorMessage!, + actionLabel: AppLocalizations.of(context)!.retry, + onAction: () => unawaited(onRefresh()), + ), + ) + else ...[ + if (errorMessage != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + AppDimens.spaceSm, + AppDimens.feedPageHorizontal, + 0, + ), + child: Text( + errorMessage!, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.followingSubtitleFont, + ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + 0, + AppDimens.feedPageHorizontal, + AppDimens.spaceSm, + ), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.followingGridGap, + mainAxisSpacing: AppDimens.followingGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + itemBuilder, + childCount: itemCount, + ), + ), + ), + SliverToBoxAdapter( + child: PagedListFooter( + loading: loadingMore, + hasMore: hasMore, + hasItems: hasItems, + errorMessage: loadMoreError, + onRetry: onLoadMore, + ), + ), + const SliverToBoxAdapter( + child: SizedBox(height: AppDimens.spaceXl), + ), + ], + ], + ), + ), + ); + }, + ); + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + if (notification.metrics.extentAfter > _loadMoreThreshold) return false; + if (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification) { + unawaited(onLoadMore()); + } + return false; + } +} diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index ede23ea..c32429d 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -40,18 +40,19 @@ void main() { expect(find.byKey(const Key('following-page')), findsOneWidget); expect(find.byKey(const Key('following-tab-favorites')), findsOneWidget); expect(find.byKey(const Key('following-tab-history')), findsOneWidget); - expect(find.byKey(const Key('following-tab-reserve')), findsOneWidget); + expect(find.byKey(const Key('following-tab-reserve')), findsNothing); expect(find.byKey(const Key('following-favorites-grid')), findsOneWidget); + expect(find.byKey(const Key('following-add-card')), findsOneWidget); expect(find.byType(CustomScrollView), findsWidgets); - final tab = tester.widget( + final tab = tester.widget( find.descendant( of: find.byKey(const Key('following-tab-favorites')), - matching: find.text('Following'), + matching: find.byType(AnimatedDefaultTextStyle), ), ); - expect(tab.style?.fontSize, AppDimens.followingTabFont); - expect(tab.style?.fontWeight, FontWeight.w700); - expect(tab.style?.color, AppColors.textPrimary); + expect(tab.style.fontSize, AppDimens.followingTabFont); + expect(tab.style.fontWeight, FontWeight.w600); + expect(tab.style.color, AppColors.textPrimary); final grid = tester.widget( find.descendant( of: find.byKey(const Key('following-favorites-grid')), @@ -60,9 +61,10 @@ void main() { ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisSpacing, AppDimens.homeGridGap); - expect(delegate.mainAxisSpacing, AppDimens.homeGridRowGap); - expect(delegate.childAspectRatio, AppDimens.followingGridAspectRatio); + expect(delegate.crossAxisCount, 3); + expect(delegate.crossAxisSpacing, AppDimens.followingGridGap); + expect(delegate.mainAxisSpacing, AppDimens.followingGridRowGap); + expect(delegate.mainAxisExtent, greaterThan(0)); final card = find.byKey(const Key('following-card-the-signal-demo')); final coverFinder = find.byKey( const Key('following-cover-the-signal-demo'), @@ -77,7 +79,7 @@ void main() { ); expect( coverClip.borderRadius, - BorderRadius.circular(AppDimens.cardCoverRadius), + BorderRadius.circular(AppDimens.homeCardRadius), ); final titleFinder = find.descendant( of: card, @@ -88,7 +90,10 @@ void main() { expect(title.style?.fontWeight, FontWeight.w500); expect(title.style?.height, AppDimens.cardTitleLineHeight); final views = tester.widget( - find.byKey(const Key('following-views-the-signal-demo')), + find.descendant( + of: find.byKey(const Key('following-views-the-signal-demo')), + matching: find.byType(Text), + ), ); expect(views.style?.fontSize, AppDimens.cardPlayCountFont); expect(views.style?.fontWeight, FontWeight.w400); @@ -107,15 +112,10 @@ void main() { ), findsOneWidget, ); - final tagsFinder = find.byKey( - const Key('following-tags-the-signal-demo'), + expect( + tester.getTopLeft(titleFinder).dx, + tester.getTopLeft(coverFinder).dx, ); - final tags = tester.widget(tagsFinder); - expect(tags.data, 'Suspense | Mystery'); - expect(tags.style?.fontSize, AppDimens.cardTagFont); - expect(tags.style?.color, AppColors.textTertiary); - expect(tester.getTopLeft(titleFinder).dx, tester.getTopLeft(coverFinder).dx); - expect(tester.getTopLeft(tagsFinder).dx, tester.getTopLeft(coverFinder).dx); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); @@ -148,9 +148,6 @@ void main() { await tester.tap(find.byKey(const Key('following-tab-history'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('following-history-empty')), findsOneWidget); - await tester.tap(find.byKey(const Key('following-tab-reserve'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('following-reserve-empty')), findsOneWidget); }); testWidgets('should_show_auth_gate_when_signed_out', (tester) async { @@ -172,7 +169,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('following-favorites-empty')), findsOneWidget); + expect(find.byKey(const Key('following-add-card')), findsOneWidget); + expect(find.byKey(const Key('following-favorites-empty')), findsNothing); await tester.tap(find.byKey(const Key('following-tab-history'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('following-history-empty')), findsOneWidget); @@ -209,7 +207,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Server title'), findsOneWidget); - expect(find.text('Watched to Episode 3'), findsOneWidget); + expect(find.text('Episode 3 / 8 total'), findsOneWidget); expect( find.byKey(const Key('following-progress-the-signal-demo')), findsOneWidget, @@ -217,6 +215,43 @@ void main() { expect(catalog.detailFetches, 0); }); + testWidgets('should_switch_following_pages_horizontally', (tester) async { + _signIn(); + await tester.pumpWidget(_app(catalog: FakeCatalogRepository())); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const Key('following-tab-pages')), + const Offset(-500, 0), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('following-history-empty')), findsOneWidget); + final historyTab = tester.widget( + find.descendant( + of: find.byKey(const Key('following-tab-history')), + matching: find.byType(AnimatedDefaultTextStyle), + ), + ); + expect(historyTab.style.fontWeight, FontWeight.w600); + expect(historyTab.style.color, AppColors.textPrimary); + }); + + testWidgets('should_open_home_from_add_card', (tester) async { + _signIn(); + await tester.pumpWidget(_app(catalog: FakeCatalogRepository())); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('following-add-card'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-grid')), findsOneWidget); + }); + testWidgets('should_reset_edit_state_when_switching_tabs', (tester) async { _signIn(); final catalog = FakeCatalogRepository();