// 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'; const double _channelRowHeight = 44; const double _channelGap = 20; const double _channelUnderlineWidth = 18; const double _channelUnderlineHeight = 2; const double _channelUnderlineGap = 4; const double _channelOverlayTop = 8; const double _channelFontSize = 15; const List _channelTextShadows = [ Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), ]; /// 浏览型首页:频道、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 [ if (banners.isNotEmpty) _heroWithChannels(l10n, banners, source) else _channels(l10n), 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, ), ); /// 频道行叠加在 hero 轮播之上;无 banner 时作为普通行显示。 Widget _heroWithChannels( AppLocalizations l10n, List banners, List dramas, ) { return SizedBox( key: const Key('home-hero-section'), height: AppDimens.heroBannerHeight, child: Stack( children: [ Positioned.fill(child: _hero(banners, dramas)), Positioned( top: _channelOverlayTop, left: 0, right: 0, child: _channels(l10n, overImage: true), ), ], ), ); } Widget _channels(AppLocalizations l10n, {bool overImage = false}) { final entries = [ (HomeChannel.hot, l10n.hot), (HomeChannel.free, l10n.free), (HomeChannel.newReleases, l10n.newDramas), (HomeChannel.ranking, l10n.rankingTab), ]; return SizedBox( key: const Key('home-channels'), height: _channelRowHeight, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: AppDimens.pageHorizontal, ), itemCount: entries.length, separatorBuilder: (_, _) => const SizedBox(width: _channelGap), itemBuilder: (context, index) { final entry = entries[index]; final selected = logic.channel == entry.$1; return _channelItem( entry.$1, entry.$2, selected: selected, overImage: overImage, ); }, ), ); } Widget _channelItem( HomeChannel channel, String label, { required bool selected, required bool overImage, }) { final color = selected ? AppColors.blue : (overImage ? Colors.white70 : AppColors.muted); final underlineColor = selected ? AppColors.blue : Colors.transparent; return Semantics( button: true, selected: selected, child: GestureDetector( key: Key('home-channel-${channel.name}'), behavior: HitTestBehavior.opaque, onTap: () => logic.selectChannel(channel), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( label, maxLines: 1, style: TextStyle( color: color, fontSize: _channelFontSize, fontWeight: selected ? FontWeight.w800 : FontWeight.w600, shadows: overImage ? _channelTextShadows : null, ), ), const SizedBox(height: _channelUnderlineGap), Container( key: Key('home-channel-underline-${channel.name}'), width: _channelUnderlineWidth, height: _channelUnderlineHeight, decoration: BoxDecoration( color: underlineColor, borderRadius: BorderRadius.circular( _channelUnderlineHeight / 2, ), ), ), ], ), ), ); } Widget _hero(List banners, List dramas) { return FeedBannerCarousel( key: const Key('home-hero-banner'), banners: banners, active: widget.active, flush: true, 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; } }