feat(account): IA 重排+邀请入口+删偏好卡+设置/联系 formFactor 门控
账户页重排为:账户信息 → 购买套餐/我的订单/兑换码(三行合一) → 邀请好友(新增, gift 图标+t.inviteEntry/inviteEntrySub,接 NavView.invite) → 设备(常显)/ 设置/联系(仅 mobile,桌面侧栏已有同入口,formFactor 门控避免重复) → 退出。 删语言/主题/协议卡及仅供其用的 _LangSwitch/_PangolinSwitch(l10n getter 保留, Settings 页仍用)。_NavRow 加可选 subtitle 用于邀请卡副标题。 新增 InviteScreen(client/lib/screens/invite_page.dart)占位屏:邀请码/链接卡 (可复制,值写死 PANGOLIN-DEMO)+ 奖励规则三条 + 我的奖励占位卡,顶部 TODO(spec-2) 标注接口点(邀请码/链接由后端签发,奖励进度来自 /v1/invite)。 顺带修复 desktop_shell.dart 非穷尽 switch 编译错误(NavView 加 invite 后未接线, 整个 client 目前无法编译);notifications 分支占位回落账户页,留给 Task 6 接线。 test/widget/account_ia_test.dart 新增桌面态 IA 结构断言(邀请入口存在/语言不在/ 兑换在);account_desktop_nav_test.dart 同步去掉桌面态设置/联系两个失效断言 (改走桌面侧栏,非账户卡),加邀请入口断言。desktop/tablet 账户页 golden 重录。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -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<AppLang> onChange;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
// 6 种语言用下拉(段控横排会挤)。
|
||||
return PopupMenuButton<AppLang>(
|
||||
initialValue: lang,
|
||||
onSelected: onChange,
|
||||
color: c.surface,
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md)),
|
||||
itemBuilder: (_) => [
|
||||
for (final l in AppLang.values)
|
||||
PopupMenuItem<AppLang>(
|
||||
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<bool> 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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> _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))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
@@ -88,19 +88,18 @@ Future<NavView> _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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> saveTokens({required String access, required String refresh}) async {}
|
||||
@override
|
||||
Future<String?> loadAccessToken() async => null;
|
||||
@override
|
||||
Future<String?> loadRefreshToken() async => null;
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
@override
|
||||
Future<void> markOnboarded() async {}
|
||||
@override
|
||||
Future<bool> isOnboarded() async => true;
|
||||
@override
|
||||
Future<void> saveLastEmail(String email) async {}
|
||||
@override
|
||||
Future<String?> 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;
|
||||
}
|
||||
});
|
||||
}
|
||||