diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 33be1cc..8aab73b 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1243,3 +1243,9 @@ $ flutter test - 追剧加号卡片移到全部追剧卡片之后,空列表时仍在左上角。 - 加号改为 `#8E8E93`、28 尺寸、1.5 描边,卡片底色与圆角不变。 - 刷新、分页与各页独立滚动位置保持;`flutter analyze`:No issues found。 + +## 2026-10-22 追剧页切换与编辑按钮修正 + +- 追剧与历史页增加 keep-alive,tab 与编辑态改用局部 ValueNotifier 更新,切换不再重建整个页面。 +- 编辑图标调整为 20、`#B0B0B0`,热区 40×40,保持右侧 14 边距和垂直居中。 +- 列表刷新期间保留已有数据与各页滚动位置;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 807e451..5ba9597 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -150,7 +150,8 @@ abstract final class AppDimens { static const double followingHeaderHeight = 52; static const double followingTabFont = 18; static const double followingTabGap = 24; - static const double followingEditIconSize = 24; + static const double followingEditIconSize = 20; + static const double followingEditHitSize = 40; static const double followingAddIconSize = 28; static const double followingAddIconStroke = 1.5; static const double followingGridGap = 6; diff --git a/lib/pages/following/following_keep_alive_tab.dart b/lib/pages/following/following_keep_alive_tab.dart new file mode 100644 index 0000000..d0c9c14 --- /dev/null +++ b/lib/pages/following/following_keep_alive_tab.dart @@ -0,0 +1,24 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; + +class FollowingKeepAliveTab extends StatefulWidget { + const FollowingKeepAliveTab({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _FollowingKeepAliveTabState(); +} + +class _FollowingKeepAliveTabState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 250db53..4780f36 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -20,6 +21,7 @@ import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'following_grid_card.dart'; import 'following_history_card.dart'; +import 'following_keep_alive_tab.dart'; import 'following_paged_grid.dart'; enum FollowingTab { favorites, history } @@ -42,9 +44,11 @@ class _FollowingPageState extends State { final PageController _pageController = PageController(); final ScrollController _favoriteScroll = ScrollController(); final ScrollController _historyScroll = ScrollController(); + final ValueNotifier _tab = ValueNotifier( + FollowingTab.favorites, + ); + final ValueNotifier _editing = ValueNotifier(false); late final WatchRecordsController _history; - FollowingTab _tab = FollowingTab.favorites; - bool _editing = false; @override void initState() { @@ -64,6 +68,8 @@ class _FollowingPageState extends State { _pageController.dispose(); _favoriteScroll.dispose(); _historyScroll.dispose(); + _tab.dispose(); + _editing.dispose(); _history.dispose(); super.dispose(); } @@ -88,18 +94,22 @@ class _FollowingPageState extends State { controller: _pageController, onPageChanged: _onPageChanged, children: [ - _FavoritesGrid( - controller: widget.favoriteController, - editing: _editing, - scrollController: _favoriteScroll, - onGoHome: widget.onGoHome, - onListEmptied: _exitEditingIfEmpty, + FollowingKeepAliveTab( + child: _FavoritesGrid( + controller: widget.favoriteController, + editing: _editing, + scrollController: _favoriteScroll, + onGoHome: widget.onGoHome, + onListEmptied: _exitEditingIfEmpty, + ), ), - _HistoryList( - controller: _history, - editing: _editing, - scrollController: _historyScroll, - onListEmptied: _exitEditingIfEmpty, + FollowingKeepAliveTab( + child: _HistoryList( + controller: _history, + editing: _editing, + scrollController: _historyScroll, + onListEmptied: _exitEditingIfEmpty, + ), ), ], ), @@ -118,63 +128,85 @@ class _FollowingPageState extends State { padding: const EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), - child: Row( - children: [ - _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: 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, - ), + child: ValueListenableBuilder( + valueListenable: _tab, + builder: (context, selectedTab, _) { + return ValueListenableBuilder( + valueListenable: _editing, + builder: (context, editing, _) { + return Row( + children: [ + _tabButton( + FollowingTab.favorites, + l10n.followingTab, + 'favorites', + selectedTab == FollowingTab.favorites, ), - ), - ), - ) - 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(width: AppDimens.followingTabGap), + _tabButton( + FollowingTab.history, + l10n.watchHistory, + 'history', + selectedTab == FollowingTab.history, + ), + const Spacer(), + if (editing) + SizedBox( + width: AppDimens.followingEditHitSize, + height: AppDimens.followingEditHitSize, + child: InkWell( + 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: AppDimens.followingEditHitSize, + height: AppDimens.followingEditHitSize, + child: InkWell( + key: const Key('following-edit'), + onTap: _toggleEditing, + child: const Align( + alignment: Alignment.centerRight, + child: Icon( + Icons.edit_outlined, + size: AppDimens.followingEditIconSize, + color: AppColors.textTabInactive, + ), + ), + ), ), ), - ), - ), - ), - ), - ], + ], + ); + }, + ); + }, ), ), ); } - Widget _tabButton(FollowingTab tab, String label, String keyName) { - final selected = _tab == tab; + Widget _tabButton( + FollowingTab tab, + String label, + String keyName, + bool selected, + ) { return InkWell( key: Key('following-tab-$keyName'), onTap: () => _selectTab(tab), @@ -198,35 +230,30 @@ class _FollowingPageState extends State { } void _selectTab(FollowingTab tab) { - if (_tab != tab || _editing) { - setState(() { - _tab = tab; - _editing = false; - }); + _editing.value = false; + _tab.value = tab; + if (_pageController.hasClients) { + unawaited( + _pageController.animateToPage( + tab.index, + duration: AppDimens.followingPageAnimationDuration, + curve: Curves.easeOutCubic, + ), + ); } - _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; - }); + _editing.value = false; + _tab.value = tab; } void _toggleEditing() { - setState(() => _editing = !_editing); + _editing.value = !_editing.value; } - void _exitEditingIfEmpty() { - if (_editing && mounted) setState(() => _editing = false); - } + void _exitEditingIfEmpty() => _editing.value = false; } class _FavoritesGrid extends StatelessWidget { @@ -239,60 +266,69 @@ class _FavoritesGrid extends StatelessWidget { }); final FavoriteController controller; - final bool editing; + final ValueListenable editing; final ScrollController scrollController; final VoidCallback onGoHome; final VoidCallback onListEmptied; @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: controller, - builder: (context, _) { - final l10n = AppLocalizations.of(context)!; - 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), - loadMoreError: controller.loadMoreError == null - ? null - : errorText(l10n, controller.loadMoreError, l10n.favoritesError), - onRefresh: controller.load, - onLoadMore: controller.loadMore, - itemCount: controller.cards.length + 1, - empty: const SizedBox.shrink(), - itemBuilder: (context, index) { - if (index == controller.cards.length) { - return FollowingAddCard(onTap: onGoHome); - } - final drama = controller.cards[index]; - final episode = drama.episodes.isEmpty - ? 1 - : drama.episodes.first.number; - return FollowingGridCard( - dramaId: drama.id, - title: drama.title, - posterUrl: drama.posterUrl, - viewCount: drama.viewCount, - editing: editing, - subtitle: l10n.favoriteEpisodeProgress( - episode, - drama.episodeCount, - ), - onTap: () => unawaited(playDramaFromCatalog(context, drama)), - onRemove: () async { - if (await _confirmRemove(context) && - await controller.remove(drama.id) && - controller.cards.isEmpty) { - onListEmptied(); + return ValueListenableBuilder( + valueListenable: editing, + builder: (context, isEditing, _) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + 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), + loadMoreError: controller.loadMoreError == null + ? null + : errorText( + l10n, + controller.loadMoreError, + l10n.favoritesError, + ), + onRefresh: controller.load, + onLoadMore: controller.loadMore, + itemCount: controller.cards.length + 1, + empty: const SizedBox.shrink(), + itemBuilder: (context, index) { + if (index == controller.cards.length) { + return FollowingAddCard(onTap: onGoHome); } + final drama = controller.cards[index]; + final episode = drama.episodes.isEmpty + ? 1 + : drama.episodes.first.number; + return FollowingGridCard( + dramaId: drama.id, + title: drama.title, + posterUrl: drama.posterUrl, + viewCount: drama.viewCount, + editing: isEditing, + subtitle: l10n.favoriteEpisodeProgress( + episode, + drama.episodeCount, + ), + onTap: () => unawaited(playDramaFromCatalog(context, drama)), + onRemove: () async { + if (await _confirmRemove(context) && + await controller.remove(drama.id) && + controller.cards.isEmpty) { + onListEmptied(); + } + }, + ); }, ); }, @@ -311,70 +347,80 @@ class _HistoryList extends StatelessWidget { }); final WatchRecordsController controller; - final bool editing; + final ValueListenable editing; final ScrollController scrollController; final VoidCallback onListEmptied; @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: controller, - builder: (context, _) { - final l10n = AppLocalizations.of(context)!; - return FollowingPagedGrid( - key: const Key('following-history-grid'), - scrollController: scrollController, - loading: controller.loading, - loadingMore: controller.loadingMore, - hasMore: controller.hasMore, - hasItems: controller.records.isNotEmpty, - singleColumn: true, - errorMessage: controller.error == null - ? null - : errorText(l10n, controller.error, l10n.progressError), - loadMoreError: controller.loadMoreError == null - ? null - : errorText(l10n, controller.loadMoreError, l10n.progressError), - onRefresh: controller.load, - onLoadMore: controller.loadMore, - itemCount: controller.records.length, - empty: EmptyState( - key: const Key('following-history-empty'), - icon: Icons.history_rounded, - title: l10n.noWatchRecords, - subtitle: l10n.emptyHint, - ), - itemBuilder: (context, index) { - final record = controller.records[index]; - final progress = record.progress.fraction; - 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: hasProgress ? progress : null, - progressLabel: hasProgress - ? l10n.watchedPercent(watchedPercent) - : null, - editing: editing, - subtitle: _historySubtitle(l10n, record), - onTap: openRecord, - onContinue: openRecord, - onRemove: () async { - if (await _confirmRemove(context) && - await controller.remove(record.dramaId) && - controller.records.isEmpty) { - onListEmptied(); + return ValueListenableBuilder( + valueListenable: editing, + builder: (context, isEditing, _) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + return FollowingPagedGrid( + key: const Key('following-history-grid'), + scrollController: scrollController, + loading: controller.loading, + loadingMore: controller.loadingMore, + hasMore: controller.hasMore, + hasItems: controller.records.isNotEmpty, + singleColumn: true, + errorMessage: controller.error == null + ? null + : errorText(l10n, controller.error, l10n.progressError), + loadMoreError: controller.loadMoreError == null + ? null + : errorText( + l10n, + controller.loadMoreError, + l10n.progressError, + ), + onRefresh: controller.load, + onLoadMore: controller.loadMore, + itemCount: controller.records.length, + empty: EmptyState( + key: const Key('following-history-empty'), + icon: Icons.history_rounded, + title: l10n.noWatchRecords, + subtitle: l10n.emptyHint, + ), + itemBuilder: (context, index) { + final record = controller.records[index]; + final progress = record.progress.fraction; + 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: hasProgress ? progress : null, + progressLabel: hasProgress + ? l10n.watchedPercent(watchedPercent) + : null, + editing: isEditing, + subtitle: _historySubtitle(l10n, record), + onTap: openRecord, + onContinue: openRecord, + onRemove: () async { + if (await _confirmRemove(context) && + await controller.remove(record.dramaId) && + controller.records.isEmpty) { + onListEmptied(); + } + }, + ); }, ); }, diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index ff0f371..0132852 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -53,6 +53,9 @@ void main() { expect(tab.style.fontSize, AppDimens.followingTabFont); expect(tab.style.fontWeight, FontWeight.w600); expect(tab.style.color, AppColors.textPrimary); + final editIcon = tester.widget(find.byIcon(Icons.edit_outlined)); + expect(editIcon.size, AppDimens.followingEditIconSize); + expect(editIcon.color, AppColors.textTabInactive); final grid = tester.widget( find.descendant( of: find.byKey(const Key('following-favorites-grid')),