// Author: fruit.com.cn import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/home_feed.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'widgets/cover_carousel.dart'; part 'widgets/home_drama_grid.dart'; class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, required this.drama, required this.duration, this.focusSearch = false, this.history, this.embedded = false, this.active = true, this.searchRequest = 0, this.signedIn = false, this.onSignIn, this.onCoins, this.onRewards, this.onAction, this.dramas, this.onDramaSelected, }); final Drama drama; final Duration duration; final bool focusSearch; final WatchHistoryRepository? history; final bool embedded; final bool active; final int searchRequest; final bool signedIn; final VoidCallback? onSignIn; final VoidCallback? onCoins; final VoidCallback? onRewards; final ValueChanged? onAction; final List? dramas; final ValueChanged? onDramaSelected; @override State createState() => _DiscoverPageState(); } class _DiscoverPageState extends State { late final DiscoverLogic logic; @override void initState() { super.initState(); logic = DiscoverBinding().create( drama: widget.drama, history: widget.history ?? context.read(), ); if (widget.focusSearch) logic.searchOpen = true; } @override void didUpdateWidget(covariant DiscoverPage oldWidget) { super.didUpdateWidget(oldWidget); logic.syncDrama( active: widget.active, wasActive: oldWidget.active, nextDrama: widget.drama, dramaChanged: oldWidget.drama.id != widget.drama.id, searchRequest: widget.searchRequest, previousSearchRequest: oldWidget.searchRequest, isActive: () => mounted && widget.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 categories = context.watch(); final homeBanners = context.watch().feed.banners; final allDramas = [...?widget.dramas, ...categories.visibleDramas]; final content = SafeArea( bottom: !widget.embedded, child: Column( children: [ _header(l10n), Expanded(child: _feed(l10n, categories, homeBanners, allDramas)), if (!widget.embedded) _bottomNav(l10n), ], ), ); return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: Theme( data: AppTheme.dark(), child: widget.embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), ), ); } Widget _feed( AppLocalizations l10n, CategoryController categories, List banners, List allDramas, ) => CustomScrollView( controller: logic.scroll, slivers: [ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), child: ClipRRect( borderRadius: BorderRadius.circular(18), child: _carousel(l10n, banners, allDramas), ), ), ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 18, 16, 14), child: _categories(l10n, categories), ), ), _HomeDramaGrid( l10n: l10n, categories: categories, onOpen: (drama) => logic.openCard( context, drama, embedded: widget.embedded, onDramaSelected: widget.onDramaSelected, onAction: widget.onAction, ), ), ], ); Widget _header(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.fromLTRB(16, 6, 12, 8), child: Row( children: [ const _BrandMark(), const SizedBox(width: 8), Expanded( child: Text( l10n.appName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.cream, fontSize: 20, fontWeight: FontWeight.w800, height: 1.1, ), ), ), const SizedBox(width: 8), if (widget.signedIn) ...[ _HeaderPill( icon: Icons.monetization_on_rounded, label: r'$ 0', onTap: widget.onCoins, ), const SizedBox(width: 8), _HeaderPill( icon: Icons.card_giftcard_rounded, label: l10n.rewards, onTap: widget.onRewards, ), ] else _LoginButton(label: l10n.logIn, onTap: widget.onSignIn), ], ), ); } Widget _carousel( AppLocalizations l10n, List banners, List allDramas, ) { final items = banners .where((banner) => banner.imageUrl.isNotEmpty) .toList(); if (items.isEmpty) return const SizedBox.shrink(); return CoverCarousel( active: widget.active, items: [ for (final banner in items) CoverItem( title: _bannerTitle(banner, allDramas), subtitle: '', image: banner.imageUrl, onOpen: () => _openBanner(banner, allDramas), ), ], ); } String _bannerTitle(FeedBanner banner, List allDramas) { final dramaId = banner.dramaId; if (dramaId == null) return ''; for (final drama in allDramas) { if (drama.id == dramaId) return drama.title; } return ''; } void _openBanner(FeedBanner banner, List allDramas) { final dramaId = banner.dramaId; if (dramaId == null) return; for (final drama in allDramas) { if (drama.id == dramaId) { logic.openCard( context, drama, embedded: widget.embedded, onDramaSelected: widget.onDramaSelected, onAction: widget.onAction, ); return; } } } Widget _categories(AppLocalizations l10n, CategoryController categories) { final choices = <({String code, String label})>[ (code: CategoryController.allCode, label: l10n.all), (code: CategoryController.hotCode, label: l10n.hot), (code: CategoryController.newCode, label: l10n.newest), for (final category in categories.categories) (code: 'category-${category.id}', label: category.name), ]; return SizedBox( key: const Key('home-category-chips'), height: AppDimens.tapTarget, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: choices.length, separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (context, index) { final choice = choices[index]; final selected = categories.selectedCode == choice.code; return Semantics( selected: selected, button: true, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => categories.selectCode(choice.code), child: Center( child: AnimatedContainer( duration: const Duration(milliseconds: 180), alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: selected ? AppColors.blue : AppColors.raised, borderRadius: BorderRadius.circular(AppDimens.pillRadius), ), child: Text( choice.label, maxLines: 1, style: TextStyle( color: selected ? AppColors.onPrimary : AppColors.cream, fontSize: AppDimens.body, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, height: 1.1, ), ), ), ), ), ); }, ), ); } Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), icon: Icons.play_circle_outline_rounded, selectedIcon: Icons.play_circle_fill_rounded, label: l10n.homeTab, onTap: () => logic.act( context, DiscoverPageAction.home, embedded: widget.embedded, onAction: widget.onAction, ), ), AppBottomNavItem( tabKey: const Key('discover-nav-current'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, label: l10n.discoverTab, selected: true, onTap: () {}, ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), icon: Icons.video_library_outlined, selectedIcon: Icons.video_library_rounded, label: l10n.myTab, onTap: () => logic.act( context, DiscoverPageAction.my, embedded: widget.embedded, onAction: widget.onAction, ), ), ], ); } const _coin = AppColors.gold; class _BrandMark extends StatelessWidget { const _BrandMark(); @override Widget build(BuildContext context) { return Container( width: 28, height: 28, alignment: Alignment.center, decoration: BoxDecoration( color: AppColors.blue, borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.play_arrow_rounded, color: Colors.white, size: 20, ), ); } } class _HeaderPill extends StatelessWidget { const _HeaderPill({ required this.icon, required this.label, required this.onTap, }); final IconData icon; final String label; final VoidCallback? onTap; @override Widget build(BuildContext context) { return Material( color: AppColors.raised, shape: StadiumBorder( side: BorderSide(color: AppColors.gold.withValues(alpha: 0.45)), ), child: InkWell( customBorder: const StadiumBorder(), onTap: onTap, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: AppDimens.tapTarget), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 15, color: _coin), const SizedBox(width: 4), Text( label, maxLines: 1, style: const TextStyle( color: AppColors.cream, fontSize: AppDimens.caption, fontWeight: FontWeight.w700, height: 1.1, ), ), ], ), ), ), ), ); } } class _LoginButton extends StatelessWidget { const _LoginButton({required this.label, required this.onTap}); final String label; final VoidCallback? onTap; @override Widget build(BuildContext context) { return TextButton( onPressed: onTap, style: TextButton.styleFrom( foregroundColor: Colors.white, backgroundColor: AppColors.blue, padding: const EdgeInsets.symmetric(horizontal: 14), minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget), shape: const StadiumBorder(), textStyle: const TextStyle( fontSize: AppDimens.body, fontWeight: FontWeight.w600, ), ), child: Text(label, maxLines: 1), ); } }