Files
ReelShort/lib/pages/following/following_page.dart
T

488 lines
16 KiB
Dart

// Author: fruit.com.cn
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';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/favorite_controller.dart';
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';
final RegExp _tagSplitter = RegExp(r'[,/、·]');
enum FollowingTab { favorites, history, reserve }
class FollowingPage extends StatefulWidget {
const FollowingPage({
super.key,
required this.onGoHome,
required this.favoriteController,
});
final VoidCallback onGoHome;
final FavoriteController favoriteController;
@override
State<FollowingPage> createState() => _FollowingPageState();
}
class _FollowingPageState extends State<FollowingPage> {
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<WatchHistoryRepository>(),
catalog: context.read<CatalogRepository>(),
);
unawaited(_history.load());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.favoriteController.load());
});
}
@override
void dispose() {
_favoriteScroll.dispose();
_historyScroll.dispose();
_history.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return ColoredBox(
key: const Key('following-page'),
color: AppColors.appBackground,
child: SafeArea(
bottom: false,
child: Column(
children: [
_header(l10n),
Expanded(
child: !signedIn
? const AuthGatePanel()
: IndexedStack(
index: _tab.index,
children: [
_FavoritesGrid(
controller: widget.favoriteController,
editing: _editing,
scrollController: _favoriteScroll,
onListEmptied: _exitEditingIfEmpty,
),
_HistoryGrid(
controller: _history,
editing: _editing,
scrollController: _historyScroll,
onListEmptied: _exitEditingIfEmpty,
),
_reserveEmpty(l10n),
],
),
),
],
),
),
);
}
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,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.feedPageHorizontal,
),
child: Row(
children: [
for (final tab in tabs)
Expanded(
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,
),
),
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,
);
}
void _exitEditingIfEmpty() {
if (_editing && mounted) setState(() => _editing = false);
}
}
class _FavoritesGrid extends StatelessWidget {
const _FavoritesGrid({
required this.controller,
required this.editing,
required this.scrollController,
required this.onListEmptied,
});
final FavoriteController controller;
final bool editing;
final ScrollController scrollController;
final VoidCallback onListEmptied;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
final l10n = AppLocalizations.of(context)!;
return _PagedGrid(
key: const Key('following-favorites-grid'),
scrollController: scrollController,
loading: controller.loading,
loadingMore: controller.loadingMore,
hasMore: controller.hasMore,
hasItems: controller.cards.isNotEmpty,
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,
empty: EmptyState(
key: const Key('following-favorites-empty'),
icon: Icons.bookmark_border_rounded,
title: l10n.noFavorites,
subtitle: l10n.emptyHint,
),
itemBuilder: (context, index) {
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,
tags: _dramaTags(drama),
editing: editing,
subtitle: l10n.favoriteEpisodeProgress(
episode,
drama.episodeCount,
),
onTap: () => unawaited(playDramaFromCatalog(context, drama)),
onRemove: () async {
if (await _confirmRemove(context)) {
await controller.remove(drama.id);
if (controller.cards.isEmpty) onListEmptied();
}
},
);
},
);
},
);
}
}
class _HistoryGrid extends StatelessWidget {
const _HistoryGrid({
required this.controller,
required this.editing,
required this.scrollController,
required this.onListEmptied,
});
final WatchRecordsController controller;
final bool editing;
final ScrollController scrollController;
final VoidCallback onListEmptied;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
final l10n = AppLocalizations.of(context)!;
return _PagedGrid(
key: const Key('following-history-grid'),
scrollController: scrollController,
loading: controller.loading,
loadingMore: controller.loadingMore,
hasMore: controller.hasMore,
hasItems: controller.records.isNotEmpty,
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 title = record.displayTitle.isEmpty
? l10n.episodeLabel(record.episodeNumber)
: record.displayTitle;
return FollowingGridCard(
dramaId: record.dramaId,
title: title,
posterUrl: record.displayCover,
viewCount: record.drama?.viewCount ?? 0,
tags: _dramaTags(record.drama),
progress: record.progress.fraction,
editing: editing,
subtitle: l10n.watchedToEpisode(record.episodeNumber),
onTap: () => unawaited(_openHistory(context, controller, record)),
onRemove: () async {
if (await _confirmRemove(context)) {
await controller.remove(record.dramaId);
if (controller.records.isEmpty) onListEmptied();
}
},
);
},
);
},
);
}
}
List<String> _dramaTags(Drama? drama) {
if (drama == null) return const [];
final seen = <String>{};
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);
}
Future<void> _openHistory(
BuildContext context,
WatchRecordsController controller,
WatchRecord record,
) async {
final drama = record.drama ?? await controller.detailForOpen(record.dramaId);
if (!context.mounted || drama == null) return;
await playDramaFromCatalog(
context,
drama,
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;
}
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<void> Function() onRefresh;
final Future<void> 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<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
onRefresh: onRefresh,
child: CustomScrollView(
key: ValueKey<String>('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;
}
}