// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/search_repository.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/data/services/theater_feed_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'discover_types.dart'; import 'widgets/discover_category_tab.dart'; import 'widgets/discover_header.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/discover_toolbar.dart'; import 'widgets/drama_grid.dart'; const double _categoryTabHeight = 44; const double _categoryTabGap = 20; const double _stickyHeaderHeight = 44; const Duration _stickyHeaderTransition = Duration(milliseconds: 160); const Duration _categoryScrollDuration = Duration(milliseconds: 200); /// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, this.active = true, this.callbacks = const DiscoverPageCallbacks(), }); final bool active; final DiscoverPageCallbacks callbacks; @override State createState() => _DiscoverPageState(); } class _DiscoverPageState extends State { late final DiscoverLogic logic; late final TheaterFeedController theater; final ValueNotifier _headerPinned = ValueNotifier(false); final ScrollController _categoryScroll = ScrollController(); final Map _categoryAnchors = {}; String? _ensuredCategoryId; bool _categoryRevealScheduled = false; bool get embedded => widget.callbacks.embedded; @override void initState() { super.initState(); logic = DiscoverBinding().create(search: context.read()); theater = TheaterFeedController(context.read()); logic.scroll.addListener(_onScroll); theater.addListener(_onTheaterChanged); unawaited(theater.select(null)); } void _onScroll() { final pinned = logic.scroll.hasClients && logic.scroll.offset > 0; if (pinned != _headerPinned.value) _headerPinned.value = pinned; if (logic.scroll.hasClients && !logic.hasSearchQuery && logic.scroll.position.extentAfter < 400) { unawaited(theater.loadMore()); } } void _onTheaterChanged() { if (mounted) setState(() {}); } @override void dispose() { logic.scroll.removeListener(_onScroll); theater.removeListener(_onTheaterChanged); theater.dispose(); _categoryScroll.dispose(); _headerPinned.dispose(); logic.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: logic, builder: (context, _) => _page(context), ); } Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); final content = ColoredBox( key: const Key('theater-page-surface'), color: AppColors.ink, child: SafeArea( bottom: false, child: Column( children: [ DiscoverHeader( controller: logic.search, focusNode: logic.searchFocus, onChanged: (_) => logic.onSearchChanged(), loading: logic.searchLoading, ), Expanded(child: _feed(l10n, categories)), if (!embedded) _bottomNav(), ], ), ), ); return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: Theme( data: AppTheme.dark(), child: embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), ), ); } Widget _feed(AppLocalizations l10n, CategoryController categories) { final showHeader = _showStickyHeader(categories); return GestureDetector( behavior: HitTestBehavior.translucent, onTap: logic.searchFocus.unfocus, child: Stack( children: [ Positioned.fill( child: AppPullToRefresh( key: const Key('discover-refresh'), edgeOffset: _stickyHeaderHeight + 8, onRefresh: () => Future.wait([categories.load(), theater.refreshCurrent()]), child: CustomScrollView( controller: logic.scroll, physics: AppPullToRefresh.physics, slivers: [ if (showHeader) const SliverToBoxAdapter( child: SizedBox(height: _stickyHeaderHeight), ), ..._content(l10n, categories), const SliverToBoxAdapter( child: SizedBox(height: AppDimens.spaceXl), ), ], ), ), ), if (showHeader) Positioned( top: 0, left: 0, right: 0, child: _stickyHeader(l10n, categories), ), ], ), ); } bool _showStickyHeader(CategoryController categories) { if (logic.hasSearchQuery || categories.error != null) return false; return !(categories.loading && categories.categories.isEmpty); } Widget _stickyHeader(AppLocalizations l10n, CategoryController categories) { // 背景常驻 ink,只有分隔线/阴影随吸顶状态过渡,避免透明→实色的闪动。 return ValueListenableBuilder( valueListenable: _headerPinned, builder: (context, pinned, child) => AnimatedContainer( key: const Key('theater-sticky-header'), duration: _stickyHeaderTransition, curve: Curves.easeOut, decoration: BoxDecoration( color: AppColors.ink, border: Border( bottom: BorderSide( color: pinned ? AppColors.border : AppColors.transparent, ), ), boxShadow: pinned ? const [ BoxShadow( color: AppColors.shadowSubtle, blurRadius: 8, offset: Offset(0, 2), ), ] : const [], ), child: child, ), child: KeyedSubtree( key: const Key('theater-sticky-content'), child: _categoryTabs(l10n, categories), ), ); } List _content(AppLocalizations l10n, CategoryController categories) { if (logic.hasSearchQuery) { return [ DiscoverSearchResults( loading: logic.searchLoading, error: logic.searchError, results: logic.searchResults, onRetry: logic.retrySearch, onOpen: _openDrama, ), ]; } if (categories.loading && categories.categories.isEmpty) { return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))]; } final error = categories.error; if (error != null) { return [ _sliverBlock( EmptyState( key: const Key('home-categories-error'), icon: Icons.cloud_off_rounded, title: errorText(l10n, error, l10n.categoriesError), subtitle: l10n.emptyHint, actionLabel: l10n.retry, onAction: () => unawaited(_retryCategories(categories)), footer: kDebugMode ? CatalogDebugInfo( error: categories.debugError ?? error, onEditEndpoint: () => unawaited(_editCategoriesEndpoint(categories)), ) : null, ), ), ]; } if (theater.loadingFirstPage) { return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))]; } final theaterError = theater.lastError; if (theaterError != null && theater.dramas.isEmpty) { return [ _sliverBlock( EmptyState( key: const Key('home-categories-error'), icon: Icons.cloud_off_rounded, title: errorText(l10n, theaterError, l10n.catalogError), subtitle: l10n.emptyHint, actionLabel: l10n.retry, onAction: () => unawaited(_retryTheater()), footer: kDebugMode ? CatalogDebugInfo( error: theaterError, onEditEndpoint: () => unawaited(_editTheaterEndpoint()), ) : null, ), ), ]; } return _theaterBlocks(l10n); } List _theaterBlocks(AppLocalizations l10n) { final dramas = theater.dramas; return [ SliverToBoxAdapter(child: _toolbar()), if (dramas.isEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(vertical: 40), child: EmptyState( key: const Key('discover-empty'), icon: Icons.movie_filter_outlined, title: l10n.noStories, subtitle: l10n.emptyHint, ), ), ) else SliverDramaGrid( key: const Key('home-all-dramas-grid'), l10n: l10n, dramas: dramas, onOpen: _openDrama, ), if (theater.loadingMore) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Center( child: SizedBox.square( key: Key('theater-load-more'), dimension: 22, child: CircularProgressIndicator(strokeWidth: 2), ), ), ), ) else if (dramas.isNotEmpty && !theater.hasMore) SliverToBoxAdapter( child: Padding( key: const Key('paged-list-no-more'), padding: const EdgeInsets.symmetric(vertical: 16), child: Text( l10n.noMoreItems, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.textHint, fontSize: 12), ), ), ), ]; } Widget _categoryTabs(AppLocalizations l10n, CategoryController categories) { _scheduleSelectedCategoryReveal(); return SizedBox( key: const Key('theater-category-chips'), height: _categoryTabHeight, child: SingleChildScrollView( key: const Key('theater-category-scroll'), controller: _categoryScroll, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( mainAxisSize: MainAxisSize.min, children: [ DiscoverCategoryTab( anchorKey: _anchorFor('all'), id: 'all', label: l10n.all, selected: logic.selectedCategoryId == null, onTap: () => _selectCategory(null), ), for (final category in categories.categories) ...[ const SizedBox(width: _categoryTabGap), DiscoverCategoryTab( anchorKey: _anchorFor(category.id), id: category.id, label: category.name, selected: logic.selectedCategoryId == category.id, onTap: () => _selectCategory(category.id), ), ], ], ), ), ); } GlobalKey _anchorFor(String id) { return _categoryAnchors.putIfAbsent(id, GlobalKey.new); } void _selectCategory(String? categoryId) { logic.selectCategory(categoryId); unawaited(theater.select(categoryId)); final id = categoryId ?? 'all'; _ensuredCategoryId = id; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(_revealCategory(id)); }); } void _scheduleSelectedCategoryReveal() { final selectedId = logic.selectedCategoryId ?? 'all'; if (_categoryRevealScheduled || _ensuredCategoryId == selectedId) return; _categoryRevealScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) async { _categoryRevealScheduled = false; if (!mounted || (logic.selectedCategoryId ?? 'all') != selectedId) return; final revealed = await _revealCategory(selectedId); if (revealed) _ensuredCategoryId = selectedId; }); } Future _revealCategory(String id) async { final target = _categoryAnchors[id]?.currentContext; if (target == null) return false; await Scrollable.ensureVisible( target, alignment: 0.5, duration: _categoryScrollDuration, curve: Curves.easeOutCubic, ); return true; } Widget _toolbar() { return DiscoverToolbar(onOpen: (page) => _openPage(context, page)); } void _openPage(BuildContext context, Widget page) { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } Widget _sliverBlock(Widget child) => SliverToBoxAdapter(child: _block(child)); Widget _block(Widget child) => ConstrainedBox( constraints: const BoxConstraints(minHeight: 280), child: Center(child: child), ); Future _retryCategories(CategoryController categories) { return categories.load(); } Future _retryTheater() { return theater.refreshCurrent(); } Future _editTheaterEndpoint() { return showCatalogEndpointEditor( context, reload: () async { await theater.refreshCurrent(); }, ); } Future _editCategoriesEndpoint(CategoryController categories) { return showCatalogEndpointEditor(context, reload: categories.load); } void _act(DiscoverPageAction action) { logic.act( context, action, embedded: embedded, onAction: widget.callbacks.onAction, ); } void _openDrama(Drama drama) { final callback = widget.callbacks.onDramaSelected; if (callback != null) { callback(drama); return; } unawaited(playDramaFromCatalog(context, drama)); } Widget _bottomNav() => AppRootBottomNav( selected: AppRootTab.recommend, onSelected: (tab) => _act(_actionOf(tab)), ); DiscoverPageAction _actionOf(AppRootTab tab) => switch (tab) { AppRootTab.home => DiscoverPageAction.home, AppRootTab.recommend => DiscoverPageAction.recommend, AppRootTab.following => DiscoverPageAction.following, AppRootTab.mine => DiscoverPageAction.mine, }; }