// 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/theme/app_colors.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'; 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 catalog = context.watch(); final allDramas = widget.dramas ?? [logic.drama]; final cards = _cardsFor( allDramas, catalog.sections, logic.category, '', ); final content = SafeArea( bottom: !widget.embedded, child: Column( children: [ _header(l10n), Expanded( child: ListView( controller: logic.scroll, padding: const EdgeInsets.only(bottom: 24), children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), child: ClipRRect( borderRadius: BorderRadius.circular(18), child: _carousel(l10n, catalog.banners, allDramas), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 18, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _categories(l10n), const SizedBox(height: 18), _continueHeader(l10n), const SizedBox(height: 12), if (cards.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Text( l10n.noSearchResults, style: const TextStyle(color: Color(0xFFE6E1E3)), ), ) else SizedBox( height: 196, child: ListView( scrollDirection: Axis.horizontal, children: [ for (final card in cards) ...[ _continueTile(l10n, card), const SizedBox(width: 12), ], ], ), ), ], ), ), ], ), ), if (!widget.embedded) _bottomNav(l10n), ], ), ); return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: Theme( data: ThemeData.dark(useMaterial3: true), child: widget.embedded ? content : Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content), ), ); } 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( 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 _continueHeader(AppLocalizations l10n) => Row( children: [ Expanded( child: Text( l10n.continueWatching, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: 0.2, height: 1.2, ), ), ), const SizedBox(width: 8), Text( l10n.seeAll, style: const TextStyle( color: AppColors.blue, fontSize: 13, fontWeight: FontWeight.w500, ), ), ], ); Widget _continueTile(AppLocalizations l10n, _ShowcaseCard card) { final drama = logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]); return SizedBox( width: 132, child: InkWell( onTap: () => logic.openCard( context, drama, embedded: widget.embedded, onDramaSelected: widget.onDramaSelected, onAction: widget.onAction, ), child: ClipRRect( borderRadius: BorderRadius.circular(14), child: Stack( fit: StackFit.expand, children: [ RemoteImage(card.image), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0x00000000), Color(0xE6000000)], stops: [0.45, 1], ), ), ), if (drama == null) const ColoredBox( color: Color(0x99000000), child: Center( child: Icon(Icons.image_outlined, color: Colors.white), ), ), Positioned( left: 10, right: 10, bottom: 10, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( card.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600, height: 1.2, ), ), const SizedBox(height: 3), Text( drama == null ? l10n.videoPending : card.genre, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Color(0xE6FFFFFF), fontSize: 11, fontWeight: FontWeight.w400, height: 1.2, ), ), ], ), ), ], ), ), ), ); } Widget _categories(AppLocalizations l10n) { final labels = [ l10n.forYou, l10n.hot, l10n.newest, l10n.romance, l10n.actionGenre, ]; return SizedBox( height: 32, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: labels.length, separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (context, index) { final selected = logic.category == index; return GestureDetector( onTap: () => logic.selectCategory(index), child: AnimatedContainer( duration: const Duration(milliseconds: 180), alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: selected ? AppColors.blue : const Color(0xFF1C1E24), borderRadius: BorderRadius.circular(16), ), child: Text( labels[index], 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 ? _orange : const Color(0xFFCAC4C7), ), Text( label, style: TextStyle( color: selected ? _orange : const Color(0xFFCAC4C7), fontSize: 10, fontWeight: FontWeight.w700, ), ), ], ), ), ); } class _ShowcaseCard { const _ShowcaseCard({ required this.title, required this.genre, required this.image, this.dramaId, }); final String title; final String genre; final String image; final String? dramaId; } /// 分区由服务端下发:0 为全部,其余按分区顺序对应筛选菜单(热门 / 最新 / 各分类)。 List<_ShowcaseCard> _cardsFor( List dramas, List sections, int category, String query, ) { final source = switch (category) { 0 => dramas, _ => category < sections.length ? sections[category].dramas : const [], }; final normalized = query.trim().toLowerCase(); return [ for (final drama in source) if (normalized.isEmpty || drama.title.toLowerCase().contains(normalized) || drama.genre.toLowerCase().contains(normalized) || drama.category.toLowerCase().contains(normalized)) _ShowcaseCard( title: drama.title, genre: drama.genre.isEmpty ? drama.category : drama.genre, image: drama.posterUrl, dramaId: drama.id, ), ]; } const _orange = AppColors.blue; 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), ); } }