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:
wangjia
2026-06-16 09:23:20 +08:00
parent 71a4cddf0b
commit 4dc4127252
92 changed files with 4536 additions and 1187 deletions
+116 -8
View File
@@ -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,
),
]);
}
}
+52
View File
@@ -0,0 +1,52 @@
// bottom_tab_bar.dart — 移动端底部 Tab4 项,对照 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,
)),
]),
),
),
]),
);
}
}
+5 -22
View File
@@ -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);
}
}
+66
View File
@@ -0,0 +1,66 @@
// content_top_bar.dart — desktop/tablet 内容区顶栏(标题 + 在线状态 + 主题切换)
//
// 对照 ui_kits/desktop/dapp.jsxheight 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),
),
),
]),
);
}
}
-888
View File
@@ -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!,
]),
);
}
}
+102
View File
@@ -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)),
]),
),
),
);
}
}
-1
View File
@@ -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';
+64
View File
@@ -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),
),
],
),
),
]),
);
}
}