// 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/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/widgets/feed_banner_carousel.dart'; import '../mine/widgets/continue_watching_card.dart'; import 'home_browse_logic.dart'; import 'widgets/home_browse_header.dart'; import 'widgets/home_drama_grid.dart'; /// 浏览型首页:频道、Hero、继续观看与剧目目录。 class HomeBrowseView extends StatefulWidget { const HomeBrowseView({ super.key, required this.active, required this.onSearch, required this.onProfile, required this.onTheater, required this.onHistory, required this.onOpenDrama, required this.onOpenContinue, }); final bool active; final VoidCallback onSearch; final VoidCallback onProfile; final VoidCallback onTheater; final VoidCallback onHistory; final Future Function(Drama drama) onOpenDrama; final Future Function(Drama drama, WatchProgress record) onOpenContinue; @override State createState() => _HomeBrowseViewState(); } class _HomeBrowseViewState extends State { late final HomeBrowseLogic logic; @override void initState() { super.initState(); logic = HomeBrowseLogic(history: context.read()); } @override void didUpdateWidget(covariant HomeBrowseView oldWidget) { super.didUpdateWidget(oldWidget); logic.syncPage(active: widget.active, wasActive: oldWidget.active); } @override void dispose() { logic.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: logic, builder: (context, _) => _page(context), ); } Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final catalog = context.watch(); return SafeArea( bottom: false, child: Column( children: [ HomeBrowseHeader( onSearch: widget.onSearch, onProfile: widget.onProfile, ), Expanded(child: _body(l10n, catalog)), ], ), ); } Widget _body(AppLocalizations l10n, CatalogController catalog) { return RefreshIndicator( key: const Key('home-refresh'), color: AppColors.blue, backgroundColor: AppColors.card, onRefresh: () async { await Future.wait([catalog.load(), logic.refreshRecent()]); }, child: ListView( key: const Key('home-browse-page'), physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), children: _sections(l10n, catalog), ), ); } List _sections(AppLocalizations l10n, CatalogController catalog) { final source = catalog.dramas; final dramas = _channelDramas(source, logic.channel); final continueItem = _continueItem(logic.recent, catalog.dramas); final banners = catalog.banners.isEmpty ? _fallbackBanners(source) : catalog.banners; return [ _channels(l10n), if (banners.isNotEmpty) _hero(banners, source), if (continueItem != null) ...[ _continueHeader(l10n), ContinueWatchingCard( cardKey: const Key('home-continue-watching'), playButtonKey: Key('home-continue-play-${continueItem.drama.id}'), progressKey: Key('home-continue-progress-${continueItem.drama.id}'), showRemaining: true, drama: continueItem.drama, record: continueItem.record, onResume: () => unawaited( widget.onOpenContinue(continueItem.drama, continueItem.record), ), ), ], _channelHeader(l10n), if (dramas.isEmpty) _empty() else HomeDramaGrid( l10n: l10n, dramas: dramas, onOpen: (drama) => unawaited(widget.onOpenDrama(drama)), ), ]; } List _fallbackBanners(List dramas) { if (dramas.isEmpty) return const []; final drama = dramas.first; return [ FeedBanner( id: 'home-fallback-${drama.id}', imageUrl: drama.posterUrl, linkType: 'DRAMA', linkValue: drama.id, ), ]; } Widget _continueHeader(AppLocalizations l10n) => _sectionHeader( l10n.continueWatching, actionKey: const Key('home-continue-all'), actionLabel: l10n.seeAll, onAction: widget.onHistory, ); Widget _channelHeader(AppLocalizations l10n) => _sectionHeader( _channelTitle(l10n), actionKey: const Key('home-popular-more'), actionLabel: l10n.seeAll, onAction: widget.onTheater, ); Widget _empty() => Padding( padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXl), child: EmptyState( key: const Key('home-browse-empty'), icon: Icons.movie_filter_outlined, title: AppLocalizations.of(context)!.noStories, subtitle: AppLocalizations.of(context)!.emptyHint, compact: true, ), ); Widget _channels(AppLocalizations l10n) { final entries = [ (HomeChannel.hot, l10n.hot), (HomeChannel.free, l10n.free), (HomeChannel.newReleases, l10n.newDramas), (HomeChannel.ranking, l10n.rankingTab), ]; return SizedBox( height: AppDimens.buttonMedium, child: ListView.separated( key: const Key('home-channels'), scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: AppDimens.pageHorizontal, ), itemCount: entries.length, separatorBuilder: (_, _) => const SizedBox(width: AppDimens.spaceSm), itemBuilder: (context, index) { final entry = entries[index]; final selected = logic.channel == entry.$1; return ChoiceChip( key: Key('home-channel-${entry.$1.name}'), label: Text(entry.$2), selected: selected, showCheckmark: false, onSelected: (_) => logic.selectChannel(entry.$1), backgroundColor: AppColors.card, selectedColor: AppColors.blue, side: BorderSide( color: selected ? AppColors.blue : AppColors.border, ), labelStyle: TextStyle( color: selected ? AppColors.onPrimary : AppColors.onSurface, fontSize: AppDimens.chipFont, fontWeight: FontWeight.w700, ), padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), shape: const StadiumBorder(), ); }, ), ); } Widget _hero(List banners, List dramas) { return Padding( padding: const EdgeInsets.only(top: AppDimens.spaceMd), child: FeedBannerCarousel( key: const Key('home-hero-banner'), banners: banners, active: widget.active, dramaFor: (banner) => _dramaForBanner(banner, dramas), onOpen: (banner) { final drama = _dramaForBanner(banner, dramas); if (drama != null) unawaited(widget.onOpenDrama(drama)); }, ), ); } Widget _sectionHeader( String title, { required Key actionKey, required String actionLabel, required VoidCallback onAction, }) { return Padding( padding: const EdgeInsets.fromLTRB( AppDimens.pageHorizontal, AppDimens.spaceLg, AppDimens.spaceSm, AppDimens.spaceSm, ), child: Row( children: [ Expanded( child: Text( title, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, fontSize: AppDimens.sectionTitle, fontWeight: FontWeight.w800, ), ), ), TextButton( key: actionKey, onPressed: onAction, style: TextButton.styleFrom( foregroundColor: AppColors.accent, minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget), ), child: Text(actionLabel), ), ], ), ); } String _channelTitle(AppLocalizations l10n) { return switch (logic.channel) { HomeChannel.hot => l10n.trendingMicroDramas, HomeChannel.free => l10n.free, HomeChannel.newReleases => l10n.newDramas, HomeChannel.ranking => l10n.rankingTitle, }; } List _channelDramas(List source, HomeChannel channel) { final dramas = List.of(source); switch (channel) { case HomeChannel.hot: dramas.sort((a, b) { final hot = (b.isHot ? 1 : 0).compareTo(a.isHot ? 1 : 0); return hot != 0 ? hot : b.viewCount.compareTo(a.viewCount); }); case HomeChannel.free: return dramas.where(_isFree).toList(); case HomeChannel.newReleases: return dramas; case HomeChannel.ranking: dramas.sort((a, b) => b.viewCount.compareTo(a.viewCount)); } return dramas; } bool _isFree(Drama drama) { return drama.freeEpisodes > 0 || drama.episodes.any((episode) => episode.isFree); } Drama? _dramaForBanner(FeedBanner banner, List dramas) { final id = banner.dramaId; if (id == null) return null; for (final drama in dramas) { if (drama.id == id) return drama; } return null; } ({Drama drama, WatchProgress record})? _continueItem( List records, List dramas, ) { for (final record in records) { if (record.finished || !record.hasPosition) continue; for (final drama in dramas) { if (drama.id == record.dramaId) { return (drama: drama, record: record); } } } return null; } }