// 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 createState() => _FollowingPageState(); } class _FollowingPageState extends State { 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(), catalog: context.read(), ); 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().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 _dramaTags(Drama? drama) { if (drama == null) return const []; final seen = {}; 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 _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 _confirmRemove(BuildContext context) async { final l10n = AppLocalizations.of(context)!; return await showDialog( 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 Function() onRefresh; final Future 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( onNotification: _onScroll, child: AppPullToRefresh( onRefresh: onRefresh, child: CustomScrollView( key: ValueKey('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; } }