// 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 createState() => _HomeShellState(); } class _HomeShellState extends ConsumerState { 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 _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 _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 _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 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 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 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 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 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!, ]), ); } }