diff --git a/client/lib/screens/account_page.dart b/client/lib/screens/account_page.dart index 7216f43..f91f661 100644 --- a/client/lib/screens/account_page.dart +++ b/client/lib/screens/account_page.dart @@ -1,4 +1,4 @@ -// account_page.dart — 账户页(套餐横幅 + 账户信息 + 设备/兑换/联系 + 语言/主题) +// account_page.dart — 账户页(套餐横幅 + 账户信息 + 购买/订单/兑换 + 邀请 + 设备/设置/联系) import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -12,6 +12,7 @@ import '../state/navigation_provider.dart'; import '../widgets/account_screens.dart'; import '../widgets/app_top_bar.dart'; import '../widgets/pangolin_icons.dart'; +import 'invite_page.dart'; import 'orders_page.dart'; import 'payment_page.dart'; import 'purchase_page.dart'; @@ -35,8 +36,6 @@ class AccountPage extends ConsumerWidget { final me = ref.watch(meProvider).valueOrNull; final email = (me?.email.isNotEmpty ?? false) ? me!.email : '—'; final expiresLabel = me?.expiresAt != null ? _fmtDate(me!.expiresAt!) : ''; - final lang = ref.watch(localeProvider); - final isDark = Theme.of(context).brightness == Brightness.dark; final pad = isWide ? 28.0 : 20.0; // desktop 在内容区下钻(切 navView);mobile/tablet 全屏 push。 @@ -57,21 +56,13 @@ class AccountPage extends ConsumerWidget { isFree: isFree, email: email, expiresLabel: expiresLabel, - // mobile/tablet 全屏 push(desktop 走 open() 的 navView 分支,mobilePage 参数被忽略, - // 由 desktop_shell 自己那份 PlansScreen(onChoose: ...) 接线,见 desktop_shell.dart)。 + // Renew/Upgrade 直达购买页(套餐 + 渠道 switch),不再经 PlansScreen。 onUpgrade: () => open( - NavView.plans, - PlansScreen( + NavView.purchase, + PurchaseScreen( t: t, - onChoose: (code) => code == 'pro' - ? Navigator.of(context).push(MaterialPageRoute( - builder: (_) => PurchaseScreen( - t: t, - onOrderCreated: () => Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => PaymentScreen(t: t))), - ), - )) - : Navigator.of(context).push(MaterialPageRoute(builder: (_) => RedeemScreen(t: t))), + onOrderCreated: () => Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => PaymentScreen(t: t))), ), ), ), @@ -84,44 +75,46 @@ class AccountPage extends ConsumerWidget { _InfoRow(icon: PangolinIcons.lock, title: t.accPassword, value: '••••••••••', trailing: _changeHint(c, t)), ]), const SizedBox(height: 16), - // 设备 / 兑换 / 联系 + // 购买套餐 / 我的订单 / 兑换码(独立一块,紧跟账户信息) _Card(children: [ - _NavRow(icon: PangolinIcons.monitorSmartphone, title: t.myDevices, onTap: () => open(NavView.devices, DevicesScreen(t: t))), + _NavRow( + icon: PangolinIcons.crown, + title: t.purchaseTitle, + onTap: () => open( + NavView.purchase, + PurchaseScreen( + t: t, + onOrderCreated: () => Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => PaymentScreen(t: t))), + ), + )), Divider(height: 1, color: c.border), _NavRow(icon: PangolinIcons.ticket, title: t.ordersTitle, onTap: () => open(NavView.orders, OrdersScreen(t: t))), Divider(height: 1, color: c.border), - _NavRow(icon: PangolinIcons.settings, title: t.settingsTitle, onTap: () => open(NavView.settings, SettingsScreen(t: t))), - Divider(height: 1, color: c.border), _NavRow(icon: PangolinIcons.shoppingBag, title: t.redeemEntry, onTap: () => open(NavView.redeem, RedeemScreen(t: t))), - Divider(height: 1, color: c.border), - _NavRow(icon: PangolinIcons.messageCircle, title: t.contactEntry, onTap: () => open(NavView.contact, ContactScreen(t: t))), ]), const SizedBox(height: 16), - // 语言 / 主题 / 协议 + // 邀请好友 _Card(children: [ - _CustomRow( - icon: PangolinIcons.globe, - title: t.language, - trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).set(l)), - ), - Divider(height: 1, color: c.border), - _CustomRow( - icon: isDark ? PangolinIcons.moon : PangolinIcons.sun, - title: t.darkAppearance, - subtitle: isDark ? t.stateOn : t.followLight, - trailing: _PangolinSwitch( - value: isDark, - onChanged: (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light, - ), - ), - Divider(height: 1, color: c.border), - _CustomRow( - icon: PangolinIcons.shield, - title: t.protocol, - trailing: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 13)), + _NavRow( + icon: PangolinIcons.gift, + title: t.inviteEntry, + subtitle: t.inviteEntrySub, + onTap: () => open(NavView.invite, InviteScreen(t: t)), ), ]), const SizedBox(height: 16), + // 设备 / 设置 / 联系(设置/联系桌面侧栏已有,仅 mobile/tablet 在此下钻) + _Card(children: [ + _NavRow(icon: PangolinIcons.monitorSmartphone, title: t.myDevices, onTap: () => open(NavView.devices, DevicesScreen(t: t))), + if (context.formFactor == FormFactor.mobile) ...[ + Divider(height: 1, color: c.border), + _NavRow(icon: PangolinIcons.settings, title: t.settingsTitle, onTap: () => open(NavView.settings, SettingsScreen(t: t))), + Divider(height: 1, color: c.border), + _NavRow(icon: PangolinIcons.messageCircle, title: t.contactEntry, onTap: () => open(NavView.contact, ContactScreen(t: t))), + ], + ]), + const SizedBox(height: 16), _Card(children: [ _NavRow( icon: PangolinIcons.logOut, @@ -309,9 +302,10 @@ class _InfoRow extends StatelessWidget { } class _NavRow extends StatelessWidget { - const _NavRow({required this.icon, required this.title, required this.onTap, this.danger = false}); + const _NavRow({required this.icon, required this.title, required this.onTap, this.subtitle, this.danger = false}); final IconData icon; final String title; + final String? subtitle; final VoidCallback onTap; final bool danger; @override @@ -324,7 +318,17 @@ class _NavRow extends StatelessWidget { child: Row(children: [ Icon(icon, size: 20, color: danger ? c.danger : c.accent), const SizedBox(width: 13), - Expanded(child: Text(title, style: PangolinText.body.copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500))), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: PangolinText.body.copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500)), + if (subtitle != null) + Text(subtitle!, style: PangolinText.caption.copyWith(color: c.fg3)), + ], + ), + ), if (!danger) Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), ]), ), @@ -332,30 +336,6 @@ class _NavRow extends StatelessWidget { } } -class _CustomRow extends StatelessWidget { - const _CustomRow({required this.icon, required this.title, this.subtitle, required this.trailing}); - final IconData icon; - final String title; - final String? subtitle; - final Widget trailing; - @override - Widget build(BuildContext context) { - final c = context.pangolin; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), - child: Row(children: [ - _IconBox(icon: icon), - const SizedBox(width: 13), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500, fontSize: 15)), - if (subtitle != null) Text(subtitle!, style: PangolinText.caption.copyWith(color: c.fg3)), - ])), - trailing, - ]), - ); - } -} - class _IconBox extends StatelessWidget { const _IconBox({required this.icon}); final IconData icon; @@ -369,74 +349,3 @@ class _IconBox extends StatelessWidget { ); } } - -/// 段控:中文 / EN(单显切换)。 -class _LangSwitch extends StatelessWidget { - const _LangSwitch({required this.lang, required this.onChange}); - final AppLang lang; - final ValueChanged onChange; - @override - Widget build(BuildContext context) { - final c = context.pangolin; - // 6 种语言用下拉(段控横排会挤)。 - return PopupMenuButton( - initialValue: lang, - onSelected: onChange, - color: c.surface, - elevation: 8, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md)), - itemBuilder: (_) => [ - for (final l in AppLang.values) - PopupMenuItem( - value: l, - height: 42, - child: Text(l.nativeLabel, - style: PangolinText.caption.copyWith( - color: l == lang ? c.accent : c.fg1, - fontWeight: l == lang ? FontWeight.w700 : FontWeight.w500, - fontSize: 13)), - ), - ], - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 6), - decoration: - BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Text(lang.nativeLabel, - style: PangolinText.caption - .copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 12.5)), - const SizedBox(width: 4), - Icon(PangolinIcons.chevronDown, size: 14, color: c.fg3), - ]), - ), - ); - } -} - -class _PangolinSwitch extends StatelessWidget { - const _PangolinSwitch({required this.value, required this.onChanged}); - final bool value; - final ValueChanged onChanged; - @override - Widget build(BuildContext context) { - final c = context.pangolin; - return GestureDetector( - onTap: () => onChanged(!value), - child: AnimatedContainer( - duration: PangolinMotion.base, - curve: PangolinMotion.easeOut, - width: 46, height: 28, - decoration: BoxDecoration(color: value ? c.accent : c.borderStrong, borderRadius: BorderRadius.circular(PangolinRadius.full)), - child: AnimatedAlign( - duration: PangolinMotion.base, - curve: PangolinMotion.easeOut, - alignment: value ? Alignment.centerRight : Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.all(3), - child: Container(width: 22, height: 22, decoration: const BoxDecoration(color: PangolinColors.white, shape: BoxShape.circle)), - ), - ), - ), - ); - } -} diff --git a/client/lib/screens/invite_page.dart b/client/lib/screens/invite_page.dart new file mode 100644 index 0000000..8225385 --- /dev/null +++ b/client/lib/screens/invite_page.dart @@ -0,0 +1,207 @@ +// invite_page.dart — 邀请好友(占位屏) +// +// TODO(spec-2): 邀请码/链接由后端签发,奖励进度来自 /v1/invite。当前码/链接/奖励进度 +// 均为静态占位,仅供 IA 走查——接后端前不接任何真实业务逻辑。 +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../l10n/app_text.dart'; +import '../pangolin_theme.dart'; +import '../widgets/pangolin_icons.dart'; + +const String _kDemoInviteCode = 'PANGOLIN-DEMO'; +const String _kDemoInviteLink = 'https://pangolin.app/i/PANGOLIN-DEMO'; + +class InviteScreen extends ConsumerWidget { + const InviteScreen({super.key, required this.t, this.onBack, this.embedded = false}); + final AppText t; + final VoidCallback? onBack; + final bool embedded; + + Future _copy(BuildContext context, String text, String toastLabel) async { + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(toastLabel), + behavior: SnackBarBehavior.floating, + duration: const Duration(milliseconds: 1400), + )); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + + Widget body = ListView( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), + children: [ + _CopyCard( + icon: PangolinIcons.gift, + label: t.inviteCodeLabel, + value: _kDemoInviteCode, + mono: true, + copyLabel: t.inviteCopyCode, + onCopy: () => _copy(context, _kDemoInviteCode, t.copied), + ), + const SizedBox(height: 14), + _CopyCard( + icon: PangolinIcons.link, + label: t.inviteLinkLabel, + value: _kDemoInviteLink, + mono: false, + copyLabel: t.inviteCopyLink, + onCopy: () => _copy(context, _kDemoInviteLink, t.copied), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 2, 10), + child: Text(t.inviteRewardsTitle, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)), + ), + Container( + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + clipBehavior: Clip.antiAlias, + child: Column(children: [ + _RewardRow(icon: PangolinIcons.users, text: t.inviteRewardRegister), + Divider(height: 1, color: c.border), + _RewardRow(icon: PangolinIcons.crown, text: t.inviteRewardFirstBuy), + Divider(height: 1, color: c.border), + _RewardRow(icon: PangolinIcons.messageCircle, text: t.inviteRewardJoinTg), + ]), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 2, 10), + child: Text(t.inviteProgressTitle, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + child: Column(children: [ + Icon(PangolinIcons.gift, size: 26, color: c.fg3), + const SizedBox(height: 10), + Text('—', style: PangolinText.body.copyWith(color: c.fg3)), + ]), + ), + ], + ); + + if (embedded) return body; + return Scaffold( + backgroundColor: c.bg, + body: SafeArea( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 16, 10), + child: Row(children: [ + IconButton( + onPressed: onBack ?? () => Navigator.of(context).maybePop(), + icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1), + ), + Text(t.inviteTitle, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + ]), + ), + Expanded(child: body), + ]), + ), + ); + } +} + +class _CopyCard extends StatelessWidget { + const _CopyCard({ + required this.icon, + required this.label, + required this.value, + required this.mono, + required this.copyLabel, + required this.onCopy, + }); + final IconData icon; + final String label; + final String value; + final bool mono; + final String copyLabel; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.xl), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)), + child: Icon(icon, size: 17, color: c.accent), + ), + const SizedBox(width: 10), + Text(label, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13.5)), + ]), + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: Text(value, + overflow: TextOverflow.ellipsis, + style: mono + ? PangolinText.mono.copyWith(color: c.fg1, fontWeight: FontWeight.w700, letterSpacing: 1.2, fontSize: 15) + : PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 10), + OutlinedButton.icon( + onPressed: onCopy, + style: OutlinedButton.styleFrom( + foregroundColor: c.accent, + side: BorderSide(color: c.accentBorder), + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + ), + icon: Icon(PangolinIcons.copy, size: 15), + label: Text(copyLabel, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w700, fontSize: 12.5)), + ), + ]), + ]), + ); + } +} + +class _RewardRow extends StatelessWidget { + const _RewardRow({required this.icon, required this.text}); + final IconData icon; + final String text; + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row(children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)), + child: Icon(icon, size: 17, color: c.accent), + ), + const SizedBox(width: 13), + Expanded(child: Text(text, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500, fontSize: 14.5))), + ]), + ); + } +} diff --git a/client/lib/shell/desktop_shell.dart b/client/lib/shell/desktop_shell.dart index 5dafbe6..11798d7 100644 --- a/client/lib/shell/desktop_shell.dart +++ b/client/lib/shell/desktop_shell.dart @@ -8,6 +8,7 @@ import '../pangolin_theme.dart'; import '../screens/account_page.dart'; import '../screens/connect_page.dart'; import '../screens/contact_page.dart'; +import '../screens/invite_page.dart'; import '../screens/nodes_page.dart'; import '../screens/orders_page.dart'; import '../screens/payment_page.dart'; @@ -52,6 +53,7 @@ class DesktopShell extends ConsumerWidget { NavView.purchase: t.purchaseTitle, NavView.payment: t.paymentTitle, NavView.orders: t.ordersTitle, + NavView.invite: t.inviteTitle, }; void go(NavView v) => ref.read(navViewProvider.notifier).state = v; @@ -84,6 +86,11 @@ class DesktopShell extends ConsumerWidget { return PaymentScreen(t: t, embedded: true, onDone: () => go(NavView.account)); case NavView.orders: return OrdersScreen(t: t, embedded: true); + case NavView.invite: + return InviteScreen(t: t, embedded: true); + case NavView.notifications: + // TODO(task-6): 通知屏尚未接线,占位回落账户页,避免非穷尽 switch 编译失败。 + return const AccountPage(isWide: true); } } diff --git a/client/test/golden/goldens/desktop_account.png b/client/test/golden/goldens/desktop_account.png index 87badbc..7b811f2 100644 Binary files a/client/test/golden/goldens/desktop_account.png and b/client/test/golden/goldens/desktop_account.png differ diff --git a/client/test/golden/goldens/tablet_account_dark_zh.png b/client/test/golden/goldens/tablet_account_dark_zh.png index 0b9f582..b0a9528 100644 Binary files a/client/test/golden/goldens/tablet_account_dark_zh.png and b/client/test/golden/goldens/tablet_account_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_account_light_en.png b/client/test/golden/goldens/tablet_account_light_en.png index d069aa6..238b256 100644 Binary files a/client/test/golden/goldens/tablet_account_light_en.png and b/client/test/golden/goldens/tablet_account_light_en.png differ diff --git a/client/test/golden/goldens/tablet_account_light_zh.png b/client/test/golden/goldens/tablet_account_light_zh.png index 0d010b6..0830a86 100644 Binary files a/client/test/golden/goldens/tablet_account_light_zh.png and b/client/test/golden/goldens/tablet_account_light_zh.png differ diff --git a/client/test/widget/account_desktop_nav_test.dart b/client/test/widget/account_desktop_nav_test.dart index 4028d04..a4a9aac 100644 --- a/client/test/widget/account_desktop_nav_test.dart +++ b/client/test/widget/account_desktop_nav_test.dart @@ -88,19 +88,18 @@ Future _tapRow(WidgetTester tester, String label) async { void main() { setUpAll(disableGoogleFontsFetching); - testWidgets('desktop account -> settings', (tester) async { - expect(await _tapRow(tester, t.settingsTitle), NavView.settings); - }); + // 注:设置/联系已从桌面态账户卡移除(IA 重构后仅 mobile/tablet 在账户页下钻, + // 桌面走侧栏 NavSidebar 的 t.settingsTitle/t.contactTitle 入口,非本文件覆盖范围)。 testWidgets('desktop account -> redeem', (tester) async { expect(await _tapRow(tester, t.redeemEntry), NavView.redeem); }); - testWidgets('desktop account -> contact', (tester) async { - expect(await _tapRow(tester, t.contactEntry), NavView.contact); - }); testWidgets('desktop account -> orders', (tester) async { expect(await _tapRow(tester, t.ordersTitle), NavView.orders); }); testWidgets('desktop account -> devices', (tester) async { expect(await _tapRow(tester, t.myDevices), NavView.devices); }); + testWidgets('desktop account -> invite', (tester) async { + expect(await _tapRow(tester, t.inviteEntry), NavView.invite); + }); } diff --git a/client/test/widget/account_ia_test.dart b/client/test/widget/account_ia_test.dart new file mode 100644 index 0000000..045bd5c --- /dev/null +++ b/client/test/widget/account_ia_test.dart @@ -0,0 +1,84 @@ +// account_ia_test.dart — 账户页 IA 结构断言:邀请入口在/语言不在/兑换在。 +// +// 桌面态(windows 平台 + 宽 surface),经 HomeShell 渲染真实 DesktopShell → AccountPage, +// 与 account_desktop_nav_test.dart 同一套 harness。 +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/l10n/app_text.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/screens/account_page.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/shell/desktop_shell.dart'; +import 'package:pangolin_vpn/shell/home_shell.dart'; +import 'package:pangolin_vpn/state/app_providers.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; +import 'package:pangolin_vpn/state/navigation_provider.dart'; + +import '../helpers/harness.dart'; + +final AppText t = StringsZh(); + +class _NullTokenStore implements TokenStore { + const _NullTokenStore(); + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +void main() { + setUpAll(disableGoogleFontsFetching); + + testWidgets('account page IA: invite entry present / language moved out / redeem present', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + tester.view.physicalSize = const Size(920, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + try { + await tester.pumpWidget(ProviderScope( + overrides: [ + tokenStoreProvider.overrideWithValue(const _NullTokenStore()), + vpnBridgeProvider.overrideWithValue(VpnBridgeMock()), + appTextProvider.overrideWithValue(t), + navViewProvider.overrideWith((ref) => NavView.account), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: const HomeShell(), + ), + )); + await tester.pumpAndSettle(); + + expect(find.byType(DesktopShell), findsOneWidget); + expect(find.byType(AccountPage), findsOneWidget); + + // 邀请入口存在(账户页内)。 + expect(find.descendant(of: find.byType(AccountPage), matching: find.text(t.inviteEntry)), findsOneWidget); + // 语言/主题不在账户页(移到 Settings)。 + expect(find.descendant(of: find.byType(AccountPage), matching: find.text(t.language)), findsNothing); + // 兑换在账户页(购买&订单&兑换块)。 + expect(find.descendant(of: find.byType(AccountPage), matching: find.text(t.redeemEntry)), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +}