// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_retry.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; import '../create/create_view.dart'; import '../mine/mine_types.dart'; import '../player/widgets/home_feed_header.dart'; import '../player/player_view.dart'; import '../mine/mine_view.dart'; import 'home_feed_channel.dart'; import 'home_binding.dart'; import 'home_logic.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @override State createState() => _HomePageState(); } class _HomePageState extends State with WidgetsBindingObserver { static const _androidSplashAsset = 'assets/icon/splash_logo_android12.png'; static const _defaultSplashAsset = 'assets/icon/splash_logo.png'; static const _androidSplashSize = 288.0; static const _defaultSplashSize = 384.0; bool _resumeRetryInFlight = false; HomeFeedController? _homeFeed; bool _recommendSeeded = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void dispose() { _homeFeed?.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state != AppLifecycleState.resumed || _resumeRetryInFlight) return; final catalog = context.read(); final categories = context.read(); if (catalog.error == null && categories.error == null) return; unawaited(_retryAfterResume(catalog, categories)); } Future _retryAfterResume( CatalogController catalog, CategoryController categories, ) async { _resumeRetryInFlight = true; try { await AppConfig.probeAndApplyBaseUrl(); await Future.wait([ if (catalog.error != null) catalog.load(), if (categories.error != null) categories.load(), ]); } finally { _resumeRetryInFlight = false; } } Future _retryHome(CatalogController catalog) { return retryCatalogLoad(reload: catalog.load); } Future _editHomeEndpoint(CatalogController catalog) { return showCatalogEndpointEditor(context, reload: catalog.load); } @override Widget build(BuildContext context) { final catalog = context.watch(); final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.light(), child: _buildHome(context, catalog, l10n), ); } Widget _buildHome( BuildContext context, CatalogController catalog, AppLocalizations l10n, ) { if (catalog.loading) { return catalog.settled ? _retryLoading() : _startupSplash(); } if (catalog.error != null) { return Scaffold( backgroundColor: Colors.transparent, body: HomeFailureView( error: catalog.debugError ?? catalog.error, onRetry: () => unawaited(_retryHome(catalog)), onEditEndpoint: () => unawaited(_editHomeEndpoint(catalog)), ), ); } if (catalog.dramas.isEmpty) { return Scaffold( backgroundColor: Colors.transparent, body: EmptyState( key: const Key('home-empty'), icon: Icons.movie_outlined, title: l10n.noStories, subtitle: l10n.emptyHint, actionLabel: l10n.retry, onAction: catalog.load, ), ); } final homeFeed = _ensureHomeFeed(catalog); if (!_recommendSeeded) { _recommendSeeded = true; homeFeed.seedRecommend(catalog.dramas); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(_refreshRecommendAndPrefetch(homeFeed)); }); } return _HomeTabs(key: const Key('home-tabs'), feedController: homeFeed); } HomeFeedController _ensureHomeFeed(CatalogController catalog) { return _homeFeed ??= HomeFeedController(catalog.repository); } Future _refreshRecommendAndPrefetch(HomeFeedController homeFeed) async { if (await homeFeed.refreshCurrent()) { await homeFeed.prefetchChannels(); } } Widget _startupSplash() { final isAndroid = defaultTargetPlatform == TargetPlatform.android; final size = isAndroid ? _androidSplashSize : _defaultSplashSize; return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: ColoredBox( color: AppColors.splashInk, child: Center( child: Image.asset( isAndroid ? _androidSplashAsset : _defaultSplashAsset, key: const Key('startup-splash'), width: size, height: size, fit: BoxFit.contain, ), ), ), ); } Widget _retryLoading() { return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: const Scaffold( backgroundColor: AppColors.splashInk, body: Center(child: CircularProgressIndicator(color: AppColors.blue)), ), ); } } /// 首页目录加载失败与重试入口。 class HomeFailureView extends StatelessWidget { const HomeFailureView({ super.key, this.error, required this.onRetry, this.onEditEndpoint, }); final String? error; final VoidCallback onRetry; final VoidCallback? onEditEndpoint; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return EmptyState( icon: Icons.cloud_off_rounded, title: l10n.catalogError, actionLabel: l10n.retry, onAction: onRetry, footer: kDebugMode ? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint) : null, ); } } /// 四个根页面共享同一路由并保持各自状态。 class _HomeTabs extends StatefulWidget { const _HomeTabs({super.key, required this.feedController}); final HomeFeedController feedController; @override State<_HomeTabs> createState() => _HomeTabsState(); } class _HomeTabsState extends State<_HomeTabs> { late final HomeLogic logic; List? _visibleDramas; HomeFeedChannel? _visibleChannel; @override void initState() { super.initState(); logic = HomeBinding().create(widget.feedController.dramas); _visibleDramas = widget.feedController.dramas; _visibleChannel = widget.feedController.channel; widget.feedController.addListener(_onFeedChanged); } @override void dispose() { widget.feedController.removeListener(_onFeedChanged); logic.dispose(); super.dispose(); } void _onFeedChanged() { if (mounted) setState(() {}); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: logic, builder: (context, _) => _scaffold(context), ); } Widget _scaffold(BuildContext context) { final l10n = AppLocalizations.of(context)!; final feed = logic.tab == 0; final content = PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; logic.onSystemBack(); }, child: Scaffold( key: logic.scaffoldKey, backgroundColor: Colors.transparent, resizeToAvoidBottomInset: false, body: _body(context), bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n), ), ); return AnnotatedRegion( value: feed ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, child: feed ? content : DecoratedBox( key: const Key('home-page-gradient'), decoration: const BoxDecoration(gradient: AppTheme.pageGradient), child: content, ), ); } Widget _body(BuildContext context) { return IndexedStack( index: logic.tab, children: [ _home(context), _discover(context), const CreatePage(), _my(context), ], ); } Widget _home(BuildContext context) { final currentDramas = widget.feedController.dramas; final currentChannel = widget.feedController.channel; if (currentDramas.isNotEmpty) { _visibleDramas = currentDramas; _visibleChannel = currentChannel; } if (currentDramas.isEmpty && !widget.feedController.loadingFirstPage) { return _feedChannelPlaceholder(context); } final visibleDramas = _visibleDramas; final visibleChannel = _visibleChannel; final switching = currentDramas.isEmpty && widget.feedController.loadingFirstPage; if (visibleDramas == null || visibleDramas.isEmpty || visibleChannel == null) { return _feedChannelPlaceholder(context); } final playerDramas = currentDramas.isEmpty ? visibleDramas : currentDramas; final playerChannel = currentDramas.isEmpty ? visibleChannel : currentChannel; return Stack( fit: StackFit.expand, children: [ PlayerPage( homeMode: true, embedded: true, active: logic.tab == 0, drama: playerDramas.first, initialIndex: 0, feedDramas: playerDramas, feedChannel: playerChannel, headerChannel: currentChannel, onOpenFullPlayer: (drama, episode) => logic.playDrama(context, drama, episodeId: episode.id), onRefreshFeed: widget.feedController.refreshCurrent, onLoadMore: widget.feedController.loadMore, onChannelSelected: _selectFeedChannel, onDramaChanged: logic.onDramaChanged, onImmersiveChanged: logic.onImmersiveChanged, onSearch: () => logic.select(1, search: true), onDiscover: () => logic.select(1), onMy: () => logic.select(3), ), if (switching) Positioned.fill( child: IgnorePointer( key: const Key('home-feed-channel-loading'), child: ColoredBox( color: Colors.black26, child: Center( child: SizedBox.square( dimension: 24, child: CircularProgressIndicator( strokeWidth: 2.4, color: Colors.white70, ), ), ), ), ), ), ], ); } Widget _feedChannelPlaceholder(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Stack( fit: StackFit.expand, children: [ const ColoredBox(color: Colors.black), SafeArea( bottom: false, child: Column( children: [ HomeFeedHeader( l10n: l10n, channel: widget.feedController.channel, onChannelSelected: _selectFeedChannel, onRefresh: () => _refreshPlaceholder(context), onSearch: () => logic.select(1, search: true), onMore: () {}, ), Expanded( child: widget.feedController.loadingFirstPage ? const Center( child: SizedBox.square( dimension: 24, child: CircularProgressIndicator( key: Key('home-feed-channel-loading'), strokeWidth: 2.4, color: Colors.white70, ), ), ) : EmptyState( icon: Icons.movie_outlined, title: l10n.noStories, actionLabel: l10n.retry, onAction: () => unawaited(widget.feedController.refreshCurrent()), ), ), ], ), ), ], ); } Future _refreshPlaceholder(BuildContext context) async { final ok = await widget.feedController.refreshCurrent(); if (!ok && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)), ); } return ok; } void _selectFeedChannel(HomeFeedChannel channel) { unawaited(widget.feedController.selectChannel(channel)); } Widget _discover(BuildContext context) { return DiscoverPage( active: logic.tab == 1, searchRequest: logic.searchRequest, callbacks: DiscoverPageCallbacks( onAction: logic.discoverAction, onDramaSelected: (drama) => logic.playDrama(context, drama), onProfile: logic.openProfile, ), ); } Widget _my(BuildContext context) { return MyPage( active: logic.tab == 3, callbacks: MyPageCallbacks( onAction: logic.myAction, onDramaSelected: (drama) => logic.openDrama(context, drama), ), ); } Widget _nav(BuildContext context, AppLocalizations l10n) { return AppBottomNav( barKey: const Key('home-tabs-bar'), dark: logic.tab == 0, items: [ _navItem(context, l10n, 0), _navItem(context, l10n, 1), _navItem(context, l10n, 2), _navItem(context, l10n, 3), ], ); } AppBottomNavItem _navItem( BuildContext context, AppLocalizations l10n, int index, ) { final selected = logic.tab == index; return switch (index) { 0 => AppBottomNavItem( tabKey: const Key('tab-home'), icon: Icons.home_outlined, selectedIcon: Icons.home_rounded, label: l10n.homeTab, selected: selected, onTap: () => _selectTab(context, index), ), 1 => AppBottomNavItem( tabKey: const Key('tab-theater'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, label: l10n.theaterTab, selected: selected, onTap: () => _selectTab(context, index), ), 2 => AppBottomNavItem( tabKey: const Key('tab-create'), icon: Icons.add_box_outlined, selectedIcon: Icons.add_box_rounded, label: l10n.createTab, selected: selected, onTap: () => _selectTab(context, index), ), _ => AppBottomNavItem( tabKey: const Key('tab-mine'), icon: Icons.person_outline_rounded, selectedIcon: Icons.person_rounded, label: l10n.myTab, selected: selected, onTap: () => _selectTab(context, index), ), }; } void _selectTab(BuildContext context, int tab) { final same = logic.tab == tab; if (!same) { unawaited(context.read().selectionClick()); } logic.select(tab); } }