// 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/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.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'; 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; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && widget.active) logic.searchFocus.requestFocus(); }); } } @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 allDramas = [...?widget.dramas, ...categories.visibleDramas]; final visible = logic.filtered(categories.visibleDramas); final content = SafeArea( bottom: !widget.embedded, child: Column( children: [ Expanded(child: _feed(l10n, categories, allDramas, visible)), 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 allDramas, List visible, ) => CustomScrollView( controller: logic.scroll, slivers: [ SliverToBoxAdapter(child: _header(l10n)), SliverToBoxAdapter(child: _search()), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(0, 14, 0, 12), child: _categories(l10n, categories), ), ), if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), child: _continueCard( l10n, logic.resumeDrama(allDramas)!, logic.resumeRecord!, ), ), ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), child: _quickActions(l10n, categories), ), ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), child: Row( children: [ Container( width: 3, height: 18, decoration: BoxDecoration( color: AppColors.blue, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 6), Expanded( child: Text( l10n.trendingMicroDramas, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: AppDimens.previewSectionTitle, fontWeight: FontWeight.w800, ), ), ), TextButton( onPressed: () => categories.selectCode(CategoryController.allCode), style: TextButton.styleFrom( foregroundColor: AppColors.accent, minimumSize: const Size(44, 44), padding: EdgeInsets.zero, ), child: Text( '${l10n.all} ›', style: const TextStyle(fontSize: 10), ), ), ], ), ), ), _HomeDramaGrid( l10n: l10n, categories: categories, dramas: visible, searching: logic.search.text.trim().isNotEmpty, 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(14, 2, 14, 10), child: Row( children: [ Container( width: 24, height: 24, alignment: Alignment.center, decoration: BoxDecoration( color: AppColors.blue, borderRadius: BorderRadius.circular(7), ), child: const Icon( Icons.play_arrow_rounded, color: AppColors.onPrimary, size: 16, ), ), const SizedBox(width: 8), Text( l10n.discoverTab, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, fontSize: 15, fontWeight: FontWeight.w800, ), ), const Spacer(), _HeaderIcon( key: const Key('discover-search-button'), icon: Icons.search_rounded, tooltip: l10n.search, onTap: logic.openSearch, ), const SizedBox(width: 8), Semantics( button: true, label: widget.signedIn ? l10n.myTab : l10n.logIn, child: InkWell( key: const Key('discover-profile'), customBorder: const CircleBorder(), onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, child: Container( width: 36, height: 36, decoration: BoxDecoration( shape: BoxShape.circle, gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF6A5CFF), AppColors.blue], ), ), child: const Icon( Icons.person_rounded, color: AppColors.onSurface, size: 18, ), ), ), ), ], ), ); } Widget _search() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 14), child: SizedBox( height: 36, child: TextField( key: const Key('discover-search'), controller: logic.search, focusNode: logic.searchFocus, onChanged: (_) => logic.onSearchChanged(), textInputAction: TextInputAction.search, style: const TextStyle(color: AppColors.onSurface, fontSize: 13), decoration: InputDecoration( isDense: true, filled: true, fillColor: AppColors.input, hintText: AppLocalizations.of(context)!.searchDiscover, hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11), prefixIcon: const Icon( Icons.search_rounded, color: AppColors.subtle, size: 17, ), contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(20), borderSide: const BorderSide(color: AppColors.blue, width: 1), ), ), ), ), ); } Widget _categories(AppLocalizations l10n, CategoryController categories) { final choices = <({String code, String label})>[ (code: CategoryController.allCode, label: l10n.recommended), 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( padding: const EdgeInsets.symmetric(horizontal: 14), 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: 15), decoration: BoxDecoration( color: selected ? AppColors.blue : const Color(0xFF29272B), borderRadius: BorderRadius.circular(999), border: Border.all( color: selected ? AppColors.blue : Colors.transparent, ), ), child: Text( choice.label, maxLines: 1, style: TextStyle( color: selected ? AppColors.onPrimary : AppColors.muted, fontSize: AppDimens.chipFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, height: 1.1, ), ), ), ), ), ); }, ), ); } Widget _continueCard( AppLocalizations l10n, Drama drama, WatchProgress record, ) { final progress = record.fraction; return Container( key: const Key('discover-continue-watching'), padding: const EdgeInsets.all(9), decoration: BoxDecoration( color: AppColors.cardAlt, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(10), child: SizedBox( width: 52, height: 66, child: RemoteImage( drama.posterUrl, fit: BoxFit.cover, showFallbackIcon: true, ), ), ), const SizedBox(width: 9), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.continueWatching, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.accent, fontSize: 8, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( drama.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 12, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( _resumeShort(drama, record), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), const SizedBox(height: 7), ClipRRect( borderRadius: BorderRadius.circular(999), child: LinearProgressIndicator( value: progress <= 0 ? null : progress, minHeight: 3, backgroundColor: const Color(0xFF474248), valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), ], ), ), const SizedBox(width: 10), FilledButton( key: const Key('discover-continue-play'), onPressed: () => logic.openCard( context, drama, embedded: widget.embedded, onDramaSelected: widget.onDramaSelected, onAction: widget.onAction, ), style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, minimumSize: const Size(56, 28), padding: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(9), ), textStyle: const TextStyle( fontSize: 10, fontWeight: FontWeight.w700, ), ), child: Text(l10n.resume), ), ], ), ); } String _resumeShort(Drama drama, WatchProgress record) { final label = AppLocalizations.of(context)!; Episode? episode; for (final item in drama.episodes) { if (item.id == record.episodeId) { episode = item; break; } } if (episode == null) return _duration(record.position); return label.episodeOfTotal(episode.number, drama.episodeCount); } Widget _quickActions(AppLocalizations l10n, CategoryController categories) { return Row( children: [ Expanded( child: _QuickAction( key: const Key('discover-quick-filter'), icon: Icons.tune_rounded, label: l10n.filter, color: AppColors.secondary, onTap: logic.openSearch, ), ), const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-charts'), icon: Icons.local_fire_department_rounded, label: l10n.topCharts, color: AppColors.blue, onTap: () => categories.selectCode(CategoryController.hotCode), ), ), const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-new'), icon: Icons.auto_awesome_rounded, label: l10n.newReleases, color: AppColors.gold, onTap: () => categories.selectCode(CategoryController.newCode), ), ), const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-watchlist'), icon: Icons.bookmark_border_rounded, label: l10n.watchlist, color: AppColors.success, onTap: () => logic.act( context, DiscoverPageAction.watchlist, embedded: widget.embedded, onAction: widget.onAction, ), ), ), ], ); } 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, ), ), ], ); } class _HeaderIcon extends StatelessWidget { const _HeaderIcon({ super.key, required this.icon, required this.tooltip, required this.onTap, }); final IconData icon; final String tooltip; final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox( width: 36, height: 36, child: IconButton( tooltip: tooltip, onPressed: onTap, padding: EdgeInsets.zero, style: IconButton.styleFrom( backgroundColor: const Color(0xFF2C2D33), foregroundColor: AppColors.onSurface, ), icon: Icon(icon, size: 18), ), ); } } class _QuickAction extends StatelessWidget { const _QuickAction({ super.key, required this.icon, required this.label, required this.color, required this.onTap, }); final IconData icon; final String label; final Color color; final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( color: AppColors.quick, borderRadius: BorderRadius.circular(12), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Container( height: 62, padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5), decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: color, size: 16), const SizedBox(height: 4), Text( label, maxLines: 2, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, fontSize: 9, fontWeight: FontWeight.w500, height: 1.05, ), ), ], ), ), ), ); } } String _duration(Duration value) { final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; }