// Author: fruit.com.cn 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/search_repository.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 '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 = Color(0xFF7B61FF); const Color _newIconColor = Color(0xFF14B8A6); 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; /// 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; 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 content = SafeArea( bottom: !embedded, child: Column( children: [ DiscoverHeader( controller: logic.search, focusNode: logic.searchFocus, onChanged: (_) => logic.onSearchChanged(), loading: logic.searchLoading, ), Expanded(child: _feed(l10n, categories)), if (!embedded) _bottomNav(l10n), ], ), ); return AnnotatedRegion( value: SystemUiOverlayStyle.dark, child: Theme( data: AppTheme.light(), child: embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), ), ); } Widget _feed(AppLocalizations l10n, CategoryController categories) { return GestureDetector( behavior: HitTestBehavior.translucent, onTap: logic.searchFocus.unfocus, child: 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: AppDimens.spaceXl), children: _content(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.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); } List _theaterBlocks( AppLocalizations l10n, CategoryController categories, ) { final dramas = logic.selectedCategoryId == null ? categories.feed.dramas : categories.dramasOf(logic.selectedCategoryId!); return [ _categoryTabs(l10n, categories), _toolbar(l10n, categories), 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), ]; } Widget _categoryTabs(AppLocalizations l10n, CategoryController categories) { return SizedBox( key: const Key('theater-category-chips'), height: _categoryTabHeight, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 14), itemCount: categories.categories.length + 1, separatorBuilder: (_, _) => const SizedBox(width: _categoryTabGap), itemBuilder: (context, index) { if (index == 0) { return _categoryTab( key: const Key('theater-category-all'), label: l10n.all, selected: logic.selectedCategoryId == null, onTap: () => logic.selectCategory(null), ); } final category = categories.categories[index - 1]; return _categoryTab( key: Key('theater-category-${category.id}'), label: category.name, selected: logic.selectedCategoryId == category.id, onTap: () => logic.selectCategory(category.id), ); }, ), ); } Widget _categoryTab({ required Key key, required String label, required bool selected, required VoidCallback onTap, }) { return GestureDetector( key: key, behavior: HitTestBehavior.opaque, onTap: onTap, child: Center( child: Text( label, maxLines: 1, style: TextStyle( color: selected ? AppColors.onSurface : AppColors.muted, fontSize: selected ? _categorySelectedFontSize : _categoryFontSize, fontWeight: selected ? FontWeight.w800 : FontWeight.w500, ), ), ), ); } Widget _toolbar(AppLocalizations l10n, CategoryController categories) { return Padding( padding: const EdgeInsets.fromLTRB(14, 10, 14, 12), child: Row( children: [ Expanded( child: _toolButton( key: const Key('theater-toolbar-filter'), icon: Icons.tune_rounded, color: _filterIconColor, label: l10n.filter, onTap: () => _openFilterSheet(l10n, categories), ), ), 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: () => _showComingSoon(l10n), ), ), 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: () => _showComingSoon(l10n), ), ), 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: () => _showComingSoon(l10n), ), ), ], ), ); } 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, ), ), ), ], ), ), ), ); } Future _openFilterSheet( AppLocalizations l10n, CategoryController categories, ) async { await showModalBottomSheet( context: context, backgroundColor: AppColors.card, showDragHandle: true, builder: (sheetContext) => SafeArea( child: ListView( key: const Key('theater-filter-sheet'), shrinkWrap: true, padding: const EdgeInsets.only(bottom: 12), children: [ ListTile( key: const Key('theater-filter-option-all'), title: Text(l10n.all), onTap: () { Navigator.pop(sheetContext); logic.selectCategory(null); }, ), for (final category in categories.categories) ListTile( key: Key('theater-filter-option-${category.id}'), title: Text(category.name), onTap: () { Navigator.pop(sheetContext); logic.selectCategory(category.id); }, ), ], ), ), ); } void _showComingSoon(AppLocalizations l10n) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.comingSoon))); } Widget _block(Widget child) => SizedBox(height: 280, child: Center(child: child)); 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; } Navigator.of(context).push( MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), ); } Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), icon: Icons.home_outlined, selectedIcon: Icons.home_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.theaterTab, selected: true, onTap: () {}, ), AppBottomNavItem( tabKey: const Key('discover-nav-create'), icon: Icons.add_box_outlined, selectedIcon: Icons.add_box_rounded, label: l10n.createTab, onTap: () => _act(DiscoverPageAction.create), ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), icon: Icons.person_outline_rounded, selectedIcon: Icons.person_rounded, label: l10n.myTab, onTap: () => _act(DiscoverPageAction.my), ), ], ); }