// Author: fruit.com.cn import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/content_category.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/home_feed.dart'; import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/search_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_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_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; import 'category_dramas_page.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'discover_types.dart'; import 'widgets/category_grid.dart'; import 'widgets/discover_header.dart'; import 'widgets/discover_search_field.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/drama_grid.dart'; import 'widgets/feed_banner_carousel.dart'; import 'widgets/ranking_section.dart'; /// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。 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; bool get embedded => widget.callbacks.embedded; @override void initState() { super.initState(); logic = DiscoverBinding().create(search: context.read()); if (widget.focusSearch) _focusOnFirstFrame(); } 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.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 banners = context.watch().banners; final content = SafeArea( bottom: !embedded, child: Column( children: [ DiscoverHeader(onSearch: logic.openSearch, onProfile: _openProfile), Expanded(child: _feed(l10n, categories, banners)), if (!embedded) _bottomNav(l10n), ], ), ); 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, List banners, ) { return RefreshIndicator( key: const Key('discover-refresh'), color: AppColors.blue, backgroundColor: AppColors.card, onRefresh: categories.load, child: ListView( controller: logic.scroll, physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: 24), children: [ Padding( padding: const EdgeInsets.only(top: 8), child: DiscoverSearchField( controller: logic.search, focusNode: logic.searchFocus, onChanged: (_) => logic.onSearchChanged(), loading: logic.searchLoading, ), ), ..._content(l10n, categories, banners), ], ), ); } List _content( AppLocalizations l10n, CategoryController categories, List banners, ) { if (logic.hasSearchQuery) return [_searchContent(l10n)]; return _theaterContent(l10n, categories, banners); } Widget _searchContent(AppLocalizations l10n) => DiscoverSearchResults( loading: logic.searchLoading, error: logic.searchError, results: logic.searchResults, onRetry: logic.retrySearch, onOpen: _openDrama, ); List _theaterContent( AppLocalizations l10n, CategoryController categories, List banners, ) { if (categories.loading && categories.feed.sections.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: categories.load, ), ), ]; } return _theaterBlocks(l10n, categories, banners); } List _theaterBlocks( AppLocalizations l10n, CategoryController categories, List banners, ) { final dramas = categories.feed.dramas; return [ Padding( padding: const EdgeInsets.only(top: 12), child: CategoryGrid( categories: categories.categories, onTap: (category) => _openCategory(category, categories), ), ), if (banners.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 12), child: FeedBannerCarousel( banners: banners, active: widget.active, onOpen: _openBanner, ), ), if (categories.feed.sections.isNotEmpty || dramas.isNotEmpty) RankingSection( sections: categories.feed.sections, fallback: dramas, onOpen: _openDrama, ), _sectionTitle(l10n.allDramas), if (dramas.isEmpty) _emptyCatalog(l10n) else _dramaGrid(l10n, dramas), ]; } Widget _block(Widget child) => SizedBox(height: 280, child: Center(child: child)); Widget _emptyCatalog(AppLocalizations l10n) => 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, ), ); Widget _dramaGrid(AppLocalizations l10n, List dramas) { return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama); } Widget _sectionTitle(String title) { return Padding( padding: const EdgeInsets.fromLTRB(14, 18, 14, 10), child: Row( children: [ Container( width: 3, height: 18, decoration: BoxDecoration( color: AppColors.blue, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 6), Expanded( child: Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: AppDimens.previewSectionTitle, fontWeight: FontWeight.w800, ), ), ), ], ), ); } void _openProfile() { final callback = widget.callbacks.onProfile; if (callback != null) { callback(); return; } _act(DiscoverPageAction.my); } void _act(DiscoverPageAction action) { logic.act( context, action, embedded: embedded, onAction: widget.callbacks.onAction, ); } void _openCategory(ContentCategory category, CategoryController categories) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => CategoryDramasPage( title: category.name, dramas: categories.dramasOf(category.id), onOpen: widget.callbacks.onDramaSelected == null ? null : _openDrama, ), ), ); } void _openDrama(Drama drama) { final callback = widget.callbacks.onDramaSelected; if (callback != null) { callback(drama); return; } _pushDetail(drama); } void _pushDetail(Drama drama) { Navigator.of(context).push( MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), ); } Future _openBanner(FeedBanner banner) async { final dramaId = banner.dramaId; if (dramaId == null) return; for (final drama in context.read().dramas) { if (drama.id == dramaId) { _pushDetail(drama); return; } } try { final drama = await context.read().fetchDrama(dramaId); if (mounted) _pushDetail(drama); } catch (_) { if (!mounted) return; final message = AppLocalizations.of(context)!.detailLoadFailed; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } } Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), icon: Icons.play_circle_outline_rounded, selectedIcon: Icons.play_circle_fill_rounded, label: l10n.homeTab, onTap: () => _act(DiscoverPageAction.home), ), AppBottomNavItem( tabKey: const Key('discover-nav-current'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, label: l10n.discoverTab, selected: true, onTap: () {}, ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), icon: Icons.video_library_outlined, selectedIcon: Icons.video_library_rounded, label: l10n.myTab, onTap: () => _act(DiscoverPageAction.my), ), ], ); }