// 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)), ]), ), ), ]), ); } }