fix: 优化追剧页切换与编辑按钮

This commit is contained in:
Zhou Weidong committed 2026-10-22 21:25:55 +08:00
1 parent f67b8bd514
commit 3f3dc9263c
5 files changed
+267 -187

No files matched your search

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