feat(mine): 重排我的页并新增迷你播放器

This commit is contained in:
Zhou Weidong committed 2026-10-22 17:44:48 +08:00
1 parent 5aa8170951
commit cbc6d80cd4
21 files changed
+860 -531

No files matched your search

+7
View File
@@ -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);
+10 -1
View File
@@ -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;
}
+8 -2
View File
@@ -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": {
+8 -2
View File
@@ -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": {
+48 -12
View File
@@ -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:
+24 -6
View File
@@ -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';
+24 -6
View File
@@ -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';
+24 -6
View File
@@ -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 集';
+8 -2
View File
@@ -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": {
+4 -6
View File
@@ -324,17 +324,15 @@ class _HomeTabsState extends State<_HomeTabs> {
Future<void> _openWallet(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
await Navigator.of(context)
.push<void>(MaterialPageRoute(builder: (_) => const WalletPage()));
await Navigator.of(
context,
).push<void>(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),
);
}
+2 -5
View File
@@ -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<MyPageAction>? onAction;
final ValueChanged<Drama>? onDramaSelected;
bool get embedded => onAction != null;
}
+130 -43
View File
@@ -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<MyPage> {
late final MineLogic logic;
bool _miniDismissed = false;
bool get embedded => widget.callbacks.embedded;
@@ -126,53 +132,51 @@ class _MyPageState extends State<MyPage> {
}
Widget _body(BuildContext context, AppLocalizations l10n) {
final auth = context.watch<AuthController>();
final wallet = context.watch<WalletController>();
final locale = context.watch<LocaleController>();
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<MyPage> {
_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<MyPage> {
}
}
void _openLogin() {
unawaited(openAuthPage(context));
}
Future<void> _openEditActions() async {
final selected = await showModalBottomSheet<String>(
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<void>(MaterialPageRoute(builder: (_) => page));
}
@@ -236,9 +300,32 @@ class _MyPageState extends State<MyPage> {
await context.read<LocaleController>().select(selected);
}
void _openRecharge() {
final wallet = context.read<WalletController>();
_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<void> _openRecent(WatchProgress recent) async {
final catalog = context.read<CatalogController>();
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() {
+120 -60
View File
@@ -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<AuthController>();
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<void> _copyUid(
BuildContext context,
String uid,
String message,
) async {
await Clipboard.setData(ClipboardData(text: uid));
if (context.mounted) showCenteredToast(context, message);
}
}
+90 -232
View File
@@ -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<MineDestination> 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,
);
}
@@ -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),
),
),
],
),
),
),
),
),
);
}
}
-1
View File
@@ -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');
});
+2 -2
View File
@@ -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')));
+2 -4
View File
@@ -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);
+218 -134
View File
@@ -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<ListTile>(
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<ColoredBox>(
find.byKey(const Key('mine-page-surface')),
);
expect(surface.color, AppColors.appBackground);
final title = tester.widget<Text>(
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<Container>(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<Icon>(
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<Container>(
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<ListTile>(menuRows)) {
expect(row.minTileHeight, AppDimens.mineRowHeight);
}
final settingsRow = tester.widget<ListTile>(
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<TextButton>(
find.byKey(const Key('mine-wallet-recharge')),
);
expect(recharge.style?.backgroundColor?.resolve(const {}), AppColors.blue);
expect(
tester.widget<Text>(find.text('Join VIP')).style?.color,
AppColors.blue,
menuRows.every((row) => row.minTileHeight == AppDimens.mineRowHeight),
isTrue,
);
final dividers = tester.widgetList<Divider>(
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<Container>(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<Container>(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<PlayerPage>(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 = <MineDestination>[];
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<void> _openMine(WidgetTester tester) async {
await tester.tap(find.byKey(const Key('tab-mine')));
await tester.pumpAndSettle();
}
Future<void> _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();
}
+5 -3
View File
@@ -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);
+2 -4
View File
@@ -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);