chore(design): 删除 design/flutter fork,更新 .gitignore

删除 design/flutter/(21 个文件):
- Flutter token/组件的 canonical 实现已在 client/lib/
- Token 数值层改由 design/codegen/gen_flutter_tokens.mjs codegen
- 设计规格由 design/preview/ specimens + SKILL.md 表达

.gitignore 新增:
- client/android/local.properties、client/android/app/src/main/java/
- client/ios/Flutter/ephemeral/、client/pubspec.lock
- .claude/worktrees/(临时工作目录)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-15 19:11:01 +08:00
parent b04cef3cc4
commit b2cb20be47
23 changed files with 9 additions and 2179 deletions
Submodule .claude/worktrees/arch-doc deleted from 3ae96ef229
+9
View File
@@ -49,6 +49,15 @@ app/kernel/.build/
server/mockserver
server/pangolin-server
# Flutter / Dart 构建产物与本地配置
client/android/local.properties
client/android/app/src/main/java/
client/ios/Flutter/ephemeral/
client/pubspec.lock
# Claude worktrees(临时工作目录,本地专用)
.claude/worktrees/
# 杂项
.DS_Store
*.log
-135
View File
@@ -1,135 +0,0 @@
# 穿山甲 VPN — Flutter 设计令牌
生产前端使用 **Flutter**。本目录提供与 `../colors_and_type.css` **一一对应**的 Dart 令牌,Flutter 可直接消费。`ui_kits/` 下的 React/HTML 是**视觉规范参考**(像素级还原 + 交互),Flutter 实现以它们为蓝本、以本文件为令牌来源。
## 快速开始
1. 新建 Flutter 工程,把本目录的 `pubspec.yaml` 覆盖到根目录(按需改 `name`)。
2. 拷贝:`pangolin_theme.dart``lib/`;`widgets/` 整个目录 → `lib/widgets/`;`main.dart``lib/`;`assets/*.svg` → 工程 `assets/`
3. 自备 4 款字体的 ttf 放 `fonts/`(或改用 `google_fonts` 包),族名须与 `pubspec.yaml` 一致。
4. `flutter pub get``flutter run``main.dart` 已串好「登录 → 引导 → 主框架」可直接跑。
## 文件
- `pubspec.yaml`**工程清单模板**:已配好 `flutter_svg` / `lucide_icons` 依赖、4 个 SVG 资产、4 款字体声明(族名对齐 `PangolinFonts`)。拷到工程根目录,补字体 ttf,`flutter pub get` 即可。
- `main.dart`**入口示例**:`runApp` + 明/暗主题 + 「登录 → 引导 → 主框架」`RootFlow` 串接,可直接跑通。
- `pangolin_theme.dart` — 全部令牌 + 明/暗 `ThemeData`:
- `PangolinColors` — clay / sand / 语义色阶(const `Color`)
- `PangolinScheme``ThemeExtension`)— 语义令牌(bg/surface/fg/accent/border/success…),含 `.light` / `.dark`
- `PangolinSpacing`(4px 基准)、`PangolinRadius``PangolinMotion`(时长 + 缓动曲线)
- `PangolinFonts``PangolinText`(字号阶)、`PangolinShadow`(暖色软阴影)
- `PangolinTheme.light` / `PangolinTheme.dark` — 开箱即用
- `widgets/`**核心组件 Dart 雏形**(纯样式,业务逻辑自接),全部读 `context.pangolin` 令牌:
- `pangolin_icons.dart`**Lucide 图标映射表**:`PangolinIcons.power` 等直用常量 + `PangolinIcons.byName('shield-check')` 查表(从 React `data-lucide` 名移植)。需 `lucide_icons` 包。
- `pangolin_button.dart``PangolinButton`(primary / secondary / ghost / danger,胶囊形)
- `country_code.dart``CountryCode`(2 字母国家码块)、`SignalBars`(延迟信号条)
- `status_pill.dart``StatusPill`(连接态 / PRO 等色点胶囊)
- `connect_button.dart``ConnectButton`(核心连接键三态:off 轨道环 / connecting 旋转弧 / on 满环 + 计时)
- `server_tile.dart``ServerTile` + `ServerInfo`(服务器列表行)
- `plan_card.dart``PlanCard`(套餐卡,专业版渐变高亮 + 「最受欢迎」浮标)
- `auth_screen.dart``AuthScreen`(登录 / 邮箱验证码注册,分屏式,带 `zh` 简单 i18n)
- `onboarding_screen.dart``OnboardingScreen`(首次引导 `PageView`,3 屏可滑动 + 进度点 + 跳过)
- `home_shell.dart``HomeShell`(主框架:底部 4 Tab 连接/节点/统计/账户 + 连接状态机 + 计时;各页骨架)
- `account_screens.dart` — 账户子页:`PlansScreen`(套餐选择)、`DevicesScreen`(设备管理 + 移除)、`RedeemScreen`(兑换码 + 购买渠道)、`ContactScreen`(联系渠道 + 服务时间);均为带返回栏的独立页,`HomeShell` 账户页已 `push` 接入
- `pangolin_logo.dart``PangolinMark` / `PangolinBrandLockup` / `PangolinWordmark` / `PangolinAppIcon`(`flutter_svg` 加载 `assets/` 矢量 logo,深色自动反白)
- `pangolin_widgets.dart` — 统一 `export`
> 这些是**视觉雏形**:对应 React UI Kit 的同名组件,Flutter 工程师可直接拷贝/微调,接上真实状态与导航。图标已走 `PangolinIcons`(Lucide),与设计一致。
## 接入
```dart
MaterialApp(
theme: PangolinTheme.light,
darkTheme: PangolinTheme.dark,
themeMode: ThemeMode.system, // 跟随系统;也可由设置里的开关控制
);
```
读语义色(随明暗自动切换):
```dart
final c = context.pangolin; // PangolinScheme
Container(color: c.surface);
Text('已连接', style: PangolinText.h3.copyWith(color: c.success));
```
令牌直取:
```dart
PangolinColors.clay500 // 品牌主色
PangolinSpacing.x4 // 16
BorderRadius.circular(PangolinRadius.lg)
AnimatedContainer(duration: PangolinMotion.base, curve: PangolinMotion.easeOut, ...)
```
组件雏形(组合示例):
```dart
import 'widgets/pangolin_widgets.dart';
// 连接键
ConnectButton(status: VpnStatus.on, elapsed: const Duration(seconds: 8048), onTap: toggle);
// 服务器行
ServerTile(
server: const ServerInfo(code: 'HK', name: '香港 · 流媒体', sub: 'Hong Kong', ping: 18),
active: true, onTap: () {},
);
// 套餐卡(专业版高亮)
PlanCard(
name: '专业版', price: '¥25', period: '/月', popularLabel: '最受欢迎', featured: true,
features: const ['80+ 全球节点', '无限流量 · 极速', '5 台设备同时在线'],
ctaLabel: '立即升级', onPressed: () {},
);
PangolinButton(label: '连接', icon: PangolinIcons.power, onPressed: () {});
StatusPill(label: '已连接', status: PangolinStatus.connected);
```
页面串接(登录 → 引导 → 主应用):
```dart
enum AppStage { auth, onboarding, app }
class _RootState extends State<Root> {
AppStage stage = AppStage.auth;
@override
Widget build(BuildContext context) => switch (stage) {
AppStage.auth => AuthScreen(onDone: () => setState(() => stage = AppStage.onboarding)),
AppStage.onboarding => OnboardingScreen(onDone: () => setState(() => stage = AppStage.app)),
AppStage.app => const HomeShell(), // 主框架:底部 4 Tab(连接/节点/统计/账户)
};
}
```
## 字体
`pubspec.yaml` 注册 **Sora**(display)、**Manrope**(正文/UI)、**Noto Sans SC**(中文)、**JetBrains Mono**(数据)。可用 `google_fonts` 包或打包本地字体;族名见 `PangolinFonts`
## 图标
UI Kit 用 Lucide(细线条)。Flutter 侧用 [`lucide_icons`](https://pub.dev/packages/lucide_icons) 包,经 `widgets/pangolin_icons.dart` 的映射表引用:`Icon(PangolinIcons.power)``pubspec.yaml``lucide_icons: ^0.x`。某常量名若在你的包版本缺失,按 IDE 提示替换为最接近名即可(文件内已注明 Lucide 原名)。
## Logo / 品牌标(SVG)
矢量 logo 在 `assets/`(`logo-mark.svg` clay 标、`logo-mark-white.svg` 反白标、`logo-wordmark.svg` 字标、`app-icon.svg` App 图标)。用 [`flutter_svg`](https://pub.dev/packages/flutter_svg) 加载,封装在 `widgets/pangolin_logo.dart`:
- `PangolinMark(size: 28)` — 仅标记,**深色主题自动用反白版**(可 `forceLight`/`forceDark`)
- `PangolinBrandLockup(zh: true)` — 标记 + 「穿山甲 / Pangolin」(随主题变色 + 双语,顶栏用)
- `PangolinWordmark(height: 28)` — 横版字标锁版(浅背景)
- `PangolinAppIcon(size: 64)` — App 图标(自动圆角)
接入步骤:
1. `pubspec.yaml` 加依赖与资产:
```yaml
dependencies:
flutter_svg: ^2.0.0
flutter:
assets:
- assets/logo-mark.svg
- assets/logo-mark-white.svg
- assets/logo-wordmark.svg
- assets/app-icon.svg
```
2. 把本目录的 `assets/*.svg` 拷进你工程的 `assets/`(若用别的前缀,改 `pangolin_logo.dart` 顶部的 `_base`)。
3. `AuthScreen` / `HomeShell` 顶栏已用 `PangolinMark` / `PangolinBrandLockup`,无需再改。
4. App 启动图标 / 桌面图标用 `app-icon.svg` 导出 PNG(`flutter_launcher_icons` 包)。
## 规则(务必遵守)
- 颜色只用语义令牌,组件天然适配明/暗,不要硬编码十六进制。
- 暖色基调:表面是暖白/暖 espresso,**不要纯白纯黑**大面积填充。
- 圆角偏大、阴影柔和暖调;状态用「色点 + 文字」,不用 emoji;国家用 2 字母码块,不用 emoji 国旗。
- 界面单语言显示(中文**或** EN),由设置切换;不要中英并排。
- App 内不放支付表单;走兑换码 + 外部渠道。
> ⚠️ 字体均为开源字体(Google Fonts),作为本品牌正式选定字体。若你已有授权品牌字体,替换族名与文件即可。
-17
View File
@@ -1,17 +0,0 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="穿山甲 app icon">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="96" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#C8794A"></stop><stop offset="1" stop-color="#9E5630"></stop>
</linearGradient>
</defs>
<rect width="96" height="96" rx="22" fill="url(#bg)"></rect>
<g transform="translate(96,0) scale(-1,1)">
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#FFFFFF"></path>
<g stroke="#B96A3D" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.9">
<path d="M26 50 Q34 43 42 50"></path>
<path d="M36 48 Q44 41 52 48"></path>
<path d="M46 48 Q54 41 62 49"></path>
</g>
<circle cx="23" cy="55" r="2.3" fill="#9E5630"></circle>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1023 B

-11
View File
@@ -1,11 +0,0 @@
<svg width="48" height="48" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="穿山甲 mark white">
<g transform="translate(96,0) scale(-1,1)">
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#F4EFE8"></path>
<g stroke="#9E5630" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.7">
<path d="M26 50 Q34 43 42 50"></path>
<path d="M36 48 Q44 41 52 48"></path>
<path d="M46 48 Q54 41 62 49"></path>
</g>
<circle cx="23" cy="55" r="2.2" fill="#9E5630"></circle>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 735 B

-11
View File
@@ -1,11 +0,0 @@
<svg width="48" height="48" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="穿山甲 mark">
<g transform="translate(96,0) scale(-1,1)">
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#B96A3D"></path>
<g stroke="#FAF3ED" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.85">
<path d="M26 50 Q34 43 42 50"></path>
<path d="M36 48 Q44 41 52 48"></path>
<path d="M46 48 Q54 41 62 49"></path>
</g>
<circle cx="23" cy="55" r="2.2" fill="#FAF3ED"></circle>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 730 B

-13
View File
@@ -1,13 +0,0 @@
<svg width="240" height="56" viewBox="0 0 240 56" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="穿山甲 · Pangolin">
<g transform="translate(4,4) scale(0.5)">
<g transform="translate(96,0) scale(-1,1)">
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#B96A3D"></path>
<g stroke="#FAF3ED" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.85">
<path d="M26 50 Q34 43 42 50"></path><path d="M36 48 Q44 41 52 48"></path><path d="M46 48 Q54 41 62 49"></path>
</g>
<circle cx="23" cy="55" r="2.2" fill="#FAF3ED"></circle>
</g>
</g>
<text x="60" y="28" font-family="Sora, sans-serif" font-size="21" font-weight="700" letter-spacing="-0.01em" fill="#1F1C18">穿山甲</text>
<text x="60" y="45" font-family="Sora, sans-serif" font-size="11" font-weight="600" letter-spacing="0.22em" fill="#B96A3D">PANGOLIN</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-58
View File
@@ -1,58 +0,0 @@
// main.dart — 入口示例
// 演示:登录 → 引导 → 主框架(4 Tab)的完整串接,套用穿山甲明/暗主题。
// 把 lib/ 下的 pangolin_theme.dart 与 widgets/ 一并拷进工程,按实际目录改 import 路径。
import 'package:flutter/material.dart';
import 'pangolin_theme.dart';
import 'widgets/pangolin_widgets.dart';
void main() => runApp(const PangolinApp());
class PangolinApp extends StatefulWidget {
const PangolinApp({super.key});
@override
State<PangolinApp> createState() => _PangolinAppState();
}
class _PangolinAppState extends State<PangolinApp> {
// 全局开关:语言 + 主题(真实工程可换成 Provider / Riverpod / Bloc 等)
bool zh = true;
ThemeMode mode = ThemeMode.system;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '穿山甲',
debugShowCheckedModeBanner: false,
theme: PangolinTheme.light,
darkTheme: PangolinTheme.dark,
themeMode: mode,
home: RootFlow(zh: zh),
);
}
}
/// 应用阶段:登录 → 引导 → 主应用
enum AppStage { auth, onboarding, app }
class RootFlow extends StatefulWidget {
const RootFlow({super.key, this.zh = true});
final bool zh;
@override
State<RootFlow> createState() => _RootFlowState();
}
class _RootFlowState extends State<RootFlow> {
AppStage stage = AppStage.auth;
@override
Widget build(BuildContext context) {
switch (stage) {
case AppStage.auth:
return AuthScreen(zh: widget.zh, onDone: () => setState(() => stage = AppStage.onboarding));
case AppStage.onboarding:
return OnboardingScreen(onDone: () => setState(() => stage = AppStage.app));
case AppStage.app:
return HomeShell(zh: widget.zh);
}
}
}
-307
View File
@@ -1,307 +0,0 @@
// pangolin_theme.dart
// 穿山甲 VPN · Pangolin VPN — Flutter design tokens
//
// Single Dart source mirroring colors_and_type.css. Generated by hand to be
// Flutter-friendly: const color ramps, spacing/radii/durations, text styles,
// and ready-to-use light + dark ThemeData.
//
// Fonts: add Sora, Manrope, Noto Sans SC, JetBrains Mono to pubspec.yaml
// (google_fonts package or bundled assets). Family names below assume those.
//
// Usage:
// MaterialApp(
// theme: PangolinTheme.light,
// darkTheme: PangolinTheme.dark,
// themeMode: ThemeMode.system,
// );
// // tokens: PangolinColors.clay500, PangolinSpacing.x4, PangolinRadius.lg ...
import 'package:flutter/material.dart';
/// ── Primitive color ramps ───────────────────────────────────────────
class PangolinColors {
PangolinColors._();
// Clay / Copper — pangolin-armor primary (warm earth)
static const clay50 = Color(0xFFFAF3ED);
static const clay100 = Color(0xFFF2E2D4);
static const clay200 = Color(0xFFE6C7AC);
static const clay300 = Color(0xFFD9A982);
static const clay400 = Color(0xFFCC8B5C);
static const clay500 = Color(0xFFB96A3D); // brand primary
static const clay600 = Color(0xFF9E5630);
static const clay700 = Color(0xFF7E4426);
static const clay800 = Color(0xFF5E331D);
static const clay900 = Color(0xFF3D2213);
// Sand / Taupe — warm neutral ramp
static const sand50 = Color(0xFFFAF8F4);
static const sand100 = Color(0xFFF2EEE7);
static const sand200 = Color(0xFFE6DFD3);
static const sand300 = Color(0xFFD2C8B8);
static const sand400 = Color(0xFFB0A491);
static const sand500 = Color(0xFF8C8270);
static const sand600 = Color(0xFF6B6253);
static const sand700 = Color(0xFF4E4940);
static const sand800 = Color(0xFF2E2A24);
static const sand900 = Color(0xFF1F1C18);
static const sand950 = Color(0xFF14110E); // warm espresso near-black
// Semantic hues (earth-tuned)
static const green400 = Color(0xFF7FB07A);
static const green500 = Color(0xFF5B8C5A); // connected / secure
static const green600 = Color(0xFF467046);
static const amber400 = Color(0xFFE2B05A);
static const amber500 = Color(0xFFD69A3C); // connecting / warning
static const amber600 = Color(0xFFB47E29);
static const red400 = Color(0xFFD4715A);
static const red500 = Color(0xFFC0533B); // error / disconnect
static const red600 = Color(0xFF9E4230);
static const white = Color(0xFFFFFFFF);
}
/// ── Semantic tokens, resolved per brightness ───────────────────────
/// Read these off the active theme: `context.pangolin` (see extension below),
/// or build directly with PangolinScheme.light / .dark.
@immutable
class PangolinScheme extends ThemeExtension<PangolinScheme> {
const PangolinScheme({
required this.bg,
required this.bgSubtle,
required this.surface,
required this.surface2,
required this.overlay,
required this.fg1,
required this.fg2,
required this.fg3,
required this.fgOnAccent,
required this.accent,
required this.accentHover,
required this.accentPress,
required this.accentSubtle,
required this.accentBorder,
required this.border,
required this.borderStrong,
required this.ring,
required this.success,
required this.successSubtle,
required this.warning,
required this.warningSubtle,
required this.danger,
required this.dangerSubtle,
});
final Color bg, bgSubtle, surface, surface2, overlay;
final Color fg1, fg2, fg3, fgOnAccent;
final Color accent, accentHover, accentPress, accentSubtle, accentBorder;
final Color border, borderStrong, ring;
final Color success, successSubtle, warning, warningSubtle, danger, dangerSubtle;
static const light = PangolinScheme(
bg: PangolinColors.sand50,
bgSubtle: PangolinColors.sand100,
surface: PangolinColors.white,
surface2: PangolinColors.sand50,
overlay: Color(0x731F1C18), // rgba(31,28,24,0.45)
fg1: PangolinColors.sand900,
fg2: PangolinColors.sand600,
fg3: PangolinColors.sand500,
fgOnAccent: PangolinColors.white,
accent: PangolinColors.clay500,
accentHover: PangolinColors.clay600,
accentPress: PangolinColors.clay700,
accentSubtle: PangolinColors.clay50,
accentBorder: PangolinColors.clay200,
border: PangolinColors.sand200,
borderStrong: PangolinColors.sand300,
ring: Color(0x59B96A3D), // rgba(185,106,61,0.35)
success: PangolinColors.green500,
successSubtle: Color(0xFFE9F0E6),
warning: PangolinColors.amber500,
warningSubtle: Color(0xFFF8EED6),
danger: PangolinColors.red500,
dangerSubtle: Color(0xFFF6E1DA),
);
static const dark = PangolinScheme(
bg: PangolinColors.sand950,
bgSubtle: PangolinColors.sand900,
surface: Color(0xFF221E19),
surface2: Color(0xFF2A251F),
overlay: Color(0x99000000), // rgba(0,0,0,0.6)
fg1: Color(0xFFF4EFE8),
fg2: Color(0xFFB6AC9C),
fg3: Color(0xFF897F6F),
fgOnAccent: PangolinColors.sand900,
accent: PangolinColors.clay400,
accentHover: PangolinColors.clay300,
accentPress: PangolinColors.clay500,
accentSubtle: Color(0x24CC8B5C), // rgba(204,139,92,0.14)
accentBorder: Color(0x4DCC8B5C), // rgba(204,139,92,0.30)
border: Color(0x1AF2EEE7), // rgba(242,238,231,0.10)
borderStrong: Color(0x2EF2EEE7), // rgba(242,238,231,0.18)
ring: Color(0x73CC8B5C), // rgba(204,139,92,0.45)
success: PangolinColors.green400,
successSubtle: Color(0x297FB07A),
warning: PangolinColors.amber400,
warningSubtle: Color(0x29E2B05A),
danger: PangolinColors.red400,
dangerSubtle: Color(0x29D4715A),
);
@override
PangolinScheme copyWith({Color? bg, Color? fg1, Color? accent}) => PangolinScheme(
bg: bg ?? this.bg, bgSubtle: bgSubtle, surface: surface, surface2: surface2,
overlay: overlay, fg1: fg1 ?? this.fg1, fg2: fg2, fg3: fg3, fgOnAccent: fgOnAccent,
accent: accent ?? this.accent, accentHover: accentHover, accentPress: accentPress,
accentSubtle: accentSubtle, accentBorder: accentBorder, border: border,
borderStrong: borderStrong, ring: ring, success: success, successSubtle: successSubtle,
warning: warning, warningSubtle: warningSubtle, danger: danger, dangerSubtle: dangerSubtle,
);
@override
PangolinScheme lerp(ThemeExtension<PangolinScheme>? other, double t) {
if (other is! PangolinScheme) return this;
Color c(Color a, Color b) => Color.lerp(a, b, t)!;
return PangolinScheme(
bg: c(bg, other.bg), bgSubtle: c(bgSubtle, other.bgSubtle), surface: c(surface, other.surface),
surface2: c(surface2, other.surface2), overlay: c(overlay, other.overlay),
fg1: c(fg1, other.fg1), fg2: c(fg2, other.fg2), fg3: c(fg3, other.fg3), fgOnAccent: c(fgOnAccent, other.fgOnAccent),
accent: c(accent, other.accent), accentHover: c(accentHover, other.accentHover), accentPress: c(accentPress, other.accentPress),
accentSubtle: c(accentSubtle, other.accentSubtle), accentBorder: c(accentBorder, other.accentBorder),
border: c(border, other.border), borderStrong: c(borderStrong, other.borderStrong), ring: c(ring, other.ring),
success: c(success, other.success), successSubtle: c(successSubtle, other.successSubtle),
warning: c(warning, other.warning), warningSubtle: c(warningSubtle, other.warningSubtle),
danger: c(danger, other.danger), dangerSubtle: c(dangerSubtle, other.dangerSubtle),
);
}
}
extension PangolinContext on BuildContext {
PangolinScheme get pangolin => Theme.of(this).extension<PangolinScheme>()!;
}
/// ── Spacing (4px base) ──────────────────────────────────────────────
class PangolinSpacing {
PangolinSpacing._();
static const double x1 = 4, x2 = 8, x3 = 12, x4 = 16, x5 = 20,
x6 = 24, x8 = 32, x10 = 40, x12 = 48, x16 = 64;
}
/// ── Radii ───────────────────────────────────────────────────────────
class PangolinRadius {
PangolinRadius._();
static const double sm = 6, md = 10, lg = 14, xl = 20, xxl = 28, full = 999;
static const rSm = Radius.circular(sm);
static const rMd = Radius.circular(md);
static const rLg = Radius.circular(lg);
static const rXl = Radius.circular(xl);
static const rXxl = Radius.circular(xxl);
}
/// ── Motion ──────────────────────────────────────────────────────────
class PangolinMotion {
PangolinMotion._();
static const fast = Duration(milliseconds: 140);
static const base = Duration(milliseconds: 220);
static const slow = Duration(milliseconds: 360);
// easeOut cubic-bezier(0.22, 1, 0.36, 1)
static const easeOut = Cubic(0.22, 1, 0.36, 1);
static const easeInOut = Cubic(0.65, 0, 0.35, 1);
}
/// ── Font families ───────────────────────────────────────────────────
class PangolinFonts {
PangolinFonts._();
static const display = 'Sora';
static const sans = 'Manrope';
static const cjk = 'Noto Sans SC';
static const mono = 'JetBrains Mono';
}
/// ── Type scale (sizes mirror the CSS rem scale at 16px root) ────────
class PangolinText {
PangolinText._();
// Build with a color at call site: PangolinText.h1.copyWith(color: ctx.pangolin.fg1)
static const displayXl = TextStyle(fontFamily: PangolinFonts.display, fontSize: 48, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.96);
static const display = TextStyle(fontFamily: PangolinFonts.display, fontSize: 36, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.72);
static const h1 = TextStyle(fontFamily: PangolinFonts.display, fontSize: 30, fontWeight: FontWeight.w600, height: 1.3, letterSpacing: -0.3);
static const h2 = TextStyle(fontFamily: PangolinFonts.display, fontSize: 24, fontWeight: FontWeight.w600, height: 1.3, letterSpacing: -0.24);
static const h3 = TextStyle(fontFamily: PangolinFonts.sans, fontSize: 20, fontWeight: FontWeight.w600, height: 1.3);
static const bodyLg = TextStyle(fontFamily: PangolinFonts.sans, fontSize: 18, fontWeight: FontWeight.w400, height: 1.65);
static const body = TextStyle(fontFamily: PangolinFonts.sans, fontSize: 16, fontWeight: FontWeight.w400, height: 1.5);
static const sm = TextStyle(fontFamily: PangolinFonts.sans, fontSize: 14, fontWeight: FontWeight.w400, height: 1.5);
static const caption = TextStyle(fontFamily: PangolinFonts.sans, fontSize: 12, fontWeight: FontWeight.w500, height: 1.5);
static const overline = TextStyle(fontFamily: PangolinFonts.sans, fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.96);
static const mono = TextStyle(fontFamily: PangolinFonts.mono, fontSize: 14, fontWeight: FontWeight.w400, fontFeatures: [FontFeature.tabularFigures()]);
}
/// ── Soft warm-tinted shadows ────────────────────────────────────────
class PangolinShadow {
PangolinShadow._();
static const _tint = Color(0xFF2D1E14); // warm 45,30,20
static List<BoxShadow> sm = [BoxShadow(color: _tint.withOpacity(0.06), blurRadius: 2, offset: const Offset(0, 1))];
static List<BoxShadow> md = [BoxShadow(color: _tint.withOpacity(0.08), blurRadius: 14, offset: const Offset(0, 4))];
static List<BoxShadow> lg = [BoxShadow(color: _tint.withOpacity(0.12), blurRadius: 32, offset: const Offset(0, 12))];
static List<BoxShadow> xl = [BoxShadow(color: _tint.withOpacity(0.16), blurRadius: 60, offset: const Offset(0, 24))];
}
/// ── Ready-to-use ThemeData ──────────────────────────────────────────
class PangolinTheme {
PangolinTheme._();
static ThemeData get light => _build(PangolinScheme.light, Brightness.light);
static ThemeData get dark => _build(PangolinScheme.dark, Brightness.dark);
static ThemeData _build(PangolinScheme s, Brightness b) {
final scheme = ColorScheme(
brightness: b,
primary: s.accent,
onPrimary: s.fgOnAccent,
secondary: s.accent,
onSecondary: s.fgOnAccent,
error: s.danger,
onError: PangolinColors.white,
surface: s.surface,
onSurface: s.fg1,
);
return ThemeData(
useMaterial3: true,
brightness: b,
colorScheme: scheme,
scaffoldBackgroundColor: s.bg,
fontFamily: PangolinFonts.sans,
extensions: [s],
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: s.accent,
foregroundColor: s.fgOnAccent,
textStyle: const TextStyle(fontFamily: PangolinFonts.sans, fontWeight: FontWeight.w600, fontSize: 15),
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14),
),
),
cardTheme: CardThemeData(
color: s.surface,
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(color: s.border),
borderRadius: BorderRadius.circular(PangolinRadius.lg),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: s.bg,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(PangolinRadius.md),
borderSide: BorderSide(color: s.borderStrong, width: 1.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(PangolinRadius.md),
borderSide: BorderSide(color: s.accent, width: 1.5),
),
),
);
}
}
-84
View File
@@ -1,84 +0,0 @@
# pubspec.yaml — 穿山甲 VPN · Pangolin VPN(模板)
# 把本文件放到 Flutter 工程根目录,按需调整 name/version,然后 `flutter pub get`。
# 字体文件需自备(Google Fonts 下载或用 google_fonts 包);SVG 资产已在 flutter/assets/。
name: pangolin_vpn
description: 穿山甲 · Pangolin — 极简、稳定、跨平台网络加速客户端。
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ^3.5.0
flutter: ">=3.24.0"
dependencies:
flutter:
sdk: flutter
flutter_svg: ^2.0.10 # 加载 assets/ 下的矢量 logo
lucide_icons: ^0.257.0 # Lucide 细线条图标(版本以 pub.dev 最新为准)
# 可选:用 google_fonts 在线加载字体,免去本地 ttf
# google_fonts: ^6.2.1
# 可选:打开外部购买/联系渠道链接
# url_launcher: ^6.3.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
# 可选:用 app-icon.svg 生成各平台启动图标
# flutter_launcher_icons: ^0.13.1
flutter:
uses-material-design: true
# ── 资产(SVG logo)──
# 把 flutter/assets/*.svg 拷到工程 assets/ 下;如改路径,同步改 pangolin_logo.dart 的 _base。
assets:
- assets/logo-mark.svg
- assets/logo-mark-white.svg
- assets/logo-wordmark.svg
- assets/app-icon.svg
# ── 字体 ──
# 自备 ttf 放 fonts/;或改用 google_fonts 包(则删掉此段,在代码里用 GoogleFonts.*)。
# 族名必须与 pangolin_theme.dart 的 PangolinFonts 完全一致。
fonts:
- family: Sora
fonts:
- asset: fonts/Sora-Medium.ttf
weight: 500
- asset: fonts/Sora-SemiBold.ttf
weight: 600
- asset: fonts/Sora-Bold.ttf
weight: 700
- family: Manrope
fonts:
- asset: fonts/Manrope-Regular.ttf
weight: 400
- asset: fonts/Manrope-Medium.ttf
weight: 500
- asset: fonts/Manrope-SemiBold.ttf
weight: 600
- asset: fonts/Manrope-Bold.ttf
weight: 700
- family: Noto Sans SC
fonts:
- asset: fonts/NotoSansSC-Regular.ttf
weight: 400
- asset: fonts/NotoSansSC-Medium.ttf
weight: 500
- asset: fonts/NotoSansSC-Bold.ttf
weight: 700
- family: JetBrains Mono
fonts:
- asset: fonts/JetBrainsMono-Regular.ttf
weight: 400
- asset: fonts/JetBrainsMono-Medium.ttf
weight: 500
# ── flutter_launcher_icons 配置(可选,取消注释并运行 `dart run flutter_launcher_icons`)──
# flutter_launcher_icons:
# image_path: "assets/app-icon-1024.png" # 先把 app-icon.svg 导出 1024px PNG
# android: true
# ios: true
# remove_alpha_ios: true
-296
View File
@@ -1,296 +0,0 @@
// account_screens.dart — 账户子页(套餐选择 / 设备管理 / 兑换 & 购买 / 联系我们)
// 对应 React UI Kit 的 PlansScreen / DAccountView 设备段 / RedeemScreen / ContactScreen。
// 均为带返回栏的独立页;HomeShell 账户页的行点击后 push 这些页即可。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
import 'pangolin_icons.dart';
import 'plan_card.dart';
import 'pangolin_button.dart';
/// 通用:带返回箭头的子页骨架
class _SubScaffold extends StatelessWidget {
const _SubScaffold({required this.title, required this.child, this.onBack});
final String title; final Widget child; final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(onPressed: onBack ?? () => Navigator.of(context).maybePop(), icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1)),
Text(title, style: PangolinText.h3.copyWith(color: c.fg1, fontFamily: PangolinFonts.display, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: child),
])),
);
}
}
/// ── 套餐选择 ──(点「续费 / 升级」进入)
class PlansScreen extends StatelessWidget {
const PlansScreen({super.key, this.zh = true, this.onChoose, this.onBack});
final bool zh; final ValueChanged<String>? onChoose; final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
final plans = [
(
id: 'free', name: zh ? '免费版' : 'Free', price: '¥0', cta: zh ? '当前' : 'Current', featured: false, current: true, pop: null,
feats: zh ? ['3 个基础节点', '每日 1GB 流量', '1 台设备'] : ['3 basic locations', '1GB / day', '1 device'],
),
(
id: 'pro', name: zh ? '专业版' : 'Pro', price: '¥25', cta: zh ? '立即升级' : 'Upgrade', featured: true, current: false, pop: zh ? '最受欢迎' : 'Popular',
feats: zh ? ['80+ 全球节点', '无限流量 · 极速', '5 台设备同时在线', '流媒体优化'] : ['80+ locations', 'Unlimited · fast', '5 devices', 'Streaming optimized'],
),
(
id: 'team', name: zh ? '团队版' : 'Team', price: '¥99', cta: zh ? '选择' : 'Choose', featured: false, current: false, pop: null,
feats: zh ? ['Pro 全部功能', '10 个成员席位', '集中计费与管理', '优先客服'] : ['Everything in Pro', '10 seats', 'Central billing', 'Priority support'],
),
];
return _SubScaffold(
title: zh ? '选择套餐' : 'Choose plan',
onBack: onBack,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
children: [
for (final p in plans) Padding(
padding: EdgeInsets.only(bottom: 14, top: p.pop != null ? 12 : 0),
child: PlanCard(
name: p.name, price: p.price, period: zh ? '/月' : '/mo',
features: p.feats, ctaLabel: p.cta, featured: p.featured, isCurrent: p.current,
popularLabel: p.pop, onPressed: () => onChoose?.call(p.id),
),
),
],
),
);
}
}
/// ── 设备管理 ──
class DeviceItem {
const DeviceItem({required this.id, required this.icon, required this.name, required this.os, required this.active, this.me = false});
final String id, name, os, active; final IconData icon; final bool me;
}
class DevicesScreen extends StatefulWidget {
const DevicesScreen({super.key, this.zh = true, this.onBack});
final bool zh; final VoidCallback? onBack;
@override
State<DevicesScreen> createState() => _DevicesScreenState();
}
class _DevicesScreenState extends State<DevicesScreen> {
late List<DeviceItem> _devices = [
DeviceItem(id: '1', icon: PangolinIcons.laptop, name: 'MacBook Pro', os: 'macOS 26', active: widget.zh ? '当前设备' : 'This device', me: true),
DeviceItem(id: '2', icon: PangolinIcons.smartphone, name: 'iPhone 17', os: 'iOS 26', active: '2 min'),
DeviceItem(id: '3', icon: PangolinIcons.monitorSmartphone, name: 'iPad Air', os: 'iPadOS 26', active: widget.zh ? '3 小时前' : '3h ago'),
];
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return _SubScaffold(
title: widget.zh ? '我的设备' : 'My devices',
onBack: widget.onBack,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
Text(widget.zh ? 'PRO 套餐最多 5 台设备同时在线' : 'Up to 5 devices on PRO', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
clipBehavior: Clip.antiAlias,
child: Column(children: [
for (var i = 0; i < _devices.length; i++) _row(c, _devices[i], i < _devices.length - 1),
]),
),
]),
);
}
Widget _row(PangolinScheme c, DeviceItem d, bool divider) {
return Container(
decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(children: [
Container(width: 38, height: 38, decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Icon(d.icon, size: 19, color: c.accent)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Flexible(child: Text(d.name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5))),
if (d.me) Container(margin: const EdgeInsets.only(left: 7), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(color: c.successSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Text(d.active, style: PangolinText.caption.copyWith(color: c.success, fontWeight: FontWeight.w600, fontSize: 10.5))),
]),
const SizedBox(height: 1),
Text(d.me ? d.os : '${d.os} · ${d.active}', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
])),
if (!d.me) OutlinedButton(
onPressed: () => setState(() => _devices.removeWhere((x) => x.id == d.id)),
style: OutlinedButton.styleFrom(foregroundColor: c.fg2, side: BorderSide(color: c.borderStrong, width: 1.5), shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), minimumSize: Size.zero),
child: Text(widget.zh ? '移除' : 'Remove', style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)),
),
]),
);
}
}
/// ── 兑换 & 购买 ──(App 内无支付,走兑换码 + 外部渠道)
class RedeemScreen extends StatefulWidget {
const RedeemScreen({super.key, this.zh = true, this.onBack});
final bool zh; final VoidCallback? onBack;
@override
State<RedeemScreen> createState() => _RedeemScreenState();
}
class _RedeemScreenState extends State<RedeemScreen> {
final _code = TextEditingController();
bool _ok = false;
@override
void dispose() { _code.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final zh = widget.zh;
final channels = [
(icon: PangolinIcons.shoppingBag, name: zh ? '自助发卡商店' : 'Self-serve store', sub: 'shop.pangolin.vpn', accent: true),
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: false),
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
(icon: PangolinIcons.mail, name: zh ? '邮箱客服' : 'Email support', sub: 'buy@pangolin.vpn', accent: false),
];
return _SubScaffold(
title: zh ? '兑换 & 购买' : 'Redeem & buy',
onBack: widget.onBack,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
// redeem code
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.xl), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(zh ? '兑换激活码' : 'Redeem a code', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
const SizedBox(height: 12),
if (_ok) Row(children: [
Icon(PangolinIcons.checkCircle, size: 20, color: c.success), const SizedBox(width: 9),
Text(zh ? '激活成功 · PRO 已开通' : 'Activated · PRO unlocked', style: PangolinText.body.copyWith(color: c.success, fontWeight: FontWeight.w600)),
]) else Row(children: [
Expanded(child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(color: c.bg, borderRadius: BorderRadius.circular(PangolinRadius.md), border: Border.all(color: c.borderStrong, width: 1.5)),
child: TextField(controller: _code,
textCapitalization: TextCapitalization.characters,
style: PangolinText.mono.copyWith(color: c.fg1, letterSpacing: 1.5),
decoration: InputDecoration(border: InputBorder.none, isCollapsed: true, contentPadding: const EdgeInsets.symmetric(vertical: 13),
hintText: zh ? '输入激活码' : 'Enter code', hintStyle: PangolinText.sm.copyWith(color: c.fg3)),
onChanged: (_) => setState(() {})),
)),
const SizedBox(width: 10),
PangolinButton(label: zh ? '激活' : 'Redeem', onPressed: _code.text.trim().length >= 4 ? () => setState(() => _ok = true) : null),
]),
]),
),
const SizedBox(height: 22),
Text(zh ? '购买渠道' : 'Where to buy', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
const SizedBox(height: 6),
Text(zh ? 'App 内不支持直接支付。请通过以下渠道获取激活码:' : 'In-app payment is unavailable. Get a code via:',
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400, height: 1.5)),
const SizedBox(height: 14),
for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _channel(c, ch.icon, ch.name, ch.sub, ch.accent)),
]),
);
}
Widget _channel(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
return InkWell(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
onTap: () {/* launchUrl(...) */},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: accent ? c.accentSubtle : c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: accent ? c.accentBorder : c.border),
boxShadow: PangolinShadow.sm,
),
child: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)),
const SizedBox(width: 13),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontFamily: PangolinFonts.mono, fontWeight: FontWeight.w400)),
])),
Icon(PangolinIcons.externalLink, size: 16, color: c.fg3),
]),
),
);
}
}
/// ── 联系我们 ──
class ContactScreen extends StatelessWidget {
const ContactScreen({super.key, this.zh = true, this.onBack});
final bool zh; final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final channels = [
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true),
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
(icon: PangolinIcons.mail, name: zh ? '邮箱客服' : 'Email support', sub: 'support@pangolin.vpn', accent: false),
(icon: PangolinIcons.shoppingBag, name: zh ? '自助发卡商店' : 'Self-serve store', sub: 'shop.pangolin.vpn', accent: false),
];
return _SubScaffold(
title: zh ? '联系我们' : 'Contact us',
onBack: onBack,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
Text(zh ? '遇到问题?通过以下任一渠道联系我们,通常数分钟内回复。' : 'Need help? Reach us via any channel below — usually replies in minutes.',
style: PangolinText.sm.copyWith(color: c.fg2, height: 1.6)),
const SizedBox(height: 16),
for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _contact(c, ch.icon, ch.name, ch.sub, ch.accent)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
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 ? '服务时间' : 'Support hours', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
const SizedBox(height: 5),
Text(zh ? '每日 9:00 24:00 (GMT+8)' : 'Daily 9:00 24:00 (GMT+8)', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontFamily: PangolinFonts.mono)),
]),
),
]),
);
}
Widget _contact(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
return InkWell(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
onTap: () {/* launchUrl(...) */},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: accent ? c.accentSubtle : c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: accent ? c.accentBorder : c.border),
boxShadow: PangolinShadow.sm,
),
child: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)),
const SizedBox(width: 13),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontFamily: PangolinFonts.mono, fontWeight: FontWeight.w400)),
])),
Icon(PangolinIcons.externalLink, size: 16, color: c.fg3),
]),
),
);
}
}
-159
View File
@@ -1,159 +0,0 @@
// auth_screen.dart — 登录 / 注册页雏形
// 对应 React UI Kit 的 AuthFlow / DAuthFlow。
// 登录:邮箱 + 密码;注册:邮箱 → 发送验证码 → 填验证码 → 设置密码(多端登录)。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
import 'pangolin_icons.dart';
import 'pangolin_button.dart';
import 'pangolin_logo.dart';
enum _AuthMode { login, register }
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key, required this.onDone, this.zh = true});
final VoidCallback onDone; // 登录/注册成功 → 进入引导或主应用
final bool zh; // 简单 i18n:true=中文 false=EN
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
_AuthMode _mode = _AuthMode.login;
int _step = 0; // 注册步骤:0 邮箱+验证码,1 设置密码
bool _sent = false;
final _email = TextEditingController();
final _code = TextEditingController();
final _pw = TextEditingController();
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
String _t(String zh, String en) => widget.zh ? zh : en;
@override
void dispose() { _email.dispose(); _code.dispose(); _pw.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
children: [
const SizedBox(height: 18),
// brand
Column(children: [
const PangolinMark(size: 52),
const SizedBox(height: 12),
Text(widget.zh ? '穿山甲' : 'Pangolin', style: PangolinText.h1.copyWith(color: c.fg1)),
const SizedBox(height: 4),
Text(_t('极速 · 稳定 · 省心', 'Fast · Stable · Effortless'),
style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)),
]),
const SizedBox(height: 28),
// tabs
Row(children: [
_tab(_AuthMode.login, _t('登录', 'Log in'), c),
_tab(_AuthMode.register, _t('注册', 'Sign up'), c),
]),
Divider(height: 1, color: c.border),
const SizedBox(height: 22),
Expanded(child: _mode == _AuthMode.login ? _login(c) : _register(c)),
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Text(
_t('继续即代表同意《服务条款》与《隐私政策》', 'By continuing you agree to our Terms & Privacy Policy'),
textAlign: TextAlign.center,
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400),
),
),
],
),
),
),
);
}
Widget _tab(_AuthMode m, String label, PangolinScheme c) {
final on = _mode == m;
return Expanded(
child: GestureDetector(
onTap: () => setState(() { _mode = m; _step = 0; _sent = false; }),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: on ? c.accent : Colors.transparent, width: 2))),
child: Center(child: Text(label, style: PangolinText.body.copyWith(color: on ? c.fg1 : c.fg3, fontWeight: on ? FontWeight.w700 : FontWeight.w500))),
),
),
);
}
Widget _field(PangolinScheme c, {required IconData icon, required String label, required Widget child}) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
const SizedBox(height: 7),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.md), border: Border.all(color: c.borderStrong, width: 1.5)),
child: Row(children: [Icon(icon, size: 18, color: c.fg3), const SizedBox(width: 10), Expanded(child: child)]),
),
]);
}
InputDecoration get _bare => const InputDecoration(border: InputBorder.none, isCollapsed: true, contentPadding: EdgeInsets.symmetric(vertical: 12));
Widget _login(PangolinScheme c) {
return Column(children: [
_field(c, icon: PangolinIcons.mail, label: _t('邮箱', 'Email'),
child: TextField(controller: _email, decoration: _bare.copyWith(hintText: 'your@email.com'), onChanged: (_) => setState(() {}))),
const SizedBox(height: 16),
_field(c, icon: PangolinIcons.lock, label: _t('密码', 'Password'),
child: TextField(controller: _pw, obscureText: true, decoration: _bare.copyWith(hintText: _t('输入密码', 'Enter password')), onChanged: (_) => setState(() {}))),
Align(alignment: Alignment.centerRight, child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_t('忘记密码?', 'Forgot password?'), style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)),
)),
const SizedBox(height: 18),
PangolinButton(label: _t('登录', 'Log in'), expand: true, onPressed: (_emailValid && _pw.text.isNotEmpty) ? widget.onDone : null),
]);
}
Widget _register(PangolinScheme c) {
if (_step == 0) {
return Column(children: [
_field(c, icon: PangolinIcons.mail, label: _t('邮箱', 'Email'),
child: Row(children: [
Expanded(child: TextField(controller: _email, decoration: _bare.copyWith(hintText: 'your@email.com'), onChanged: (_) => setState(() {}))),
GestureDetector(
onTap: _emailValid ? () => setState(() => _sent = true) : null,
child: Text(_sent ? _t('重新发送', 'Resend') : _t('发送验证码', 'Send code'),
style: PangolinText.caption.copyWith(color: _emailValid ? c.accent : c.fg3, fontWeight: FontWeight.w700)),
),
])),
if (_sent) Padding(padding: const EdgeInsets.only(top: 8), child: Row(children: [
Icon(PangolinIcons.checkCircle, size: 14, color: c.success), const SizedBox(width: 6),
Text('${_t('验证码已发送至', 'Code sent to')} ${_email.text}', style: PangolinText.caption.copyWith(color: c.success, fontWeight: FontWeight.w400)),
])),
const SizedBox(height: 16),
_field(c, icon: PangolinIcons.shieldCheck, label: _t('邮箱验证码', 'Verification code'),
child: TextField(controller: _code, keyboardType: TextInputType.number, maxLength: 6,
style: PangolinText.mono.copyWith(letterSpacing: 6, color: c.fg1),
decoration: _bare.copyWith(counterText: '', hintText: '······'), onChanged: (_) => setState(() {}))),
const SizedBox(height: 8),
PangolinButton(label: _t('下一步', 'Next'), expand: true, onPressed: (_sent && _code.text.length == 6) ? () => setState(() => _step = 1) : null),
]);
}
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('密码', 'Password'),
child: TextField(controller: _pw, obscureText: true,
decoration: _bare.copyWith(hintText: _t('设置登录密码(用于多端登录)', 'Set a password (multi-device)')), onChanged: (_) => setState(() {}))),
const SizedBox(height: 18),
PangolinButton(label: _t('创建账户', 'Create account'), expand: true, onPressed: _pw.text.length >= 6 ? widget.onDone : null),
]);
}
}
-147
View File
@@ -1,147 +0,0 @@
// connect_button.dart — 核心连接键(三态:off / connecting / on)
// 对应 React UI Kit 的 ConnectScreen 大圆按钮:暖底轨道环 → 旋转弧 → 满环。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
import 'pangolin_icons.dart';
enum VpnStatus { off, connecting, on }
class ConnectButton extends StatefulWidget {
const ConnectButton({
super.key,
required this.status,
required this.onTap,
this.elapsed = Duration.zero,
this.size = 208,
});
final VpnStatus status;
final VoidCallback onTap;
final Duration elapsed;
final double size;
@override
State<ConnectButton> createState() => _ConnectButtonState();
}
class _ConnectButtonState extends State<ConnectButton> with SingleTickerProviderStateMixin {
late final AnimationController _spin =
AnimationController(vsync: this, duration: const Duration(milliseconds: 1400))..repeat();
@override
void dispose() {
_spin.dispose();
super.dispose();
}
String _fmt(Duration d) {
String two(int n) => n.toString().padLeft(2, '0');
return '${two(d.inHours)}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}';
}
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final s = widget.status;
final Color fill = switch (s) {
VpnStatus.off => c.bgSubtle,
VpnStatus.connecting => c.accent,
VpnStatus.on => c.success,
};
final Color fg = s == VpnStatus.off ? c.accent : PangolinColors.white;
final IconData icon = switch (s) {
VpnStatus.off => PangolinIcons.power,
VpnStatus.connecting => PangolinIcons.loader,
VpnStatus.on => PangolinIcons.shieldCheck,
};
final List<BoxShadow> glow = s == VpnStatus.off
? PangolinShadow.md
: [
BoxShadow(
color: (s == VpnStatus.on ? c.success : c.accent).withOpacity(0.18),
blurRadius: 0,
spreadRadius: 9,
),
...PangolinShadow.lg,
];
return GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: PangolinMotion.slow,
curve: PangolinMotion.easeOut,
width: widget.size,
height: widget.size,
decoration: BoxDecoration(color: fill, shape: BoxShape.circle, boxShadow: glow),
child: Stack(
alignment: Alignment.center,
children: [
// track ring
Positioned.fill(
child: AnimatedBuilder(
animation: _spin,
builder: (_, __) => CustomPaint(
painter: _RingPainter(
status: s,
track: c.sand200,
progress: PangolinColors.white,
turns: s == VpnStatus.connecting ? _spin.value : 0,
),
),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 50, color: fg),
const SizedBox(height: 6),
if (s == VpnStatus.on)
Text(_fmt(widget.elapsed), style: PangolinText.mono.copyWith(color: fg, fontSize: 18, fontWeight: FontWeight.w600))
else
Text(s == VpnStatus.connecting ? '···' : 'TAP',
style: PangolinText.mono.copyWith(color: fg, fontSize: 14, fontWeight: FontWeight.w700, letterSpacing: 1.1)),
],
),
],
),
),
);
}
}
class _RingPainter extends CustomPainter {
_RingPainter({required this.status, required this.track, required this.progress, required this.turns});
final VpnStatus status;
final Color track;
final Color progress;
final double turns;
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final radius = size.width / 2 - 8;
final rect = Rect.fromCircle(center: center, radius: radius);
if (status == VpnStatus.off) {
// dotted track
final p = Paint()..style = PaintingStyle.stroke..strokeWidth = 3..strokeCap = StrokeCap.round..color = track;
const dash = 0.045, gap = 0.11;
for (double a = 0; a < 6.283; a += dash + gap) {
canvas.drawArc(rect, a, dash, false, p);
}
} else if (status == VpnStatus.connecting) {
final base = Paint()..style = PaintingStyle.stroke..strokeWidth = 4..color = progress.withOpacity(0.3);
canvas.drawArc(rect, 0, 6.283, false, base);
final arc = Paint()..style = PaintingStyle.stroke..strokeWidth = 4..strokeCap = StrokeCap.round..color = progress;
canvas.drawArc(rect, turns * 6.283, 1.6, false, arc);
} else {
final p = Paint()..style = PaintingStyle.stroke..strokeWidth = 4..strokeCap = StrokeCap.round..color = progress.withOpacity(0.85);
canvas.drawArc(rect, 0, 6.283, false, p);
}
}
@override
bool shouldRepaint(_RingPainter old) => old.turns != turns || old.status != status;
}
-61
View File
@@ -1,61 +0,0 @@
// country_code.dart — 国家码方块 + 信号条
// 对应 React UI Kit 的 CC / Signal 组件。不用 emoji 国旗。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
class CountryCode extends StatelessWidget {
const CountryCode({super.key, required this.code, this.active = false, this.size = 38});
final String code;
final bool active;
final double size;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? c.accent : c.bgSubtle,
borderRadius: BorderRadius.circular(PangolinRadius.md),
),
child: Text(
code,
style: PangolinText.mono.copyWith(
fontSize: size * 0.31,
fontWeight: FontWeight.w600,
color: active ? PangolinColors.white : c.fg2,
),
),
);
}
}
/// 三格信号条,依据延迟 ping 显示强度。
class SignalBars extends StatelessWidget {
const SignalBars({super.key, required this.ping});
final int ping;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final level = ping < 40 ? 3 : ping < 90 ? 2 : 1;
const heights = [4.0, 8.0, 13.0];
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(3, (i) {
return Container(
width: 3,
height: heights[i],
margin: EdgeInsets.only(left: i == 0 ? 0 : 2),
decoration: BoxDecoration(
color: i < level ? c.success : c.borderStrong,
borderRadius: BorderRadius.circular(1),
),
);
}),
);
}
}
-328
View File
@@ -1,328 +0,0 @@
// home_shell.dart — 主框架雏形(底部 4 Tab:连接 / 节点 / 统计 / 账户)
// 对应 React UI Kit 的 BottomTab + App 状态机。
// 这是「外壳」:负责导航与连接状态机,各页内容为占位骨架,工程师按需接真实逻辑。
import 'dart:async';
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
import 'pangolin_icons.dart';
import 'connect_button.dart';
import 'server_tile.dart';
import 'country_code.dart';
import 'account_screens.dart';
import 'pangolin_logo.dart';
class HomeShell extends StatefulWidget {
const HomeShell({super.key, this.zh = true});
final bool zh;
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
int _tab = 0; // 0 连接 1 节点 2 统计 3 账户
VpnStatus _status = VpnStatus.off;
ServerInfo _server = const ServerInfo(code: 'HK', name: '香港 · 流媒体', sub: 'Hong Kong', ping: 18);
Timer? _timer;
int _elapsed = 0;
String _t(String zh, String en) => widget.zh ? zh : en;
void _toggle() {
if (_status == VpnStatus.off) {
setState(() => _status = VpnStatus.connecting);
Future.delayed(const Duration(milliseconds: 1500), () {
if (!mounted) return;
setState(() { _status = VpnStatus.on; _elapsed = 0; });
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() => _elapsed++));
});
} else {
_timer?.cancel();
setState(() => _status = VpnStatus.off);
}
}
void _pick(ServerInfo s) {
setState(() { _server = s; _tab = 0; });
if (_status == VpnStatus.on) {
_timer?.cancel();
setState(() => _status = VpnStatus.connecting);
Future.delayed(const Duration(milliseconds: 1200), () {
if (!mounted) return;
setState(() { _status = VpnStatus.on; _elapsed = 0; });
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() => _elapsed++));
});
}
}
@override
void dispose() { _timer?.cancel(); super.dispose(); }
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final pages = [
_ConnectPage(zh: widget.zh, status: _status, server: _server, elapsedSec: _elapsed, onToggle: _toggle, onOpenServers: () => setState(() => _tab = 1)),
_ServersPage(zh: widget.zh, current: _server.code, onPick: _pick),
_StatsPage(zh: widget.zh),
_AccountPage(zh: widget.zh),
];
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(bottom: false, child: pages[_tab]),
bottomNavigationBar: _BottomTab(zh: widget.zh, index: _tab, onTap: (i) => setState(() => _tab = i)),
);
}
}
/// ── Bottom tab bar ──
class _BottomTab extends StatelessWidget {
const _BottomTab({required this.zh, required this.index, required this.onTap});
final bool zh; final int index; final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final items = [
(PangolinIcons.power, zh ? '连接' : 'Connect'),
(PangolinIcons.globe, zh ? '节点' : 'Servers'),
(PangolinIcons.barChart, zh ? '统计' : 'Stats'),
(PangolinIcons.user, zh ? '账户' : 'Account'),
];
return Container(
decoration: BoxDecoration(color: c.surface, border: Border(top: BorderSide(color: c.border))),
padding: const EdgeInsets.only(top: 8, bottom: 22),
child: Row(children: [
for (var i = 0; i < items.length; i++)
Expanded(child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(i),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(items[i].$1, size: 22, color: i == index ? c.accent : c.fg3),
const SizedBox(height: 4),
Text(items[i].$2, style: PangolinText.caption.copyWith(color: i == index ? c.accent : c.fg3, fontWeight: i == index ? FontWeight.w700 : FontWeight.w500)),
]),
)),
]),
);
}
}
/// ── Connect page ──
class _ConnectPage extends StatelessWidget {
const _ConnectPage({required this.zh, required this.status, required this.server, required this.elapsedSec, required this.onToggle, required this.onOpenServers});
final bool zh; final VpnStatus status; final ServerInfo server; final int elapsedSec; final VoidCallback onToggle, onOpenServers;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final cap = switch (status) {
VpnStatus.off => zh ? '未连接 · 轻点连接' : 'Tap to connect',
VpnStatus.connecting => zh ? '连接中…' : 'Connecting…',
VpnStatus.on => zh ? '已连接 · 网络已加密' : 'Connected · Encrypted',
};
return Column(children: [
_TopBar(zh: zh, trailing: Text(status == VpnStatus.on ? (zh ? '● 在线' : '● Online') : (zh ? '○ 离线' : '○ Offline'),
style: PangolinText.caption.copyWith(fontFamily: PangolinFonts.mono, color: status == VpnStatus.on ? c.success : c.fg3, fontWeight: FontWeight.w600))),
Expanded(child: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
ConnectButton(status: status, elapsed: Duration(seconds: elapsedSec), onTap: onToggle),
const SizedBox(height: 24),
Text(cap, style: PangolinText.body.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
]))),
// current server card
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
child: GestureDetector(
onTap: onOpenServers,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
child: Row(children: [
CountryCode(code: server.code, active: true),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(server.name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
Text('${server.sub} · ${server.ping}ms', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
])),
Icon(PangolinIcons.chevronRight, size: 20, color: c.fg3),
]),
),
),
),
]);
}
}
/// ── Servers page ──
class _ServersPage extends StatelessWidget {
const _ServersPage({required this.zh, required this.current, required this.onPick});
final bool zh; final String current; final ValueChanged<ServerInfo> onPick;
static const _servers = [
ServerInfo(code: 'HK', name: '香港 · 流媒体', sub: 'Hong Kong', ping: 18),
ServerInfo(code: 'JP', name: '日本 东京', sub: 'Tokyo', ping: 32),
ServerInfo(code: 'TW', name: '台湾 台北', sub: 'Taipei', ping: 28),
ServerInfo(code: 'SG', name: '新加坡', sub: 'Singapore · P2P', ping: 54),
ServerInfo(code: 'KR', name: '韩国 首尔', sub: 'Seoul', ping: 41),
ServerInfo(code: 'US', name: '美国 洛杉矶', sub: 'Los Angeles', ping: 146),
];
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_TopBar(zh: zh),
Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Text(zh ? '选择节点' : 'Choose server', style: PangolinText.h2.copyWith(color: c.fg1))),
Expanded(child: ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
itemCount: _servers.length,
itemBuilder: (_, i) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ServerTile(server: _servers[i], active: _servers[i].code == current, onTap: () => onPick(_servers[i])),
),
)),
]);
}
}
/// ── Stats page (skeleton) ──
class _StatsPage extends StatelessWidget {
const _StatsPage({required this.zh});
final bool zh;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0];
final labels = zh ? ['','','','','','',''] : ['M','T','W','T','F','S','S'];
const maxV = 6.1;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_TopBar(zh: zh),
Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Text(zh ? '使用统计' : 'Statistics', style: PangolinText.h2.copyWith(color: c.fg1))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(children: [
for (final m in [(zh ? '本月流量' : 'Traffic', '42.6', 'GB'), (zh ? '平均延迟' : 'Ping', '29', 'ms'), (zh ? '本月时长' : 'Time', '86.4', 'h')])
Expanded(child: Container(
margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(m.$1, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text.rich(TextSpan(text: m.$2, style: PangolinText.mono.copyWith(fontSize: 20, color: c.fg1, fontWeight: FontWeight.w500),
children: [TextSpan(text: ' ${m.$3}', style: PangolinText.caption.copyWith(color: c.fg3))])),
]),
)),
]),
),
const SizedBox(height: 16),
Expanded(child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(zh ? '本周流量 (GB)' : 'This week (GB)', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
const SizedBox(height: 18),
Expanded(child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
for (var i = 0; i < vals.length; i++)
Expanded(child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
Text('${vals[i]}', style: PangolinText.caption.copyWith(fontFamily: PangolinFonts.mono, color: c.fg3)),
const SizedBox(height: 6),
Container(height: 90 * (vals[i] / maxV), margin: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(color: c.accent.withOpacity(.85), borderRadius: const BorderRadius.vertical(top: Radius.circular(6)))),
const SizedBox(height: 6),
Text(labels[i], style: PangolinText.caption.copyWith(color: c.fg3)),
])),
])),
]),
),
)),
]);
}
}
/// ── Account page (skeleton — 套餐横幅 + 账户信息 + 设备入口) ──
class _AccountPage extends StatelessWidget {
const _AccountPage({required this.zh});
final bool zh;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_TopBar(zh: zh),
Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 14),
child: Text(zh ? '我的' : 'Account', style: PangolinText.h2.copyWith(color: c.fg1))),
Expanded(child: ListView(padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), children: [
// plan banner
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [PangolinColors.clay600, PangolinColors.clay800]),
borderRadius: BorderRadius.circular(PangolinRadius.xl), boxShadow: PangolinShadow.md,
),
child: Row(children: [
Container(width: 44, height: 44, decoration: BoxDecoration(color: PangolinColors.white.withOpacity(.18), shape: BoxShape.circle),
child: const Icon(PangolinIcons.crown, size: 22, color: PangolinColors.white)),
const SizedBox(width: 11),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('me@pangolin.vpn', style: TextStyle(color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 16)),
const SizedBox(height: 4),
Text(zh ? 'PRO 会员 · 至 2026-12-31' : 'PRO · until 2026-12-31', style: TextStyle(color: PangolinColors.white.withOpacity(.85), fontSize: 12)),
])),
FilledButton(onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => PlansScreen(zh: zh, onChoose: (_) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)))))), style: FilledButton.styleFrom(backgroundColor: PangolinColors.white, foregroundColor: PangolinColors.clay700, shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10)),
child: Text(zh ? '续费 / 升级' : 'Renew', style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13))),
]),
),
const SizedBox(height: 18),
_accountRow(c, PangolinIcons.mail, zh ? '邮箱' : 'Email', 'me@pangolin.vpn'),
_accountRow(c, PangolinIcons.lock, zh ? '密码' : 'Password', '••••••••••'),
_accountRow(c, PangolinIcons.monitorSmartphone, zh ? '我的设备' : 'My devices', '',
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DevicesScreen(zh: zh)))),
_accountRow(c, PangolinIcons.shoppingBag, zh ? '兑换 & 购买' : 'Redeem & buy', '',
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)))),
_accountRow(c, PangolinIcons.messageCircle, zh ? '联系我们' : 'Contact us', '',
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ContactScreen(zh: zh)))),
_accountRow(c, PangolinIcons.logOut, zh ? '退出登录' : 'Sign out', '', danger: true),
])),
]);
}
Widget _accountRow(PangolinScheme c, IconData icon, String title, String value, {bool danger = false, VoidCallback? onTap}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
child: Row(children: [
Icon(icon, size: 20, color: danger ? c.danger : c.accent),
const SizedBox(width: 13),
Expanded(child: Text(title, style: PangolinText.body.copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500))),
if (value.isNotEmpty) Text(value, style: PangolinText.sm.copyWith(color: c.fg3))
else Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
]),
),
);
}
}
/// ── Shared top bar ──
class _TopBar extends StatelessWidget {
const _TopBar({required this.zh, this.trailing});
final bool zh; final Widget? trailing;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 14),
child: Row(children: [
PangolinBrandLockup(zh: zh, markSize: 24),
const Spacer(),
if (trailing != null) trailing!,
]),
);
}
}
@@ -1,103 +0,0 @@
// onboarding_screen.dart — 首次引导页雏形(3 屏)
// 对应 React UI Kit 的 Onboarding / DOnboarding。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
import 'pangolin_icons.dart';
import 'pangolin_button.dart';
class OnboardingStep {
const OnboardingStep({required this.icon, required this.title, required this.body, required this.cta, this.tint, this.tintBg});
final IconData icon;
final String title, body, cta;
final Color? tint, tintBg;
}
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key, required this.onDone, this.steps});
final VoidCallback onDone;
final List<OnboardingStep>? steps; // 不传则用默认中文 3 屏
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
int _i = 0;
final _pager = PageController();
List<OnboardingStep> _defaults(PangolinScheme c) => [
OnboardingStep(icon: PangolinIcons.globe, title: '一键连接全球', body: '80+ 节点,智能选择最快线路,稳定不掉线。', cta: '下一步'),
OnboardingStep(icon: PangolinIcons.shield, title: '授权网络配置', body: '系统会请求添加一个网络配置,用于建立加密隧道。', cta: '允许并继续'),
OnboardingStep(icon: PangolinIcons.shieldCheck, title: '安全 · 无日志', body: '端到端加密,我们不记录你的任何浏览数据。', cta: '开始使用',
tint: c.success, tintBg: c.successSubtle),
];
@override
void dispose() { _pager.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final steps = widget.steps ?? _defaults(c);
final last = _i == steps.length - 1;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(children: [
// skip
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.all(8),
child: TextButton(onPressed: widget.onDone, child: Text('跳过', style: PangolinText.sm.copyWith(color: c.fg3, fontWeight: FontWeight.w600))),
),
),
Expanded(
child: PageView.builder(
controller: _pager,
itemCount: steps.length,
onPageChanged: (v) => setState(() => _i = v),
itemBuilder: (_, idx) {
final s = steps[idx];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Container(
width: 120, height: 120,
decoration: BoxDecoration(color: s.tintBg ?? c.accentSubtle, shape: BoxShape.circle),
child: Icon(s.icon, size: 52, color: s.tint ?? c.accent),
),
const SizedBox(height: 28),
Text(s.title, textAlign: TextAlign.center, style: PangolinText.h1.copyWith(color: c.fg1, fontSize: 26)),
const SizedBox(height: 12),
Text(s.body, textAlign: TextAlign.center, style: PangolinText.body.copyWith(color: c.fg2, height: 1.6)),
]),
);
},
),
),
// dots
Row(mainAxisAlignment: MainAxisAlignment.center, children: List.generate(steps.length, (k) {
final on = k == _i;
return AnimatedContainer(
duration: PangolinMotion.base, curve: PangolinMotion.easeOut,
width: on ? 22 : 7, height: 7, margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(color: on ? c.accent : c.borderStrong, borderRadius: BorderRadius.circular(PangolinRadius.full)),
);
})),
Padding(
padding: const EdgeInsets.fromLTRB(28, 22, 28, 26),
child: PangolinButton(
label: steps[_i].cta, expand: true,
onPressed: () {
if (last) { widget.onDone(); }
else { _pager.nextPage(duration: PangolinMotion.base, curve: PangolinMotion.easeOut); }
},
),
),
]),
),
);
}
}
@@ -1,67 +0,0 @@
// pangolin_button.dart — 胶囊按钮(primary / secondary / ghost / danger)
// 对应 React UI Kit 的 .btn / Pill 组件。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
enum PangolinButtonVariant { primary, secondary, ghost, danger }
class PangolinButton extends StatelessWidget {
const PangolinButton({
super.key,
required this.label,
this.onPressed,
this.icon,
this.variant = PangolinButtonVariant.primary,
this.expand = false,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final PangolinButtonVariant variant;
final bool expand;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final disabled = onPressed == null;
late final Color bg, fg;
Border? border;
switch (variant) {
case PangolinButtonVariant.primary:
bg = c.accent; fg = c.fgOnAccent; break;
case PangolinButtonVariant.secondary:
bg = c.surface; fg = c.fg1; border = Border.all(color: c.borderStrong, width: 1.5); break;
case PangolinButtonVariant.ghost:
bg = Colors.transparent; fg = c.accent; break;
case PangolinButtonVariant.danger:
bg = c.dangerSubtle; fg = c.danger; break;
}
final child = Row(
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[Icon(icon, size: 18, color: fg), const SizedBox(width: 8)],
Text(label, style: PangolinText.sm.copyWith(color: fg, fontWeight: FontWeight.w600, fontSize: 15)),
],
);
return Opacity(
opacity: disabled ? 0.45 : 1,
child: Material(
color: bg,
shape: StadiumBorder(side: border?.top ?? BorderSide.none),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 13),
child: child,
),
),
),
);
}
}
@@ -1,85 +0,0 @@
// pangolin_icons.dart — Lucide 图标映射表
//
// React UI Kit 用 Lucide 图标(按名引用,如 'power' / 'shield-check')。
// 本文件把这些名映射到 Flutter 的 lucide_icons 包常量,保证与设计像素一致。
//
// 依赖:在 pubspec.yaml 添加
// dependencies:
// lucide_icons: ^0.x # https://pub.dev/packages/lucide_icons
//
// 用法 A(直用常量,推荐):
// Icon(PangolinIcons.power, size: 20, color: c.accent)
// 用法 B(按 Lucide 名查表,便于从 React data-lucide 移植):
// Icon(PangolinIcons.byName('shield-check'))
//
// ⚠️ lucide_icons 各版本常量名可能略有差异;若某常量在你的版本里不存在,
// 按 IDE 提示替换为最接近的名称即可(已在右侧注明 Lucide 原名)。
import 'package:flutter/widgets.dart';
import 'package:lucide_icons/lucide_icons.dart';
class PangolinIcons {
PangolinIcons._();
// ── 连接 / 状态 ──
static const power = LucideIcons.power; // power — 连接键
static const shieldCheck = LucideIcons.shieldCheck; // shield-check — 已加密
static const shield = LucideIcons.shield; // shield — Kill Switch
static const loader = LucideIcons.loader; // loader-circle — 连接中(旋转)
static const zap = LucideIcons.zap; // zap — 延迟 / 极速
static const globe = LucideIcons.globe; // globe — 节点
// ── 导航 / 操作 ──
static const settings = LucideIcons.settings; // settings
static const user = LucideIcons.user; // user — 账户
static const search = LucideIcons.search; // search
static const check = LucideIcons.check; // check — 选中
static const checkCircle = LucideIcons.checkCircle; // check-circle — 成功
static const x = LucideIcons.x; // x — 关闭
static const chevronRight = LucideIcons.chevronRight; // chevron-right
static const chevronDown = LucideIcons.chevronDown; // chevron-down
static const arrowLeft = LucideIcons.arrowLeft; // arrow-left — 返回
static const arrowUp = LucideIcons.arrowUp; // arrow-up — 上传
static const arrowDown = LucideIcons.arrowDown; // arrow-down — 下载
static const refreshCw = LucideIcons.refreshCw; // refresh-cw — 检查更新
static const compass = LucideIcons.compass; // compass — 引导
static const barChart = LucideIcons.barChart; // chart-no-axes-column / chart — 统计
static const mapPin = LucideIcons.mapPin; // map-pin
// ── 主题 ──
static const moon = LucideIcons.moon; // moon — 深色
static const sun = LucideIcons.sun; // sun — 浅色
// ── 账户 / 套餐 / 兑换 ──
static const crown = LucideIcons.crown; // crown — PRO
static const ticket = LucideIcons.ticket; // ticket — 兑换 / 续费
static const creditCard = LucideIcons.creditCard; // credit-card
static const shoppingBag = LucideIcons.shoppingBag; // shopping-bag — 发卡商店
static const mail = LucideIcons.mail; // mail — 邮箱
static const lock = LucideIcons.lock; // lock — 密码
static const logOut = LucideIcons.logOut; // log-out — 退出
static const send = LucideIcons.send; // send — Telegram
static const messageCircle= LucideIcons.messageCircle; // message-circle — LINE / 联系
static const externalLink = LucideIcons.externalLink; // external-link — 外链
// ── 设备 ──
static const laptop = LucideIcons.laptop; // laptop
static const smartphone = LucideIcons.smartphone; // smartphone
// monitor-smartphone 在部分版本缺失,回退到 smartphone(平板亦可用 LucideIcons.tablet)
static const monitorSmartphone = LucideIcons.smartphone; // monitor-smartphone(回退)
/// 按 Lucide 原名查表(React data-lucide 名 → IconData)。未命中返回 null。
static IconData? byName(String name) => _byName[name];
static const Map<String, IconData> _byName = {
'power': power, 'shield-check': shieldCheck, 'shield': shield, 'loader-circle': loader,
'zap': zap, 'globe': globe, 'settings': settings, 'user': user, 'search': search,
'check': check, 'check-circle': checkCircle, 'x': x, 'chevron-right': chevronRight,
'chevron-down': chevronDown, 'arrow-left': arrowLeft, 'arrow-up': arrowUp, 'arrow-down': arrowDown,
'refresh-cw': refreshCw, 'compass': compass, 'chart-no-axes-column': barChart, 'chart': barChart,
'map-pin': mapPin, 'moon': moon, 'sun': sun, 'crown': crown, 'ticket': ticket,
'credit-card': creditCard, 'shopping-bag': shoppingBag, 'mail': mail, 'lock': lock,
'log-out': logOut, 'send': send, 'message-circle': messageCircle, 'external-link': externalLink,
'laptop': laptop, 'smartphone': smartphone, 'monitor-smartphone': monitorSmartphone,
};
}
-74
View File
@@ -1,74 +0,0 @@
// pangolin_logo.dart — 品牌标 / 字标 / App 图标(SVG)
// 用 flutter_svg 加载 assets/ 下的矢量 logo,自动适配明暗主题。
//
// 依赖:pubspec.yaml
// dependencies:
// flutter_svg: ^2.0.0
// flutter:
// assets:
// - assets/logo-mark.svg
// - assets/logo-mark-white.svg
// - assets/logo-wordmark.svg
// - assets/app-icon.svg
//
// 资产路径:把本目录上一级的 assets/*.svg 拷进你的 Flutter 工程 assets/。
// 若放在其他包/前缀下,改 _base 即可。
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../pangolin_theme.dart';
const String _base = 'assets';
/// 仅标记(行走穿山甲)。深色主题自动用反白版。
class PangolinMark extends StatelessWidget {
const PangolinMark({super.key, this.size = 28, this.forceLight, this.forceDark});
final double size;
final bool? forceLight; // 强制浅色锁版(clay 标)
final bool? forceDark; // 强制深色锁版(反白标)
@override
Widget build(BuildContext context) {
final isDark = forceDark ?? (forceLight == true ? false : Theme.of(context).brightness == Brightness.dark);
final asset = isDark ? '$_base/logo-mark-white.svg' : '$_base/logo-mark.svg';
return SvgPicture.asset(asset, width: size, height: size);
}
}
/// 标记 + 字标(横版锁版)。深色下文字需反白时建议改用 PangolinMark + Text 自绘,
/// 因 wordmark.svg 内文字为深色;此处提供浅背景用法。
class PangolinWordmark extends StatelessWidget {
const PangolinWordmark({super.key, this.height = 28});
final double height;
@override
Widget build(BuildContext context) => SvgPicture.asset('$_base/logo-wordmark.svg', height: height);
}
/// App 图标(clay 渐变圆角方 + 白色穿山甲)。圆角随尺寸。
class PangolinAppIcon extends StatelessWidget {
const PangolinAppIcon({super.key, this.size = 64});
final double size;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(size * 0.22),
child: SvgPicture.asset('$_base/app-icon.svg', width: size, height: size),
);
}
}
/// 顶栏品牌锁版:标记 + 「穿山甲 / Pangolin」字样(用本地字体,随主题变色)。
/// 比直接用 wordmark.svg 更灵活(可双语 + 深浅自适应)。
class PangolinBrandLockup extends StatelessWidget {
const PangolinBrandLockup({super.key, this.zh = true, this.markSize = 26});
final bool zh; final double markSize;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Row(mainAxisSize: MainAxisSize.min, children: [
PangolinMark(size: markSize),
const SizedBox(width: 9),
Text(zh ? '穿山甲' : 'Pangolin',
style: PangolinText.h3.copyWith(color: c.fg1, fontFamily: PangolinFonts.display, fontWeight: FontWeight.w700)),
]);
}
}
@@ -1,14 +0,0 @@
// pangolin_widgets.dart — 统一导出穿山甲 VPN 核心组件雏形。
// 用法:import 'widgets/pangolin_widgets.dart';
export 'pangolin_icons.dart';
export 'pangolin_button.dart';
export 'country_code.dart';
export 'status_pill.dart';
export 'connect_button.dart';
export 'server_tile.dart';
export 'plan_card.dart';
export 'auth_screen.dart';
export 'onboarding_screen.dart';
export 'home_shell.dart';
export 'account_screens.dart';
export 'pangolin_logo.dart';
-112
View File
@@ -1,112 +0,0 @@
// plan_card.dart — 套餐卡(免费 / 专业渐变高亮 / 团队)
// 对应 React UI Kit 的 PlanCard / PlansScreen。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
import 'pangolin_icons.dart';
class PlanCard extends StatelessWidget {
const PlanCard({
super.key,
required this.name,
required this.price,
required this.period,
required this.features,
required this.ctaLabel,
this.featured = false,
this.isCurrent = false,
this.popularLabel,
this.onPressed,
});
final String name, price, period, ctaLabel;
final List<String> features;
final bool featured, isCurrent;
final String? popularLabel;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final onCard = featured ? PangolinColors.white : c.fg1;
final subColor = featured ? PangolinColors.white.withOpacity(0.92) : c.fg2;
final card = Container(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 20),
decoration: BoxDecoration(
gradient: featured
? const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [PangolinColors.clay600, PangolinColors.clay800])
: null,
color: featured ? null : c.surface,
border: featured ? null : Border.all(color: c.border),
borderRadius: BorderRadius.circular(PangolinRadius.xl),
boxShadow: featured ? PangolinShadow.md : PangolinShadow.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(name, style: PangolinText.sm.copyWith(color: featured ? PangolinColors.white.withOpacity(0.85) : c.fg2, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(price, style: PangolinText.display.copyWith(color: onCard, fontSize: 30)),
Text(period, style: PangolinText.caption.copyWith(color: subColor, fontSize: 13)),
],
),
const SizedBox(height: 16),
...features.map((f) => Padding(
padding: const EdgeInsets.only(bottom: 9),
child: Row(children: [
Icon(PangolinIcons.check, size: 15, color: featured ? PangolinColors.white : c.success),
const SizedBox(width: 9),
Expanded(child: Text(f, style: PangolinText.sm.copyWith(color: subColor, fontSize: 13))),
]),
)),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: Material(
color: featured ? PangolinColors.white : (isCurrent ? Colors.transparent : c.accent),
shape: StadiumBorder(side: isCurrent ? BorderSide(color: c.borderStrong, width: 1.5) : BorderSide.none),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isCurrent ? null : onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: Text(ctaLabel,
style: PangolinText.sm.copyWith(
fontWeight: FontWeight.w700,
fontSize: 14,
color: featured ? PangolinColors.clay700 : (isCurrent ? c.fg2 : PangolinColors.white),
)),
),
),
),
),
),
],
),
);
if (popularLabel == null) return card;
// 带「最受欢迎」浮标
return Stack(clipBehavior: Clip.none, children: [
card,
Positioned(
top: -10,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 3),
decoration: BoxDecoration(color: PangolinColors.white, borderRadius: BorderRadius.circular(PangolinRadius.full), boxShadow: PangolinShadow.sm),
child: Text(popularLabel!, style: PangolinText.caption.copyWith(color: PangolinColors.clay700, fontWeight: FontWeight.w700, fontSize: 11)),
),
),
),
]);
}
}
-51
View File
@@ -1,51 +0,0 @@
// server_tile.dart — 服务器列表行(国家码 · 名称 · 延迟 · 信号 · 选中态)
// 对应 React UI Kit 的 ServerRow。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
import 'country_code.dart';
import 'pangolin_icons.dart';
class ServerInfo {
const ServerInfo({required this.code, required this.name, required this.sub, required this.ping});
final String code, name, sub;
final int ping;
}
class ServerTile extends StatelessWidget {
const ServerTile({super.key, required this.server, this.active = false, this.onTap});
final ServerInfo server;
final bool active;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return InkWell(
onTap: onTap,
child: Container(
color: active ? c.accentSubtle : Colors.transparent,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
CountryCode(code: server.code, active: active),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(server.name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 15)),
const SizedBox(height: 2),
Text(server.sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
],
),
),
Text('${server.ping}ms', style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
const SizedBox(width: 8),
SignalBars(ping: server.ping),
if (active) ...[const SizedBox(width: 10), Icon(PangolinIcons.check, size: 18, color: c.accent)],
],
),
),
);
}
}
-45
View File
@@ -1,45 +0,0 @@
// status_pill.dart — 状态胶囊(色点 + 文字)。不用 emoji。
// 对应 React UI Kit 的 .pill / 徽章。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
enum PangolinStatus { connected, connecting, error, neutral, pro }
class StatusPill extends StatelessWidget {
const StatusPill({super.key, required this.label, this.status = PangolinStatus.neutral});
final String label;
final PangolinStatus status;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
late final Color bg, fg, dot;
bool showDot = true;
switch (status) {
case PangolinStatus.connected:
bg = c.successSubtle; fg = c.success; dot = c.success; break;
case PangolinStatus.connecting:
bg = c.warningSubtle; fg = c.warning; dot = c.warning; break;
case PangolinStatus.error:
bg = c.dangerSubtle; fg = c.danger; dot = c.danger; break;
case PangolinStatus.pro:
bg = c.accent; fg = c.fgOnAccent; dot = c.fgOnAccent; showDot = false; break;
case PangolinStatus.neutral:
bg = c.bgSubtle; fg = c.fg2; dot = c.fg3; break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showDot) ...[
Container(width: 7, height: 7, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)),
const SizedBox(width: 7),
],
Text(label, style: PangolinText.caption.copyWith(color: fg, fontWeight: FontWeight.w600)),
],
),
);
}
}