f97ae8186b
对照 design/ui_kits mobile+tablet 原型逐屏补齐 Flutter 客户端: - l10n 资源层(strings_zh/en,单显)+ Riverpod 状态层(连接状态机/免费额度/ 节点选择/语言/主题),UI 与数据解耦,mock 数据接 API 不动 UI。 - 连接键严格三态(off 虚线轨道环 / connecting 旋转弧 / on 满环+计时+光晕), 状态来自 connectionProvider,点击只派发事件——禁止乐观显示。 - 节点页置顶「智能选择」推荐卡(clay 渐变 zap + 推荐胶囊,默认选中); 免费额度卡(剩余分钟+进度条 ≤3 分钟切 warning + 看广告解锁变绿)。 - Tab 左右滑动切换(手势竞技场仲裁,子页滚动不误触发,200ms 方向感知滑入)。 - iPad/宽屏 ≥900 LayoutBuilder 切侧栏分栏(导航行高 ≥48,连接页双栏/节点双列网格), 复用同一批原子组件,不 fork 页面。 - 语义 token 零硬编码;文案全部走 l10n;套餐数字对齐 §7;无支付表单/emoji 国旗。 - CI 红线词扫描扩展到 client/lib;新增 Flutter analyze+test CI 任务。 - 测试:连接状态机/额度/节点单测、连接键三态与卡片组件测试、 golden(连接键三态/推荐卡/额度卡 × 明暗)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
322 lines
13 KiB
Dart
322 lines
13 KiB
Dart
// pangolin_theme.dart
|
|
// 穿山甲 · Pangolin — 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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|