// 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/catalog_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/drama_grid_skeleton.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/paged_list_footer.dart'; import '../../../l10n/app_localizations.dart'; import 'home_category_dramas.dart'; import 'home_feed_card.dart'; import 'home_grid_header.dart'; import 'home_ranking_dramas.dart'; const double _loadMoreThreshold = 240; 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 { int _sectionIndex = 0; final PageController _pageController = PageController(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _pageController.hasClients) { _pageController.jumpToPage(_sectionIndex); } }); } @override void dispose() { _pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch().categories; final catalog = context.watch(); final auth = context.watch(); final wallet = context.watch(); final shortcuts = _categoryShortcuts( categories, Localizations.localeOf(context).languageCode, ); final walletBadge = auth.isSignedIn && wallet.snapshot != null ? wallet.balance : null; return DecoratedBox( key: const Key('home-grid'), decoration: const BoxDecoration(color: AppColors.appBackground), child: SafeArea( bottom: false, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ HomeGridHeader( callbacks: widget.callbacks, categories: shortcuts, selectedIndex: _sectionIndex, onSectionSelected: _selectSection, walletBadge: walletBadge, ), Expanded( child: ListenableBuilder( listenable: widget.feedController, builder: (context, _) => PageView.builder( key: const Key('home-primary-page-view'), controller: _pageController, itemCount: shortcuts.isEmpty ? 1 : shortcuts.length, onPageChanged: (index) => setState(() => _sectionIndex = index), itemBuilder: (context, index) { if (shortcuts.isEmpty) { return _categoryPlaceholder( l10n, context.watch(), catalog, ); } final category = shortcuts[index]; return _KeepAlivePage( key: PageStorageKey( 'home-primary-page-${category.categoryId}', ), child: _tabGrid( context, l10n, category: category, catalog: catalog, ), ); }, ), ), ), ], ), ), ); } void _selectSection(int index) { if (index == _sectionIndex) return; if (!_pageController.hasClients) { setState(() => _sectionIndex = index); return; } unawaited( _pageController.animateToPage( index, duration: AppDimens.homePrimaryTabAnimationDuration, curve: Curves.easeOut, ), ); } Widget _categoryPlaceholder( AppLocalizations l10n, CategoryController controller, CatalogController catalog, ) { if (catalog.error != null) { return _catalogErrorState(l10n, catalog); } if (controller.loading) { return const DramaGridSkeleton( key: Key('home-grid-skeleton'), columns: 3, horizontalPadding: AppDimens.feedPageHorizontal, crossAxisSpacing: AppDimens.homeGridGap, mainAxisSpacing: AppDimens.homeGridRowGap, ); } return EmptyState( key: const Key('home-grid-empty'), icon: Icons.movie_filter_outlined, title: l10n.emptyContent, ); } Widget _tabGrid( BuildContext context, AppLocalizations l10n, { required HomeGridCategoryShortcut category, required CatalogController catalog, }) { final controller = context.read(); if (category.isRanking) { return HomeRankingDramas( categoryId: category.categoryId, onOpenDrama: widget.onOpenDrama, ); } if (controller.dramaPageLoader != null && category.categoryId.isNotEmpty) { return HomeCategoryDramas( lockedCategoryId: category.categoryId, onOpenDrama: widget.onOpenDrama, ); } if (catalog.error != null && widget.feedController.dramas.isEmpty) { return _catalogErrorState(l10n, catalog); } if ((!catalog.settled || catalog.loading) && widget.feedController.dramas.isEmpty) { return const DramaGridSkeleton( key: Key('home-grid-skeleton'), columns: 3, horizontalPadding: AppDimens.feedPageHorizontal, crossAxisSpacing: AppDimens.homeGridGap, mainAxisSpacing: AppDimens.homeGridRowGap, ); } final dramas = _dramasFor(category); if (widget.feedController.loadingFirstPage && dramas.isEmpty) { return const DramaGridSkeleton( key: Key('home-grid-skeleton'), columns: 3, horizontalPadding: AppDimens.feedPageHorizontal, crossAxisSpacing: AppDimens.homeGridGap, mainAxisSpacing: AppDimens.homeGridRowGap, ); } 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: null, physics: AppPullToRefresh.physics, slivers: [ if (dramas.isEmpty) SliverFillRemaining( hasScrollBody: false, child: _emptyState(l10n), ) 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: true, errorMessage: widget.feedController.lastError == null ? null : l10n.catalogError, onRetry: widget.feedController.loadMore, ), ), ), ], ), ), ); }, ); } Widget _catalogErrorState(AppLocalizations l10n, CatalogController catalog) { final theme = Theme.of(context); return Theme( data: theme.copyWith( colorScheme: theme.colorScheme.copyWith( onSurface: AppColors.textTertiary, ), ), child: EmptyState( key: const Key('home-grid-error'), icon: Icons.cloud_off_rounded, title: l10n.catalogError, actionLabel: l10n.retry, onAction: catalog.load, ), ); } 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, String languageCode, ) { return [ for (final category in categories) HomeGridCategoryShortcut( label: category.localizedName(languageCode), categoryName: category.name, categoryId: category.id, tab: HomePrimaryTab.categories, layoutType: category.layoutType, ), ]; } List _dramasFor(HomeGridCategoryShortcut category) { final dramas = widget.feedController.dramas; final categoryName = category.categoryName.toLowerCase(); if (categoryName.isEmpty) return dramas; final matched = dramas .where( (drama) => drama.category.toLowerCase().contains(categoryName) || drama.genre.toLowerCase().contains(categoryName), ) .toList(growable: false); return matched.isEmpty ? dramas : matched; } Widget _emptyState(AppLocalizations l10n) { 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()), ); } return EmptyState( key: const Key('home-grid-empty'), icon: Icons.movie_filter_outlined, title: l10n.emptyContent, ); } } 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; } }