feat(app): 完善播放协议搜索与空状态

This commit is contained in:
Zhou Weidong committed 2026-10-23 12:38:47 +08:00
1 parent 671e0ce143
commit ef29fc5f15
85 files changed
+2976 -768

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
enum FollowingTab { favorites, history }
/// 编辑底栏的全选与取消动作。
class FollowingEditActions {
const FollowingEditActions({
required this.onToggleAll,
required this.onRemove,
});
final void Function(Set<String> ids) onToggleAll;
final VoidCallback onRemove;
}
const double _barHeight = 48;
const double _barHorizontal = 16;
const double _barFontSize = 13;
const double _barButtonHeight = 30;
const double _removeDialogButtonHeight = 32;
const double _removeDialogRadius = 6;
const double _removeDialogBottomInset = 8;
/// 播放历史移除确认。只把移除按钮压矮。
Future<bool> confirmHistoryRemove(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
actionsPadding: const EdgeInsets.fromLTRB(
24,
0,
16,
_removeDialogBottomInset,
),
content: Text(l10n.confirmRemove),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _removeDialogButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_removeDialogRadius),
),
),
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.remove),
),
],
),
) ??
false;
}
/// 确认后返回是否取消已选追剧。
Future<bool> confirmCancelFollowing(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
content: Text(l10n.confirmCancelFollowing),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.cancelFollowing),
),
],
),
) ??
false;
}
/// 只在追剧编辑中显示底栏。
class FollowingEditSlot extends StatelessWidget {
const FollowingEditSlot({
super.key,
required this.tab,
required this.editing,
required this.favorites,
required this.selected,
required this.actions,
});
final ValueListenable<FollowingTab> tab;
final ValueListenable<bool> editing;
final FavoriteController favorites;
final ValueListenable<Set<String>> selected;
final FollowingEditActions actions;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<FollowingTab>(
valueListenable: tab,
builder: (context, current, _) {
return ValueListenableBuilder<bool>(
valueListenable: editing,
builder: (context, isEditing, _) {
if (!isEditing || current != FollowingTab.favorites) {
return const SizedBox.shrink();
}
return _bar();
},
);
},
);
}
Widget _bar() {
return ListenableBuilder(
listenable: favorites,
builder: (context, _) {
return ValueListenableBuilder<Set<String>>(
valueListenable: selected,
builder: (context, picked, _) {
final ids = favorites.cards.map((drama) => drama.id).toSet();
return FollowingEditBar(
allSelected: ids.isNotEmpty && ids.every(picked.contains),
canRemove: picked.isNotEmpty,
onToggleAll: () => actions.onToggleAll(ids),
onRemove: actions.onRemove,
);
},
);
},
);
}
}
/// 追剧编辑底栏:全选,以及取消已选追剧。
class FollowingEditBar extends StatelessWidget {
const FollowingEditBar({
super.key,
required this.allSelected,
required this.canRemove,
required this.onToggleAll,
required this.onRemove,
});
final bool allSelected;
final bool canRemove;
final VoidCallback onToggleAll;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Material(
key: const Key('following-edit-bar'),
color: AppColors.card,
child: SafeArea(
top: false,
child: SizedBox(
height: _barHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _barHorizontal),
child: Row(
children: [
TextButton(
key: const Key('following-select-all'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _barButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 4),
textStyle: const TextStyle(
fontSize: _barFontSize,
fontWeight: FontWeight.w500,
),
),
onPressed: onToggleAll,
child: Text(
allSelected ? l10n.deselectAll : l10n.selectAll,
style: const TextStyle(
fontSize: _barFontSize,
fontWeight: FontWeight.w500,
),
),
),
const Spacer(),
FilledButton(
key: const Key('following-cancel-follow'),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _removeDialogButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_removeDialogRadius),
),
textStyle: const TextStyle(
fontSize: _barFontSize,
fontWeight: FontWeight.w600,
),
),
onPressed: canRemove ? onRemove : null,
child: Text(
l10n.cancelFollowing,
style: const TextStyle(
fontSize: _barFontSize,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,118 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../l10n/app_localizations.dart';
import '../player/open_player.dart';
import 'following_grid_card.dart';
import 'following_paged_grid.dart';
/// 追剧编辑中的选中状态。
class FollowingFavoriteSelection {
const FollowingFavoriteSelection({
required this.editing,
required this.selected,
required this.onToggle,
});
final ValueListenable<bool> editing;
final ValueListenable<Set<String>> selected;
final ValueChanged<String> onToggle;
}
/// 追剧列表。编辑时点卡片只切换选中,不进入播放。
class FollowingFavoritesGrid extends StatelessWidget {
const FollowingFavoritesGrid({
super.key,
required this.controller,
required this.selection,
required this.scrollController,
required this.onGoHome,
});
final FavoriteController controller;
final FollowingFavoriteSelection selection;
final ScrollController scrollController;
final VoidCallback onGoHome;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: selection.editing,
builder: (context, isEditing, _) {
return ValueListenableBuilder<Set<String>>(
valueListenable: selection.selected,
builder: (context, picked, _) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) => _grid(context, isEditing, picked),
);
},
);
},
);
}
Widget _grid(BuildContext context, bool isEditing, Set<String> picked) {
final l10n = AppLocalizations.of(context)!;
final extra = isEditing ? 0 : 1;
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 + extra,
empty: const SizedBox.shrink(),
itemBuilder: (context, index) => _item(context, index, isEditing, picked),
);
}
Widget _item(
BuildContext context,
int index,
bool isEditing,
Set<String> picked,
) {
if (index == controller.cards.length) {
return FollowingAddCard(onTap: onGoHome);
}
final l10n = AppLocalizations.of(context)!;
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,
selected: isEditing ? picked.contains(drama.id) : null,
subtitle: l10n.favoriteEpisodeProgress(episode, drama.episodeCount),
onTap: () => _openOrToggle(context, drama.id, isEditing),
);
}
void _openOrToggle(BuildContext context, String dramaId, bool isEditing) {
if (isEditing) {
selection.onToggle(dramaId);
return;
}
final drama = controller.cards.firstWhere((item) => item.id == dramaId);
unawaited(playDramaFromCatalog(context, drama));
}
}
+46 -1
View File
@@ -7,6 +7,11 @@ import '../../core/theme/app_dimens.dart';
import '../../core/widgets/cover_play_count.dart';
import '../../core/widgets/remote_image.dart';
const double _selectMarkSize = 22;
const double _selectMarkInset = 6;
const double _selectMarkIconSize = 16;
const double _selectBorderWidth = 1.5;
class FollowingGridCard extends StatelessWidget {
const FollowingGridCard({
super.key,
@@ -18,6 +23,7 @@ class FollowingGridCard extends StatelessWidget {
required this.onTap,
this.progress,
this.editing = false,
this.selected,
this.onRemove,
});
@@ -29,6 +35,7 @@ class FollowingGridCard extends StatelessWidget {
final VoidCallback onTap;
final double? progress;
final bool editing;
final bool? selected;
final VoidCallback? onRemove;
@override
@@ -59,7 +66,16 @@ class FollowingGridCard extends StatelessWidget {
),
),
),
if (editing)
if (selected != null)
Positioned(
top: _selectMarkInset,
right: _selectMarkInset,
child: _SelectMark(
key: Key('following-select-$dramaId'),
selected: selected!,
),
)
else if (editing)
Positioned(
top: -2,
right: -2,
@@ -141,6 +157,35 @@ class FollowingGridCard extends StatelessWidget {
}
}
class _SelectMark extends StatelessWidget {
const _SelectMark({super.key, required this.selected});
final bool selected;
@override
Widget build(BuildContext context) {
return Container(
width: _selectMarkSize,
height: _selectMarkSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? AppColors.blue : AppColors.transparent,
border: Border.all(
color: selected ? AppColors.blue : AppColors.textPrimary,
width: _selectBorderWidth,
),
),
child: selected
? const Icon(
Icons.check_rounded,
size: _selectMarkIconSize,
color: AppColors.onPrimary,
)
: null,
);
}
}
class FollowingAddCard extends StatelessWidget {
const FollowingAddCard({super.key, required this.onTap});
+114 -143
View File
@@ -19,12 +19,14 @@ import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../player/open_player.dart';
import 'following_grid_card.dart';
import 'following_edit_bar.dart';
import 'following_favorites_grid.dart';
import 'following_history_card.dart';
import 'following_keep_alive_tab.dart';
import 'following_paged_grid.dart';
enum FollowingTab { favorites, history }
const double _doneFontSize = 14;
const double _doneTextGap = 12;
class FollowingPage extends StatefulWidget {
const FollowingPage({
@@ -50,21 +52,33 @@ class _FollowingPageState extends State<FollowingPage> {
FollowingTab.favorites,
);
final ValueNotifier<bool> _editing = ValueNotifier(false);
final ValueNotifier<Set<String>> _selected = ValueNotifier({});
late final WatchRecordsController _history;
late final AuthController _auth;
bool _wasSignedIn = false;
@override
void initState() {
super.initState();
_auth = context.read<AuthController>();
_wasSignedIn = _auth.isSignedIn;
_auth.addListener(_onAuthChanged);
_history = WatchRecordsController(
history: context.read<WatchHistoryRepository>(),
catalog: context.read<CatalogRepository>(),
);
unawaited(_history.load());
if (_wasSignedIn) unawaited(_history.load());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.favoriteController.load());
});
}
void _onAuthChanged() {
final signedIn = _auth.isSignedIn;
if (signedIn && !_wasSignedIn) unawaited(_history.load());
_wasSignedIn = signedIn;
}
@override
void dispose() {
_pageController.dispose();
@@ -72,6 +86,8 @@ class _FollowingPageState extends State<FollowingPage> {
_historyScroll.dispose();
_tab.dispose();
_editing.dispose();
_selected.dispose();
_auth.removeListener(_onAuthChanged);
_history.dispose();
super.dispose();
}
@@ -106,12 +122,15 @@ class _FollowingPageState extends State<FollowingPage> {
onPageChanged: _onPageChanged,
children: [
FollowingKeepAliveTab(
child: _FavoritesGrid(
child: FollowingFavoritesGrid(
controller: widget.favoriteController,
editing: _editing,
selection: FollowingFavoriteSelection(
editing: _editing,
selected: _selected,
onToggle: _toggleDrama,
),
scrollController: _favoriteScroll,
onGoHome: widget.onGoHome,
onListEmptied: _exitEditingIfEmpty,
),
),
FollowingKeepAliveTab(
@@ -125,6 +144,17 @@ class _FollowingPageState extends State<FollowingPage> {
],
),
),
if (signedIn)
FollowingEditSlot(
tab: _tab,
editing: _editing,
favorites: widget.favoriteController,
selected: _selected,
actions: FollowingEditActions(
onToggleAll: _toggleAll,
onRemove: () => unawaited(_cancelSelected()),
),
),
],
),
),
@@ -147,36 +177,43 @@ class _FollowingPageState extends State<FollowingPage> {
builder: (context, editing, _) {
return Row(
children: [
_tabButton(
FollowingTab.favorites,
l10n.followingTab,
'favorites',
selectedTab == FollowingTab.favorites,
Expanded(
child: Row(
children: [
Flexible(
fit: FlexFit.loose,
child: _tabButton(
FollowingTab.favorites,
l10n.followingTab,
'favorites',
selectedTab == FollowingTab.favorites,
),
),
const SizedBox(width: AppDimens.followingTabGap),
Flexible(
fit: FlexFit.loose,
child: _tabButton(
FollowingTab.history,
l10n.watchHistory,
'history',
selectedTab == FollowingTab.history,
),
),
],
),
),
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,
),
InkWell(
key: const Key('following-edit'),
onTap: _toggleEditing,
child: Padding(
padding: const EdgeInsets.only(left: _doneTextGap),
child: Text(
l10n.done,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _doneFontSize,
fontWeight: FontWeight.w500,
),
),
),
@@ -223,7 +260,9 @@ class _FollowingPageState extends State<FollowingPage> {
onTap: () => _selectTab(tab),
child: SizedBox(
height: AppDimens.followingHeaderHeight,
child: Center(
child: Align(
alignment: Alignment.centerLeft,
widthFactor: 1,
child: AnimatedDefaultTextStyle(
duration: AppDimens.followingTabAnimationDuration,
style: TextStyle(
@@ -233,7 +272,7 @@ class _FollowingPageState extends State<FollowingPage> {
fontSize: AppDimens.followingTabFont,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
child: Text(label, maxLines: 1),
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
),
@@ -241,7 +280,7 @@ class _FollowingPageState extends State<FollowingPage> {
}
void _selectTab(FollowingTab tab) {
_editing.value = false;
_leaveEditing();
_tab.value = tab;
if (_pageController.hasClients) {
unawaited(
@@ -256,99 +295,50 @@ class _FollowingPageState extends State<FollowingPage> {
void _onPageChanged(int index) {
final tab = FollowingTab.values[index];
_editing.value = false;
_leaveEditing();
_tab.value = tab;
if (tab == FollowingTab.history && _auth.isSignedIn) {
unawaited(_history.load());
}
}
void _toggleEditing() {
_editing.value = !_editing.value;
if (!_editing.value) _selected.value = {};
}
void _leaveEditing() {
_editing.value = false;
_selected.value = {};
}
void _toggleDrama(String id) {
final next = {..._selected.value};
if (!next.add(id)) next.remove(id);
_selected.value = next;
}
void _toggleAll(Set<String> ids) {
final picked = _selected.value;
final all = ids.isNotEmpty && ids.every(picked.contains);
_selected.value = all ? {} : ids;
}
Future<void> _cancelSelected() async {
final ids = _selected.value.toList();
if (ids.isEmpty || !mounted || !await confirmCancelFollowing(context)) {
return;
}
if (!mounted) return;
final removed = await widget.favoriteController.removeMany(ids);
if (!removed || !mounted) return;
_selected.value = {};
if (widget.favoriteController.cards.isEmpty) _editing.value = false;
}
void _exitEditingIfEmpty() => _editing.value = false;
}
class _FavoritesGrid extends StatelessWidget {
const _FavoritesGrid({
required this.controller,
required this.editing,
required this.scrollController,
required this.onGoHome,
required this.onListEmptied,
});
final FavoriteController controller;
final ValueListenable<bool> editing;
final ScrollController scrollController;
final VoidCallback onGoHome;
final VoidCallback onListEmptied;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
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();
}
},
);
},
);
},
);
},
);
}
}
class _HistoryList extends StatelessWidget {
const _HistoryList({
required this.controller,
@@ -415,7 +405,9 @@ class _HistoryList extends StatelessWidget {
title: _historyTitle(l10n, record),
posterUrl: record.displayCover,
viewCount:
record.drama?.viewCount ?? record.progress.playCount,
record.progress.viewCount ??
record.drama?.viewCount ??
record.progress.playCount,
progress: hasProgress ? progress : null,
progressLabel: hasProgress
? l10n.watchedPercent(watchedPercent)
@@ -425,7 +417,7 @@ class _HistoryList extends StatelessWidget {
onTap: openRecord,
onContinue: openRecord,
onRemove: () async {
if (await _confirmRemove(context) &&
if (await confirmHistoryRemove(context) &&
await controller.remove(record.dramaId) &&
controller.records.isEmpty) {
onListEmptied();
@@ -469,24 +461,3 @@ Future<void> _openHistory(
episodeId: record.progress.episodeId,
);
}
Future<bool> _confirmRemove(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
content: Text(l10n.confirmRemove),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.remove),
),
],
),
) ??
false;
}