// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.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/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_history_card.dart'; import 'following_keep_alive_tab.dart'; import 'following_paged_grid.dart'; enum FollowingTab { favorites, history } 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 { final PageController _pageController = PageController(); final ScrollController _favoriteScroll = ScrollController(); final ScrollController _historyScroll = ScrollController(); final ValueNotifier _tab = ValueNotifier( FollowingTab.favorites, ); final ValueNotifier _editing = ValueNotifier(false); late final WatchRecordsController _history; @override void initState() { super.initState(); _history = WatchRecordsController( history: context.read(), catalog: context.read(), ); unawaited(_history.load()); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(widget.favoriteController.load()); }); } @override void dispose() { _pageController.dispose(); _favoriteScroll.dispose(); _historyScroll.dispose(); _tab.dispose(); _editing.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() : PageView( key: const Key('following-tab-pages'), controller: _pageController, onPageChanged: _onPageChanged, children: [ FollowingKeepAliveTab( child: _FavoritesGrid( controller: widget.favoriteController, editing: _editing, scrollController: _favoriteScroll, onGoHome: widget.onGoHome, onListEmptied: _exitEditingIfEmpty, ), ), FollowingKeepAliveTab( child: _HistoryList( controller: _history, editing: _editing, scrollController: _historyScroll, onListEmptied: _exitEditingIfEmpty, ), ), ], ), ), ], ), ), ); } Widget _header(AppLocalizations l10n) { return SizedBox( key: const Key('following-header'), height: AppDimens.followingHeaderHeight, child: Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), child: ValueListenableBuilder( valueListenable: _tab, builder: (context, selectedTab, _) { return ValueListenableBuilder( valueListenable: _editing, builder: (context, editing, _) { return Row( children: [ _tabButton( FollowingTab.favorites, l10n.followingTab, 'favorites', selectedTab == FollowingTab.favorites, ), 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, ), ), ), ), ) 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, bool selected, ) { return InkWell( key: Key('following-tab-$keyName'), onTap: () => _selectTab(tab), child: SizedBox( height: AppDimens.followingHeaderHeight, child: Center( child: AnimatedDefaultTextStyle( duration: AppDimens.followingTabAnimationDuration, style: TextStyle( color: selected ? AppColors.textPrimary : AppColors.textTabInactive, fontSize: AppDimens.followingTabFont, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), child: Text(label, maxLines: 1), ), ), ), ); } void _selectTab(FollowingTab tab) { _editing.value = false; _tab.value = tab; if (_pageController.hasClients) { unawaited( _pageController.animateToPage( tab.index, duration: AppDimens.followingPageAnimationDuration, curve: Curves.easeOutCubic, ), ); } } void _onPageChanged(int index) { final tab = FollowingTab.values[index]; _editing.value = false; _tab.value = tab; } void _toggleEditing() { _editing.value = !_editing.value; } 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 editing; final ScrollController scrollController; final VoidCallback onGoHome; final VoidCallback onListEmptied; @override Widget build(BuildContext context) { return ValueListenableBuilder( 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, required this.editing, required this.scrollController, required this.onListEmptied, }); final WatchRecordsController controller; final ValueListenable editing; final ScrollController scrollController; final VoidCallback onListEmptied; @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: editing, builder: (context, isEditing, _) { return ListenableBuilder( listenable: controller, builder: (context, _) { final l10n = AppLocalizations.of(context)!; return FollowingPagedGrid( key: const Key('following-history-grid'), scrollController: scrollController, loading: controller.loading, loadingMore: controller.loadingMore, hasMore: controller.hasMore, hasItems: controller.records.isNotEmpty, singleColumn: true, 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 progress = record.progress.fraction; final hasProgress = record.progress.duration > Duration.zero; final watchedPercent = (progress * 100) .round() .clamp(0, 100) .toInt(); void openRecord() { unawaited(_openHistory(context, controller, record)); } 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(); } }, ); }, ); }, ); }, ); } } String _historyTitle(AppLocalizations l10n, WatchRecord record) { return record.displayTitle.isEmpty ? l10n.episodeLabel(record.episodeNumber) : record.displayTitle; } String _historySubtitle(AppLocalizations l10n, WatchRecord record) { final total = record.progress.totalEpisodes ?? record.drama?.episodeCount ?? 0; if (record.episodeNumber <= 0 || total <= 0) { return l10n.watchedToEpisode(record.episodeNumber); } return l10n.favoriteEpisodeProgress(record.episodeNumber, total); } 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; }