Files
pangolin/client/lib/widgets/home_shell.dart
T
wangjia bf1717a369 merge: 控制面联调 M6 [tsk_nuoKSM4Vt-zK]
# Conflicts:
#	.gitignore
#	client/lib/widgets/home_shell.dart
#	client/lib/widgets/server_tile.dart
#	client/pubspec.yaml
2026-06-13 17:32:26 +08:00

889 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// home_shell.dart — 主框架(4 Tab:连接 / 节点 / 统计 / 账户)
//
// 自适应断点:同一份页面代码,宽度 ≥900 时从底部 Tab 切换为左侧栏分栏
// (LayoutBuilder 开关,不 fork 页面)。窄屏支持左右滑动切换 Tab。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../pangolin_theme.dart';
import '../screens/account_page.dart';
import '../screens/connect_page.dart';
import '../screens/nodes_page.dart';
import '../screens/stats_page.dart';
import '../state/app_providers.dart';
import '../state/connection_provider.dart';
import '../state/nodes_provider.dart';
import 'pangolin_icons.dart';
import 'pangolin_logo.dart';
import 'tab_swipe.dart';
/// 宽屏断点(逻辑像素)。≥该值切侧栏分栏。
const double kWideBreakpoint = 900;
class HomeShell extends ConsumerStatefulWidget {
const HomeShell({super.key});
// home_shell.dart — 主框架(底部 4 Tab:连接 / 节点 / 统计 / 账户)
//
// M6 改动:_toggle/_pick 替换为真实 ConnectApi.fetchConfig → VpnBridge.start。
// API URL / token 由 --dart-define 注入(见 README M6 联调说明),默认指向本地 mock server。
// tsk_nuoKSM4Vt-zK
import 'dart:async';
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
import '../services/connect_api.dart';
import '../services/vpn_bridge.dart';
import 'pangolin_icons.dart';
import 'connect_button.dart';
import 'server_tile.dart';
import 'country_code.dart';
import 'account_screens.dart';
import 'pangolin_logo.dart';
// ── 联调配置(由 --dart-define 注入;不入库)──
// 运行示例:flutter run --dart-define=PANGOLIN_API_URL=http://localhost:8081 \
// --dart-define=PANGOLIN_API_TOKEN=dev-mock-token \
// --dart-define=PANGOLIN_DEVICE_ID=demo-device-001
const _kApiUrl = String.fromEnvironment(
'PANGOLIN_API_URL',
defaultValue: 'http://localhost:8081',
);
const _kApiToken = String.fromEnvironment(
'PANGOLIN_API_TOKEN',
defaultValue: 'dev-mock-token',
);
const _kDeviceId = String.fromEnvironment(
'PANGOLIN_DEVICE_ID',
defaultValue: 'demo-device-001',
);
class HomeShell extends StatefulWidget {
const HomeShell({super.key, this.zh = true});
final bool zh;
@override
ConsumerState<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends ConsumerState<HomeShell> {
int _tab = 0;
int _dir = 0;
Timer? _dirReset;
void _goTab(int i) {
if (i == _tab) return;
setState(() {
_dir = i > _tab ? 1 : -1;
_tab = i;
});
_dirReset?.cancel();
_dirReset = Timer(const Duration(milliseconds: 260), () {
if (mounted) setState(() => _dir = 0);
});
}
void _swipe(int delta) => _goTab((_tab + delta).clamp(0, 3));
@override
void dispose() {
_dirReset?.cancel();
super.dispose();
}
List<Widget> _pages(bool isWide) => [
ConnectPage(isWide: isWide, onOpenNodes: () => _goTab(1)),
NodesPage(isWide: isWide, onPicked: () => _goTab(0)),
StatsPage(isWide: isWide),
AccountPage(isWide: isWide),
];
VpnStatus _status = VpnStatus.off;
ServerInfo _server = const ServerInfo(
code: 'HK',
name: '香港 · 流媒体',
sub: 'Hong Kong',
ping: 18,
nodeId: 'hk-1',
);
Timer? _timer;
int _elapsed = 0;
// ── 控制面 + 数据面 ──
late final ConnectApi _api = ConnectApi(baseUrl: _kApiUrl, authToken: _kApiToken);
final VpnBridge _bridge = VpnBridge();
String _t(String zh, String en) => widget.zh ? zh : en;
// ── 连接/断开(异步,含错误路径)──
void _toggle() {
if (_status == VpnStatus.connecting) return; // 防重入
if (_status == VpnStatus.off) {
_connect(_server);
} else {
_disconnect();
}
}
Future<void> _connect(ServerInfo server) async {
setState(() => _status = VpnStatus.connecting);
final messenger = ScaffoldMessenger.of(context);
try {
// 1. 从控制面拉取完整 sing-box config JSON
final configJson = await _api.fetchConfig(
nodeId: server.effectiveNodeId,
deviceId: _kDeviceId,
);
// 2. 原样透传给 VpnBridge.start(禁止在 Dart 层修改 configJson
await _bridge.start(configJson);
if (!mounted) return;
setState(() {
_status = VpnStatus.on;
_elapsed = 0;
});
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => setState(() => _elapsed++),
);
} on ConnectApiException catch (e) {
if (!mounted) return;
setState(() => _status = VpnStatus.off);
messenger.showSnackBar(SnackBar(
content: Text(widget.zh ? e.messageZh : e.messageEn),
backgroundColor: Theme.of(context).colorScheme.error,
behavior: SnackBarBehavior.floating,
));
} catch (e) {
if (!mounted) return;
setState(() => _status = VpnStatus.off);
messenger.showSnackBar(SnackBar(
content: Text(_t('连接失败,请稍后重试', 'Connection failed, please try again')),
behavior: SnackBarBehavior.floating,
));
}
}
Future<void> _disconnect() async {
_timer?.cancel();
setState(() => _status = VpnStatus.off);
await _bridge.stop();
}
void _pick(ServerInfo s) {
setState(() {
_server = s;
_tab = 0;
});
if (_status == VpnStatus.on) {
_timer?.cancel();
_connect(s);
}
}
@override
void dispose() {
_timer?.cancel();
_api.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Scaffold(
backgroundColor: c.bg,
body: LayoutBuilder(builder: (context, constraints) {
final isWide = constraints.maxWidth >= kWideBreakpoint;
final page = DirectionalTabSwitcher(index: _tab, direction: _dir, child: _pages(isWide)[_tab]);
if (isWide) {
return SafeArea(child: _WideLayout(tab: _tab, onTab: _goTab, child: page));
}
return SafeArea(
bottom: false,
child: Column(children: [
Expanded(
child: HorizontalSwipeArea(
onSwipeLeft: () => _swipe(1),
onSwipeRight: () => _swipe(-1),
child: page,
),
),
_BottomTab(index: _tab, onTap: _goTab),
]),
);
}),
final pages = [
_ConnectPage(
zh: widget.zh,
status: _status,
server: _server,
elapsedSec: _elapsed,
onToggle: _toggle,
onOpenServers: () => setState(() => _tab = 1),
),
_ServersPage(zh: widget.zh, current: _server.code, onPick: _pick),
_StatsPage(zh: widget.zh),
_AccountPage(zh: widget.zh),
];
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(bottom: false, child: pages[_tab]),
bottomNavigationBar: _BottomTab(
zh: widget.zh,
index: _tab,
onTap: (i) => setState(() => _tab = i),
),
);
}
}
/// ── 宽屏分栏:左侧栏导航 + 内容区 ──
class _WideLayout extends ConsumerWidget {
const _WideLayout({required this.tab, required this.onTab, required this.child});
final int tab;
final ValueChanged<int> onTab;
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
final conn = ref.watch(connectionProvider);
final node = ref.watch(effectiveNodeProvider);
final titles = [t.tabConnect, t.tabServers, t.tabStats, t.tabMe];
return Row(children: [
_SideRail(tab: tab, onTab: onTab),
Expanded(
child: Column(children: [
// 内容区顶栏:标题 + 在线状态
SizedBox(
height: 56,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Row(children: [
Text(titles[tab], style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
const Spacer(),
Text(
conn.phase == VpnPhase.on ? '${node.code}' : '',
style: PangolinText.mono.copyWith(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: conn.phase == VpnPhase.on ? c.success : c.fg3),
),
]),
),
),
Expanded(child: child),
]),
),
]);
}
}
class _SideRail extends ConsumerWidget {
const _SideRail({required this.tab, required this.onTab});
final int tab;
final ValueChanged<int> onTab;
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
final isFree = ref.watch(isFreePlanProvider);
final items = [
(PangolinIcons.power, t.tabConnect),
(PangolinIcons.globe, t.tabServers),
(PangolinIcons.barChart, t.tabStats),
(PangolinIcons.user, t.tabMe),
];
return Container(
width: 232,
decoration: BoxDecoration(color: c.bgSubtle, border: Border(right: BorderSide(color: c.border))),
padding: const EdgeInsets.fromLTRB(14, 18, 14, 16),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
// 品牌锁版
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 20),
child: Row(children: [
const PangolinMark(size: 30),
const SizedBox(width: 10),
Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(t.brand, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 17, height: 1)),
const SizedBox(height: 3),
Text('PANGOLIN', style: PangolinText.overline.copyWith(color: c.accent, fontSize: 9, letterSpacing: 1.8)),
]),
]),
),
for (var i = 0; i < items.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: _RailItem(icon: items[i].$1, label: items[i].$2, active: i == tab, onTap: () => onTab(i)),
),
const Spacer(),
// 套餐迷你卡
Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 12),
decoration: BoxDecoration(
color: isFree ? c.surface : null,
gradient: isFree
? null
: const LinearGradient(
begin: Alignment.topLeft, end: Alignment.bottomRight,
colors: [PangolinColors.clay600, PangolinColors.clay800]),
border: isFree ? Border.all(color: c.border) : null,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
),
child: Row(children: [
Container(
width: 32, height: 32,
decoration: BoxDecoration(color: isFree ? c.bgSubtle : PangolinColors.white.withOpacity(0.18), shape: BoxShape.circle),
child: Icon(isFree ? PangolinIcons.user : PangolinIcons.crown, size: 16, color: isFree ? c.fg2 : PangolinColors.white),
),
const SizedBox(width: 9),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(isFree ? t.freePlanName : t.proMember,
overflow: TextOverflow.ellipsis,
style: PangolinText.caption.copyWith(color: isFree ? c.fg1 : PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 12.5)),
Text(kDemoEmail,
overflow: TextOverflow.ellipsis,
style: PangolinText.caption.copyWith(color: isFree ? c.fg3 : PangolinColors.white.withOpacity(0.7), fontSize: 10.5)),
])),
]),
),
]),
);
}
}
class _RailItem extends StatelessWidget {
const _RailItem({required this.icon, required this.label, required this.active, required this.onTap});
final IconData icon;
final String label;
final bool active;
final VoidCallback onTap;
/// ── Bottom tab bar ──
class _BottomTab extends StatelessWidget {
const _BottomTab({required this.zh, required this.index, required this.onTap});
final bool zh;
final int index;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Material(
color: active ? c.accentSubtle : Colors.transparent,
borderRadius: BorderRadius.circular(PangolinRadius.md),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 48), // 触控尺寸 ≥48
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(children: [
Icon(icon, size: 20, color: active ? c.accent : c.fg3),
const SizedBox(width: 12),
Text(label, style: PangolinText.body.copyWith(color: active ? c.accent : c.fg2, fontWeight: active ? FontWeight.w700 : FontWeight.w500)),
]),
),
),
),
);
}
}
/// ── 底部 Tab 栏(窄屏)──
class _BottomTab extends ConsumerWidget {
const _BottomTab({required this.index, required this.onTap});
final int index;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
final items = [
(PangolinIcons.power, t.tabConnect),
(PangolinIcons.globe, t.tabServers),
(PangolinIcons.barChart, t.tabStats),
(PangolinIcons.user, t.tabMe),
];
return Container(
decoration: BoxDecoration(
color: c.surface,
border: Border(top: BorderSide(color: c.border)),
),
padding: const EdgeInsets.only(top: 8, bottom: 22),
child: Row(
children: [
for (var i = 0; i < items.length; i++)
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(i),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(items[i].$1, size: 22, color: i == index ? c.accent : c.fg3),
const SizedBox(height: 4),
Text(
items[i].$2,
style: PangolinText.caption.copyWith(
color: i == index ? c.accent : c.fg3,
fontWeight: i == index ? FontWeight.w700 : FontWeight.w500,
),
),
]),
),
),
],
),
);
}
}
/// ── Connect page ──
class _ConnectPage extends StatelessWidget {
const _ConnectPage({
required this.zh,
required this.status,
required this.server,
required this.elapsedSec,
required this.onToggle,
required this.onOpenServers,
});
final bool zh;
final VpnStatus status;
final ServerInfo server;
final int elapsedSec;
final VoidCallback onToggle, onOpenServers;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final cap = switch (status) {
VpnStatus.off => zh ? '未连接 · 轻点连接' : 'Tap to connect',
VpnStatus.connecting => zh ? '连接中…' : 'Connecting…',
VpnStatus.on => zh ? '已连接 · 网络已加密' : 'Connected · Encrypted',
VpnStatus.error => zh ? '连接错误' : 'Connection error',
};
return Column(children: [
_TopBar(
zh: zh,
trailing: Text(
status == VpnStatus.on
? (zh ? '● 在线' : '● Online')
: (zh ? '○ 离线' : '○ Offline'),
style: PangolinText.mono.copyWith(
fontSize: 12,
color: status == VpnStatus.on ? c.success : c.fg3,
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
ConnectButton(
status: status,
elapsed: Duration(seconds: elapsedSec),
onTap: onToggle,
),
const SizedBox(height: 24),
Text(
cap,
style: PangolinText.body.copyWith(color: c.fg2, fontWeight: FontWeight.w600),
),
]),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
child: GestureDetector(
onTap: onOpenServers,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
child: Row(children: [
CountryCode(code: server.code, active: true),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
server.name,
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600),
),
Text(
'${server.sub} · ${server.ping}ms',
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400),
),
]),
),
Icon(PangolinIcons.chevronRight, size: 20, color: c.fg3),
]),
),
),
),
]);
}
}
/// ── Servers page ──
class _ServersPage extends StatelessWidget {
const _ServersPage({required this.zh, required this.current, required this.onPick});
final bool zh;
final String current;
final ValueChanged<ServerInfo> onPick;
static const _servers = [
ServerInfo(code: 'HK', name: '香港 · 流媒体', sub: 'Hong Kong', ping: 18, nodeId: 'hk-1'),
ServerInfo(code: 'JP', name: '日本 东京', sub: 'Tokyo', ping: 32, nodeId: 'jp-1'),
ServerInfo(code: 'TW', name: '台湾 台北', sub: 'Taipei', ping: 28, nodeId: 'tw-1'),
ServerInfo(code: 'SG', name: '新加坡', sub: 'Singapore · P2P', ping: 54, nodeId: 'sg-1'),
ServerInfo(code: 'KR', name: '韩国 首尔', sub: 'Seoul', ping: 41, nodeId: 'kr-1'),
ServerInfo(code: 'US', name: '美国 洛杉矶', sub: 'Los Angeles', ping: 146, nodeId: 'us-1'),
];
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_TopBar(zh: zh),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Text(
zh ? '选择节点' : 'Choose server',
style: PangolinText.h2.copyWith(color: c.fg1),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
itemCount: _servers.length,
itemBuilder: (_, i) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ServerTile(
server: _servers[i],
active: _servers[i].code == current,
onTap: () => onPick(_servers[i]),
),
),
),
),
]);
}
}
/// ── Stats page ──
class _StatsPage extends StatelessWidget {
const _StatsPage({required this.zh});
final bool zh;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0];
final labels = zh ? ['', '', '', '', '', '', ''] : ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
const maxV = 6.1;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_TopBar(zh: zh),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Text(zh ? '使用统计' : 'Statistics', style: PangolinText.h2.copyWith(color: c.fg1)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(children: [
for (final m in [(zh ? '本月流量' : 'Traffic', '42.6', 'GB'), (zh ? '平均延迟' : 'Ping', '29', 'ms'), (zh ? '本月时长' : 'Time', '86.4', 'h')])
Expanded(child: Container(
margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.all(14),
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(m.$1, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text.rich(TextSpan(text: m.$2, style: PangolinText.mono.copyWith(fontSize: 20, color: c.fg1, fontWeight: FontWeight.w500),
children: [TextSpan(text: ' ${m.$3}', style: PangolinText.caption.copyWith(color: c.fg3))])),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(i),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(items[i].$1, size: 22, color: i == index ? c.accent : c.fg3),
const SizedBox(height: 4),
Text(items[i].$2,
style: PangolinText.caption.copyWith(
color: i == index ? c.accent : c.fg3, fontWeight: i == index ? FontWeight.w700 : FontWeight.w500)),
]),
),
),
for (final m in [
(zh ? '本月流量' : 'Traffic', '42.6', 'GB'),
(zh ? '平均延迟' : 'Ping', '29', 'ms'),
(zh ? '本月时长' : 'Time', '86.4', 'h'),
])
Expanded(
child: Container(
margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.all(14),
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(
m.$1,
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Text.rich(TextSpan(
text: m.$2,
style: PangolinText.mono
.copyWith(fontSize: 20, color: c.fg1, fontWeight: FontWeight.w500),
children: [
TextSpan(
text: ' ${m.$3}',
style: PangolinText.caption.copyWith(color: c.fg3),
)
],
)),
]),
),
),
]),
),
const SizedBox(height: 16),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
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 ? '本周流量 (GB)' : 'This week (GB)',
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600),
),
const SizedBox(height: 18),
Expanded(
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
for (var i = 0; i < vals.length; i++)
Expanded(
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
Text(
'${vals[i]}',
style: PangolinText.mono.copyWith(fontSize: 12, color: c.fg3),
),
const SizedBox(height: 6),
Container(
height: 90 * (vals[i] / maxV),
margin: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: c.accent.withOpacity(.85),
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
),
),
const SizedBox(height: 6),
Text(labels[i], style: PangolinText.caption.copyWith(color: c.fg3)),
]),
),
]),
),
]),
),
),
),
]);
}
}
/// ── Account page ──
class _AccountPage extends StatelessWidget {
const _AccountPage({required this.zh});
final bool zh;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_TopBar(zh: zh),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 14),
child: Text(zh ? '我的' : 'Account', style: PangolinText.h2.copyWith(color: c.fg1)),
),
Expanded(
child: ListView(padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), children: [
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(.18),
shape: BoxShape.circle,
),
child: const Icon(PangolinIcons.crown, size: 22, color: PangolinColors.white),
),
const SizedBox(width: 11),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text(
'me@pangolin.vpn',
style: TextStyle(
color: PangolinColors.white,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
zh ? 'PRO 会员 · 至 2026-12-31' : 'PRO · until 2026-12-31',
style: TextStyle(color: PangolinColors.white.withOpacity(.85), fontSize: 12),
),
]),
),
FilledButton(
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PlansScreen(
zh: zh,
onChoose: (_) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)),
),
),
)),
style: FilledButton.styleFrom(
backgroundColor: PangolinColors.white,
foregroundColor: PangolinColors.clay700,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
child: Text(
zh ? '续费 / 升级' : 'Renew',
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13),
),
),
]),
),
const SizedBox(height: 18),
_accountRow(c, PangolinIcons.mail, zh ? '邮箱' : 'Email', 'me@pangolin.vpn'),
_accountRow(c, PangolinIcons.lock, zh ? '密码' : 'Password', '••••••••••'),
_accountRow(
c,
PangolinIcons.monitorSmartphone,
zh ? '我的设备' : 'My devices',
'',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => DevicesScreen(zh: zh)),
),
),
_accountRow(
c,
PangolinIcons.shoppingBag,
zh ? '兑换 & 购买' : 'Redeem & buy',
'',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)),
),
),
_accountRow(
c,
PangolinIcons.messageCircle,
zh ? '联系我们' : 'Contact us',
'',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ContactScreen(zh: zh)),
),
),
_accountRow(
c,
PangolinIcons.logOut,
zh ? '退出登录' : 'Sign out',
'',
danger: true,
),
]),
),
]);
}
Widget _accountRow(
PangolinScheme c,
IconData icon,
String title,
String value, {
bool danger = false,
VoidCallback? onTap,
}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
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 (value.isNotEmpty)
Text(value, style: PangolinText.sm.copyWith(color: c.fg3))
else
Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
]),
),
);
}
}
/// ── Shared top bar ──
class _TopBar extends StatelessWidget {
const _TopBar({required this.zh, this.trailing});
final bool zh;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 14),
child: Row(children: [
PangolinBrandLockup(zh: zh, markSize: 24),
const Spacer(),
if (trailing != null) trailing!,
]),
);
}
}