fe16f89bc3
- desktop 上套餐选择/兑换购买/设备管理改为内容区下钻(侧栏保留、shell 顶栏带 返回箭头),不再全屏 push 盖住侧栏;mobile/tablet 仍全屏 push - _SubScaffold 加 embedded 参数(true 只返回内容),PlansScreen/DevicesScreen/ RedeemScreen 透传;NavView 加 devices + kAccountSubViews 集合 - desktop_shell 渲染子页(embedded) + 顶栏 onBack→account;account_page 按 formFactor 选下钻(切 navView)或 push - desktop 整页 golden 补 套餐/兑换/设备 三子页(共 8 页) 测试: flutter test 84 通过(+3 子页 golden), analyze 0 error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
314 lines
15 KiB
Dart
314 lines
15 KiB
Dart
// 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';
|
||
|
||
/// 通用:带返回箭头的子页骨架。embedded=true 时只返回内容(desktop 内容区下钻,
|
||
/// 返回/标题由外层 shell 顶栏提供),否则整页 Scaffold(mobile/tablet 全屏 push)。
|
||
class _SubScaffold extends StatelessWidget {
|
||
const _SubScaffold({required this.title, required this.child, this.onBack, this.embedded = false});
|
||
final String title;
|
||
final Widget child;
|
||
final VoidCallback? onBack;
|
||
final bool embedded;
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final c = context.pangolin;
|
||
if (embedded) return child;
|
||
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),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// ── 套餐选择 ──
|
||
class PlansScreen extends StatelessWidget {
|
||
const PlansScreen({super.key, required this.t, this.onChoose, this.onBack, this.embedded = false});
|
||
final AppText t;
|
||
final ValueChanged<String>? onChoose;
|
||
final VoidCallback? onBack;
|
||
final bool embedded;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final plans = [
|
||
(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: t.choosePlan,
|
||
onBack: onBack,
|
||
embedded: embedded,
|
||
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: t.perMonth,
|
||
features: p.feats, ctaLabel: p.cta, featured: p.featured, isCurrent: p.current,
|
||
popularLabel: p.pop, onPressed: () => onChoose?.call(p.id),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// ── 设备管理 ──
|
||
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;
|
||
}
|
||
|
||
class DevicesScreen extends StatefulWidget {
|
||
const DevicesScreen({super.key, required this.t, this.onBack, this.embedded = false});
|
||
final AppText t;
|
||
final VoidCallback? onBack;
|
||
final bool embedded;
|
||
@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.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.t.lang == AppLang.zh ? '3 小时前' : '3h ago'),
|
||
];
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final c = context.pangolin;
|
||
return _SubScaffold(
|
||
title: widget.t.myDevices,
|
||
onBack: widget.onBack,
|
||
embedded: widget.embedded,
|
||
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
|
||
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),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(children: [
|
||
for (var i = 0; i < _devices.length; i++) _row(c, _devices[i], i < _devices.length - 1),
|
||
]),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _row(PangolinScheme c, DeviceItem d, bool divider) {
|
||
return Container(
|
||
decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
child: Row(children: [
|
||
Container(width: 38, height: 38, decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
|
||
child: Icon(d.icon, size: 19, color: c.accent)),
|
||
const SizedBox(width: 12),
|
||
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),
|
||
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)),
|
||
),
|
||
]),
|
||
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.t.remove, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// ── 兑换 & 购买 ──
|
||
class RedeemScreen extends StatefulWidget {
|
||
const RedeemScreen({super.key, required this.t, this.onBack, this.embedded = false});
|
||
final AppText t;
|
||
final VoidCallback? onBack;
|
||
final bool embedded;
|
||
@override
|
||
State<RedeemScreen> createState() => _RedeemScreenState();
|
||
}
|
||
|
||
class _RedeemScreenState extends State<RedeemScreen> {
|
||
final _code = TextEditingController();
|
||
bool _ok = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_code.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final c = context.pangolin;
|
||
final t = widget.t;
|
||
final channels = [
|
||
(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: t.chEmail, sub: 'buy@pangolin.vpn', accent: false),
|
||
];
|
||
return _SubScaffold(
|
||
title: t.redeemTitle,
|
||
onBack: widget.onBack,
|
||
embedded: widget.embedded,
|
||
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(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(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: 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(t.buyTitle, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
|
||
const SizedBox(height: 6),
|
||
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)),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _channel(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
|
||
return InkWell(
|
||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||
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),
|
||
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)),
|
||
const SizedBox(width: 13),
|
||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
|
||
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||
])),
|
||
Icon(PangolinIcons.externalLink, size: 16, color: c.fg3),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// ── 联系我们 ──
|
||
class ContactScreen extends StatelessWidget {
|
||
const ContactScreen({super.key, required this.t, this.onBack});
|
||
final AppText t;
|
||
final VoidCallback? onBack;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final c = context.pangolin;
|
||
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: t.contactEmail, sub: 'support@pangolin.vpn', accent: false),
|
||
(icon: PangolinIcons.shoppingBag, name: t.contactStore, sub: 'shop.pangolin.vpn', accent: false),
|
||
];
|
||
return _SubScaffold(
|
||
title: t.contactTitle,
|
||
onBack: onBack,
|
||
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
|
||
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),
|
||
Container(
|
||
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(t.contactHoursTitle, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 5),
|
||
Text(t.contactHours, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
|
||
]),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _contact(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
|
||
return InkWell(
|
||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||
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),
|
||
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)),
|
||
const SizedBox(width: 13),
|
||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
|
||
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||
])),
|
||
Icon(PangolinIcons.externalLink, size: 16, color: c.fg3),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|