// 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}); @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), ]; @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), ]), ); }), ); } } /// ── 宽屏分栏:左侧栏导航 + 内容区 ── 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; @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), 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 ── 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)), ]), ), ), ]), ); } }