// 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 { String? _selectedCategory; String? _selectedTag; @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 activeCategory = shortcuts.any((shortcut) => shortcut.categoryName == _selectedCategory) ? _selectedCategory : null; final activeTag = tags.any((tag) => tag.id == _selectedTag) ? _selectedTag : null; final dramas = _filteredDramas(activeCategory, activeTag); 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: LayoutBuilder( builder: (context, constraints) { final itemWidth = (constraints.maxWidth - AppDimens.feedPageHorizontal * 2 - AppDimens.homeGridGap * 2) / 3; final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent; return NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( key: const Key('home-grid-refresh'), onRefresh: _refresh, child: CustomScrollView( key: const Key('home-grid-scroll'), physics: AppPullToRefresh.physics, slivers: [ SliverToBoxAdapter( child: HomeGridHeader( callbacks: widget.callbacks, categories: shortcuts, tags: tags, selectedCategory: activeCategory, selectedTag: activeTag, onCategorySelected: _selectCategory, onTagSelected: (tag) => setState(() => _selectedTag = tag), walletBadge: walletBadge, ), ), if (widget.feedController.loadingFirstPage) const SliverFillRemaining( hasScrollBody: false, child: Center( child: SizedBox.square( dimension: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ) else if (dramas.isEmpty) SliverFillRemaining( hasScrollBody: false, child: _emptyState(l10n, activeCategory, activeTag), ) 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; } void _selectCategory(String? category) { setState(() { _selectedCategory = category; _selectedTag = null; }); } List _categoryShortcuts( List categories, AppLocalizations l10n, ) { final shortcuts = []; final costume = _findCategory(categories, _costumeAliases); final community = _findCategory(categories, _communityAliases); if (costume != null) { shortcuts.add( HomeGridCategoryShortcut( label: l10n.homeSectionCostume, categoryName: costume.name, ), ); } if (community != null) { shortcuts.add( HomeGridCategoryShortcut( label: l10n.homeSectionCommunity, categoryName: community.name, ), ); } return shortcuts; } 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) { final definitions = [ 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), ]; final categoryText = context .read() .categories .map((category) => category.name) .toList(growable: false); return definitions .where((option) { if (option.id == 'hot') { return widget.feedController.dramas.any(isHotDrama); } return categoryText.any((text) => tagMatchesText(option.id, text)) || widget.feedController.dramas.any( (drama) => matchesHomeTag(drama, option.id), ); }) .toList(growable: false); } List _filteredDramas(String? category, String? tag) { final normalizedCategory = category?.toLowerCase(); return widget.feedController.dramas .where((drama) { final categoryMatches = normalizedCategory == null || drama.category.toLowerCase().contains(normalizedCategory) || drama.genre.toLowerCase().contains(normalizedCategory); final tagMatches = tag == null || matchesHomeTag(drama, tag); return categoryMatches && tagMatches; }) .toList(growable: false); } Widget _emptyState(AppLocalizations l10n, String? category, 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 = category != null || 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(() { _selectedCategory = null; _selectedTag = null; }) : null, ); } }