feat(tsk_iRa5iBC4v7Tu): Flutter 客户端工程搭建(演示态)

基于 design/flutter/ 起步包在 client/ 下建立 Flutter 工程:

- pubspec.yaml:flutter_svg / lucide_icons / google_fonts 依赖;
  SVG 资产注册;字体由 google_fonts 运行时加载,无需本地 ttf
- lib/pangolin_theme.dart:完整设计令牌(色阶 / 间距 / 圆角 /
  动效 / 文字阶),PangolinText 改用 GoogleFonts getter,
  PangolinTheme.light / .dark 开箱即用
- lib/main.dart:runApp + 明暗主题 + 登录 → 引导 → 主框架 RootFlow
- lib/widgets/:全套组件雏形
    pangolin_icons    Lucide 图标映射
    pangolin_button   胶囊按钮四态
    country_code      国家码块 + 信号条
    status_pill       色点胶囊
    connect_button    核心连接键三态(off/connecting/on + 动画)
    server_tile       服务器列表行
    plan_card         套餐卡(专业版渐变高亮)
    auth_screen       登录 / 注册(邮箱验证码 + 设密码)
    onboarding_screen 首次引导 PageView(3 屏可滑动 + 进度点)
    home_shell        底部 4 Tab(连接/节点/统计/账户 + 状态机)
    account_screens   套餐选择 / 设备管理 / 兑换 / 联系我们
    pangolin_logo     PangolinMark / BrandLockup / AppIcon(SVG)
- assets/:logo-mark / logo-mark-white / logo-wordmark / app-icon(SVG)
- android/:Kotlin 主 Activity + Manifest + Gradle 配置
- ios/:Runner.xcodeproj + xcscheme + Podfile + AppDelegate + storyboard

运行方式(cd client/):
  flutter pub get && flutter run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 02:15:42 +08:00
parent a642bf16a2
commit 526e7f2f33
50 changed files with 3103 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
// main.dart — 入口(演示态)
// 串接:登录 → 引导 → 主框架(4 Tab)
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> {
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);
}
}
}
+321
View File
@@ -0,0 +1,321 @@
// pangolin_theme.dart
// 穿山甲 VPN · Pangolin VPN — Flutter design tokens
//
// Single Dart source mirroring colors_and_type.css.
// Fonts are loaded at runtime via the google_fonts package (no local ttf needed).
//
// Usage:
// MaterialApp(
// theme: PangolinTheme.light,
// darkTheme: PangolinTheme.dark,
// themeMode: ThemeMode.system,
// );
// // tokens: PangolinColors.clay500, PangolinSpacing.x4, PangolinRadius.lg ...
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.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 ───────────────────────
@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),
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),
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),
fg1: Color(0xFFF4EFE8),
fg2: Color(0xFFB6AC9C),
fg3: Color(0xFF897F6F),
fgOnAccent: PangolinColors.sand900,
accent: PangolinColors.clay400,
accentHover: PangolinColors.clay300,
accentPress: PangolinColors.clay500,
accentSubtle: Color(0x24CC8B5C),
accentBorder: Color(0x4DCC8B5C),
border: Color(0x1AF2EEE7),
borderStrong: Color(0x2EF2EEE7),
ring: Color(0x73CC8B5C),
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);
static const easeOut = Cubic(0.22, 1, 0.36, 1);
static const easeInOut = Cubic(0.65, 0, 0.35, 1);
}
/// ── Font families (via google_fonts at runtime) ─────────────────────
class PangolinFonts {
PangolinFonts._();
// These string constants are used as display labels only.
// Actual font loading is done via google_fonts in PangolinText getters.
static const display = 'Sora';
static const sans = 'Manrope';
static const cjk = 'Noto Sans SC';
static const mono = 'JetBrains Mono';
}
/// ── Type scale ──────────────────────────────────────────────────────
/// Uses google_fonts for runtime font loading (no local ttf required).
/// All PangolinText members are static getters (not const) so they can
/// reference google_fonts, which registers fonts dynamically.
class PangolinText {
PangolinText._();
static TextStyle get displayXl => GoogleFonts.sora(
fontSize: 48, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.96);
static TextStyle get display => GoogleFonts.sora(
fontSize: 36, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.72);
static TextStyle get h1 => GoogleFonts.sora(
fontSize: 30, fontWeight: FontWeight.w600, height: 1.3, letterSpacing: -0.3);
static TextStyle get h2 => GoogleFonts.sora(
fontSize: 24, fontWeight: FontWeight.w600, height: 1.3, letterSpacing: -0.24);
static TextStyle get h3 => GoogleFonts.manrope(
fontSize: 20, fontWeight: FontWeight.w600, height: 1.3);
static TextStyle get bodyLg => GoogleFonts.manrope(
fontSize: 18, fontWeight: FontWeight.w400, height: 1.65);
static TextStyle get body => GoogleFonts.manrope(
fontSize: 16, fontWeight: FontWeight.w400, height: 1.5);
static TextStyle get sm => GoogleFonts.manrope(
fontSize: 14, fontWeight: FontWeight.w400, height: 1.5);
static TextStyle get caption => GoogleFonts.manrope(
fontSize: 12, fontWeight: FontWeight.w500, height: 1.5);
static TextStyle get overline => GoogleFonts.manrope(
fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.96);
static TextStyle get mono => GoogleFonts.jetBrainsMono(
fontSize: 14, fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()]);
}
/// ── Soft warm-tinted shadows ────────────────────────────────────────
class PangolinShadow {
PangolinShadow._();
static const _tint = Color(0xFF2D1E14);
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,
// Use google_fonts Manrope as app-wide base font
textTheme: GoogleFonts.manropeTextTheme(
ThemeData(brightness: b).textTheme,
),
extensions: [s],
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: s.accent,
foregroundColor: s.fgOnAccent,
textStyle: GoogleFonts.manrope(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),
),
),
);
}
}
+293
View File
@@ -0,0 +1,293 @@
// account_screens.dart — 账户子页(套餐选择 / 设备管理 / 兑换 & 购买 / 联系我们)
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, 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 as String?,
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 as String?,
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)),
),
]),
);
}
}
/// ── 兑换 & 购买 ──
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: [
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: () {},
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, 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)),
]),
),
]),
);
}
Widget _contact(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
return InkWell(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
onTap: () {},
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, fontWeight: FontWeight.w400)),
])),
Icon(PangolinIcons.externalLink, size: 16, color: c.fg3),
]),
),
);
}
}
+155
View File
@@ -0,0 +1,155 @@
// auth_screen.dart — 登录 / 注册页
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;
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
_AuthMode _mode = _AuthMode.login;
int _step = 0;
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),
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),
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),
]);
}
}
+144
View File
@@ -0,0 +1,144 @@
// connect_button.dart — 核心连接键(三态:off / connecting / on)
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: [
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) {
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;
}
+60
View File
@@ -0,0 +1,60 @@
// country_code.dart — 国家码方块 + 信号条
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),
),
);
}),
);
}
}
+327
View File
@@ -0,0 +1,327 @@
// home_shell.dart — 主框架(底部 4 Tab:连接 / 节点 / 统计 / 账户)
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;
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.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),
ServerInfo(code: 'JP', name: '日本 东京', sub: 'Tokyo', ping: 32),
ServerInfo(code: 'TW', name: '台湾 台北', sub: 'Taipei', ping: 28),
ServerInfo(code: 'SG', name: '新加坡', sub: 'Singapore · P2P', ping: 54),
ServerInfo(code: 'KR', name: '韩国 首尔', sub: 'Seoul', ping: 41),
ServerInfo(code: 'US', name: '美国 洛杉矶', sub: 'Los Angeles', ping: 146),
];
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_TopBar(zh: zh),
Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Text(zh ? '选择节点' : 'Choose server', style: PangolinText.h2.copyWith(color: c.fg1))),
Expanded(child: ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
itemCount: _servers.length,
itemBuilder: (_, i) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ServerTile(server: _servers[i], active: _servers[i].code == current, onTap: () => onPick(_servers[i])),
),
)),
]);
}
}
/// ── Stats page ──
class _StatsPage extends StatelessWidget {
const _StatsPage({required this.zh});
final bool zh;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0];
final labels = zh ? ['','','','','','',''] : ['M','T','W','T','F','S','S'];
const maxV = 6.1;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_TopBar(zh: zh),
Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Text(zh ? '使用统计' : 'Statistics', style: PangolinText.h2.copyWith(color: c.fg1))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(children: [
for (final m in [(zh ? '本月流量' : 'Traffic', '42.6', 'GB'), (zh ? '平均延迟' : 'Ping', '29', 'ms'), (zh ? '本月时长' : 'Time', '86.4', 'h')])
Expanded(child: Container(
margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(m.$1, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text.rich(TextSpan(text: m.$2, style: PangolinText.mono.copyWith(fontSize: 20, color: c.fg1, fontWeight: FontWeight.w500),
children: [TextSpan(text: ' ${m.$3}', style: PangolinText.caption.copyWith(color: c.fg3))])),
]),
)),
]),
),
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!,
]),
);
}
}
+100
View File
@@ -0,0 +1,100 @@
// onboarding_screen.dart — 首次引导页(3 屏可滑动)
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;
@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: [
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)),
]),
);
},
),
),
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); }
},
),
),
]),
),
);
}
}
+66
View File
@@ -0,0 +1,66 @@
// pangolin_button.dart — 胶囊按钮(primary / secondary / ghost / danger)
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,
),
),
),
);
}
}
+67
View File
@@ -0,0 +1,67 @@
// pangolin_icons.dart — Lucide 图标映射表
import 'package:flutter/widgets.dart';
import 'package:lucide_icons/lucide_icons.dart';
class PangolinIcons {
PangolinIcons._();
// ── 连接 / 状态 ──
static const power = LucideIcons.power;
static const shieldCheck = LucideIcons.shieldCheck;
static const shield = LucideIcons.shield;
static const loader = LucideIcons.loader;
static const zap = LucideIcons.zap;
static const globe = LucideIcons.globe;
// ── 导航 / 操作 ──
static const settings = LucideIcons.settings;
static const user = LucideIcons.user;
static const search = LucideIcons.search;
static const check = LucideIcons.check;
static const checkCircle = LucideIcons.checkCircle;
static const x = LucideIcons.x;
static const chevronRight = LucideIcons.chevronRight;
static const chevronDown = LucideIcons.chevronDown;
static const arrowLeft = LucideIcons.arrowLeft;
static const arrowUp = LucideIcons.arrowUp;
static const arrowDown = LucideIcons.arrowDown;
static const refreshCw = LucideIcons.refreshCw;
static const compass = LucideIcons.compass;
static const barChart = LucideIcons.barChart;
static const mapPin = LucideIcons.mapPin;
// ── 主题 ──
static const moon = LucideIcons.moon;
static const sun = LucideIcons.sun;
// ── 账户 / 套餐 / 兑换 ──
static const crown = LucideIcons.crown;
static const ticket = LucideIcons.ticket;
static const creditCard = LucideIcons.creditCard;
static const shoppingBag = LucideIcons.shoppingBag;
static const mail = LucideIcons.mail;
static const lock = LucideIcons.lock;
static const logOut = LucideIcons.logOut;
static const send = LucideIcons.send;
static const messageCircle= LucideIcons.messageCircle;
static const externalLink = LucideIcons.externalLink;
// ── 设备 ──
static const laptop = LucideIcons.laptop;
static const smartphone = LucideIcons.smartphone;
static const monitorSmartphone = LucideIcons.smartphone; // 回退
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,
};
}
+58
View File
@@ -0,0 +1,58 @@
// pangolin_logo.dart — 品牌标 / 字标 / App 图标(SVG)
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;
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);
}
}
/// 横版字标锁版(浅背景)
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」
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, fontWeight: FontWeight.w700)),
]);
}
}
+13
View File
@@ -0,0 +1,13 @@
// pangolin_widgets.dart — 统一导出穿山甲 VPN 核心组件。
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';
+110
View File
@@ -0,0 +1,110 @@
// plan_card.dart — 套餐卡(免费 / 专业渐变高亮 / 团队)
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)),
),
),
),
]);
}
}
+50
View File
@@ -0,0 +1,50 @@
// server_tile.dart — 服务器列表行
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)],
],
),
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
// status_pill.dart — 状态胶囊(色点 + 文字)
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)),
],
),
);
}
}