// 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_theme.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: const Color(0xFF0B0C10), 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, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _categories(l10n, categories), const SizedBox(height: 18), Text( l10n.allDramas, key: const Key('home-all-dramas-title'), style: const TextStyle( color: AppColors.onPrimary, fontSize: 18, fontWeight: FontWeight.w700, height: 1.2, ), ), ], ), ), ), _HomeDramaGrid( l10n: l10n, categories: categories, onOpen: (drama) => logic.openCard( context, drama, embedded: widget.embedded, onDramaSelected: widget.onDramaSelected, onAction: widget.onAction, ), ), const SliverToBoxAdapter(child: SizedBox(height: 24)), ], ); 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: Colors.white, fontSize: 20, fontWeight: FontWeight.w700, 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.forYou), (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( height: 32, 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 GestureDetector( onTap: () => categories.selectCode(choice.code), child: AnimatedContainer( duration: const Duration(milliseconds: 180), alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: selected ? AppColors.bluePressed : const Color(0xFF1C1E24), borderRadius: BorderRadius.circular(16), ), child: Text( choice.label, maxLines: 1, style: TextStyle( color: selected ? Colors.white : AppColors.muted, fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, height: 1.1, ), ), ), ); }, ), ); } Widget _bottomNav(AppLocalizations l10n) => Container( height: 63, decoration: const BoxDecoration( color: Color(0xFF0D0D11), border: Border(top: BorderSide(color: Color(0xFF242329))), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _DiscoverNavItem( icon: Icons.play_circle_outline_rounded, label: l10n.homeTab, onTap: () => logic.act( context, DiscoverPageAction.home, embedded: widget.embedded, onAction: widget.onAction, ), ), _DiscoverNavItem( icon: Icons.explore_outlined, label: l10n.discoverTab, selected: true, onTap: () {}, ), _DiscoverNavItem( icon: Icons.video_library_outlined, label: l10n.myTab, onTap: () => logic.act( context, DiscoverPageAction.my, embedded: widget.embedded, onAction: widget.onAction, ), ), ], ), ); } class _DiscoverNavItem extends StatelessWidget { const _DiscoverNavItem({ required this.icon, required this.label, required this.onTap, this.selected = false, }); final IconData icon; final String label; final VoidCallback onTap; final bool selected; @override Widget build(BuildContext context) => InkWell( onTap: onTap, child: SizedBox( width: 72, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( icon, size: 21, color: selected ? _accent : const Color(0xFFCAC4C7), ), Text( label, style: TextStyle( color: selected ? _accent : const Color(0xFFCAC4C7), fontSize: 10, fontWeight: FontWeight.w700, ), ), ], ), ), ); } const _accent = AppColors.blueSoft; const _coin = Color(0xFFF5C451); 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: const Color(0xFF1C1E24), shape: const StadiumBorder(side: BorderSide(color: Color(0x66F5C451))), child: InkWell( customBorder: const StadiumBorder(), onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 15, color: _coin), const SizedBox(width: 4), Text( label, maxLines: 1, style: const TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600, 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, vertical: 6), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: const StadiumBorder(), textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), child: Text(label, maxLines: 1), ); } }