// 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/catalog_retry.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 '../filter/filter_page.dart'; import '../new_releases/new_releases_page.dart'; import '../player/open_player.dart'; import '../ranking/ranking_page.dart'; import '../reserve/reserve_page.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'discover_types.dart'; import 'widgets/discover_header.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/drama_grid.dart'; const Color _filterIconColor = AppColors.blueSoft; const Color _newIconColor = AppColors.blueSoft; const double _toolbarHeight = 36; const double _toolbarGap = 10; const double _toolbarRadius = 10; const double _categoryTabHeight = 44; const double _categoryTabGap = 20; const double _categorySelectedFontSize = 16; const double _categoryFontSize = 15; const double _categoryUnderlineWidth = 20; const double _categoryUnderlineHeight = 3; const double _categoryUnderlineStrokeWidth = 2; 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.searchRequest = 0, this.focusSearch = false, this.callbacks = const DiscoverPageCallbacks(), }); final bool active; final int searchRequest; final bool focusSearch; 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)); if (widget.focusSearch) _focusOnFirstFrame(); } 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(() {}); } void _focusOnFirstFrame() { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && widget.active) logic.openSearch(); }); } @override void didUpdateWidget(covariant DiscoverPage oldWidget) { super.didUpdateWidget(oldWidget); logic.syncSearch( active: widget.active, request: widget.searchRequest, previousRequest: oldWidget.searchRequest, isActive: () => mounted && widget.active, ); } @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: ListView( controller: logic.scroll, physics: AppPullToRefresh.physics, padding: EdgeInsets.only( top: showHeader ? _stickyHeaderHeight : 0, bottom: AppDimens.spaceXl, ), children: _content(l10n, categories), ), ), ), 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 [_block(const CircularProgressIndicator(strokeWidth: 2))]; } final error = categories.error; if (error != null) { return [ _block( 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 [_block(const CircularProgressIndicator(strokeWidth: 2))]; } final theaterError = theater.lastError; if (theaterError != null && theater.dramas.isEmpty) { return [ _block( 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 [ _toolbar(l10n), if (dramas.isEmpty) 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 DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama), if (theater.loadingMore) const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Center( child: SizedBox.square( key: Key('theater-load-more'), dimension: 22, child: CircularProgressIndicator(strokeWidth: 2), ), ), ), ]; } 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: [ _categoryTab( key: const Key('theater-category-all'), 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), _categoryTab( key: Key('theater-category-${category.id}'), anchorKey: _anchorFor(category.id), id: category.id, label: category.name, selected: logic.selectedCategoryId == category.id, onTap: () => _selectCategory(category.id), ), ], ], ), ), ); } Widget _categoryTab({ required Key key, required GlobalKey anchorKey, required String id, required String label, required bool selected, required VoidCallback onTap, }) { return KeyedSubtree( key: anchorKey, child: GestureDetector( key: key, behavior: HitTestBehavior.opaque, onTap: onTap, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( label, maxLines: 1, style: TextStyle( color: selected ? AppColors.onSurface : AppColors.muted, fontSize: selected ? _categorySelectedFontSize : _categoryFontSize, fontWeight: selected ? FontWeight.w800 : FontWeight.w500, ), ), const SizedBox(height: 4), CustomPaint( key: Key('theater-category-underline-$id'), size: const Size( _categoryUnderlineWidth, _categoryUnderlineHeight, ), painter: CategoryUnderlinePainter( color: selected ? AppColors.blue : AppColors.transparent, ), ), ], ), ), ); } 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(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.fromLTRB(14, 2, 14, 12), child: Row( children: [ Expanded( child: _toolButton( key: const Key('theater-toolbar-filter'), icon: Icons.tune_rounded, color: _filterIconColor, label: l10n.filter, onTap: () => _openPage(context, const FilterPage()), ), ), const SizedBox(width: _toolbarGap), Expanded( child: _toolButton( key: const Key('theater-toolbar-ranking'), icon: Icons.local_fire_department_rounded, color: AppColors.blue, label: l10n.rankingTab, onTap: () => _openPage(context, const RankingPage()), ), ), const SizedBox(width: _toolbarGap), Expanded( child: _toolButton( key: const Key('theater-toolbar-new'), icon: Icons.play_circle_fill_rounded, color: _newIconColor, label: l10n.newDramas, onTap: () => _openPage(context, const NewReleasesPage()), ), ), const SizedBox(width: _toolbarGap), Expanded( child: _toolButton( key: const Key('theater-toolbar-reserve'), icon: Icons.calendar_month_rounded, color: AppColors.blue, label: l10n.reserve, onTap: () => _openPage(context, const ReservePage()), ), ), ], ), ); } Widget _toolButton({ required Key key, required IconData icon, required Color color, required String label, required VoidCallback onTap, }) { return Material( color: AppColors.card, borderRadius: BorderRadius.circular(_toolbarRadius), child: InkWell( key: key, onTap: onTap, borderRadius: BorderRadius.circular(_toolbarRadius), child: Container( height: _toolbarHeight, decoration: BoxDecoration( borderRadius: BorderRadius.circular(_toolbarRadius), border: Border.all(color: AppColors.border), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, size: 16, color: color), const SizedBox(width: 4), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ], ), ), ), ); } void _openPage(BuildContext context, Widget page) { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } Widget _block(Widget child) => ConstrainedBox( constraints: const BoxConstraints(minHeight: 280), child: Center(child: child), ); Future _retryCategories(CategoryController categories) { return retryCatalogLoad(reload: categories.load); } Future _retryTheater() async { await retryCatalogLoad( reload: () async { await 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, }; } /// 分类 tab 的弧形选中线。 class CategoryUnderlinePainter extends CustomPainter { const CategoryUnderlinePainter({required this.color}); final Color color; @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..style = PaintingStyle.stroke ..strokeWidth = _categoryUnderlineStrokeWidth ..strokeCap = StrokeCap.round; final path = Path() ..moveTo(1, 1) ..quadraticBezierTo(size.width / 2, size.height + 1, size.width - 1, 1); canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant CategoryUnderlinePainter oldDelegate) => color != oldDelegate.color; }