// 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/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/search_page.dart'; import '../player/open_player.dart'; import '../recommend/widgets/search_gift_glyphs.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'; class FollowingPage extends StatefulWidget { const FollowingPage({ super.key, required this.onGoHome, required this.favoriteController, this.historyRequest = 0, this.visible = true, }); final VoidCallback onGoHome; final FavoriteController favoriteController; final int historyRequest; /// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证横幅是最新进度。 final bool visible; @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(), ); // IndexedStack 启动时就会建好本页:不可见时先不拉数据,等切到本 tab 再加载, // 免得冷启动和首页 feed 抢网络。 if (widget.visible) { unawaited(_history.load()); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(widget.favoriteController.load()); }); } } void _onAuthChanged() { final signedIn = _auth.isSignedIn; if (signedIn == _wasSignedIn) return; _wasSignedIn = signedIn; _leaveEditing(); _history.reset(); widget.favoriteController.clear(); unawaited(_reloadForIdentity()); } Future _reloadForIdentity() async { await Future.wait([_history.load(), widget.favoriteController.load()]); } @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.visible && widget.visible) { unawaited(_history.load()); // 启动时本页不可见,收藏没在这里拉过;首次可见时补齐(已有数据就不重复请求)。 // 放在帧后,避免构建期间触发 controller 通知。 if (widget.favoriteController.cards.isEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(widget.favoriteController.load()); }); } } if (oldWidget.historyRequest == widget.historyRequest) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _selectTab(FollowingTab.history); }); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return DecoratedBox( key: const Key('following-page'), // 纯黑底 + 顶部暗红光晕,与设计稿一致。 decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ AppColors.followingGlow, AppColors.followingGlowMid, AppColors.appBackground, ], stops: [0, 0.14, 0.3], ), ), child: SafeArea( bottom: false, child: Column( children: [ _header(l10n), Expanded( child: PageView( key: const Key('following-tab-pages'), controller: _pageController, onPageChanged: _onPageChanged, children: [ FollowingKeepAliveTab( child: FollowingFavoritesGrid( controller: widget.favoriteController, history: _history, selection: FollowingFavoriteSelection( editing: _editing, selected: _selected, onToggle: _toggleDrama, ), scrollController: _favoriteScroll, onGoHome: widget.onGoHome, onContinueRecord: (record) => unawaited( _openHistory(context, _history, record), ), ), ), FollowingKeepAliveTab( child: _HistoryList( controller: _history, editing: _editing, scrollController: _historyScroll, onListEmptied: _exitEditingIfEmpty, ), ), ], ), ), FollowingEditSlot( tab: _tab, editing: _editing, favorites: widget.favoriteController, selected: _selected, actions: FollowingEditActions( onToggleAll: _toggleAll, onRemove: () => unawaited(_cancelSelected()), ), ), ], ), ), ); } Widget _header(AppLocalizations l10n) { return Padding( key: const Key('following-header'), padding: const EdgeInsets.fromLTRB( AppDimens.feedPageHorizontal, AppDimens.followingTitleTopGap, AppDimens.feedPageHorizontal, 0, ), child: Column( children: [ SizedBox( height: AppDimens.followingTitleRowHeight, child: Row( children: [ Expanded( child: Text( l10n.followingTab, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textPrimary, fontSize: AppDimens.followingTitleFont, fontWeight: FontWeight.w400, height: 1.0, ), ), ), _searchButton(l10n), const SizedBox(width: AppDimens.followingHeaderActionGap), _manageButton(l10n), ], ), ), SizedBox( height: AppDimens.followingSegmentRowHeight, child: ValueListenableBuilder( valueListenable: _tab, builder: (context, selectedTab, _) { return Row( children: [ ListenableBuilder( listenable: widget.favoriteController, builder: (context, _) { return _segment( FollowingTab.favorites, Text( '${l10n.myFavorites} ${widget.favoriteController.total}', maxLines: 1, ), 'favorites', selectedTab == FollowingTab.favorites, ); }, ), const SizedBox(width: AppDimens.followingSegmentGap), _segment( FollowingTab.history, Text(l10n.watchHistory, maxLines: 1), 'history', selectedTab == FollowingTab.history, ), ], ); }, ), ), ], ), ); } Widget _searchButton(AppLocalizations l10n) { return Semantics( button: true, label: l10n.search, child: InkWell( key: const Key('following-search'), borderRadius: BorderRadius.circular(8), onTap: () { Navigator.of(context).push( MaterialPageRoute(builder: (_) => const SearchPage()), ); }, child: Padding( padding: EdgeInsets.all(4), child: SearchGlyph( size: AppDimens.followingSearchIcon, color: AppColors.textPrimary, ), ), ), ); } Widget _manageButton(AppLocalizations l10n) { return ValueListenableBuilder( valueListenable: _editing, builder: (context, editing, _) { return InkWell( key: const Key('following-edit'), borderRadius: BorderRadius.circular(8), onTap: _toggleEditing, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8), child: Text( editing ? l10n.done : l10n.manage, style: const TextStyle( color: AppColors.textPrimary, fontSize: AppDimens.followingManageFont, fontWeight: FontWeight.w400, height: 1.0, ), ), ), ); }, ); } Widget _segment( FollowingTab tab, Widget label, String keyName, bool selected, ) { return InkWell( key: Key('following-tab-$keyName'), onTap: () => _selectTab(tab), borderRadius: BorderRadius.circular(8), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedDefaultTextStyle( duration: AppDimens.followingTabAnimationDuration, style: TextStyle( color: selected ? AppColors.textPrimary : AppColors.textTabInactive, fontSize: AppDimens.followingSegmentFont, fontWeight: FontWeight.w400, ), child: label, ), const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap), AnimatedContainer( duration: AppDimens.followingTabAnimationDuration, curve: Curves.easeOut, width: selected ? AppDimens.followingSegmentIndicatorWidth : 0, height: AppDimens.followingSegmentIndicatorHeight, decoration: BoxDecoration( color: AppColors.brandRed, borderRadius: BorderRadius.circular( AppDimens.followingSegmentIndicatorRadius, ), ), ), ], ), ); } 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) { 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) 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 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, ); }