diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 6d2e57e..08aa77e 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -39,6 +39,9 @@ abstract final class AppColors { /// 金币与奖励。 static const gold = Color(0xFFF3B34C); static const goldDeep = Color(0xFFD9A23A); + static const vipGoldStart = Color(0xFFF5D9A0); + static const vipGoldEnd = Color(0xFFE8B65A); + static const vipBadgeSurface = Color(0x1F3D2408); static const bronze = Color(0xFFCD7F32); static const bronzeDeep = Color(0xFFA9713A); @@ -69,6 +72,10 @@ abstract final class AppColors { static const contentChip = Color(0xFF2C2C2E); static const contentBorder = Color(0x1AFFFFFF); + /// 「我的」页卡片与描边。 + static const mineCard = Color(0xFF16161A); + static const mineOutline = Color(0xFF4A4A4A); + /// 次级文字与列表 chevron。 static const textPrimary = Color(0xFFFFFFFF); static const textSecondary = Color(0xFF8E8E93); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 64f20ab..7706f55 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -90,7 +90,16 @@ abstract final class AppDimens { /// 「我的」页中性视觉。 static const double mineCardRadius = 16; - static const double mineRowHeight = 52; + static const double mineRowHeight = 56; static const double minePageTitle = 21; static const double mineSectionTitle = 15; + static const double mineAvatar = 56; + static const double mineUserName = 20; + static const double mineUid = 13; + static const double mineMenuIcon = 22; + static const double mineMiniWidth = 96; + static const double mineMiniHeight = 132; + static const double mineMiniRadius = 8; + static const double mineMiniTitle = 10; + static const double mineMiniClose = 22; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 31d95d2..a63715b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -115,7 +115,6 @@ "emptyHint": "New content will appear here.", "demoCatalogNotice": "Three demo dramas share the same local video.", "myTab": "My", - "localViewer": "Local Viewer", "localDemoProfile": "Local demo profile", "continueWatching": "Continue Watching", "episodeRemaining": "Episode {episode} · {minutes} min left", @@ -153,7 +152,6 @@ }, "resume": "Resume", "history": "History", - "watchlist": "Favorites", "likedTab": "Liked", "all": "All", "inProgress": "In Progress", @@ -733,6 +731,14 @@ "modernFilter": "Modern", "edit": "Edit", "done": "Done", + "visitor": "Visitor", + "copyUid": "Copy UID", + "uidCopied": "UID copied", + "offlineDownloads": "Offline downloads", + "watchPreferences": "Watch preferences", + "contactSupport": "Contact support", + "vipBrandName": "Netshort VIP", + "mineVipSubtitle": "Enjoy exclusive benefits", "favoriteEpisodeProgress": "Episode {episode} / {total} total", "@favoriteEpisodeProgress": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 78edd86..4250d91 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -94,7 +94,6 @@ "emptyHint": "Konten baru akan muncul di sini.", "demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.", "myTab": "Saya", - "localViewer": "Penonton Lokal", "localDemoProfile": "Profil demo lokal", "continueWatching": "Lanjut Tonton", "episodeRemaining": "Episode {episode} · {minutes} menit tersisa", @@ -132,7 +131,6 @@ }, "resume": "Lanjutkan", "history": "Riwayat", - "watchlist": "Daftar Tonton", "likedTab": "Disukai", "all": "Semua", "inProgress": "Sedang Berjalan", @@ -690,6 +688,14 @@ "modernFilter": "Modern", "edit": "Edit", "done": "Selesai", + "visitor": "Pengunjung", + "copyUid": "Salin UID", + "uidCopied": "UID disalin", + "offlineDownloads": "Unduhan offline", + "watchPreferences": "Preferensi tontonan", + "contactSupport": "Hubungi dukungan", + "vipBrandName": "Netshort VIP", + "mineVipSubtitle": "Nikmati manfaat eksklusif", "favoriteEpisodeProgress": "Episode {episode} / total {total}", "@favoriteEpisodeProgress": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8170424..39502e6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -568,12 +568,6 @@ abstract class AppLocalizations { /// **'My'** String get myTab; - /// No description provided for @localViewer. - /// - /// In en, this message translates to: - /// **'Local Viewer'** - String get localViewer; - /// No description provided for @localDemoProfile. /// /// In en, this message translates to: @@ -628,12 +622,6 @@ abstract class AppLocalizations { /// **'History'** String get history; - /// No description provided for @watchlist. - /// - /// In en, this message translates to: - /// **'Favorites'** - String get watchlist; - /// No description provided for @likedTab. /// /// In en, this message translates to: @@ -3208,6 +3196,54 @@ abstract class AppLocalizations { /// **'Done'** String get done; + /// No description provided for @visitor. + /// + /// In en, this message translates to: + /// **'Visitor'** + String get visitor; + + /// No description provided for @copyUid. + /// + /// In en, this message translates to: + /// **'Copy UID'** + String get copyUid; + + /// No description provided for @uidCopied. + /// + /// In en, this message translates to: + /// **'UID copied'** + String get uidCopied; + + /// No description provided for @offlineDownloads. + /// + /// In en, this message translates to: + /// **'Offline downloads'** + String get offlineDownloads; + + /// No description provided for @watchPreferences. + /// + /// In en, this message translates to: + /// **'Watch preferences'** + String get watchPreferences; + + /// No description provided for @contactSupport. + /// + /// In en, this message translates to: + /// **'Contact support'** + String get contactSupport; + + /// No description provided for @vipBrandName. + /// + /// In en, this message translates to: + /// **'Netshort VIP'** + String get vipBrandName; + + /// No description provided for @mineVipSubtitle. + /// + /// In en, this message translates to: + /// **'Enjoy exclusive benefits'** + String get mineVipSubtitle; + /// No description provided for @favoriteEpisodeProgress. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5c4772a..458b70a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -261,9 +261,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get myTab => 'My'; - @override - String get localViewer => 'Local Viewer'; - @override String get localDemoProfile => 'Local demo profile'; @@ -301,9 +298,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get history => 'History'; - @override - String get watchlist => 'Favorites'; - @override String get likedTab => 'Liked'; @@ -1671,6 +1665,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get done => 'Done'; + @override + String get visitor => 'Visitor'; + + @override + String get copyUid => 'Copy UID'; + + @override + String get uidCopied => 'UID copied'; + + @override + String get offlineDownloads => 'Offline downloads'; + + @override + String get watchPreferences => 'Watch preferences'; + + @override + String get contactSupport => 'Contact support'; + + @override + String get vipBrandName => 'Netshort VIP'; + + @override + String get mineVipSubtitle => 'Enjoy exclusive benefits'; + @override String favoriteEpisodeProgress(int episode, int total) { return 'Episode $episode / $total total'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 6dc7af2..1a7e93d 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -261,9 +261,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get myTab => 'Saya'; - @override - String get localViewer => 'Penonton Lokal'; - @override String get localDemoProfile => 'Profil demo lokal'; @@ -301,9 +298,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get history => 'Riwayat'; - @override - String get watchlist => 'Daftar Tonton'; - @override String get likedTab => 'Disukai'; @@ -1673,6 +1667,30 @@ class AppLocalizationsId extends AppLocalizations { @override String get done => 'Selesai'; + @override + String get visitor => 'Pengunjung'; + + @override + String get copyUid => 'Salin UID'; + + @override + String get uidCopied => 'UID disalin'; + + @override + String get offlineDownloads => 'Unduhan offline'; + + @override + String get watchPreferences => 'Preferensi tontonan'; + + @override + String get contactSupport => 'Hubungi dukungan'; + + @override + String get vipBrandName => 'Netshort VIP'; + + @override + String get mineVipSubtitle => 'Nikmati manfaat eksklusif'; + @override String favoriteEpisodeProgress(int episode, int total) { return 'Episode $episode / total $total'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0d5aeb2..96d0ee5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -256,9 +256,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get myTab => '我的'; - @override - String get localViewer => '本地观众'; - @override String get localDemoProfile => '本地演示档案'; @@ -296,9 +293,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get history => '历史'; - @override - String get watchlist => '收藏'; - @override String get likedTab => '喜欢'; @@ -1647,6 +1641,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get done => '完成'; + @override + String get visitor => '访客'; + + @override + String get copyUid => '复制 UID'; + + @override + String get uidCopied => 'UID 已复制'; + + @override + String get offlineDownloads => '离线缓存'; + + @override + String get watchPreferences => '观看偏好'; + + @override + String get contactSupport => '联系客服'; + + @override + String get vipBrandName => 'Netshort VIP'; + + @override + String get mineVipSubtitle => '享受专属权益'; + @override String favoriteEpisodeProgress(int episode, int total) { return '第 $episode 集 / 共 $total 集'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9e01e24..7f5c954 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -94,7 +94,6 @@ "emptyHint": "有新内容时会显示在这里。", "demoCatalogNotice": "三部演示短剧共用同一条本地视频。", "myTab": "我的", - "localViewer": "本地观众", "localDemoProfile": "本地演示档案", "continueWatching": "继续观看", "episodeRemaining": "第 {episode} 集 · 还剩 {minutes} 分钟", @@ -132,7 +131,6 @@ }, "resume": "继续播放", "history": "历史", - "watchlist": "收藏", "likedTab": "喜欢", "all": "全部", "inProgress": "未看完", @@ -652,6 +650,14 @@ "modernFilter": "现代", "edit": "编辑", "done": "完成", + "visitor": "访客", + "copyUid": "复制 UID", + "uidCopied": "UID 已复制", + "offlineDownloads": "离线缓存", + "watchPreferences": "观看偏好", + "contactSupport": "联系客服", + "vipBrandName": "Netshort VIP", + "mineVipSubtitle": "享受专属权益", "favoriteEpisodeProgress": "第 {episode} 集 / 共 {total} 集", "@favoriteEpisodeProgress": { "placeholders": { diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index ebe5245..f719c42 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -324,17 +324,15 @@ class _HomeTabsState extends State<_HomeTabs> { Future _openWallet(BuildContext context) async { if (!await ensureSignedIn(context) || !context.mounted) return; - await Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => const WalletPage())); + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const WalletPage())); } Widget _my(BuildContext context) { return MyPage( active: logic.tab == 3, - callbacks: MyPageCallbacks( - onAction: logic.myAction, - onDramaSelected: (drama) => logic.openDrama(context, drama), - ), + callbacks: MyPageCallbacks(onAction: logic.myAction), ); } diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart index b7da322..0304e98 100644 --- a/lib/pages/mine/mine_types.dart +++ b/lib/pages/mine/mine_types.dart @@ -2,18 +2,16 @@ import 'package:flutter/foundation.dart'; -import '../../core/data/models/drama.dart'; - /// 「我的」页与根导航之间的返回动作。 enum MyPageAction { home, recommend, following, mine, resume } /// 「我的」页可进入的功能目的地。 enum MineDestination { - watchlist, history, downloads, wallet, vip, + preferences, works, creatorCenter, notices, @@ -23,10 +21,9 @@ enum MineDestination { /// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。 class MyPageCallbacks { - const MyPageCallbacks({this.onAction, this.onDramaSelected}); + const MyPageCallbacks({this.onAction}); final ValueChanged? onAction; - final ValueChanged? onDramaSelected; bool get embedded => onAction != null; } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index a3e91da..3f77213 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -5,8 +5,11 @@ import 'dart:async'; import 'package:flutter/material.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/auth_controller.dart'; +import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/locale_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/theme/app_colors.dart'; @@ -17,10 +20,11 @@ import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../downloads/downloads_page.dart'; -import '../favorites/favorites_page.dart'; +import '../account/account_profile_page.dart'; +import '../filter/filter_page.dart'; import '../notices/notices_page.dart'; +import '../player/open_player.dart'; import '../settings/settings_page.dart'; -import '../store/recharge_page.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; @@ -29,6 +33,7 @@ import 'mine_logic.dart'; import 'mine_types.dart'; import 'widgets/mine_header.dart'; import 'widgets/mine_menu.dart'; +import 'widgets/mine_mini_player.dart'; /// K28-B 我的页。 class MyPage extends StatefulWidget { @@ -47,6 +52,7 @@ class MyPage extends StatefulWidget { class _MyPageState extends State { late final MineLogic logic; + bool _miniDismissed = false; bool get embedded => widget.callbacks.embedded; @@ -126,53 +132,51 @@ class _MyPageState extends State { } Widget _body(BuildContext context, AppLocalizations l10n) { - final auth = context.watch(); - final wallet = context.watch(); final locale = context.watch(); - return ListView( - key: const Key('mine-page-scroll'), - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - 0, - AppDimens.pageHorizontal, - AppDimens.spaceXl, - ), + final recent = _miniRecord; + return Stack( children: [ - MineHeader(onTap: () => _openDestination(MineDestination.settings)), - const SizedBox(height: AppDimens.spaceMd), - MineShortcutGrid(onOpen: _openDestination), - const SizedBox(height: AppDimens.spaceMd), - MineWalletCard( - balance: _walletValue(l10n, auth, wallet), - onOpen: () => _openDestination(MineDestination.wallet), - onRecharge: _openRecharge, - ), - const SizedBox(height: AppDimens.spaceMd), - MineVipCard(onOpen: () => _openDestination(MineDestination.vip)), - const SizedBox(height: AppDimens.spaceMd), - MineMenuList( - onOpen: _openDestination, - onLanguage: _openLanguage, - languageLabel: _languageLabel(l10n, locale.locale), + Positioned.fill( + child: ListView( + key: const Key('mine-page-scroll'), + padding: EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + 0, + AppDimens.pageHorizontal, + recent == null ? AppDimens.spaceXl : 156, + ), + children: [ + MineHeader(onLogin: _openLogin, onEdit: _openEditActions), + MineVipBanner( + onOpen: () => _openDestination(MineDestination.vip), + ), + const SizedBox(height: AppDimens.spaceMd), + MineMenuList( + onOpen: _openDestination, + onLanguage: _openLanguage, + languageLabel: _languageLabel(l10n, locale.locale), + ), + ], + ), ), + if (recent != null) + Positioned( + left: AppDimens.pageHorizontal, + bottom: AppDimens.spaceMd, + child: MineMiniPlayer( + title: _miniTitle(l10n, recent), + coverUrl: recent.dramaCover?.trim() ?? '', + episodeLabel: l10n.episodeLabel(recent.episodeNumber ?? 0), + onTap: () => unawaited(_openRecent(recent)), + onClose: () => setState(() => _miniDismissed = true), + ), + ), ], ); } - String _walletValue( - AppLocalizations l10n, - AuthController auth, - WalletController wallet, - ) { - if (!auth.isSignedIn) return l10n.logInHint; - if (wallet.snapshot == null) return l10n.coins; - return l10n.walletBalanceShort(wallet.balance); - } - void _openDestination(MineDestination destination) { switch (destination) { - case MineDestination.watchlist: - _push(const FavoritesPage()); case MineDestination.history: _push(const WatchRecordsPage()); case MineDestination.downloads: @@ -181,6 +185,8 @@ class _MyPageState extends State { _openProtected(const WalletPage()); case MineDestination.vip: _openVip(); + case MineDestination.preferences: + _push(const FilterPage()); case MineDestination.works: _pushPlaceholder(MinePlaceholderKind.works); case MineDestination.creatorCenter: @@ -194,6 +200,64 @@ class _MyPageState extends State { } } + void _openLogin() { + unawaited(openAuthPage(context)); + } + + Future _openEditActions() async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.transparent, + showDragHandle: false, + builder: (sheetContext) => Material( + color: AppColors.mineCard, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppDimens.mineCardRadius), + ), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('mine-edit-profile'), + title: Text(AppLocalizations.of(sheetContext)!.accountProfile), + onTap: () => Navigator.pop(sheetContext, 'profile'), + ), + ListTile( + key: const Key('mine-edit-works'), + title: Text(AppLocalizations.of(sheetContext)!.myWorks), + onTap: () => Navigator.pop(sheetContext, 'works'), + ), + ListTile( + key: const Key('mine-edit-creator'), + title: Text(AppLocalizations.of(sheetContext)!.creatorCenter), + onTap: () => Navigator.pop(sheetContext, 'creator'), + ), + ListTile( + key: const Key('mine-edit-notices'), + title: Text(AppLocalizations.of(sheetContext)!.notices), + onTap: () => Navigator.pop(sheetContext, 'notices'), + ), + ], + ), + ), + ), + ); + if (!mounted || selected == null) return; + switch (selected) { + case 'profile': + _push(const AccountProfilePage()); + case 'works': + _pushPlaceholder(MinePlaceholderKind.works); + case 'creator': + _pushPlaceholder(MinePlaceholderKind.creator); + case 'notices': + _push(const NoticesPage()); + } + } + void _push(Widget page) { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } @@ -236,9 +300,32 @@ class _MyPageState extends State { await context.read().select(selected); } - void _openRecharge() { - final wallet = context.read(); - _openProtected(RechargePage(onPurchased: () => unawaited(wallet.load()))); + WatchProgress? get _miniRecord { + if (_miniDismissed || logic.recent.isEmpty) return null; + final recent = logic.recent.first; + return recent.episodeId.isEmpty ? null : recent; + } + + String _miniTitle(AppLocalizations l10n, WatchProgress recent) { + final title = recent.dramaTitle?.trim() ?? ''; + return title.isEmpty ? l10n.episodeLabel(recent.episodeNumber ?? 0) : title; + } + + Future _openRecent(WatchProgress recent) async { + final catalog = context.read(); + final seed = Drama( + id: recent.dramaId, + title: recent.dramaTitle?.trim() ?? '', + synopsis: '', + posterUrl: recent.dramaCover?.trim() ?? '', + ); + try { + final detail = await catalog.requireDetail(seed); + if (!mounted) return; + await playDramaFromCatalog(context, detail, episodeId: recent.episodeId); + } catch (_) { + // 最近记录失效时不投射错误页,等待下一次记录刷新。 + } } Widget _bottomNav() { diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index 91b8bd2..27c28bd 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -1,19 +1,28 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../../core/data/services/auth_controller.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/centered_toast.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -/// 「我的」页顶部身份卡。 -class MineHeader extends StatelessWidget { - const MineHeader({super.key, required this.onTap}); +const double _buttonHeight = 34; +const double _buttonRadius = 20; +const double _copyIconSize = 15; - final VoidCallback onTap; +/// 「我的」页用户区。 +class MineHeader extends StatelessWidget { + const MineHeader({super.key, required this.onLogin, required this.onEdit}); + + final VoidCallback onLogin; + final VoidCallback onEdit; @override Widget build(BuildContext context) { @@ -21,41 +30,80 @@ class MineHeader extends StatelessWidget { final auth = context.watch(); final user = auth.user; final signedIn = auth.isSignedIn; - final name = signedIn && (user?.nickname.isNotEmpty ?? false) - ? user!.nickname - : l10n.localViewer; - final subtitle = signedIn && (user?.uid.isNotEmpty ?? false) - ? 'ID: ${user!.uid}' - : l10n.logInHint; - return Container( + final nickname = signedIn && (user?.nickname.trim().isNotEmpty ?? false) + ? user!.nickname.trim() + : l10n.visitor; + final uid = signedIn ? user?.uid.trim() ?? '' : ''; + return Padding( key: const Key('mine-user-row'), - decoration: BoxDecoration( - color: AppColors.contentCard, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - border: Border.all(color: AppColors.contentBorder), - ), - child: Material( - color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(AppDimens.cardPadding), - child: Row( + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceMd), + child: Row( + children: [ + _avatar(user?.avatarUrl ?? ''), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _avatar(user?.avatarUrl ?? ''), - const SizedBox(width: AppDimens.spaceMd), - Expanded(child: _labels(name, subtitle)), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.textSecondary, - size: 20, + Text( + nickname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.textPrimary, + fontSize: AppDimens.mineUserName, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Row( + children: [ + Flexible( + child: Text( + '${l10n.accountUid}: ${uid.isEmpty ? '--' : uid}', + key: const Key('mine-uid'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.mineUid, + ), + ), + ), + const SizedBox(width: AppDimens.spaceXs), + IconButton( + key: const Key('mine-uid-copy'), + tooltip: l10n.copyUid, + onPressed: uid.isEmpty + ? null + : () => unawaited( + _copyUid(context, uid, l10n.uidCopied), + ), + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: AppDimens.tapTarget, + height: AppDimens.tapTarget, + ), + padding: EdgeInsets.zero, + icon: const Icon( + Icons.copy_rounded, + color: AppColors.textSecondary, + size: _copyIconSize, + ), + ), + ], ), ], ), ), - ), + const SizedBox(width: AppDimens.spaceSm), + _accountButton( + key: signedIn ? const Key('mine-edit') : const Key('mine-login'), + label: signedIn ? l10n.edit : l10n.logIn, + onTap: signedIn ? onEdit : onLogin, + ), + ], ), ); } @@ -63,8 +111,8 @@ class MineHeader extends StatelessWidget { Widget _avatar(String path) { return Container( key: const Key('mine-avatar'), - width: 64, - height: 64, + width: AppDimens.mineAvatar, + height: AppDimens.mineAvatar, decoration: const BoxDecoration( color: AppColors.contentSurface, shape: BoxShape.circle, @@ -74,38 +122,50 @@ class MineHeader extends StatelessWidget { ? const Icon( Icons.person_rounded, color: AppColors.textPrimary, - size: 30, + size: 28, ) : RemoteImage(path, fit: BoxFit.cover), ); } - Widget _labels(String name, String subtitle) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w700, - ), + Widget _accountButton({ + required Key key, + required String label, + required VoidCallback onTap, + }) { + return OutlinedButton( + key: key, + onPressed: onTap, + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, _buttonHeight), + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + foregroundColor: AppColors.textPrimary, + side: const BorderSide(color: AppColors.mineOutline), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_buttonRadius), ), - const SizedBox(height: AppDimens.spaceXs), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.caption, - ), + textStyle: const TextStyle( + fontSize: AppDimens.body, + fontWeight: FontWeight.w500, ), - ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 2), + const Icon(Icons.chevron_right_rounded, size: 17), + ], + ), ); } + + Future _copyUid( + BuildContext context, + String uid, + String message, + ) async { + await Clipboard.setData(ClipboardData(text: uid)); + if (context.mounted) showCenteredToast(context, message); + } } diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index b1dce67..ed5be25 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -7,180 +7,13 @@ import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; import '../mine_types.dart'; -/// 我的页三宫格快捷入口。 -class MineShortcutGrid extends StatelessWidget { - const MineShortcutGrid({super.key, required this.onOpen}); +const double _vipRadius = 12; +const double _vipBadgeSize = 36; +const double _vipButtonHeight = 34; - final ValueChanged onOpen; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Row( - children: [ - Expanded( - child: _ShortcutTile( - key: const Key('mine-watchlist'), - icon: Icons.bookmark_border_rounded, - label: l10n.watchlist, - onTap: () => onOpen(MineDestination.watchlist), - ), - ), - const SizedBox(width: AppDimens.homeGridGap), - Expanded( - child: _ShortcutTile( - key: const Key('mine-history'), - icon: Icons.history_rounded, - label: l10n.watchHistory, - onTap: () => onOpen(MineDestination.history), - ), - ), - const SizedBox(width: AppDimens.homeGridGap), - Expanded( - child: _ShortcutTile( - key: const Key('mine-downloads'), - icon: Icons.download_outlined, - label: l10n.downloads, - onTap: () => onOpen(MineDestination.downloads), - ), - ), - ], - ); - } -} - -class _ShortcutTile extends StatelessWidget { - const _ShortcutTile({ - super.key, - required this.icon, - required this.label, - required this.onTap, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: AppColors.contentCard, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - border: Border.all(color: AppColors.contentBorder), - ), - child: Material( - color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: SizedBox( - height: 88, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: AppColors.textPrimary, size: 24), - const SizedBox(height: AppDimens.spaceSm), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -/// 金币余额卡。 -class MineWalletCard extends StatelessWidget { - const MineWalletCard({ - super.key, - required this.balance, - required this.onOpen, - required this.onRecharge, - }); - - final String balance; - final VoidCallback onOpen; - final VoidCallback onRecharge; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Container( - key: const Key('mine-wallet'), - padding: const EdgeInsets.all(AppDimens.cardPadding), - decoration: BoxDecoration( - color: AppColors.contentCard, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - border: Border.all(color: AppColors.contentBorder), - ), - child: Row( - children: [ - const Icon( - Icons.toll_outlined, - color: AppColors.textSecondary, - size: 28, - ), - const SizedBox(width: AppDimens.spaceMd), - Expanded( - child: InkWell( - onTap: onOpen, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.coinBalance, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.caption, - ), - ), - const SizedBox(height: 3), - Text( - balance, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.pageTitle, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ), - TextButton( - key: const Key('mine-wallet-recharge'), - onPressed: onRecharge, - style: TextButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - minimumSize: const Size(64, AppDimens.buttonCompact), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppDimens.buttonRadius), - ), - ), - child: Text(l10n.topUpWallet), - ), - ], - ), - ); - } -} - -/// 会员权益卡。 -class MineVipCard extends StatelessWidget { - const MineVipCard({super.key, required this.onOpen}); +/// 金色会员横幅。 +class MineVipBanner extends StatelessWidget { + const MineVipBanner({super.key, required this.onOpen}); final VoidCallback onOpen; @@ -190,30 +23,34 @@ class MineVipCard extends StatelessWidget { return Container( key: const Key('mine-vip'), decoration: BoxDecoration( - color: AppColors.contentCard, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - border: Border.all(color: AppColors.contentBorder), + gradient: const LinearGradient( + colors: [AppColors.vipGoldStart, AppColors.vipGoldEnd], + ), + borderRadius: BorderRadius.circular(_vipRadius), ), child: Material( color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), + borderRadius: BorderRadius.circular(_vipRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onOpen, child: Padding( - padding: const EdgeInsets.all(AppDimens.cardPadding), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.cardPadding, + vertical: AppDimens.spaceMd, + ), child: Row( children: [ Container( - width: AppDimens.buttonLarge, - height: AppDimens.buttonLarge, + width: _vipBadgeSize, + height: _vipBadgeSize, decoration: const BoxDecoration( - color: AppColors.contentSurface, + color: AppColors.vipBadgeSurface, shape: BoxShape.circle, ), child: const Icon( Icons.workspace_premium_rounded, - color: AppColors.textPrimary, + color: AppColors.onGold, size: 22, ), ), @@ -223,33 +60,44 @@ class MineVipCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l10n.vipMembership, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.mineSectionTitle, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 3), - Text( - l10n.vipUnlockMessage, + l10n.vipBrandName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.textSecondary, + color: AppColors.onGold, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + l10n.mineVipSubtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onGold, fontSize: AppDimens.caption, ), ), ], ), ), - Text( - l10n.joinVip, - style: const TextStyle( - color: AppColors.blue, - fontSize: AppDimens.body, - fontWeight: FontWeight.w700, + const SizedBox(width: AppDimens.spaceSm), + TextButton( + key: const Key('mine-vip-open'), + onPressed: onOpen, + style: TextButton.styleFrom( + minimumSize: const Size(0, _vipButtonHeight), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.spaceMd, + ), + backgroundColor: AppColors.onGold, + foregroundColor: AppColors.textPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_vipButtonHeight), + ), ), + child: Text(l10n.joinVip), ), ], ), @@ -260,7 +108,7 @@ class MineVipCard extends StatelessWidget { } } -/// 我的页功能菜单。 +/// 「我的」页单列功能菜单。 class MineMenuList extends StatelessWidget { const MineMenuList({ super.key, @@ -279,7 +127,7 @@ class MineMenuList extends StatelessWidget { return Container( key: const Key('mine-menu'), decoration: BoxDecoration( - color: AppColors.contentCard, + color: AppColors.mineCard, borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), border: Border.all(color: AppColors.contentBorder), ), @@ -290,57 +138,63 @@ class MineMenuList extends StatelessWidget { child: Column( children: [ _row( - key: const Key('mine-works'), - icon: Icons.video_collection_outlined, - title: l10n.myWorks, - destination: MineDestination.works, + key: const Key('mine-wallet'), + icon: Icons.account_balance_wallet_outlined, + title: l10n.wallet, + destination: MineDestination.wallet, ), _divider(), _row( - key: const Key('mine-creator-center'), - icon: Icons.auto_awesome_rounded, - title: l10n.creatorCenter, - destination: MineDestination.creatorCenter, + key: const Key('mine-downloads'), + icon: Icons.download_outlined, + title: l10n.offlineDownloads, + destination: MineDestination.downloads, ), _divider(), _row( - key: const Key('mine-notices'), - icon: Icons.notifications_none_rounded, - title: l10n.notices, - destination: MineDestination.notices, + key: const Key('mine-history'), + icon: Icons.history_rounded, + title: l10n.watchHistory, + destination: MineDestination.history, ), _divider(), + _row( + key: const Key('mine-watch-preferences'), + icon: Icons.tune_rounded, + title: l10n.watchPreferences, + destination: MineDestination.preferences, + ), + _divider(), + _row( + key: const Key('mine-help'), + icon: Icons.support_agent_rounded, + title: l10n.contactSupport, + destination: MineDestination.help, + ), + _divider(), + _languageRow(l10n), + _divider(), _row( key: const Key('mine-settings'), icon: Icons.settings_outlined, title: l10n.settings, destination: MineDestination.settings, ), - _divider(), - _languageRow(context), - _divider(), - _row( - key: const Key('mine-help'), - icon: Icons.help_outline_rounded, - title: l10n.helpFeedback, - destination: MineDestination.help, - ), ], ), ), ); } - Widget _languageRow(BuildContext context) { - final l10n = AppLocalizations.of(context)!; + Widget _languageRow(AppLocalizations l10n) { return ListTile( key: const Key('mine-language'), minTileHeight: AppDimens.mineRowHeight, - contentPadding: const EdgeInsets.symmetric(horizontal: 14), + contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), leading: const Icon( Icons.language_rounded, - color: AppColors.textSecondary, - size: 20, + color: AppColors.textPrimary, + size: AppDimens.mineMenuIcon, ), title: Text( l10n.language, @@ -360,7 +214,7 @@ class MineMenuList extends StatelessWidget { fontSize: AppDimens.caption, ), ), - const SizedBox(width: 4), + const SizedBox(width: AppDimens.spaceXs), const Icon( Icons.chevron_right_rounded, color: AppColors.textSecondary, @@ -381,8 +235,12 @@ class MineMenuList extends StatelessWidget { return ListTile( key: key, minTileHeight: AppDimens.mineRowHeight, - contentPadding: const EdgeInsets.symmetric(horizontal: 14), - leading: Icon(icon, color: AppColors.textSecondary, size: 20), + contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), + leading: Icon( + icon, + color: AppColors.textPrimary, + size: AppDimens.mineMenuIcon, + ), title: Text( title, style: const TextStyle( @@ -403,8 +261,8 @@ class MineMenuList extends StatelessWidget { Widget _divider() { return const Divider( height: 1, - indent: 14, - endIndent: 14, + indent: AppDimens.spaceLg, + endIndent: AppDimens.spaceLg, color: AppColors.contentSurface, ); } diff --git a/lib/pages/mine/widgets/mine_mini_player.dart b/lib/pages/mine/widgets/mine_mini_player.dart new file mode 100644 index 0000000..5886061 --- /dev/null +++ b/lib/pages/mine/widgets/mine_mini_player.dart @@ -0,0 +1,124 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; + +const double _infoHeight = 42; + +/// 「我的」页左下角最近观看迷你播放器。 +class MineMiniPlayer extends StatelessWidget { + const MineMiniPlayer({ + super.key, + required this.title, + required this.coverUrl, + required this.episodeLabel, + required this.onTap, + required this.onClose, + }); + + final String title; + final String coverUrl; + final String episodeLabel; + final VoidCallback onTap; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const Key('mine-mini-player'), + width: AppDimens.mineMiniWidth, + height: AppDimens.mineMiniHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.mineCard, + borderRadius: BorderRadius.circular(AppDimens.mineMiniRadius), + border: Border.all(color: AppColors.contentBorder), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.mineMiniRadius), + child: Material( + color: AppColors.transparent, + child: InkWell( + onTap: onTap, + child: Stack( + children: [ + Column( + children: [ + Expanded( + child: RemoteImage( + coverUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + ), + SizedBox( + height: _infoHeight, + child: ColoredBox( + color: AppColors.mineCard, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineMiniTitle, + fontWeight: FontWeight.w600, + ), + ), + Text( + episodeLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.mineMiniTitle, + ), + ), + ], + ), + ), + ), + ), + ], + ), + Positioned( + right: 3, + top: 3, + child: IconButton( + key: const Key('mine-mini-close'), + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, + onPressed: onClose, + style: IconButton.styleFrom( + minimumSize: const Size.square(AppDimens.mineMiniClose), + maximumSize: const Size.square(AppDimens.mineMiniClose), + padding: EdgeInsets.zero, + backgroundColor: AppColors.controlScrim, + foregroundColor: AppColors.textPrimary, + ), + iconSize: 14, + icon: const Icon(Icons.close_rounded), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/test/config/l10n_id_test.dart b/test/config/l10n_id_test.dart index 4e09c28..0686c0b 100644 --- a/test/config/l10n_id_test.dart +++ b/test/config/l10n_id_test.dart @@ -13,7 +13,6 @@ void main() { expect(l10n.homeTab, 'Beranda'); expect(l10n.watchNow, 'Tonton gratis'); - expect(l10n.watchlist, 'Daftar Tonton'); expect(l10n.coins, 'Koin'); expect(l10n.signIn, 'Masuk'); }); diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 59675ae..239a01d 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -238,8 +238,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Viewer'), findsOneWidget); expect(find.byKey(const Key('mine-menu')), findsOneWidget); - expect(find.byKey(const Key('mine-watchlist')), findsOneWidget); - expect(find.byKey(const Key('mine-creator-center')), findsOneWidget); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + expect(find.byKey(const Key('mine-edit')), findsOneWidget); expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); await tester.tap(find.byKey(const Key('tab-follow'))); diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 21579ef..adfff12 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -228,12 +228,10 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-mine'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mine-watchlist'))); + await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(Key('favorite-card-${drama.id}'))); + await tester.tap(find.byKey(Key('following-card-${drama.id}'))); await tester.pumpAndSettle(); expect(find.byType(PlayerPage), findsOneWidget); diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 5def2ce..587a02d 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -3,160 +3,73 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; +import 'package:fruitshorts/pages/mine/mine_types.dart'; +import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; +import 'package:fruitshorts/pages/store/vip_page.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; -import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_minimal_profile_shortcuts_wallet_vip_and_menu', ( + testWidgets('should_render_visitor_user_vip_and_seven_menu_rows', ( tester, ) async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final catalog = FakeCatalogRepository(); - await tester.pumpWidget( - LumaDramaApp( - playerFactory: createWidgetPlayer, - catalogRepository: catalog, - categoryRepository: FakeCategoryRepository(catalog: catalog), - watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), - playbackRepository: FakePlaybackRepository(), - commentRepository: FakeCommentRepository(), - interactionRepository: FakeInteractionRepository(), - noticeRepository: FakeNoticeRepository(), - haptics: FakeHaptics(), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-mine'))); - await tester.pumpAndSettle(); + await _pumpApp(tester, signedIn: false); + await _openMine(tester); expect(find.byKey(const Key('mine-user-row')), findsOneWidget); - expect(find.byKey(const Key('mine-continue-watching')), findsNothing); - for (final key in const [ + expect(find.byKey(const Key('mine-avatar')), findsOneWidget); + expect(find.text('Visitor'), findsOneWidget); + expect(find.text('UID: --'), findsOneWidget); + expect(find.byKey(const Key('mine-uid-copy')), findsOneWidget); + expect(find.byKey(const Key('mine-login')), findsOneWidget); + expect(find.byKey(const Key('mine-edit')), findsNothing); + expect(find.byKey(const Key('mine-vip')), findsOneWidget); + expect(find.byKey(const Key('mine-vip-open')), findsOneWidget); + expect(find.byKey(const Key('mine-menu')), findsOneWidget); + expect(find.byKey(const Key('mine-mini-player')), findsNothing); + for (final oldShortcut in const [ Key('mine-watchlist'), - Key('mine-history'), + Key('mine-history-shortcut'), + Key('mine-downloads-shortcut'), + ]) { + expect(find.byKey(oldShortcut), findsNothing); + } + + final menuRows = tester.widgetList( + find.descendant( + of: find.byKey(const Key('mine-menu')), + matching: find.byType(ListTile), + ), + ); + expect(menuRows.map((row) => row.key), const [ + Key('mine-wallet'), Key('mine-downloads'), - Key('mine-wallet'), - Key('mine-wallet-recharge'), - Key('mine-vip'), - Key('mine-menu'), - Key('mine-works'), - Key('mine-creator-center'), - Key('mine-notices'), - Key('mine-settings'), - Key('mine-language'), + Key('mine-history'), + Key('mine-watch-preferences'), Key('mine-help'), - ]) { - expect(find.byKey(key), findsOneWidget); - } - expect(find.byKey(const Key('mine-language')), findsOneWidget); - expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); - expect(find.byKey(const Key('mine-library-tab-1')), findsNothing); - expect(find.byKey(const Key('mine-library-tab-2')), findsNothing); - final surface = tester.widget( - find.byKey(const Key('mine-page-surface')), - ); - expect(surface.color, AppColors.appBackground); - final title = tester.widget( - find.descendant( - of: find.byKey(const Key('mine-page-surface')), - matching: find.text('My'), - ), - ); - expect(title.style?.color, AppColors.textPrimary); - expect(title.style?.fontSize, AppDimens.minePageTitle); - expect(title.style?.fontWeight, FontWeight.w700); - - for (final key in const [ - Key('mine-user-row'), - Key('mine-wallet'), - Key('mine-vip'), - Key('mine-menu'), - ]) { - final container = tester.widget(find.byKey(key)); - final decoration = container.decoration! as BoxDecoration; - expect(decoration.color, AppColors.contentCard); - expect( - decoration.borderRadius, - BorderRadius.circular(AppDimens.mineCardRadius), - ); - expect(decoration.gradient, isNull); - expect(decoration.boxShadow, isNull); - expect((decoration.border! as Border).top.color, AppColors.contentBorder); - } - - final shortcutIcon = tester.widget( - find.descendant( - of: find.byKey(const Key('mine-watchlist')), - matching: find.byType(Icon), - ), - ); - expect(shortcutIcon.size, 24); - expect(shortcutIcon.color, AppColors.textPrimary); - final shortcutKeys = [ - const Key('mine-watchlist'), - const Key('mine-history'), - const Key('mine-downloads'), - ]; - final shortcutWidths = shortcutKeys - .map((key) => tester.getSize(find.byKey(key)).width) - .toList(); - expect(shortcutWidths.toSet(), hasLength(1)); - final shortcutCard = tester.widget( - find - .descendant( - of: find.byKey(const Key('mine-watchlist')), - matching: find.byType(Container), - ) - .first, - ); - final shortcutDecoration = shortcutCard.decoration! as BoxDecoration; - expect(shortcutDecoration.color, AppColors.contentCard); + Key('mine-language'), + Key('mine-settings'), + ]); + expect(menuRows, hasLength(7)); expect( - shortcutDecoration.borderRadius, - BorderRadius.circular(AppDimens.mineCardRadius), - ); - expect(shortcutDecoration.boxShadow, isNull); - final menuRows = find.descendant( - of: find.byKey(const Key('mine-menu')), - matching: find.byType(ListTile), - ); - expect(menuRows, findsNWidgets(6)); - for (final row in tester.widgetList(menuRows)) { - expect(row.minTileHeight, AppDimens.mineRowHeight); - } - final settingsRow = tester.widget( - find.byKey(const Key('mine-settings')), - ); - final settingsTitle = settingsRow.title! as Text; - expect(settingsTitle.style?.color, AppColors.textPrimary); - expect(settingsTitle.style?.fontSize, AppDimens.listTitle); - expect((settingsRow.trailing! as Icon).color, AppColors.textSecondary); - final recharge = tester.widget( - find.byKey(const Key('mine-wallet-recharge')), - ); - expect(recharge.style?.backgroundColor?.resolve(const {}), AppColors.blue); - expect( - tester.widget(find.text('Join VIP')).style?.color, - AppColors.blue, + menuRows.every((row) => row.minTileHeight == AppDimens.mineRowHeight), + isTrue, ); final dividers = tester.widgetList( find.descendant( @@ -164,15 +77,186 @@ void main() { matching: find.byType(Divider), ), ); - expect(dividers, hasLength(5)); + expect(dividers, hasLength(6)); expect( dividers.every((divider) => divider.color == AppColors.contentSurface), isTrue, ); + + final menu = tester.widget(find.byKey(const Key('mine-menu'))); + final menuDecoration = menu.decoration! as BoxDecoration; + expect(menuDecoration.color, AppColors.mineCard); + expect( + menuDecoration.borderRadius, + BorderRadius.circular(AppDimens.mineCardRadius), + ); + expect(menuDecoration.boxShadow, isNull); + final vip = tester.widget(find.byKey(const Key('mine-vip'))); + final vipDecoration = vip.decoration! as BoxDecoration; + expect((vipDecoration.gradient! as LinearGradient).colors, const [ + AppColors.vipGoldStart, + AppColors.vipGoldEnd, + ]); + expect(vipDecoration.boxShadow, isNull); expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const Key('mine-watchlist'))); + await tester.tap(find.byKey(const Key('mine-login'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('favorites-page')), findsOneWidget); + expect(find.byType(AuthPage), findsOneWidget); + }); + + testWidgets('should_render_signed_in_user_and_dismiss_mini_player', ( + tester, + ) async { + await _pumpApp( + tester, + signedIn: true, + history: InMemoryWatchHistoryRepository(seed: [_recent]), + ); + await _openMine(tester); + + expect(find.text('Viewer'), findsOneWidget); + expect(find.text('UID: U-100'), findsOneWidget); + expect(find.byKey(const Key('mine-edit')), findsOneWidget); + expect(find.byKey(const Key('mine-login')), findsNothing); + expect(find.byKey(const Key('mine-mini-player')), findsOneWidget); + expect(find.text('Recent drama'), findsOneWidget); + expect(find.text('Episode 3'), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('mine-mini-player'))), + const Size(AppDimens.mineMiniWidth, AppDimens.mineMiniHeight), + ); + + await tester.tap(find.byKey(const Key('mine-mini-close'))); + await tester.pump(); + expect(find.byKey(const Key('mine-mini-player')), findsNothing); + + await tester.tap(find.byKey(const Key('mine-edit'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mine-edit-profile')), findsOneWidget); + expect(find.byKey(const Key('mine-edit-works')), findsOneWidget); + expect(find.byKey(const Key('mine-edit-creator')), findsOneWidget); + expect(find.byKey(const Key('mine-edit-notices')), findsOneWidget); + await tester.tap(find.byKey(const Key('mine-edit-works'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mine-placeholder-page')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_open_vip_and_recent_player_from_mine_entries', ( + tester, + ) async { + await _pumpApp( + tester, + signedIn: true, + history: InMemoryWatchHistoryRepository(seed: [_recent]), + ); + await _openMine(tester); + + await tester.tap(find.byKey(const Key('mine-vip-open'))); + await tester.pumpAndSettle(); + expect(find.byType(VipPage), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('mine-mini-player'))); + await tester.pumpAndSettle(); + final player = tester.widget(find.byType(PlayerPage).last); + expect(player.embedded, isFalse); + }); + + testWidgets('should_expose_seven_menu_destinations_in_order', (tester) async { + tester.view.physicalSize = const Size(390, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final opened = []; + var languageOpened = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(AppDimens.pageHorizontal), + child: MineMenuList( + onOpen: opened.add, + onLanguage: () => languageOpened++, + languageLabel: 'English', + ), + ), + ), + ), + ); + + const rows = [ + (Key('mine-wallet'), MineDestination.wallet), + (Key('mine-downloads'), MineDestination.downloads), + (Key('mine-history'), MineDestination.history), + (Key('mine-watch-preferences'), MineDestination.preferences), + (Key('mine-help'), MineDestination.help), + ]; + for (final row in rows) { + await tester.tap(find.byKey(row.$1)); + await tester.pump(); + expect(opened.last, row.$2); + } + await tester.tap(find.byKey(const Key('mine-language'))); + await tester.pump(); + expect(languageOpened, 1); + await tester.tap(find.byKey(const Key('mine-settings'))); + await tester.pump(); + expect(opened.last, MineDestination.settings); }); } + +const _recent = WatchProgress( + dramaId: 'city-lights-demo', + episodeId: 'city-lights-demo-3', + dramaTitle: 'Recent drama', + dramaCover: '/files/recent.jpg', + episodeNumber: 3, + totalEpisodes: 8, + position: Duration(seconds: 30), + duration: Duration(minutes: 2), +); + +Future _openMine(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); +} + +Future _pumpApp( + WidgetTester tester, { + required bool signedIn, + WatchHistoryRepository? history, +}) async { + tester.view.physicalSize = const Size(390, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferencesAsyncPlatform.instance = signedIn + ? InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'access', + 'auth.refreshToken': 'refresh', + 'auth.user': '{"uid":"U-100","nickname":"Viewer","avatarUrl":""}', + }) + : InMemorySharedPreferencesAsync.empty(); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index 5515a63..f9de1bf 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -17,7 +17,7 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_open_notices_from_mine_menu_without_login_gate', ( + testWidgets('should_open_notices_from_settings_without_login_gate', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -46,9 +46,11 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); - await tester.ensureVisible(find.byKey(const Key('mine-notices'))); + await tester.ensureVisible(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mine-notices'))); + await tester.tap(find.byKey(const Key('mine-settings'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('settings-notices'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('notices-page')), findsOneWidget); diff --git a/test/widget_test.dart b/test/widget_test.dart index a6621d5..28f8aa5 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -135,7 +135,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); expect(find.text('Vicky sheng'), findsOneWidget); - expect(find.text('Coins'), findsOneWidget); + expect(find.text('Netshort VIP'), findsOneWidget); expect(find.byKey(const Key('mine-language')), findsOneWidget); expect(find.byKey(const Key('mine-wallet')), findsOneWidget); @@ -297,9 +297,7 @@ void main() { await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-mine'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mine-watchlist'))); + await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); expect(find.text('City Lights & Velvet Nights'), findsWidgets);