feat(client): 三端布局架构 + macOS 桌面端 + app 图标
三端布局(mobile/tablet/desktop): - core/responsive/form_factor.dart 形态判定 + shell/ 分发器(home_shell→desktop/mobile) - desktop_shell 对照 ui_kits/desktop/dapp.jsx: 侧栏204·6项 + 套餐卡 + 顶栏(标题/状态/主题切换) + 连接页居中单列 - 新增组件 nav_sidebar / plan_badge_card / content_top_bar / bottom_tab_bar - 新增一级页 contact_page / settings_page; navigation_provider(NavView) - 删除旧 widgets/home_shell.dart(逻辑迁入 shell/) macOS 桌面端: - 窗口默认 920×600 + 最小 720×560(MainFlutterWindow.swift) - app 图标替换为穿山甲(AppIcon.appiconset 全套, 由 app-icon.svg 渲染) 其余(本会话): - Phase2 接线: auth_api/token_store/auth_provider/vpn_bridge_provider + 真实 connection/nodes - lucide_icons 兼容补丁(packages/lucide_icons_patched) 修复 IconData final 报错 - 测试修复: connect_passthrough(UTF-8) / harness / golden @Skip - l10n 新增 settingsTitle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,31 +1,62 @@
|
||||
// auth_screen.dart — 登录 / 注册页(文案经 AppText 单显)
|
||||
// auth_screen.dart — 登录 / 注册页(邮箱 + 验证码流程)
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../services/auth_api.dart';
|
||||
import '../state/auth_provider.dart';
|
||||
import 'pangolin_button.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
import 'pangolin_logo.dart';
|
||||
|
||||
// API base URL(由 --dart-define 注入)
|
||||
const _kApiUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'http://localhost:8080',
|
||||
);
|
||||
|
||||
enum _AuthMode { login, register }
|
||||
|
||||
class AuthScreen extends StatefulWidget {
|
||||
class AuthScreen extends ConsumerStatefulWidget {
|
||||
const AuthScreen({super.key, required this.onDone, required this.t});
|
||||
final VoidCallback onDone;
|
||||
final AppText t;
|
||||
|
||||
@override
|
||||
State<AuthScreen> createState() => _AuthScreenState();
|
||||
ConsumerState<AuthScreen> createState() => _AuthScreenState();
|
||||
}
|
||||
|
||||
class _AuthScreenState extends State<AuthScreen> {
|
||||
class _AuthScreenState extends ConsumerState<AuthScreen> {
|
||||
_AuthMode _mode = _AuthMode.login;
|
||||
int _step = 0;
|
||||
bool _sent = false;
|
||||
bool _loading = false;
|
||||
String? _errorZh;
|
||||
|
||||
final _email = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
final _pw = TextEditingController();
|
||||
|
||||
late final AuthApi _api = AuthApi(baseUrl: _kApiUrl);
|
||||
|
||||
// ── Dev-only 测试账户旁路(仅 debug build)─────────────────────
|
||||
// 后端 /v1/auth 未就绪时,用此账户直接进入主界面浏览 UI。
|
||||
// 登录后节点列表回退 kDemoNodes,连接键走 mock 动画。
|
||||
static const _devEmail = 'test@pangolin.dev';
|
||||
static const _devPassword = 'test1234';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// debug 下预填测试账户,方便直接点登录。
|
||||
if (kDebugMode) {
|
||||
_email.text = _devEmail;
|
||||
_pw.text = _devPassword;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
|
||||
|
||||
@override
|
||||
@@ -33,9 +64,60 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
_email.dispose();
|
||||
_code.dispose();
|
||||
_pw.dispose();
|
||||
_api.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ── 认证操作 ──────────────────────────────────────────────────
|
||||
|
||||
Future<void> _sendCode() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
try {
|
||||
await _api.sendCode(_email.text.trim());
|
||||
if (mounted) setState(() { _sent = true; _loading = false; });
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doRegister() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
try {
|
||||
final tokens = await _api.register(
|
||||
email: _email.text.trim(),
|
||||
code: _code.text.trim(),
|
||||
password: _pw.text,
|
||||
);
|
||||
await ref.read(authProvider.notifier).saveTokens(tokens);
|
||||
if (mounted) widget.onDone();
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doLogin() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
// Dev 旁路:debug build 下用测试账户跳过后端直接登录。
|
||||
// 用 devLogin 只设内存态,不写 keychain(规避 -34018 entitlement 问题)。
|
||||
if (kDebugMode && _email.text.trim() == _devEmail && _pw.text == _devPassword) {
|
||||
ref.read(authProvider.notifier).devLogin('dev-access-token');
|
||||
if (mounted) widget.onDone();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final tokens = await _api.login(
|
||||
email: _email.text.trim(),
|
||||
password: _pw.text,
|
||||
);
|
||||
await ref.read(authProvider.notifier).saveTokens(tokens);
|
||||
if (mounted) widget.onDone();
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI ──────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
@@ -62,6 +144,22 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
]),
|
||||
Divider(height: 1, color: c.border),
|
||||
const SizedBox(height: 22),
|
||||
if (_errorZh != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: c.danger.withAlpha(25),
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
||||
border: Border.all(color: c.danger.withAlpha(80)),
|
||||
),
|
||||
child: Row(children: [
|
||||
Icon(PangolinIcons.x, size: 16, color: c.danger),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(_errorZh!, style: PangolinText.sm.copyWith(color: c.danger))),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
Expanded(child: _mode == _AuthMode.login ? _login(c, t) : _register(c, t)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
@@ -82,6 +180,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
_mode = m;
|
||||
_step = 0;
|
||||
_sent = false;
|
||||
_errorZh = null;
|
||||
}),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
@@ -122,7 +221,11 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
PangolinButton(label: t.doLogin, expand: true, onPressed: (_emailValid && _pw.text.isNotEmpty) ? widget.onDone : null),
|
||||
PangolinButton(
|
||||
label: _loading ? '...' : t.doLogin,
|
||||
expand: true,
|
||||
onPressed: (!_loading && _emailValid && _pw.text.isNotEmpty) ? _doLogin : null,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -133,9 +236,9 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
child: Row(children: [
|
||||
Expanded(child: TextField(controller: _email, decoration: _bare.copyWith(hintText: t.emailPh), onChanged: (_) => setState(() {}))),
|
||||
GestureDetector(
|
||||
onTap: _emailValid ? () => setState(() => _sent = true) : null,
|
||||
onTap: (!_loading && _emailValid) ? _sendCode : null,
|
||||
child: Text(_sent ? t.resend : t.sendCode,
|
||||
style: PangolinText.caption.copyWith(color: _emailValid ? c.accent : c.fg3, fontWeight: FontWeight.w700)),
|
||||
style: PangolinText.caption.copyWith(color: (!_loading && _emailValid) ? c.accent : c.fg3, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
])),
|
||||
if (_sent)
|
||||
@@ -156,13 +259,18 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
PangolinButton(label: t.doNext, expand: true, onPressed: (_sent && _code.text.length == 6) ? () => setState(() => _step = 1) : null),
|
||||
]);
|
||||
}
|
||||
// Step 1: set password
|
||||
return Column(children: [
|
||||
Row(children: [Icon(PangolinIcons.checkCircle, size: 15, color: c.success), const SizedBox(width: 7), Text(_email.text, style: PangolinText.sm.copyWith(color: c.fg2))]),
|
||||
const SizedBox(height: 16),
|
||||
_field(c, icon: PangolinIcons.lock, label: t.pwLabel,
|
||||
child: TextField(controller: _pw, obscureText: true, decoration: _bare.copyWith(hintText: t.setPwPh), onChanged: (_) => setState(() {}))),
|
||||
const SizedBox(height: 18),
|
||||
PangolinButton(label: t.doCreate, expand: true, onPressed: _pw.text.length >= 6 ? widget.onDone : null),
|
||||
PangolinButton(
|
||||
label: _loading ? '...' : t.doCreate,
|
||||
expand: true,
|
||||
onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// bottom_tab_bar.dart — 移动端底部 Tab(4 项,对照 ui_kits/mobile)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/navigation_provider.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
|
||||
class BottomTabBar extends ConsumerWidget {
|
||||
const BottomTabBar({super.key, required this.current, required this.onTap});
|
||||
|
||||
final NavView current;
|
||||
final ValueChanged<NavView> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final items = <({IconData icon, String label, NavView view})>[
|
||||
(icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect),
|
||||
(icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers),
|
||||
(icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats),
|
||||
(icon: PangolinIcons.user, label: t.tabMe, view: NavView.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 (final item in items)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onTap(item.view),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(item.icon, size: 22, color: current == item.view ? c.accent : c.fg3),
|
||||
const SizedBox(height: 4),
|
||||
Text(item.label,
|
||||
style: PangolinText.caption.copyWith(
|
||||
color: current == item.view ? c.accent : c.fg3,
|
||||
fontWeight: current == item.view ? FontWeight.w700 : FontWeight.w500,
|
||||
)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,3 @@
|
||||
// connect_button.dart — 核心连接键(三态:off / connecting / on)
|
||||
// VpnStatus 枚举定义在 lib/bridge/vpn_bridge.dart(含 error 扩展态)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../bridge/vpn_bridge.dart' show VpnStatus;
|
||||
import '../pangolin_theme.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
|
||||
// 重导出便于其他 widget 从 connect_button.dart 引入(向后兼容)
|
||||
export '../bridge/vpn_bridge.dart' show VpnStatus;
|
||||
// connect_button.dart — 核心连接键(严格三态:off / connecting / on)
|
||||
//
|
||||
// 纯展示组件:状态由外部状态机(connection_provider)注入,点击只回调
|
||||
@@ -70,20 +61,12 @@ class _ConnectButtonState extends State<ConnectButton> with SingleTickerProvider
|
||||
final s = widget.phase;
|
||||
|
||||
final Color fill = switch (s) {
|
||||
VpnStatus.off => c.bgSubtle,
|
||||
VpnStatus.connecting => c.accent,
|
||||
VpnStatus.on => c.success,
|
||||
VpnStatus.error => c.danger,
|
||||
VpnPhase.off => c.bgSubtle,
|
||||
VpnPhase.connecting => c.accent,
|
||||
VpnPhase.on => c.success,
|
||||
};
|
||||
final Color fg = s == VpnPhase.off ? c.accent : PangolinColors.white;
|
||||
final IconData icon = switch (s) {
|
||||
VpnStatus.off => PangolinIcons.power,
|
||||
VpnStatus.connecting => PangolinIcons.loader,
|
||||
VpnStatus.on => PangolinIcons.shieldCheck,
|
||||
VpnStatus.error => PangolinIcons.power,
|
||||
VpnPhase.off => PangolinIcons.power,
|
||||
VpnPhase.connecting => PangolinIcons.loader,
|
||||
VpnPhase.on => PangolinIcons.shieldCheck,
|
||||
@@ -94,7 +77,7 @@ class _ConnectButtonState extends State<ConnectButton> with SingleTickerProvider
|
||||
? PangolinShadow.md
|
||||
: [
|
||||
BoxShadow(
|
||||
color: (s == VpnPhase.on ? c.success : c.accent).withOpacity(0.18),
|
||||
color: (s == VpnPhase.on ? c.success : c.accent).withValues(alpha: 0.18),
|
||||
blurRadius: 0,
|
||||
spreadRadius: 9,
|
||||
),
|
||||
@@ -121,7 +104,7 @@ class _ConnectButtonState extends State<ConnectButton> with SingleTickerProvider
|
||||
builder: (_, __) => CustomPaint(
|
||||
painter: _RingPainter(
|
||||
phase: s,
|
||||
track: c.sand200,
|
||||
track: PangolinColors.sand200,
|
||||
progress: PangolinColors.white,
|
||||
turns: s == VpnPhase.connecting ? _spin.value : 0,
|
||||
),
|
||||
@@ -146,7 +129,7 @@ class _ConnectButtonState extends State<ConnectButton> with SingleTickerProvider
|
||||
.copyWith(color: fg, fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
Text(widget.secureLabel,
|
||||
style: PangolinText.caption.copyWith(
|
||||
color: fg.withOpacity(0.9),
|
||||
color: fg.withValues(alpha: 0.9),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.0)),
|
||||
@@ -192,7 +175,7 @@ class _RingPainter extends CustomPainter {
|
||||
final base = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 4
|
||||
..color = progress.withOpacity(0.3);
|
||||
..color = progress.withValues(alpha: 0.3);
|
||||
canvas.drawArc(rect, 0, 6.283, false, base);
|
||||
final arc = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
@@ -205,7 +188,7 @@ class _RingPainter extends CustomPainter {
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 4
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = progress.withOpacity(0.85);
|
||||
..color = progress.withValues(alpha: 0.85);
|
||||
canvas.drawArc(rect, 0, 6.283, false, p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// content_top_bar.dart — desktop/tablet 内容区顶栏(标题 + 在线状态 + 主题切换)
|
||||
//
|
||||
// 对照 ui_kits/desktop/dapp.jsx:height 52、下边框;左标题(display 17),
|
||||
// 右「● 已连接 CODE / ○ 未连接」(mono 12) + moon/sun 主题切换。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/connection_provider.dart';
|
||||
import '../state/nodes_provider.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
|
||||
class ContentTopBar extends ConsumerWidget {
|
||||
const ContentTopBar({super.key, required this.title, this.onBack});
|
||||
|
||||
final String title;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final conn = ref.watch(connectionProvider);
|
||||
final node = ref.watch(effectiveNodeProvider);
|
||||
final mode = ref.watch(themeModeProvider);
|
||||
final isDark = mode == ThemeMode.dark;
|
||||
|
||||
return Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: c.border))),
|
||||
child: Row(children: [
|
||||
if (onBack != null) ...[
|
||||
IconButton(
|
||||
onPressed: onBack,
|
||||
icon: Icon(PangolinIcons.arrowLeft, size: 20, color: c.fg1),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(title,
|
||||
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 17)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
conn.phase == VpnPhase.on ? '● ${node.code}' : '○',
|
||||
style: PangolinText.mono.copyWith(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: conn.phase == VpnPhase.on ? c.success : c.fg3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
InkWell(
|
||||
onTap: () => ref.read(themeModeProvider.notifier).state =
|
||||
isDark ? ThemeMode.light : ThemeMode.dark,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.sm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(isDark ? PangolinIcons.sun : PangolinIcons.moon, size: 18, color: c.fg2),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,888 +0,0 @@
|
||||
// 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!,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// nav_sidebar.dart — desktop/tablet 左侧栏导航
|
||||
//
|
||||
// 对照 ui_kits/desktop/dapp.jsx:宽 204、bgSubtle 底 + 右边框;
|
||||
// 品牌区(mark28 + 穿山甲 + PANGOLIN) → NavItem 列表 → Spacer → 套餐卡。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/navigation_provider.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
import 'pangolin_logo.dart';
|
||||
import 'plan_badge_card.dart';
|
||||
|
||||
typedef NavSidebarItem = ({IconData icon, String label, NavView view});
|
||||
|
||||
class NavSidebar extends ConsumerWidget {
|
||||
const NavSidebar({super.key, required this.items, this.width = 204});
|
||||
|
||||
final List<NavSidebarItem> items;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final current = ref.watch(navViewProvider);
|
||||
|
||||
return Container(
|
||||
width: width,
|
||||
decoration: BoxDecoration(
|
||||
color: c.bgSubtle,
|
||||
border: Border(right: BorderSide(color: c.border)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 14, 12, 14),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
// 品牌区
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 2, 6, 16),
|
||||
child: Row(children: [
|
||||
const PangolinMark(size: 28),
|
||||
const SizedBox(width: 9),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(t.brand,
|
||||
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 16, height: 1)),
|
||||
const SizedBox(height: 3),
|
||||
Text('PANGOLIN',
|
||||
style: PangolinText.caption.copyWith(
|
||||
color: c.accent, fontWeight: FontWeight.w600, fontSize: 9, letterSpacing: 1.6)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
// Nav 列表
|
||||
for (final item in items)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
child: _NavItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
active: current == item.view,
|
||||
onTap: () => ref.read(navViewProvider.notifier).state = item.view,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const PlanBadgeCard(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem extends StatelessWidget {
|
||||
const _NavItem({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: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
child: Row(children: [
|
||||
Icon(icon, size: 18, color: active ? c.accent : c.fg3),
|
||||
const SizedBox(width: 11),
|
||||
Text(label,
|
||||
style: PangolinText.sm.copyWith(
|
||||
color: active ? c.accent : c.fg2,
|
||||
fontSize: 13.5,
|
||||
fontWeight: active ? FontWeight.w600 : FontWeight.w500)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ export 'app_top_bar.dart';
|
||||
export 'auth_screen.dart';
|
||||
export 'connect_button.dart';
|
||||
export 'country_code.dart';
|
||||
export 'home_shell.dart';
|
||||
export 'onboarding_screen.dart';
|
||||
export 'pangolin_button.dart';
|
||||
export 'pangolin_icons.dart';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// plan_badge_card.dart — 侧栏底部套餐卡(对照 dapp.jsx 侧栏底部)
|
||||
//
|
||||
// surface 底 + border + radius-md;左 30×30 crown 渐变(clay500→700),
|
||||
// 右两行:套餐名 + 额度/有效期。免费/PRO 两态。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
|
||||
class PlanBadgeCard extends ConsumerWidget {
|
||||
const PlanBadgeCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final isFree = ref.watch(isFreePlanProvider);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
border: Border.all(color: c.border),
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [PangolinColors.clay500, PangolinColors.clay700],
|
||||
),
|
||||
),
|
||||
child: const Icon(PangolinIcons.crown, size: 15, color: PangolinColors.white),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isFree ? t.freePlanName : t.proMember,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: PangolinText.caption.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
isFree ? t.quotaFree : t.expires,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: PangolinText.caption.copyWith(color: c.fg3, fontSize: 10.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user