Files
pangolin/client/lib/screens/account_page.dart
T
wangjia f97ae8186b feat(client): Flutter 逐屏还原 + iPad ≥900 断点 (tsk_gpPXi-icyeOE)
对照 design/ui_kits mobile+tablet 原型逐屏补齐 Flutter 客户端:

- l10n 资源层(strings_zh/en,单显)+ Riverpod 状态层(连接状态机/免费额度/
  节点选择/语言/主题),UI 与数据解耦,mock 数据接 API 不动 UI。
- 连接键严格三态(off 虚线轨道环 / connecting 旋转弧 / on 满环+计时+光晕),
  状态来自 connectionProvider,点击只派发事件——禁止乐观显示。
- 节点页置顶「智能选择」推荐卡(clay 渐变 zap + 推荐胶囊,默认选中);
  免费额度卡(剩余分钟+进度条 ≤3 分钟切 warning + 看广告解锁变绿)。
- Tab 左右滑动切换(手势竞技场仲裁,子页滚动不误触发,200ms 方向感知滑入)。
- iPad/宽屏 ≥900 LayoutBuilder 切侧栏分栏(导航行高 ≥48,连接页双栏/节点双列网格),
  复用同一批原子组件,不 fork 页面。
- 语义 token 零硬编码;文案全部走 l10n;套餐数字对齐 §7;无支付表单/emoji 国旗。
- CI 红线词扫描扩展到 client/lib;新增 Flutter analyze+test CI 任务。
- 测试:连接状态机/额度/节点单测、连接键三态与卡片组件测试、
  golden(连接键三态/推荐卡/额度卡 × 明暗)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:32:36 +08:00

388 lines
14 KiB
Dart

// account_page.dart — 账户页(套餐横幅 + 账户信息 + 设备/兑换/联系 + 语言/主题)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../state/app_providers.dart';
import '../widgets/account_screens.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/pangolin_icons.dart';
const String kDemoEmail = 'me@pangolin.vpn';
class AccountPage extends ConsumerWidget {
const AccountPage({super.key, required this.isWide});
final bool isWide;
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
final isFree = ref.watch(isFreePlanProvider);
final lang = ref.watch(localeProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
final pad = isWide ? 28.0 : 20.0;
void push(Widget page) =>
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
final body = ListView(
padding: EdgeInsets.fromLTRB(pad, isWide ? 6 : 0, pad, 24),
children: [
_PlanBanner(t: t, isFree: isFree, onUpgrade: () => push(PlansScreen(t: t))),
const SizedBox(height: 18),
// 账户信息
_SectionLabel(text: t.accInfoTitle),
_Card(children: [
_InfoRow(icon: PangolinIcons.mail, title: t.accEmail, value: kDemoEmail, trailing: _changeHint(c, t)),
Divider(height: 1, color: c.border),
_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: () => push(DevicesScreen(t: t))),
Divider(height: 1, color: c.border),
_NavRow(icon: PangolinIcons.shoppingBag, title: t.redeemEntry, onTap: () => push(RedeemScreen(t: t))),
Divider(height: 1, color: c.border),
_NavRow(icon: PangolinIcons.messageCircle, title: t.contactEntry, onTap: () => push(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).state = 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('WireGuard', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 13)),
),
]),
const SizedBox(height: 16),
// 演示:免费版视角开关
_Card(children: [
_CustomRow(
icon: PangolinIcons.clock,
title: lang == AppLang.zh ? '演示:免费版视角' : 'Demo: free-tier view',
subtitle: lang == AppLang.zh ? '切换额度卡与套餐横幅' : 'Toggles quota UI & plan banner',
trailing: _PangolinSwitch(
value: isFree,
onChanged: (v) => ref.read(isFreePlanProvider.notifier).state = v,
),
),
]),
const SizedBox(height: 16),
_Card(children: [
_NavRow(icon: PangolinIcons.logOut, title: t.signOut, danger: true, onTap: () {}),
]),
],
);
if (isWide) return body;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
AppTopBar(brand: t.brand),
PageTitle(title: t.meTitle),
Expanded(child: body),
]);
}
Widget _changeHint(PangolinScheme c, AppText t) =>
Text(t.accChange, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600, fontSize: 13));
}
class _PlanBanner extends StatelessWidget {
const _PlanBanner({required this.t, required this.isFree, required this.onUpgrade});
final AppText t;
final bool isFree;
final VoidCallback onUpgrade;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
if (isFree) {
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: 44, height: 44,
decoration: BoxDecoration(color: c.bgSubtle, shape: BoxShape.circle),
child: Icon(PangolinIcons.user, size: 22, color: c.fg2),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(kDemoEmail, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 2),
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Text(t.freePlanName, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 11)),
),
])),
_UpgradeButton(label: t.upgradeBtn, onTap: onUpgrade),
]),
]),
);
}
// PRO 渐变横幅
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft, end: Alignment.bottomRight,
colors: [PangolinColors.clay600, PangolinColors.clay800],
),
borderRadius: BorderRadius.circular(PangolinRadius.xl),
boxShadow: PangolinShadow.md,
),
child: Row(children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(color: PangolinColors.white.withOpacity(0.18), shape: BoxShape.circle),
child: const Icon(PangolinIcons.crown, size: 22, color: PangolinColors.white),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(kDemoEmail, style: const TextStyle(color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 16)),
const SizedBox(height: 4),
Text('${t.proMember} · 2026-12-31',
style: TextStyle(color: PangolinColors.white.withOpacity(0.85), fontSize: 12)),
])),
FilledButton(
onPressed: onUpgrade,
style: FilledButton.styleFrom(
backgroundColor: PangolinColors.white,
foregroundColor: PangolinColors.clay700,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
child: Text(t.upgradeBtn, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
),
]),
);
}
}
class _UpgradeButton extends StatelessWidget {
const _UpgradeButton({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return FilledButton(
onPressed: onTap,
style: FilledButton.styleFrom(
backgroundColor: c.accent,
foregroundColor: c.fgOnAccent,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
),
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 10),
child: Text(text, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return 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: children),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.icon, required this.title, required this.value, this.trailing});
final IconData icon;
final String title, value;
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)),
Text(value, style: PangolinText.caption.copyWith(color: c.fg3)),
])),
if (trailing != null) trailing!,
]),
);
}
}
class _NavRow extends StatelessWidget {
const _NavRow({required this.icon, required this.title, required this.onTap, this.danger = false});
final IconData icon;
final String title;
final VoidCallback onTap;
final bool danger;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
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))),
if (!danger) Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
]),
),
);
}
}
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;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Container(
width: 32, height: 32,
decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)),
child: Icon(icon, size: 17, color: c.accent),
);
}
}
/// 段控:中文 / 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;
Widget seg(AppLang v, String label) {
final active = lang == v;
return GestureDetector(
onTap: () => onChange(v),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 5),
decoration: BoxDecoration(
color: active ? c.accent : Colors.transparent,
borderRadius: BorderRadius.circular(PangolinRadius.full),
),
child: Text(label,
style: PangolinText.caption.copyWith(
color: active ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12.5)),
),
);
}
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
seg(AppLang.zh, '中文'),
const SizedBox(width: 2),
seg(AppLang.en, 'EN'),
]),
);
}
}
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)),
),
),
),
);
}
}