// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/home_feed.dart'; import '../../core/data/remote/catalog_api.dart'; import '../../core/data/services/category_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 '../player/open_player.dart'; import '../home/widgets/home_ranking_dramas.dart'; import 'widgets/home_hero_carousel.dart'; import 'widgets/home_recommend_content.dart'; import 'widgets/recommend_channel_nav.dart'; /// 底部第二个 tab「推荐」(礼花)。 /// /// 频道全部来自分类接口;内容区是一个横向 [PageView],左右滑动即可切换频道, /// 顶部频道导航随之高亮,并把选中频道滚动到可见区域。 /// 第一个频道(接口的「精选」)在网格顶部额外挂一张 banners 轮播图, /// 轮播数据来自 `GET /app/home/feed`。 class RecommendDiscoverPage extends StatefulWidget { const RecommendDiscoverPage({ super.key, required this.active, required this.onSearch, this.onOpenWallet, }); final bool active; final VoidCallback onSearch; /// 顶部金色礼物盒(钱包入口)。 final VoidCallback? onOpenWallet; @override State createState() => _RecommendDiscoverPageState(); } class _RecommendDiscoverPageState extends State with AutomaticKeepAliveClientMixin { /// 当前频道 id;频道全部来自分类接口,首项即「精选」。 String? _channelId; final Set _requestedCategories = {}; final PageController _pageController = PageController(); /// `GET /app/home/feed` 返回的轮播图,只挂在第一个「精选」频道。 List _banners = const []; bool _bannersRequested = false; @override bool get wantKeepAlive => true; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _loadBanners()); } @override void dispose() { _pageController.dispose(); super.dispose(); } /// 点击顶部频道:更新高亮并让 PageView 滑到对应页。 void _selectChannel(String id, int index) { if (id == _channelId && _pageController.hasClients) { // 已在该页时仍允许回到顶部对齐。 return; } setState(() => _channelId = id); _ensureCategory(id); if (_pageController.hasClients) { unawaited( _pageController.animateToPage( index, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ), ); } } /// 左右滑动翻页:更新高亮(不反向驱动 PageView)。 void _onPageChanged(int index, List orderedIds) { if (index < 0 || index >= orderedIds.length) return; final id = orderedIds[index]; if (id != _channelId) setState(() => _channelId = id); _ensureCategory(id); } void _ensureCategory(String id) { if (_requestedCategories.contains(id)) return; _requestedCategories.add(id); final categories = context.read(); final category = categories.categories .where((item) => item.id == id) .firstOrNull; if (category?.isRanking ?? false) { final slot = categories.rankingSlotOf(id); if (categories.rankingLoader == null || slot.ranking != null || slot.loading) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(categories.openRanking(id)); }); return; } if (categories.dramaPageLoader == null) return; final slot = categories.slotOf(id); if (slot.dramas.isNotEmpty || slot.loading) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(categories.openCategory(id)); }); } /// 下拉刷新:重拉该分类第一页;精选频道同时刷新轮播图。 Future _refreshCategory(String id, bool isFeatured) async { final categories = context.read(); final tasks = >[categories.openCategory(id)]; if (isFeatured) tasks.add(_loadBanners(force: true)); await Future.wait(tasks); } /// 轮播图只依赖 home/feed 的 banners;接口缺失(如测试注入)时静默跳过, /// 不影响下方分类网格。 Future _loadBanners({bool force = false}) async { if (_bannersRequested && !force) return; _bannersRequested = true; CatalogApi? api; try { api = context.read(); } on ProviderNotFoundException { api = null; } if (api == null) return; try { final feed = await api.homeFeed(); if (mounted && feed.banners.isNotEmpty) { setState(() => _banners = feed.banners); } } catch (_) { // 轮播拉取失败时保留纯网格。 } } Future _openDrama(Drama drama) => playDramaFromCatalog(context, drama); @override Widget build(BuildContext context) { super.build(context); final categories = context.watch(); final mediaTop = MediaQuery.of(context).padding.top; final categoryList = categories.categories; final orderedIds = categoryList.map((c) => c.id).toList(); // 频道来自接口分类;默认选中第一个分类(即「精选」)。 _channelId ??= categoryList.isEmpty ? null : categoryList.first.id; final selectedIndex = _channelId == null ? 0 : orderedIds.indexOf(_channelId!).clamp(0, orderedIds.length - 1); return ColoredBox( key: const Key('recommend-page'), color: AppColors.appBackground, child: Stack( children: [ Positioned.fill( child: categoryList.isEmpty ? const Center( child: CircularProgressIndicator(color: AppColors.brandRed), ) : PageView.builder( key: const Key('recommend-channel-pager'), controller: _pageController, itemCount: categoryList.length, onPageChanged: (i) => _onPageChanged(i, orderedIds), itemBuilder: (context, index) { final category = categoryList[index]; if (category.isRanking) { return HomeRankingDramas( key: PageStorageKey( 'recommend-ranking-${category.id}', ), categoryId: category.id, topPadding: mediaTop + RecommendChannelNav.height, onOpenDrama: _openDrama, ); } return _ChannelFeed( key: PageStorageKey( 'recommend-channel-${category.id}', ), categoryId: category.id, isFeatured: index == 0, banners: _banners, carouselActive: widget.active && index == selectedIndex, mediaTop: mediaTop, onRequest: _ensureCategory, onRefresh: () => _refreshCategory(category.id, index == 0), onOpenDrama: _openDrama, ); }, ), ), Positioned( top: 0, left: 0, right: 0, child: IgnorePointer( child: SizedBox( height: mediaTop + RecommendChannelNav.height, child: const DecoratedBox( key: Key('recommend-top-gradient'), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [AppColors.mediaBlack, AppColors.transparent], ), ), ), ), ), ), Positioned( top: 0, left: 0, right: 0, child: SafeArea( bottom: false, child: RecommendChannelNav( categories: categoryList, selectedId: _channelId, onSelected: (id) { final index = orderedIds.indexOf(id); if (index >= 0) _selectChannel(id, index); }, onSearch: widget.onSearch, onGift: widget.onOpenWallet, ), ), ), ], ), ); } } /// 单个分类频道的内容:可选顶部轮播 + 该分类短剧三列网格。 class _ChannelFeed extends StatefulWidget { const _ChannelFeed({ super.key, required this.categoryId, required this.isFeatured, required this.banners, required this.carouselActive, required this.mediaTop, required this.onRequest, required this.onRefresh, required this.onOpenDrama, }); final String categoryId; final bool isFeatured; final List banners; final bool carouselActive; final double mediaTop; final ValueChanged onRequest; final Future Function() onRefresh; final ValueChanged onOpenDrama; @override State<_ChannelFeed> createState() => _ChannelFeedState(); } class _ChannelFeedState extends State<_ChannelFeed> with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override void initState() { super.initState(); widget.onRequest(widget.categoryId); } Drama? _dramaForBanner(FeedBanner banner, List pool) { final id = banner.dramaId; if (id == null) return null; for (final drama in pool) { if (drama.id == id) return drama; } return null; } @override Widget build(BuildContext context) { super.build(context); final categories = context.watch(); final slot = categories.slotOf(widget.categoryId); final dramas = slot.dramas; final showCarousel = widget.isFeatured && widget.banners.isNotEmpty; final bottomInset = MediaQuery.of(context).padding.bottom; // 精选频道:按接口下发的标签(tag)把「上新」筛出来横滑,其余短剧一行两个。 List newArrivals = const []; List rest = dramas; if (widget.isFeatured && dramas.isNotEmpty) { newArrivals = dramas.where(isNewArrivalTag).toList(growable: false); if (newArrivals.isNotEmpty) { final newIds = newArrivals.map((drama) => drama.id).toSet(); rest = dramas .where((drama) => !newIds.contains(drama.id)) .toList(growable: false); } } final sliverList = []; if (showCarousel) { sliverList.add( SliverToBoxAdapter( child: HomeHeroCarousel( banners: widget.banners, active: widget.carouselActive, dramaFor: (banner) => _dramaForBanner(banner, dramas), onWatch: widget.onOpenDrama, ), ), ); } if (dramas.isEmpty) { sliverList.add( SliverFillRemaining( hasScrollBody: false, child: Center(child: _placeholder(slot.loading)), ), ); } else if (widget.isFeatured) { // 无轮播图时内容需避开顶部悬浮导航。 final contentTop = showCarousel ? 24.0 : widget.mediaTop + RecommendChannelNav.height + AppDimens.spaceSm; if (newArrivals.isNotEmpty) { sliverList.add(SliverToBoxAdapter(child: SizedBox(height: contentTop))); sliverList.add( SliverToBoxAdapter( child: HomeNewArrivalRow( dramas: newArrivals, onOpen: widget.onOpenDrama, ), ), ); sliverList.add(const SliverToBoxAdapter(child: SizedBox(height: 28))); } sliverList.add( _gridSliver( rest, topPadding: newArrivals.isNotEmpty ? 0 : contentTop, crossAxisCount: 2, ), ); } else { sliverList.add( _gridSliver( dramas, topPadding: showCarousel ? 20 : widget.mediaTop + RecommendChannelNav.height + AppDimens.spaceSm, crossAxisCount: 3, ), ); } sliverList.add( SliverToBoxAdapter( child: Padding( padding: EdgeInsets.only(bottom: bottomInset), child: PagedListFooter( key: const Key('recommend-category-footer'), loading: slot.loadingMore, hasMore: slot.hasMore, hasItems: dramas.isNotEmpty, errorMessage: null, onRetry: () => categories.loadMore(widget.categoryId), ), ), ), ); return NotificationListener( onNotification: (notification) { if (notification.depth != 0) return false; if (notification.metrics.extentAfter > 300) return false; if (notification is! ScrollUpdateNotification && notification is! ScrollEndNotification && notification is! OverscrollNotification) { return false; } unawaited(categories.loadMore(widget.categoryId)); return false; }, child: AppPullToRefresh( edgeOffset: widget.mediaTop + RecommendChannelNav.height - AppPullToRefresh.indicatorRevealOffset, onRefresh: widget.onRefresh, child: CustomScrollView( key: PageStorageKey('recommend-feed-${widget.categoryId}'), physics: AppPullToRefresh.physics, slivers: sliverList, ), ), ); } Widget _placeholder(bool loading) { if (loading) { return const CircularProgressIndicator(color: AppColors.brandRed); } final l10n = AppLocalizations.of(context)!; return EmptyState( key: const Key('recommend-category-empty'), icon: Icons.movie_outlined, title: l10n.noStories, compact: true, stretchContent: true, ); } Widget _gridSliver( List dramas, { required double topPadding, required int crossAxisCount, }) { const pageSide = 14.0; const cardGap = 9.0; const rowGap = 16.0; // 两列时卡更大,卡间距略放宽。 final spacing = crossAxisCount == 2 ? 12.0 : cardGap; return SliverPadding( padding: EdgeInsets.fromLTRB(pageSide, topPadding, pageSide, 0), sliver: SliverLayoutBuilder( builder: (context, constraints) { final itemWidth = (constraints.crossAxisExtent - spacing * (crossAxisCount - 1)) / crossAxisCount; final itemExtent = itemWidth * 157 / 110 + homePosterCardBodyExtent; return SliverGrid( key: Key('recommend-category-grid-${widget.categoryId}'), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, crossAxisSpacing: spacing, mainAxisSpacing: rowGap, mainAxisExtent: itemExtent, ), delegate: SliverChildBuilderDelegate((context, index) { final drama = dramas[index]; return HomePosterCard( key: Key('recommend-category-card-${drama.id}'), drama: drama, onTap: () => widget.onOpenDrama(drama), ); }, childCount: dramas.length), ); }, ), ); } } /// 「上新」标签语义码,对应后台 content_tag.code。 const String newArrivalTagCode = 'NEW_ARRIVAL'; /// 判定短剧是否属于「上新」板块。 /// /// 只看语义码:码由后台标签管理配置,文案怎么改、切什么语言都不影响。 bool isNewArrivalTag(Drama drama) => drama.tagCode.trim().toUpperCase() == newArrivalTagCode;