// Author: fruit.com.cn import 'package:flutter/material.dart'; import '../../../core/data/models/content_category.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/models/playlist_detail.dart'; import '../../../core/data/services/category_controller.dart'; import '../../../core/data/services/home_feed_controller.dart'; import '../../../core/data/services/playlist_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 '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; import 'recommend_section_card.dart'; const List _costumeNames = ['古装', 'costume', 'kostum']; const int _sectionPreviewCount = 10; const double _loadMoreThreshold = 200; enum RecommendSectionKind { hits, newReleases, costume } class RecommendSectionsView extends StatelessWidget { const RecommendSectionsView({ super.key, required this.feedController, required this.categories, required this.onOpenDrama, required this.onMore, this.playlists, this.onOpenPlaylist, }); final HomeFeedController feedController; final CategoryController categories; final ValueChanged onOpenDrama; final ValueChanged onMore; final PlaylistController? playlists; final ValueChanged? onOpenPlaylist; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final source = feedController.dramas; final remote = playlists; return AppPullToRefresh( key: const Key('recommend-sections-refresh'), onRefresh: () async { await Future.wait([ feedController.refreshCurrent(), categories.load(), if (remote != null) remote.load(), ]); }, child: NotificationListener( onNotification: (notification) => remote == null ? false : _onPlaylistScroll(notification, remote), child: ListView( key: const Key('recommend-sections-scroll'), physics: AppPullToRefresh.physics, padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), children: remote == null ? _localSections(context, l10n, source) : _remoteSections(context, l10n, remote), ), ), ); } List _localSections( BuildContext context, AppLocalizations l10n, List source, ) { final hits = recommendSectionDramas( kind: RecommendSectionKind.hits, source: source, ); final newest = recommendSectionDramas( kind: RecommendSectionKind.newReleases, source: source, ); return [ _section( context, l10n, key: 'hits', title: l10n.weeklyHitsTitle, subtitle: l10n.weeklyHitsSubtitle, dramas: hits.take(_sectionPreviewCount).toList(), onMore: () => onMore(RecommendSectionKind.hits), ), const SizedBox(height: AppDimens.recommendSectionGap), _section( context, l10n, key: 'new', title: l10n.weeklyNewTitle, subtitle: l10n.weeklyNewSubtitle, dramas: newest.take(_sectionPreviewCount).toList(), onMore: () => onMore(RecommendSectionKind.newReleases), ), const SizedBox(height: AppDimens.recommendSectionGap), _section( context, l10n, key: 'costume', title: l10n.costumeAwardsTitle, subtitle: l10n.costumeAwardsSubtitle, dramas: recommendSectionDramas( kind: RecommendSectionKind.costume, source: source, categories: categories, ).take(_sectionPreviewCount).toList(), onMore: () => onMore(RecommendSectionKind.costume), ), ]; } List _remoteSections( BuildContext context, AppLocalizations l10n, PlaylistController remote, ) { if (!remote.loaded && remote.loading) { return const [ SizedBox( height: 240, child: Center( child: CircularProgressIndicator( key: Key('recommend-playlists-loading'), ), ), ), ]; } if (remote.items.isEmpty) { return [ EmptyState( key: const Key('recommend-playlists-empty'), icon: Icons.video_library_outlined, title: remote.error == null ? l10n.emptyContent : l10n.networkError, actionLabel: l10n.retry, onAction: remote.load, ), ]; } final sections = []; for (final playlist in remote.items) { if (sections.isNotEmpty) { sections.add(const SizedBox(height: AppDimens.recommendSectionGap)); } sections.add( _section( context, l10n, key: playlist.id, title: playlist.localizedName( Localizations.localeOf(context).languageCode, ), subtitle: playlist.localizedDescription( Localizations.localeOf(context).languageCode, ), dramas: playlist.dramas.take(_sectionPreviewCount).toList(), onMore: () => onOpenPlaylist?.call(playlist), ), ); } if (remote.loadingMore) { sections.add( const Padding( padding: EdgeInsets.symmetric(vertical: AppDimens.spaceMd), child: Center( child: CircularProgressIndicator( key: Key('recommend-playlists-more'), ), ), ), ); } else if (!remote.hasMore) { sections.add( Padding( key: const Key('paged-list-no-more'), padding: const EdgeInsets.symmetric(vertical: 16), child: Text( l10n.noMoreItems, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.textHint, fontSize: 12), ), ), ); } return sections; } bool _onPlaylistScroll( ScrollNotification notification, PlaylistController remote, ) { if (notification.depth != 0) return false; if (notification.metrics.extentAfter > _loadMoreThreshold) return false; if (notification is! ScrollUpdateNotification && notification is! ScrollEndNotification) { return false; } remote.loadMore(); return false; } Widget _section( BuildContext context, AppLocalizations l10n, { required String key, required String title, required String subtitle, required List dramas, required VoidCallback onMore, }) { return Column( key: Key('recommend-section-$key'), crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( color: AppColors.textPrimary, fontSize: AppDimens.recommendSectionTitle, fontWeight: FontWeight.w400, ), ), const SizedBox(height: 2), Text( subtitle, style: const TextStyle( color: AppColors.textSecondary, fontSize: AppDimens.recommendSectionSubtitle, ), ), ], ), ), TextButton( key: Key('recommend-more-$key'), onPressed: onMore, style: TextButton.styleFrom( foregroundColor: AppColors.textSecondary, padding: const EdgeInsets.symmetric(horizontal: 4), minimumSize: const Size(0, 30), ), child: Row( children: [ Text( l10n.more, style: const TextStyle( color: AppColors.textSecondary, fontSize: AppDimens.recommendSectionMore, fontWeight: FontWeight.w500, ), ), const Icon(Icons.chevron_right_rounded, size: 16), ], ), ), ], ), ), const SizedBox(height: AppDimens.spaceXs), SizedBox( height: AppDimens.recommendRowHeight, child: ListView.separated( key: Key('recommend-row-$key'), scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), itemCount: dramas.length, separatorBuilder: (_, _) => const SizedBox(width: AppDimens.homeGridGap), itemBuilder: (context, index) => RecommendSectionCard( sectionKey: key, drama: dramas[index], onTap: () => onOpenDrama(dramas[index]), ), ), ), ], ); } } /// 剧单分区的完整片单,横滑预览和更多页共用同一排序。 List recommendSectionDramas({ required RecommendSectionKind kind, required List source, CategoryController? categories, }) { switch (kind) { case RecommendSectionKind.hits: return [...source]..sort((a, b) => b.viewCount.compareTo(a.viewCount)); case RecommendSectionKind.newReleases: return [...source]..sort(_newestFirst); case RecommendSectionKind.costume: return _costumeDramas(source, categories); } } int _newestFirst(Drama leftDrama, Drama rightDrama) { final left = publishedAtForDrama(leftDrama); final right = publishedAtForDrama(rightDrama); if (left == null && right == null) { return rightDrama.id.compareTo(leftDrama.id); } if (left == null) { return 1; } if (right == null) { return -1; } return right.compareTo(left); } List _costumeDramas(List source, CategoryController? categories) { final category = _costumeCategory(categories); if (category != null) { final fromCategory = categories!.dramasOf(category.id); if (fromCategory.isNotEmpty) return fromCategory; } return source.where(_isCostumeDrama).toList(growable: false); } ContentCategory? _costumeCategory(CategoryController? categories) { if (categories == null) return null; for (final category in categories.categories) { if (_containsCostume(category.name)) return category; } return null; } bool _isCostumeDrama(Drama drama) { return _containsCostume(drama.category) || _containsCostume(drama.genre); } bool _containsCostume(String value) { final normalized = value.toLowerCase(); return _costumeNames.any(normalized.contains); }