// 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/glass_panel.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(16, 2, 16, 12), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.trendingMicroDramas, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, fontSize: 17, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( l10n.trendingSubtitle, style: const TextStyle( color: AppColors.subtle, fontSize: 11, ), ), ], ), ), TextButton( onPressed: () => categories.selectCode(CategoryController.hotCode), style: TextButton.styleFrom( foregroundColor: AppColors.blue, minimumSize: const Size(44, 44), padding: const EdgeInsets.symmetric(horizontal: 8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( l10n.seeAll, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, ), ), const Icon(Icons.chevron_right_rounded, size: 16), ], ), ), ], ), ), ), _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(16, 6, 16, 12), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 8, height: 8, decoration: const BoxDecoration( color: AppColors.blue, shape: BoxShape.circle, ), ), const SizedBox(width: 8), Flexible( child: Text( l10n.appName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, fontSize: 21, fontWeight: FontWeight.w700, height: 1.05, letterSpacing: 0, ), ), ), ], ), const SizedBox(height: 3), Text( l10n.verticalMiniSeries, style: const TextStyle( color: AppColors.muted, fontSize: 10, fontWeight: FontWeight.w500, letterSpacing: 0.6, ), ), ], ), ), const SizedBox(width: 12), 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: 38, height: 38, decoration: BoxDecoration( shape: BoxShape.circle, color: AppColors.raised, border: Border.all( color: AppColors.secondary.withValues(alpha: 0.7), width: 2, ), ), child: const Icon( Icons.person_rounded, color: AppColors.onSurface, size: 21, ), ), ), ), ], ), ); } Widget _search() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SizedBox( height: AppDimens.searchHeight, 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.card, hintText: AppLocalizations.of(context)!.searchDiscover, hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12.5), prefixIcon: const Icon( Icons.search_rounded, color: AppColors.subtle, size: 20, ), suffixIcon: const Icon( Icons.mic_none_rounded, color: AppColors.muted, size: 19, ), contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), borderSide: const BorderSide(color: AppColors.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), borderSide: const BorderSide(color: AppColors.blue, width: 1.2), ), ), ), ), ); } 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( padding: const EdgeInsets.symmetric(horizontal: 16), 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 : AppColors.card, borderRadius: BorderRadius.circular(999), border: Border.all( color: selected ? AppColors.blue : Colors.white.withValues(alpha: 0.08), ), ), child: Text( choice.label, maxLines: 1, style: TextStyle( color: selected ? AppColors.onPrimary : AppColors.muted, fontSize: 12, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, height: 1.1, ), ), ), ), ), ); }, ), ); } Widget _continueCard( AppLocalizations l10n, Drama drama, WatchProgress record, ) { final progress = record.fraction; return GlassPanel( key: const Key('discover-continue-watching'), radius: AppDimens.glassSmallRadius, blur: 18, padding: const EdgeInsets.all(12), gradient: const LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [Color(0x1FFFFFFF), Color(0x0AFFFFFF)], ), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(12), child: SizedBox( width: 64, height: 88, child: RemoteImage( drama.posterUrl, fit: BoxFit.cover, showFallbackIcon: true, ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 6, height: 6, decoration: const BoxDecoration( color: AppColors.blue, shape: BoxShape.circle, ), ), const SizedBox(width: 5), Flexible( child: Text( l10n.continueWatching, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.blue, fontSize: 9.5, fontWeight: FontWeight.w700, letterSpacing: 0.4, ), ), ), ], ), const SizedBox(height: 3), Text( drama.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 13, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( _resumeMeta(drama, record), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, fontSize: 10.5, ), ), const SizedBox(height: 7), ClipRRect( borderRadius: BorderRadius.circular(999), child: LinearProgressIndicator( value: progress <= 0 ? null : progress, minHeight: 3, backgroundColor: Colors.white.withValues(alpha: 0.12), 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(64, 34), padding: const EdgeInsets.symmetric(horizontal: 10), shape: const StadiumBorder(), textStyle: const TextStyle( fontSize: 11, fontWeight: FontWeight.w700, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(l10n.play), const SizedBox(width: 2), const Icon(Icons.play_arrow_rounded, size: 14), ], ), ), ], ), ); } String _resumeMeta(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; } } final head = episode == null ? '' : '${label.episodeLabel(episode.number)} / ${drama.episodeCount} ยท '; return '$head${_duration(record.position)} / ${_duration(record.duration)}'; } 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: 8), 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: 8), Expanded( child: _QuickAction( key: const Key('discover-quick-new'), icon: Icons.auto_awesome_rounded, label: l10n.newest, color: AppColors.gold, onTap: () => categories.selectCode(CategoryController.newCode), ), ), const SizedBox(width: 8), 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.home_outlined, selectedIcon: Icons.home_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.person_outline_rounded, selectedIcon: Icons.person_rounded, label: l10n.myTab, onTap: () => logic.act( context, DiscoverPageAction.my, embedded: widget.embedded, onAction: widget.onAction, ), ), ], ); } 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.card, borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Container( height: 76, padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), border: Border.all(color: AppColors.border), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 32, height: 32, decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), ), child: Icon(icon, color: color, size: 17), ), const SizedBox(height: 5), Text( label, maxLines: 2, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, fontSize: 9.5, 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'; }