feat(app): 完善播放协议搜索与空状态
This commit is contained in:
1 parent
671e0ce143
commit
ef29fc5f15
85 files changed
+2976
-768
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user