// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/config/locale_controller.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/auth_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/theme/app_colors.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 '../account/account_entry_card.dart'; import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; import '../notices/notices_page.dart'; import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; class MyPage extends StatefulWidget { const MyPage({ super.key, required this.drama, required this.duration, this.initialSection = 0, this.profile = false, this.history, this.embedded = false, this.active = true, this.sectionRequest = 0, this.onAction, this.signedIn = false, this.onSignIn, this.onSignOut, this.dramas, this.onDramaSelected, this.wallet, }); final Drama drama; final Duration duration; final int initialSection; final bool profile; final WatchHistoryRepository? history; final bool embedded; final bool active; final int sectionRequest; final ValueChanged? onAction; final bool signedIn; final VoidCallback? onSignIn; final VoidCallback? onSignOut; final List? dramas; final ValueChanged? onDramaSelected; final WalletController? wallet; @override State createState() => _MyPageState(); } class _MyPageState extends State { late final MineLogic logic; @override void initState() { super.initState(); logic = MineBinding().create( drama: widget.drama, initialSection: widget.initialSection, history: widget.history ?? context.read(), ); } @override void didUpdateWidget(covariant MyPage oldWidget) { super.didUpdateWidget(oldWidget); logic.syncPage( active: widget.active, wasActive: oldWidget.active, nextDrama: widget.drama, dramaChanged: oldWidget.drama.id != widget.drama.id, sectionRequest: widget.sectionRequest, previousSectionRequest: oldWidget.sectionRequest, initialSection: widget.initialSection, ); } @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 content = SafeArea( bottom: !widget.embedded, child: Column( children: [ _topBar(l10n), Expanded( child: ListView( padding: const EdgeInsets.fromLTRB(14, 2, 14, 18), children: [ _userRow(l10n), const SizedBox(height: 12), _wallet(l10n), if (_continueDrama != null) ...[ const SizedBox(height: 12), _continueCard(l10n, _continueDrama!, _continueRecord!), ], const SizedBox(height: 14), _library(l10n), const SizedBox(height: 14), _languageRow(l10n), if (widget.signedIn) ...[ const SizedBox(height: 12), const AccountEntryCard(), ], const SizedBox(height: 12), _menuCard(l10n), ], ), ), if (!widget.embedded) _bottomNav(l10n), ], ), ); return Theme( data: AppTheme.dark(), child: widget.embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), ); } Widget _topBar(AppLocalizations l10n) => SizedBox( height: 46, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14), 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.myTab, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, fontSize: 15, fontWeight: FontWeight.w800, ), ), const Spacer(), _RoundHeaderButton( key: const Key('mine-search-button'), tooltip: l10n.search, icon: Icons.search_rounded, onTap: () => logic.act( context, MyPageAction.discover, embedded: widget.embedded, onAction: widget.onAction, ), ), const SizedBox(width: 8), Container( key: const Key('mine-avatar'), width: 36, height: 36, decoration: const BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF6A5CFF), AppColors.blue], ), ), child: const Icon( Icons.person_rounded, color: AppColors.onSurface, size: 18, ), ), ], ), ), ); Widget _userRow(AppLocalizations l10n) { final user = widget.signedIn ? context.watch().user : null; final name = widget.signedIn ? ((user?.nickname.isNotEmpty ?? false) ? user!.nickname : l10n.localViewer) : l10n.localViewer; final subtitle = widget.signedIn ? ((user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.myAccount) : l10n.logInHint; return Row( key: const Key('mine-user-row'), children: [ Container( width: 48, height: 48, decoration: const BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF6A5CFF), AppColors.blue], ), ), child: const Icon( Icons.person_rounded, color: AppColors.onSurface, size: 22, ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 15, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10), ), ], ), ), if (widget.signedIn) TextButton( onPressed: widget.onSignOut, style: TextButton.styleFrom( foregroundColor: AppColors.muted, backgroundColor: const Color(0xFF24252A), minimumSize: const Size(52, 30), padding: const EdgeInsets.symmetric(horizontal: 10), shape: const StadiumBorder(), textStyle: const TextStyle(fontSize: 10), ), child: Text(l10n.signOut), ) else TextButton( key: const Key('mine-sign-in'), onPressed: widget.onSignIn, style: TextButton.styleFrom( foregroundColor: AppColors.cream, backgroundColor: const Color(0xFF24252A), minimumSize: const Size(64, 30), padding: const EdgeInsets.symmetric(horizontal: 10), shape: const StadiumBorder(), textStyle: const TextStyle(fontSize: 10), ), child: Text('${l10n.logIn}/${l10n.signUp}'), ), ], ); } Widget _wallet(AppLocalizations l10n) { final wallet = widget.wallet; if (wallet == null) return _walletCard(l10n, null); return ListenableBuilder( listenable: wallet, builder: (context, _) => _walletCard(l10n, wallet), ); } Widget _walletCard(AppLocalizations l10n, WalletController? wallet) { final value = wallet == null ? l10n.coins : l10n.walletBalanceShort(wallet.balance); return InkWell( key: const Key('mine-wallet'), onTap: () => _openWallet(context), borderRadius: BorderRadius.circular(13), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.goldCard, borderRadius: BorderRadius.circular(13), ), child: Row( children: [ const Icon( Icons.monetization_on_rounded, color: AppColors.gold, size: 20, ), const SizedBox(width: 9), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.coins, style: const TextStyle( color: AppColors.onSurface, fontSize: 12, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( widget.signedIn ? value : l10n.logInHint, style: const TextStyle( color: AppColors.accent, fontSize: 9, ), ), ], ), ), const Icon( Icons.chevron_right_rounded, color: Color(0xFF666666), size: 16, ), ], ), ), ); } void _openWallet(BuildContext context) { if (!widget.signedIn) { widget.onSignIn?.call(); return; } Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => const WalletPage())); } WatchProgress? get _continueRecord { for (final record in logic.recent) { if (!record.finished && record.hasPosition) return record; } return null; } Drama? get _continueDrama { final record = _continueRecord; if (record == null) return null; for (final drama in [...?widget.dramas, widget.drama]) { if (drama.id == record.dramaId) return drama; } return null; } Widget _continueCard( AppLocalizations l10n, Drama drama, WatchProgress record, ) { return Container( key: const Key('mine-continue-watching'), padding: const EdgeInsets.all(9), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ _poster(drama, width: 50, height: 64, radius: 7), const SizedBox(width: 9), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.continueWatching, style: const TextStyle( color: AppColors.accent, fontSize: 9, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( localizedDramaTitle(l10n, drama), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 12, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( '${_episodeText(drama, record)} · ' '${_clock(record.position)} / ${_clock(record.duration)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), ], ), ), const SizedBox(width: 8), SizedBox( width: 60, height: 28, child: FilledButton( onPressed: () => _resume(drama), style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, padding: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(9), ), textStyle: const TextStyle( fontSize: 10, fontWeight: FontWeight.w700, ), ), child: Text(l10n.resume), ), ), ], ), ); } Widget _library(AppLocalizations l10n) { final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ for (var index = 0; index < tabs.length; index++) Padding( padding: EdgeInsets.only( right: index == tabs.length - 1 ? 0 : 20, ), child: Semantics( selected: logic.section == index, button: true, child: InkWell( key: Key('mine-library-tab-$index'), onTap: () => logic.selectSection(index), child: Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( tabs[index], maxLines: 1, style: TextStyle( color: logic.section == index ? AppColors.blue : const Color(0xFFE4DBDD), fontSize: 14, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 4), AnimatedContainer( duration: const Duration(milliseconds: 160), width: 18, height: 2, decoration: BoxDecoration( color: logic.section == index ? AppColors.blue : Colors.transparent, borderRadius: BorderRadius.circular(1), ), ), ], ), ), ), ), ), ], ), ), const SizedBox(height: 12), if (logic.section == 1 && context.watch().loading) const Center( child: Padding( padding: EdgeInsets.all(12), child: CircularProgressIndicator(strokeWidth: 2), ), ) else if (logic.section == 1) ..._favoriteCards(l10n) else if (logic.section == 2) ..._likedCards(l10n) else if (logic.recentLoading) const Center( child: Padding( padding: EdgeInsets.all(12), child: CircularProgressIndicator(strokeWidth: 2), ), ) else if (_recentDramas.isEmpty) EmptyState( key: const Key('mine-recent-empty'), icon: Icons.history_rounded, title: l10n.noStories, subtitle: l10n.emptyHint, compact: true, ) else for (final drama in _recentDramas) ...[ _historyCard(l10n, drama), const SizedBox(height: 8), ], ], ); } List _favoriteCards(AppLocalizations l10n) { final favorites = context.watch(); if (favorites.error != null && favorites.cards.isEmpty) { return [ EmptyState( icon: Icons.cloud_off_rounded, title: errorText(l10n, favorites.error, l10n.favoritesError), actionLabel: l10n.retry, onAction: () => unawaited(favorites.load()), compact: true, ), ]; } if (favorites.cards.isEmpty) { return [ EmptyState( key: const Key('mine-saved-empty'), icon: Icons.bookmark_border_rounded, title: l10n.noSavedShows, subtitle: l10n.emptyHint, compact: true, ), ]; } return [ for (final drama in favorites.cards) ...[ _smallDramaCard(l10n, drama), const SizedBox(height: 8), ], ]; } List _likedCards(AppLocalizations l10n) { final interactions = context.watch(); final dramas = [ ...?widget.dramas, widget.drama, ].where((drama) => interactions.isLiked(drama.id)).toList(); if (dramas.isEmpty) { return [ EmptyState( key: const Key('mine-saved-empty'), icon: Icons.favorite_border_rounded, title: l10n.noSavedShows, subtitle: l10n.emptyHint, compact: true, ), ]; } return [ for (final drama in dramas) ...[ _smallDramaCard(l10n, drama), const SizedBox(height: 8), ], ]; } Widget _historyCard(AppLocalizations l10n, Drama drama) { final record = _recordFor(drama.id); return InkWell( key: Key('mine-history-card-${drama.id}'), onTap: () => _resume(drama), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ _poster(drama, width: 48, height: 60, radius: 7), const SizedBox(width: 9), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( localizedDramaTitle(l10n, drama), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 11, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 3), Text( record == null ? l10n.episodeCount(drama.episodeCount) : '${_episodeText(drama, record)} · ' '${_clock(record.position)} / ${_clock(record.duration)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Color(0xFFBBBBBB), fontSize: 9, ), ), ], ), ), Container( width: 26, height: 26, decoration: const BoxDecoration( color: Color(0xFF313137), shape: BoxShape.circle, ), child: const Icon( Icons.play_arrow_rounded, color: AppColors.blue, size: 15, ), ), ], ), ), ); } Widget _smallDramaCard(AppLocalizations l10n, Drama drama) { return InkWell( onTap: () { if (widget.onDramaSelected != null) { widget.onDramaSelected!(drama); } else { _resume(drama); } }, borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ _poster(drama, width: 48, height: 60, radius: 7), const SizedBox(width: 9), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( localizedDramaTitle(l10n, drama), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 11, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 3), Text( l10n.episodeCount(drama.episodeCount), style: const TextStyle( color: Color(0xFFBBBBBB), fontSize: 9, ), ), ], ), ), const Icon( Icons.chevron_right_rounded, color: AppColors.muted, size: 16, ), ], ), ), ); } Widget _languageRow(AppLocalizations l10n) { final locale = context.watch(); final code = locale.locale?.languageCode ?? 'id'; final value = switch (code) { 'zh' => l10n.simplifiedChinese, 'en' => l10n.english, _ => l10n.bahasaIndonesia, }; return InkWell( key: const Key('mine-language'), onTap: () => logic.showLanguage(context), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ const Icon( Icons.language_rounded, color: AppColors.muted, size: 14, ), const SizedBox(width: 9), Expanded( child: Text( l10n.appLanguage, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 11, ), ), ), Text( value, style: const TextStyle(color: AppColors.accent, fontSize: 10), ), const SizedBox(width: 3), const Icon( Icons.chevron_right_rounded, color: AppColors.accent, size: 16, ), ], ), ), ); } Widget _menuCard(AppLocalizations l10n) { return Material( color: AppColors.card, borderRadius: BorderRadius.circular(12), clipBehavior: Clip.antiAlias, child: Column( children: [ _menuTile( icon: Icons.notifications_none_rounded, title: l10n.notices, key: const Key('mine-notices'), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const NoticesPage()), ), ), _menuTile( icon: Icons.history_rounded, title: l10n.watchHistory, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const WatchRecordsPage()), ), ), _menuTile( icon: Icons.favorite_outline_rounded, title: l10n.myFavorites, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const FavoritesPage()), ), ), _menuTile( icon: Icons.download_outlined, title: l10n.downloads, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DownloadsPage()), ), ), _menuTile( icon: Icons.settings_rounded, title: l10n.settings, onTap: () => logic.showLanguage(context), ), ], ), ); } Widget _menuTile({ required IconData icon, required String title, required VoidCallback onTap, Key? key, }) { return ListTile( key: key, dense: true, minTileHeight: 44, leading: Icon(icon, color: AppColors.secondary, size: 17), title: Text( title, style: const TextStyle(color: AppColors.onSurface, fontSize: 11), ), trailing: const Icon( Icons.chevron_right_rounded, color: AppColors.muted, size: 16, ), onTap: onTap, ); } Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( tabKey: const Key('mine-nav-home'), icon: Icons.play_circle_outline_rounded, selectedIcon: Icons.play_circle_fill_rounded, label: l10n.homeTab, onTap: () => logic.act( context, MyPageAction.home, embedded: widget.embedded, onAction: widget.onAction, ), ), AppBottomNavItem( tabKey: const Key('mine-nav-discover'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, label: l10n.discoverTab, onTap: () => logic.act( context, MyPageAction.discover, embedded: widget.embedded, onAction: widget.onAction, ), ), AppBottomNavItem( tabKey: const Key('mine-nav-current'), icon: Icons.video_library_outlined, selectedIcon: Icons.video_library_rounded, label: l10n.myTab, selected: true, onTap: () {}, ), ], ); List get _recentDramas { final pool = [...?widget.dramas, widget.drama]; final result = []; for (final item in logic.recent) { for (final drama in pool) { if (drama.id == item.dramaId && !result.any((picked) => picked.id == drama.id)) { result.add(drama); } } } return result; } WatchProgress? _recordFor(String dramaId) { for (final record in logic.recent) { if (record.dramaId == dramaId) return record; } return null; } String _episodeText(Drama drama, WatchProgress record) { for (final episode in drama.episodes) { if (episode.id == record.episodeId) { return AppLocalizations.of( context, )!.episodeOfTotal(episode.number, drama.episodeCount); } } return AppLocalizations.of(context)!.episodeCount(drama.episodeCount); } void _resume(Drama drama) { if (widget.onDramaSelected != null) { widget.onDramaSelected!(drama); } else { logic.act( context, MyPageAction.resume, embedded: widget.embedded, onAction: widget.onAction, ); } } Widget _poster( Drama drama, { required double width, required double height, required double radius, }) => ClipRRect( borderRadius: BorderRadius.circular(radius), child: RemoteImage(drama.posterUrl, width: width, height: height), ); String _clock(Duration duration) { final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; } } class _RoundHeaderButton extends StatelessWidget { const _RoundHeaderButton({ super.key, required this.tooltip, required this.icon, required this.onTap, }); final String tooltip; final IconData icon; 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), ), ); } }