Files
pangolin/client/lib/pangolin_theme.dart
T
wangjia 6fc032bac8 feat(client): 字体改本地打包(拉丁全量 + Noto Sans SC GB2312 子集)
生产 PangolinFonts.useBundled=true,离线可用,不再运行时拉 google_fonts;
中文经 fontFamilyFallback=[Noto Sans SC] 兜底,子集外字符再兜系统 CJK 字体。
- 拉丁:Sora/Manrope/JetBrains Mono 静态权重(复用 test/fonts)。
- 中文:Noto Sans SC 变量字体 instance 到 Regular + subset 到 GB2312 6763 字
  (单权重,粗体引擎合成),client/fonts 合计约 3.4MB。
- tools/fonts/make-cjk-subset.sh 可复现生成(GB2312 字表无需联网)。
- 新增 test/unit/fonts_test.dart 锁定接线;golden 因 notdef 占位字形微调已更新。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 07:48:21 +08:00

283 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// pangolin_theme.dart
// 穿山甲 · Pangolin — Flutter theme implementation
//
// Token 数值层(颜色/间距/圆角/动效/阴影)由 codegen 自动维护,见 pangolin_tokens.gen.dart。
// 本文件只保留 Flutter 实现层:
// · PangolinScheme — ThemeExtension(语义色映射 + light/dark 实例)
// · PangolinContext — BuildContext 扩展
// · PangolinFonts — 字族标签常量
// · PangolinText — google_fonts 运行时字型
// · PangolinTheme — ThemeData 组装
//
// Usage:
// MaterialApp(
// theme: PangolinTheme.light,
// darkTheme: PangolinTheme.dark,
// themeMode: ThemeMode.system,
// );
// // tokens: context.pangolin.accent, PangolinSpacing.x4, PangolinRadius.lg ...
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
// token 数值层:import 供本文件使用,export 让调用方 import pangolin_theme.dart 时也能访问。
import 'pangolin_tokens.gen.dart';
export 'pangolin_tokens.gen.dart';
/// ── 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>()!;
}
/// ── Font families ───────────────────────────────────────────────────
/// 生产用 google_fonts 运行时加载;测试态设 [useBundled]=true,改用本地
/// FontLoader 注册的同名 family(避免 google_fonts 在无网/禁拉取时抛异常,
/// 让 golden 截图回归可跑)。封装函数是 PangolinText/_build 的唯一字体入口。
class PangolinFonts {
PangolinFonts._();
static const display = 'Sora';
static const sans = 'Manrope';
static const cjk = 'Noto Sans SC';
static const mono = 'JetBrains Mono';
/// 生产默认 true:用本地打包 ttfpubspec fonts: 段),离线可用,不走 google_fonts
/// 运行时拉取。测试态由 flutter_test_config.dart 经 FontLoader 注册 test/fonts/。
/// 中文经 [cjk]Noto Sans SC 子集)走 fontFamilyFallback;子集外字符再兜系统 CJK 字体。
static bool useBundled = true;
static TextStyle sora({double? fontSize, FontWeight? fontWeight, double? height, double? letterSpacing}) =>
useBundled
? TextStyle(fontFamily: display, fontFamilyFallback: const [cjk], fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing)
: GoogleFonts.sora(fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing);
static TextStyle manrope(
{double? fontSize, FontWeight? fontWeight, double? height, double? letterSpacing, List<FontFeature>? fontFeatures}) =>
useBundled
? TextStyle(
fontFamily: sans,
fontFamilyFallback: const [cjk],
fontSize: fontSize,
fontWeight: fontWeight,
height: height,
letterSpacing: letterSpacing,
fontFeatures: fontFeatures)
: GoogleFonts.manrope(
fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing, fontFeatures: fontFeatures);
static TextStyle jetBrainsMono({double? fontSize, FontWeight? fontWeight, List<FontFeature>? fontFeatures}) =>
useBundled
? TextStyle(fontFamily: mono, fontFamilyFallback: const [cjk], fontSize: fontSize, fontWeight: fontWeight, fontFeatures: fontFeatures)
: GoogleFonts.jetBrainsMono(fontSize: fontSize, fontWeight: fontWeight, fontFeatures: fontFeatures);
static TextTheme manropeTextTheme(TextTheme base) =>
useBundled ? base.apply(fontFamily: sans, fontFamilyFallback: const [cjk]) : GoogleFonts.manropeTextTheme(base);
}
/// ── 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 => PangolinFonts.sora(
fontSize: 48, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.96);
static TextStyle get display => PangolinFonts.sora(
fontSize: 36, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.72);
static TextStyle get h1 => PangolinFonts.sora(
fontSize: 30, fontWeight: FontWeight.w600, height: 1.3, letterSpacing: -0.3);
static TextStyle get h2 => PangolinFonts.sora(
fontSize: 24, fontWeight: FontWeight.w600, height: 1.3, letterSpacing: -0.24);
static TextStyle get h3 => PangolinFonts.manrope(
fontSize: 20, fontWeight: FontWeight.w600, height: 1.3);
static TextStyle get bodyLg => PangolinFonts.manrope(
fontSize: 18, fontWeight: FontWeight.w400, height: 1.65);
static TextStyle get body => PangolinFonts.manrope(
fontSize: 16, fontWeight: FontWeight.w400, height: 1.5);
static TextStyle get sm => PangolinFonts.manrope(
fontSize: 14, fontWeight: FontWeight.w400, height: 1.5);
static TextStyle get caption => PangolinFonts.manrope(
fontSize: 12, fontWeight: FontWeight.w500, height: 1.5);
static TextStyle get overline => PangolinFonts.manrope(
fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.96);
static TextStyle get mono => PangolinFonts.jetBrainsMono(
fontSize: 14, fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()]);
}
/// ── 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,
// Manrope app-wide base font(生产 google_fonts / 测试 bundled
textTheme: PangolinFonts.manropeTextTheme(
ThemeData(brightness: b).textTheme,
),
extensions: [s],
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: s.accent,
foregroundColor: s.fgOnAccent,
textStyle: PangolinFonts.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),
),
),
);
}
}