// 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_edit_bar.dart'; import 'following_favorites_grid.dart'; import 'following_history_card.dart'; import 'following_keep_alive_tab.dart'; import 'following_paged_grid.dart'; const double _doneFontSize = 14; const double _doneTextGap = 12; class FollowingPage extends StatefulWidget { const FollowingPage({ super.key, required this.onGoHome, required this.favoriteController, this.historyRequest = 0, }); final VoidCallback onGoHome; final FavoriteController favoriteController; final int historyRequest; @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); final ValueNotifier> _selected = ValueNotifier({}); late final WatchRecordsController _history; late final AuthController _auth; bool _wasSignedIn = false; @override void initState() { super.initState(); _auth = context.read(); _wasSignedIn = _auth.isSignedIn; _auth.addListener(_onAuthChanged); _history = WatchRecordsController( history: context.read(), catalog: context.read(), ); 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(); _favoriteScroll.dispose(); _historyScroll.dispose(); _tab.dispose(); _editing.dispose(); _selected.dispose(); _auth.removeListener(_onAuthChanged); _history.dispose(); super.dispose(); } @override void didUpdateWidget(covariant FollowingPage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.historyRequest == widget.historyRequest) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _selectTab(FollowingTab.history); }); } @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: FollowingFavoritesGrid( controller: widget.favoriteController, selection: FollowingFavoriteSelection( editing: _editing, selected: _selected, onToggle: _toggleDrama, ), scrollController: _favoriteScroll, onGoHome: widget.onGoHome, ), ), FollowingKeepAliveTab( child: _HistoryList( controller: _history, editing: _editing, scrollController: _historyScroll, onListEmptied: _exitEditingIfEmpty, ), ), ], ), ), if (signedIn) FollowingEditSlot( tab: _tab, editing: _editing, favorites: widget.favoriteController, selected: _selected, actions: FollowingEditActions( onToggleAll: _toggleAll, onRemove: () => unawaited(_cancelSelected()), ), ), ], ), ), ); } 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: [ 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, ), ), ], ), ), if (editing) 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, ), ), ), ) 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: Align( alignment: Alignment.centerLeft, widthFactor: 1, 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, overflow: TextOverflow.ellipsis), ), ), ), ); } void _selectTab(FollowingTab tab) { _leaveEditing(); _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]; _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 ids) { final picked = _selected.value; final all = ids.isNotEmpty && ids.every(picked.contains); _selected.value = all ? {} : ids; } Future _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 _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, Localizations.localeOf(context).languageCode, ), posterUrl: record.displayCover, viewCount: record.progress.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 confirmHistoryRemove(context) && await controller.remove(record.dramaId) && controller.records.isEmpty) { onListEmptied(); } }, ); }, ); }, ); }, ); } } String _historyTitle( AppLocalizations l10n, WatchRecord record, String languageCode, ) { final title = record.localizedDisplayTitle(languageCode); return title.isEmpty ? l10n.episodeLabel(record.episodeNumber) : title; } 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, ); }