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>
This commit is contained in:
wangjia
2026-06-13 14:32:36 +08:00
parent 787151245e
commit f97ae8186b
40 changed files with 3369 additions and 567 deletions
+107 -96
View File
@@ -1,68 +1,74 @@
// account_screens.dart — 账户子页(套餐选择 / 设备管理 / 兑换 & 购买 / 联系我们)
//
// 文案全部经 AppText(l10n,单显)。套餐数字以 design/CLAUDE.md §7 为准。
// App 内无任何支付表单——购买仅引导至外部渠道。
import 'package:flutter/material.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import 'pangolin_button.dart';
import 'pangolin_icons.dart';
import 'plan_card.dart';
import 'pangolin_button.dart';
/// 通用带返回箭头的子页骨架
/// 通用:带返回箭头的子页骨架
class _SubScaffold extends StatelessWidget {
const _SubScaffold({required this.title, required this.child, this.onBack});
final String title; final Widget child; final VoidCallback? onBack;
final String title;
final Widget child;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
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(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: child),
])),
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(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: child),
]),
),
);
}
}
/// ── 套餐选择 ──
class PlansScreen extends StatelessWidget {
const PlansScreen({super.key, this.zh = true, this.onChoose, this.onBack});
final bool zh; final ValueChanged<String>? onChoose; final VoidCallback? onBack;
const PlansScreen({super.key, required this.t, this.onChoose, this.onBack});
final AppText t;
final ValueChanged<String>? onChoose;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
final plans = [
(
id: 'free', name: zh ? '免费版' : 'Free', price: '¥0', cta: zh ? '当前' : 'Current', featured: false, current: true, pop: null as String?,
feats: zh ? ['3 个基础节点', '每日 1GB 流量', '1 台设备'] : ['3 basic locations', '1GB / day', '1 device'],
),
(
id: 'pro', name: zh ? '专业版' : 'Pro', price: '¥25', cta: zh ? '立即升级' : 'Upgrade', featured: true, current: false, pop: zh ? '最受欢迎' : 'Popular',
feats: zh ? ['80+ 全球节点', '无限流量 · 极速', '5 台设备同时在线', '流媒体优化'] : ['80+ locations', 'Unlimited · fast', '5 devices', 'Streaming optimized'],
),
(
id: 'team', name: zh ? '团队版' : 'Team', price: '¥99', cta: zh ? '选择' : 'Choose', featured: false, current: false, pop: null as String?,
feats: zh ? ['Pro 全部功能', '10 个成员席位', '集中计费与管理', '优先客服'] : ['Everything in Pro', '10 seats', 'Central billing', 'Priority support'],
),
(id: 'free', name: t.freePlan, price: '¥0', cta: t.current, featured: false, current: true, pop: null as String?, feats: t.featsFree),
(id: 'pro', name: t.proPlan, price: '¥25', cta: t.upgrade, featured: true, current: false, pop: t.mostPopular, feats: t.featsPro),
(id: 'team', name: t.teamPlan, price: '¥99', cta: t.choose, featured: false, current: false, pop: null as String?, feats: t.featsTeam),
];
return _SubScaffold(
title: zh ? '选择套餐' : 'Choose plan',
title: t.choosePlan,
onBack: onBack,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
children: [
for (final p in plans) Padding(
padding: EdgeInsets.only(bottom: 14, top: p.pop != null ? 12 : 0),
child: PlanCard(
name: p.name, price: p.price, period: zh ? '/月' : '/mo',
features: p.feats, ctaLabel: p.cta, featured: p.featured, isCurrent: p.current,
popularLabel: p.pop, onPressed: () => onChoose?.call(p.id),
for (final p in plans)
Padding(
padding: EdgeInsets.only(bottom: 14, top: p.pop != null ? 12 : 0),
child: PlanCard(
name: p.name, price: p.price, period: t.perMonth,
features: p.feats, ctaLabel: p.cta, featured: p.featured, isCurrent: p.current,
popularLabel: p.pop, onPressed: () => onChoose?.call(p.id),
),
),
),
],
),
);
@@ -72,31 +78,34 @@ class PlansScreen extends StatelessWidget {
/// ── 设备管理 ──
class DeviceItem {
const DeviceItem({required this.id, required this.icon, required this.name, required this.os, required this.active, this.me = false});
final String id, name, os, active; final IconData icon; final bool me;
final String id, name, os, active;
final IconData icon;
final bool me;
}
class DevicesScreen extends StatefulWidget {
const DevicesScreen({super.key, this.zh = true, this.onBack});
final bool zh; final VoidCallback? onBack;
const DevicesScreen({super.key, required this.t, this.onBack});
final AppText t;
final VoidCallback? onBack;
@override
State<DevicesScreen> createState() => _DevicesScreenState();
}
class _DevicesScreenState extends State<DevicesScreen> {
late List<DeviceItem> _devices = [
DeviceItem(id: '1', icon: PangolinIcons.laptop, name: 'MacBook Pro', os: 'macOS 26', active: widget.zh ? '当前设备' : 'This device', me: true),
DeviceItem(id: '1', icon: PangolinIcons.laptop, name: 'MacBook Pro', os: 'macOS 26', active: widget.t.thisDevice, me: true),
DeviceItem(id: '2', icon: PangolinIcons.smartphone, name: 'iPhone 17', os: 'iOS 26', active: '2 min'),
DeviceItem(id: '3', icon: PangolinIcons.monitorSmartphone, name: 'iPad Air', os: 'iPadOS 26', active: widget.zh ? '3 小时前' : '3h ago'),
DeviceItem(id: '3', icon: PangolinIcons.monitorSmartphone, name: 'iPad Air', os: 'iPadOS 26', active: widget.t.lang == AppLang.zh ? '3 小时前' : '3h ago'),
];
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return _SubScaffold(
title: widget.zh ? '我的设备' : 'My devices',
title: widget.t.myDevices,
onBack: widget.onBack,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
Text(widget.zh ? 'PRO 套餐最多 5 台设备同时在线' : 'Up to 5 devices on PRO', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
Text(widget.t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
@@ -120,18 +129,22 @@ class _DevicesScreenState extends State<DevicesScreen> {
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Flexible(child: Text(d.name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5))),
if (d.me) Container(margin: const EdgeInsets.only(left: 7), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
if (d.me)
Container(
margin: const EdgeInsets.only(left: 7), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(color: c.successSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Text(d.active, style: PangolinText.caption.copyWith(color: c.success, fontWeight: FontWeight.w600, fontSize: 10.5))),
child: Text(d.active, style: PangolinText.caption.copyWith(color: c.success, fontWeight: FontWeight.w600, fontSize: 10.5)),
),
]),
const SizedBox(height: 1),
Text(d.me ? d.os : '${d.os} · ${d.active}', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
])),
if (!d.me) OutlinedButton(
onPressed: () => setState(() => _devices.removeWhere((x) => x.id == d.id)),
style: OutlinedButton.styleFrom(foregroundColor: c.fg2, side: BorderSide(color: c.borderStrong, width: 1.5), shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), minimumSize: Size.zero),
child: Text(widget.zh ? '移除' : 'Remove', style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)),
),
if (!d.me)
OutlinedButton(
onPressed: () => setState(() => _devices.removeWhere((x) => x.id == d.id)),
style: OutlinedButton.styleFrom(foregroundColor: c.fg2, side: BorderSide(color: c.borderStrong, width: 1.5), shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), minimumSize: Size.zero),
child: Text(widget.t.remove, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)),
),
]),
);
}
@@ -139,8 +152,9 @@ class _DevicesScreenState extends State<DevicesScreen> {
/// ── 兑换 & 购买 ──
class RedeemScreen extends StatefulWidget {
const RedeemScreen({super.key, this.zh = true, this.onBack});
final bool zh; final VoidCallback? onBack;
const RedeemScreen({super.key, required this.t, this.onBack});
final AppText t;
final VoidCallback? onBack;
@override
State<RedeemScreen> createState() => _RedeemScreenState();
}
@@ -150,52 +164,59 @@ class _RedeemScreenState extends State<RedeemScreen> {
bool _ok = false;
@override
void dispose() { _code.dispose(); super.dispose(); }
void dispose() {
_code.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final zh = widget.zh;
final t = widget.t;
final channels = [
(icon: PangolinIcons.shoppingBag, name: zh ? '自助发卡商店' : 'Self-serve store', sub: 'shop.pangolin.vpn', accent: true),
(icon: PangolinIcons.shoppingBag, name: t.chStore, sub: 'shop.pangolin.vpn', accent: true),
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: false),
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
(icon: PangolinIcons.mail, name: zh ? '邮箱客服' : 'Email support', sub: 'buy@pangolin.vpn', accent: false),
(icon: PangolinIcons.mail, name: t.chEmail, sub: 'buy@pangolin.vpn', accent: false),
];
return _SubScaffold(
title: zh ? '兑换 & 购买' : 'Redeem & buy',
title: t.redeemTitle,
onBack: widget.onBack,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
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: [
Text(zh ? '兑换激活码' : 'Redeem a code', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
Text(t.redeemCodeTitle, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
const SizedBox(height: 12),
if (_ok) Row(children: [
Icon(PangolinIcons.checkCircle, size: 20, color: c.success), const SizedBox(width: 9),
Text(zh ? '激活成功 · PRO 已开通' : 'Activated · PRO unlocked', style: PangolinText.body.copyWith(color: c.success, fontWeight: FontWeight.w600)),
]) else Row(children: [
Expanded(child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(color: c.bg, borderRadius: BorderRadius.circular(PangolinRadius.md), border: Border.all(color: c.borderStrong, width: 1.5)),
child: TextField(controller: _code,
if (_ok)
Row(children: [
Icon(PangolinIcons.checkCircle, size: 20, color: c.success),
const SizedBox(width: 9),
Text(t.redeemOk, style: PangolinText.body.copyWith(color: c.success, fontWeight: FontWeight.w600)),
])
else
Row(children: [
Expanded(child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(color: c.bg, borderRadius: BorderRadius.circular(PangolinRadius.md), border: Border.all(color: c.borderStrong, width: 1.5)),
child: TextField(
controller: _code,
textCapitalization: TextCapitalization.characters,
style: PangolinText.mono.copyWith(color: c.fg1, letterSpacing: 1.5),
decoration: InputDecoration(border: InputBorder.none, isCollapsed: true, contentPadding: const EdgeInsets.symmetric(vertical: 13),
hintText: zh ? '输入激活码' : 'Enter code', hintStyle: PangolinText.sm.copyWith(color: c.fg3)),
onChanged: (_) => setState(() {})),
)),
const SizedBox(width: 10),
PangolinButton(label: zh ? '激活' : 'Redeem', onPressed: _code.text.trim().length >= 4 ? () => setState(() => _ok = true) : null),
]),
decoration: InputDecoration(border: InputBorder.none, isCollapsed: true, contentPadding: const EdgeInsets.symmetric(vertical: 13), hintText: t.redeemPh, hintStyle: PangolinText.sm.copyWith(color: c.fg3)),
onChanged: (_) => setState(() {}),
),
)),
const SizedBox(width: 10),
PangolinButton(label: t.redeemBtn, onPressed: _code.text.trim().length >= 4 ? () => setState(() => _ok = true) : null),
]),
]),
),
const SizedBox(height: 22),
Text(zh ? '购买渠道' : 'Where to buy', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
Text(t.buyTitle, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
const SizedBox(height: 6),
Text(zh ? 'App 内不支持直接支付。请通过以下渠道获取激活码:' : 'In-app payment is unavailable. Get a code via:',
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400, height: 1.5)),
Text(t.buySub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400, height: 1.5)),
const SizedBox(height: 14),
for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _channel(c, ch.icon, ch.name, ch.sub, ch.accent)),
]),
@@ -208,12 +229,7 @@ class _RedeemScreenState extends State<RedeemScreen> {
onTap: () {},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: accent ? c.accentSubtle : c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: accent ? c.accentBorder : c.border),
boxShadow: PangolinShadow.sm,
),
decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm),
child: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)),
@@ -231,8 +247,9 @@ class _RedeemScreenState extends State<RedeemScreen> {
/// ── 联系我们 ──
class ContactScreen extends StatelessWidget {
const ContactScreen({super.key, this.zh = true, this.onBack});
final bool zh; final VoidCallback? onBack;
const ContactScreen({super.key, required this.t, this.onBack});
final AppText t;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
@@ -240,15 +257,14 @@ class ContactScreen extends StatelessWidget {
final channels = [
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true),
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
(icon: PangolinIcons.mail, name: zh ? '邮箱客服' : 'Email support', sub: 'support@pangolin.vpn', accent: false),
(icon: PangolinIcons.shoppingBag, name: zh ? '自助发卡商店' : 'Self-serve store', sub: 'shop.pangolin.vpn', accent: false),
(icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@pangolin.vpn', accent: false),
(icon: PangolinIcons.shoppingBag, name: t.contactStore, sub: 'shop.pangolin.vpn', accent: false),
];
return _SubScaffold(
title: zh ? '联系我们' : 'Contact us',
title: t.contactTitle,
onBack: onBack,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
Text(zh ? '遇到问题?通过以下任一渠道联系我们,通常数分钟内回复。' : 'Need help? Reach us via any channel below — usually replies in minutes.',
style: PangolinText.sm.copyWith(color: c.fg2, height: 1.6)),
Text(t.contactIntro, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.6)),
const SizedBox(height: 16),
for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _contact(c, ch.icon, ch.name, ch.sub, ch.accent)),
const SizedBox(height: 8),
@@ -256,9 +272,9 @@ class ContactScreen extends StatelessWidget {
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(zh ? '服务时间' : 'Support hours', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
Text(t.contactHoursTitle, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
const SizedBox(height: 5),
Text(zh ? '每日 9:00 24:00 (GMT+8)' : 'Daily 9:00 24:00 (GMT+8)', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
Text(t.contactHours, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
]),
),
]),
@@ -271,12 +287,7 @@ class ContactScreen extends StatelessWidget {
onTap: () {},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: accent ? c.accentSubtle : c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: accent ? c.accentBorder : c.border),
boxShadow: PangolinShadow.sm,
),
decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm),
child: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)),