// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/data/models/content_category.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/services/auth_controller.dart'; import '../../../core/data/services/category_controller.dart'; import '../../../core/data/services/home_feed_controller.dart'; import '../../../core/data/services/wallet_controller.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/app_pull_to_refresh.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/paged_list_footer.dart'; import '../../../l10n/app_localizations.dart'; import 'home_drama_classification.dart'; import 'home_feed_card.dart'; import 'home_grid_header.dart'; const double _loadMoreThreshold = 240; const List _costumeAliases = ['古装', 'costume', 'period']; const List _communityAliases = ['社群', 'community', 'komunitas']; class HomeFeedGrid extends StatefulWidget { const HomeFeedGrid({ super.key, required this.feedController, required this.callbacks, required this.onOpenDrama, }); final HomeFeedController feedController; final HomeGridCallbacks callbacks; final ValueChanged onOpenDrama; @override State createState() => _HomeFeedGridState(); } class _HomeFeedGridState extends State { HomePrimaryTab _selectedPrimary = HomePrimaryTab.featured; final PageController _pageController = PageController(); final Map _scrollControllers = { for (final tab in HomePrimaryTab.values) tab: ScrollController(), }; final Map _selectedTags = { for (final tab in HomePrimaryTab.values) tab: null, }; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _pageController.hasClients) { _pageController.jumpToPage(_selectedPrimary.index); } }); } @override void dispose() { _pageController.dispose(); for (final controller in _scrollControllers.values) { controller.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch().categories; final auth = context.watch(); final wallet = context.watch(); final shortcuts = _categoryShortcuts(categories, l10n); final tags = _availableTags(l10n); final activeTag = tags.any((tag) => tag.id == _selectedTags[_selectedPrimary]) ? _selectedTags[_selectedPrimary] : null; final walletBadge = auth.isSignedIn && wallet.snapshot != null ? wallet.balance : null; return ColoredBox( key: const Key('home-grid'), color: AppColors.appBackground, child: SafeArea( bottom: false, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ HomeGridHeader( callbacks: widget.callbacks, categories: shortcuts, tags: tags, selectedPrimary: _selectedPrimary, selectedTag: activeTag, onPrimarySelected: _selectPrimary, onTagSelected: (tag) => setState(() => _selectedTags[_selectedPrimary] = tag), walletBadge: walletBadge, ), Expanded( child: ListenableBuilder( listenable: widget.feedController, builder: (context, _) => PageView.builder( key: const Key('home-primary-page-view'), controller: _pageController, itemCount: HomePrimaryTab.values.length, onPageChanged: (index) => setState( () => _selectedPrimary = HomePrimaryTab.values[index], ), itemBuilder: (context, index) => _KeepAlivePage( key: PageStorageKey( 'home-primary-page-${HomePrimaryTab.values[index].name}', ), child: _tabGrid( context, l10n, tab: HomePrimaryTab.values[index], shortcuts: shortcuts, ), ), ), ), ), ], ), ), ); } void _selectPrimary(HomePrimaryTab tab) { if (tab == _selectedPrimary) return; if (!_pageController.hasClients) { setState(() => _selectedPrimary = tab); return; } unawaited( _pageController.animateToPage( tab.index, duration: AppDimens.homePrimaryTabAnimationDuration, curve: Curves.easeOut, ), ); } Widget _tabGrid( BuildContext context, AppLocalizations l10n, { required HomePrimaryTab tab, required List shortcuts, }) { if (widget.feedController.loadingFirstPage) { return const Center( child: SizedBox.square( dimension: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ); } final tag = _selectedTags[tab]; final dramas = _dramasFor(tab, tag, shortcuts); return LayoutBuilder( builder: (context, constraints) { final itemWidth = (constraints.maxWidth - AppDimens.feedPageHorizontal * 2 - AppDimens.homeGridGap * 2) / 3; final itemExtent = itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.homeCardBodyExtent; return NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( key: const Key('home-grid-refresh'), onRefresh: _refresh, child: CustomScrollView( key: const Key('home-grid-scroll'), controller: _scrollControllers[tab], physics: AppPullToRefresh.physics, slivers: [ if (dramas.isEmpty) SliverFillRemaining( hasScrollBody: false, child: _emptyState(l10n, tab, tag), ) else SliverPadding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), sliver: SliverGrid( key: const Key('home-grid-cards'), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: AppDimens.homeGridGap, mainAxisSpacing: AppDimens.homeGridRowGap, mainAxisExtent: itemExtent, ), delegate: SliverChildBuilderDelegate( (context, index) => HomeFeedCard( drama: dramas[index], onTap: () => widget.onOpenDrama(dramas[index]), ), childCount: dramas.length, ), ), ), if (dramas.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), child: PagedListFooter( loading: widget.feedController.loadingMore, hasMore: widget.feedController.hasMore, hasItems: dramas.isNotEmpty, errorMessage: widget.feedController.lastError == null ? null : l10n.catalogError, onRetry: widget.feedController.loadMore, ), ), ), ], ), ), ); }, ); } Future _refresh() async { await widget.feedController.refreshCurrent(); } bool _onScroll(ScrollNotification notification) { if (notification.depth != 0) return false; final reachedBottom = notification.metrics.extentAfter <= _loadMoreThreshold; if (reachedBottom && (notification is ScrollUpdateNotification || notification is ScrollEndNotification || notification is OverscrollNotification)) { unawaited(widget.feedController.loadMore()); } return false; } List _categoryShortcuts( List categories, AppLocalizations l10n, ) { final costume = _findCategory(categories, _costumeAliases); final community = _findCategory(categories, _communityAliases); return [ HomeGridCategoryShortcut( label: l10n.homeSectionCostume, categoryName: costume?.name ?? '', tab: HomePrimaryTab.costume, ), HomeGridCategoryShortcut( label: l10n.homeSectionCommunity, categoryName: community?.name ?? '', tab: HomePrimaryTab.community, ), ]; } ContentCategory? _findCategory( List categories, List aliases, ) { for (final category in categories) { final name = category.name.toLowerCase(); if (aliases.any(name.contains)) return category; } return null; } List _availableTags(AppLocalizations l10n) { // UI 先完整展示筛选维度;后续接口补齐题材字段时只替换数据来源。 return [ HomeGridTagOption(id: 'hot', label: l10n.homeTagHot), HomeGridTagOption(id: 'uplifting', label: l10n.homeTagUplifting), HomeGridTagOption(id: 'sweet', label: l10n.homeTagSweet), HomeGridTagOption(id: 'faceSlap', label: l10n.homeTagFaceSlap), HomeGridTagOption(id: 'angst', label: l10n.homeTagAngst), ]; } List _dramasFor( HomePrimaryTab tab, String? tag, List shortcuts, ) { Iterable source = widget.feedController.dramas; if (tab == HomePrimaryTab.costume || tab == HomePrimaryTab.community) { final categoryName = shortcuts .firstWhere((shortcut) => shortcut.tab == tab) .categoryName .toLowerCase(); if (categoryName.isEmpty) return const []; source = source.where( (drama) => drama.category.toLowerCase().contains(categoryName) || drama.genre.toLowerCase().contains(categoryName), ); } if (tag != null) { source = source.where((drama) => matchesHomeTag(drama, tag)); } final dramas = source.toList(growable: false); switch (tab) { case HomePrimaryTab.newReleases: return [...dramas]..sort((a, b) { final left = publishedAtForDrama(a); final right = publishedAtForDrama(b); if (left == null && right == null) return b.id.compareTo(a.id); if (left == null) return 1; if (right == null) return -1; return right.compareTo(left); }); case HomePrimaryTab.ranking: return [...dramas]..sort((a, b) => b.viewCount.compareTo(a.viewCount)); case HomePrimaryTab.featured: case HomePrimaryTab.categories: case HomePrimaryTab.costume: case HomePrimaryTab.community: return dramas; } } Widget _emptyState(AppLocalizations l10n, HomePrimaryTab tab, String? tag) { if (widget.feedController.lastError != null) { return EmptyState( key: const Key('home-grid-error'), icon: Icons.cloud_off_rounded, title: l10n.catalogError, actionLabel: l10n.retry, onAction: () => unawaited(widget.feedController.refreshCurrent()), ); } final filtered = tab == HomePrimaryTab.costume || tab == HomePrimaryTab.community || tag != null; return EmptyState( key: const Key('home-grid-empty'), icon: Icons.movie_filter_outlined, title: filtered ? l10n.homeGridFilterEmpty : l10n.noStories, subtitle: filtered ? null : l10n.emptyHint, actionLabel: filtered ? l10n.homeGridClearFilter : null, onAction: filtered ? () => setState(() => _selectedTags[tab] = null) : null, ); } } class _KeepAlivePage extends StatefulWidget { const _KeepAlivePage({super.key, required this.child}); final Widget child; @override State<_KeepAlivePage> createState() => _KeepAlivePageState(); } class _KeepAlivePageState extends State<_KeepAlivePage> with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); return widget.child; } }