// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/widgets/remote_image.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; import '../watch_records/watch_records_page.dart'; import '../favorites/favorites_page.dart'; import '../notices/notices_page.dart'; import '../downloads/downloads_page.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, }); 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; @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: [ if (!widget.profile) _topBar(l10n), Expanded( child: FutureBuilder( future: logic.position, builder: (context, snapshot) { final position = snapshot.data ?? Duration.zero; final total = widget.duration > Duration.zero ? widget.duration : const Duration(seconds: 12); final fraction = (position.inMilliseconds / total.inMilliseconds).clamp( 0.0, 1.0, ); return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), children: [ if (widget.profile) ..._accountPage(l10n) else ...[ _library(l10n, position, total, fraction), ], const SizedBox(height: 24), Center( child: Text( l10n.brandMark, style: TextStyle( color: Color(0xFFD2CDD0), fontSize: 10, letterSpacing: 1.3, ), ), ), ], ); }, ), ), if (!widget.embedded) _bottomNav(l10n), ], ), ); return Theme( data: AppTheme.dark(), child: widget.embedded ? content : Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content), ); } Widget _topBar(AppLocalizations l10n) => SizedBox( height: 54, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27), const SizedBox(width: 7), Text( l10n.myListTab, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800), ), const Spacer(), IconButton( tooltip: l10n.search, onPressed: () => logic.act( context, MyPageAction.discover, embedded: widget.embedded, onAction: widget.onAction, ), icon: const Icon(Icons.search_rounded, size: 23), ), const SizedBox(width: 6), const CircleAvatar( radius: 14, backgroundColor: Color(0xFF2C2D33), child: Icon(Icons.person_rounded, color: Colors.white, size: 19), ), ], ), ), ); List _accountPage(AppLocalizations l10n) => [ if (widget.signedIn) _signedInRow(l10n) else _guestRow(l10n), if (widget.signedIn) ...[const SizedBox(height: 18), _wallet(l10n)], const SizedBox(height: 14), _menuCard(l10n), const SizedBox(height: 18), Text( l10n.preferencesHeading, style: const TextStyle( color: AppColors.muted, fontSize: 12, fontWeight: FontWeight.w800, letterSpacing: 1.1, ), ), const SizedBox(height: 8), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.language_rounded, color: AppColors.blueSoft), title: Text(l10n.appLanguage), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => logic.showLanguage(context), ), ]; Widget _guestRow(AppLocalizations l10n) { return Row( children: [ const CircleAvatar( radius: 28, backgroundColor: Color(0xFF32343A), child: Icon(Icons.person_rounded, color: Colors.white, size: 30), ), const SizedBox(width: 12), Expanded( child: Text( l10n.logInHint, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), ), ), const SizedBox(width: 8), TextButton( onPressed: widget.onSignIn, style: TextButton.styleFrom( foregroundColor: Colors.white, backgroundColor: AppColors.blue, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), shape: const StadiumBorder(), ), child: Text(l10n.logIn), ), ], ); } Widget _signedInRow(AppLocalizations l10n) { // 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案 final user = context.watch().user; final name = (user?.nickname.isNotEmpty ?? false) ? user!.nickname : l10n.mockUserName; final uidText = (user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.mockUserId; return Row( children: [ const CircleAvatar( radius: 28, backgroundColor: Color(0xFF32343A), child: Icon(Icons.person_rounded, color: Colors.white, size: 30), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( uidText, style: const TextStyle(color: AppColors.muted, fontSize: 12), ), ], ), ), TextButton( onPressed: widget.onSignOut ?? () => _mockSnack(l10n), style: TextButton.styleFrom( backgroundColor: const Color(0xFF2A2C33), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), shape: const StadiumBorder(), ), child: Text(l10n.signOut), ), ], ); } Widget _wallet(AppLocalizations l10n) { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: const Color(0xFF17191F), borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.myAccount, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800), ), const SizedBox(height: 12), Row( children: [ Expanded( child: _accountTile( label: l10n.coins, value: '\$ 0', action: l10n.topUpWallet, color: AppColors.blue, onPressed: () => _mockSnack(l10n), ), ), const SizedBox(width: 10), Expanded( child: _accountTile( label: l10n.vipMembership, value: l10n.notActive, action: l10n.joinVip, color: const Color(0xFFF5C451), onPressed: () => _mockSnack(l10n), ), ), ], ), ], ), ); } void _mockSnack(AppLocalizations l10n) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.mockNotice))); } Widget _accountTile({ required String label, required String value, required String action, required Color color, required VoidCallback onPressed, }) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFF12141A), borderRadius: BorderRadius.circular(14), border: Border.all(color: color.withValues(alpha: 0.45)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(color: color, fontSize: 12)), const SizedBox(height: 6), Text( value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), ), const SizedBox(height: 10), SizedBox( width: double.infinity, child: FilledButton( onPressed: onPressed, style: FilledButton.styleFrom( backgroundColor: color, foregroundColor: color == AppColors.blue ? Colors.white : const Color(0xFF2A2110), visualDensity: VisualDensity.compact, shape: const StadiumBorder(), ), child: Text(action, maxLines: 1, overflow: TextOverflow.ellipsis), ), ), ], ), ); } Widget _menuCard(AppLocalizations l10n) { return Material( color: const Color(0xFF17191F), borderRadius: BorderRadius.circular(16), clipBehavior: Clip.antiAlias, child: Column( children: [ ListTile( leading: const Icon( Icons.card_giftcard_rounded, color: AppColors.blueSoft, ), title: Text(l10n.earnRewards), trailing: Text( l10n.freeBonus, style: const TextStyle(color: AppColors.blueSoft, fontSize: 12), ), onTap: () => _mockSnack(l10n), ), const Divider(height: 1, color: Color(0xFF2A2C33)), ListTile( key: const Key('mine-notices'), leading: const Icon( Icons.notifications_none_rounded, color: AppColors.blueSoft, ), title: Text(l10n.notices), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const NoticesPage()), ), ), const Divider(height: 1, color: Color(0xFF2A2C33)), ListTile( leading: const Icon( Icons.history_rounded, color: AppColors.blueSoft, ), title: Text(l10n.watchHistory), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const WatchRecordsPage()), ), ), const Divider(height: 1, color: Color(0xFF2A2C33)), ListTile( leading: const Icon( Icons.favorite_outline_rounded, color: AppColors.blueSoft, ), title: Text(l10n.myFavorites), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const FavoritesPage()), ), ), const Divider(height: 1, color: Color(0xFF2A2C33)), ListTile( leading: const Icon( Icons.download_outlined, color: AppColors.blueSoft, ), title: Text(l10n.downloads), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DownloadsPage()), ), ), const Divider(height: 1, color: Color(0xFF2A2C33)), ListTile( leading: const Icon( Icons.settings_rounded, color: AppColors.blueSoft, ), title: Text(l10n.settings), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => logic.showLanguage(context), ), ], ), ); } Widget _library( AppLocalizations l10n, Duration position, Duration total, double fraction, ) { final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; final filters = [l10n.all, l10n.inProgress, l10n.completed]; final hasHistory = logic.filter == 0 || (logic.filter == 1 && fraction < 0.99) || (logic.filter == 2 && fraction >= 0.99); final interactions = context.watch(); // 收藏以服务端为准;剧集点赞暂按当前会话状态过滤 feed。 final favorites = context.watch(); final isWatchlist = logic.section == 1; final markedDramas = isWatchlist ? favorites.cards : (widget.dramas ?? [widget.drama]) .where((drama) => interactions.isLiked(drama.id)) .toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ for (var index = 0; index < tabs.length; index++) ...[ InkWell( onTap: () { logic.selectSection(index); // 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐) if (index == 1) unawaited(favorites.load()); }, borderRadius: BorderRadius.circular(20), child: Container( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 8, ), decoration: BoxDecoration( color: logic.section == index ? AppColors.bluePressed : const Color(0xFF24262E), borderRadius: BorderRadius.circular(20), ), child: Text( tabs[index], style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w700, ), ), ), ), if (index < tabs.length - 1) const SizedBox(width: 18), ], ], ), ), const SizedBox(height: 17), if (logic.section == 0) SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ for (var index = 0; index < filters.length; index++) Padding( padding: const EdgeInsets.only(right: 7), child: ChoiceChip( label: Text(filters[index]), selected: logic.filter == index, onSelected: (_) => logic.selectFilter(index), showCheckmark: false, visualDensity: VisualDensity.compact, backgroundColor: const Color(0xFF242328), selectedColor: _accent, labelStyle: TextStyle( color: logic.filter == index ? Colors.white : const Color(0xFFD5CED0), fontSize: 11, fontWeight: FontWeight.w700, ), side: BorderSide.none, ), ), ], ), ), if (logic.section == 0) const SizedBox(height: 11), if (logic.section == 0 && hasHistory) InkWell( onTap: () => logic.act( context, MyPageAction.resume, embedded: widget.embedded, onAction: widget.onAction, ), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(9), decoration: BoxDecoration( color: const Color(0xFF1B1C21), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ _poster(width: 74, height: 103), const SizedBox(width: 11), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( localizedDramaTitle(l10n, widget.drama), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 5), Text( l10n.dramaTag, style: const TextStyle( color: Color(0xFFE6E1E3), fontSize: 12, ), ), const SizedBox(height: 32), Text( '${l10n.episodeLabel(1)} · ${_clock(position)} / ${_clock(total)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.blueSoft, fontSize: 10, ), ), ], ), ), const CircleAvatar( radius: 15, backgroundColor: Color(0xFF313137), child: Icon( Icons.play_arrow_rounded, color: _accent, size: 20, ), ), ], ), ), ) else if (isWatchlist && favorites.loading) const Padding( padding: EdgeInsets.symmetric(vertical: 22), child: Center( child: SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2), ), ), ) else if (isWatchlist && favorites.error != null) Padding( padding: const EdgeInsets.symmetric(vertical: 18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( errorText(l10n, favorites.error, l10n.favoritesError), style: const TextStyle(color: Color(0xFFE6E1E3)), ), TextButton( onPressed: () => unawaited(favorites.load()), child: Text(l10n.retry), ), ], ), ) else if (logic.section != 0 && markedDramas.isNotEmpty) for (final drama in markedDramas) ...[ _markedDramaCard(drama, l10n), const SizedBox(height: 9), ] else Padding( padding: const EdgeInsets.symmetric(vertical: 22), child: Text( l10n.noSavedShows, style: const TextStyle(color: Color(0xFFE6E1E3)), ), ), // 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧; // 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。 if (logic.section == 0) ...[ const SizedBox(height: 18), Text( l10n.history, style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 9), if (logic.positionFailed) Row( children: [ Expanded( child: Text( l10n.progressError, style: const TextStyle(color: Color(0xFFE6E1E3)), ), ), TextButton( onPressed: logic.refreshPosition, child: Text(l10n.retry), ), ], ) else if (logic.recentLoading) const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ), ) else if (logic.recentError != null) Row( children: [ Expanded( child: Text( errorText(l10n, logic.recentError, l10n.progressError), style: const TextStyle(color: Color(0xFFE6E1E3)), ), ), TextButton( onPressed: () => unawaited(logic.refreshRecent()), child: Text(l10n.retry), ), ], ) else if (_recentDramas.isEmpty) Text( l10n.noStories, style: const TextStyle(color: Color(0xFFB7B3B8)), ) else for (final drama in _recentDramas) ...[ _markedDramaCard(drama, l10n), const SizedBox(height: 9), ], ], ], ); } /// 服务端最近观看 → 已加载目录里的卡片(按 id 匹配,去重,当前剧也算一条)。 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; } Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell( onTap: () { if (widget.onDramaSelected != null) { widget.onDramaSelected!(drama); } else if (drama.id == widget.drama.id) { logic.act( context, MyPageAction.resume, embedded: widget.embedded, onAction: widget.onAction, ); } }, borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(9), decoration: BoxDecoration( color: const Color(0xFF1B1C21), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: RemoteImage(drama.posterUrl, width: 74, height: 103), ), const SizedBox(width: 11), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( localizedDramaTitle(l10n, drama), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 7), Text( l10n.episodeCount(drama.episodeCount), style: const TextStyle( color: Color(0xFFE6E1E3), fontSize: 12, ), ), ], ), ), const Icon(Icons.chevron_right_rounded, color: Color(0xFFF2EEEF)), ], ), ), ); 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: [ _NavItem( icon: Icons.play_circle_outline_rounded, label: l10n.homeTab, onTap: () => logic.act( context, MyPageAction.home, embedded: widget.embedded, onAction: widget.onAction, ), ), _NavItem( icon: Icons.explore_outlined, label: l10n.discoverTab, onTap: () => logic.act( context, MyPageAction.discover, embedded: widget.embedded, onAction: widget.onAction, ), ), _NavItem( icon: Icons.video_library_outlined, label: l10n.myTab, selected: true, onTap: () {}, ), ], ), ); Widget _poster({required double width, required double height}) => ClipRRect( borderRadius: BorderRadius.circular(7), child: RemoteImage(widget.drama.posterUrl, width: width, height: height), ); } class _NavItem extends StatelessWidget { const _NavItem({ 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, ), ), ], ), ), ); } String _clock(Duration duration) { final seconds = duration.inSeconds; return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; } const _accent = AppColors.blueSoft;