// home_shell.dart — 主框架雏形(底部 4 Tab:连接 / 节点 / 统计 / 账户) // 对应 React UI Kit 的 BottomTab + App 状态机。 // 这是「外壳」:负责导航与连接状态机,各页内容为占位骨架,工程师按需接真实逻辑。 import 'dart:async'; import 'package:flutter/material.dart'; import '../pangolin_theme.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'; class HomeShell extends StatefulWidget { const HomeShell({super.key, this.zh = true}); final bool zh; @override State createState() => _HomeShellState(); } class _HomeShellState extends State { int _tab = 0; // 0 连接 1 节点 2 统计 3 账户 VpnStatus _status = VpnStatus.off; ServerInfo _server = const ServerInfo(code: 'HK', name: '香港 · 流媒体', sub: 'Hong Kong', ping: 18); Timer? _timer; int _elapsed = 0; String _t(String zh, String en) => widget.zh ? zh : en; void _toggle() { if (_status == VpnStatus.off) { setState(() => _status = VpnStatus.connecting); Future.delayed(const Duration(milliseconds: 1500), () { if (!mounted) return; setState(() { _status = VpnStatus.on; _elapsed = 0; }); _timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() => _elapsed++)); }); } else { _timer?.cancel(); setState(() => _status = VpnStatus.off); } } void _pick(ServerInfo s) { setState(() { _server = s; _tab = 0; }); if (_status == VpnStatus.on) { _timer?.cancel(); setState(() => _status = VpnStatus.connecting); Future.delayed(const Duration(milliseconds: 1200), () { if (!mounted) return; setState(() { _status = VpnStatus.on; _elapsed = 0; }); _timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() => _elapsed++)); }); } } @override void dispose() { _timer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final c = context.pangolin; 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)), ); } } /// ── 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; final items = [ (PangolinIcons.power, zh ? '连接' : 'Connect'), (PangolinIcons.globe, zh ? '节点' : 'Servers'), (PangolinIcons.barChart, zh ? '统计' : 'Stats'), (PangolinIcons.user, zh ? '账户' : 'Account'), ]; 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', }; return Column(children: [ _TopBar(zh: zh, trailing: Text(status == VpnStatus.on ? (zh ? '● 在线' : '● Online') : (zh ? '○ 离线' : '○ Offline'), style: PangolinText.caption.copyWith(fontFamily: PangolinFonts.mono, 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)), ]))), // current server card 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), ServerInfo(code: 'JP', name: '日本 东京', sub: 'Tokyo', ping: 32), ServerInfo(code: 'TW', name: '台湾 台北', sub: 'Taipei', ping: 28), ServerInfo(code: 'SG', name: '新加坡', sub: 'Singapore · P2P', ping: 54), ServerInfo(code: 'KR', name: '韩国 首尔', sub: 'Seoul', ping: 41), ServerInfo(code: 'US', name: '美国 洛杉矶', sub: 'Los Angeles', ping: 146), ]; @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 (skeleton) ── 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))])), ]), )), ]), ), 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.caption.copyWith(fontFamily: PangolinFonts.mono, 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 (skeleton — 套餐横幅 + 账户信息 + 设备入口) ── 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: [ // plan banner 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!, ]), ); } }