828bb4ca94
真相源/主题里原无 toast 定义,SnackBar 用 Material 默认黑底,明亮主题下突兀。 PangolinTheme 加 snackBarTheme:surface 底 + fg1 字 + 描边圆角(与卡片同风格)、floating, 浅色得浅 toast、深色得深 toast。自动连接的 toast 随之适配。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
320 lines
14 KiB
Dart
320 lines
14 KiB
Dart
// pangolin_theme.dart
|
||
// 穿山甲 · Pangolin — Flutter theme implementation
|
||
//
|
||
// Token 数值层(颜色/间距/圆角/动效/阴影)由 codegen 自动维护,见 pangolin_tokens.gen.dart。
|
||
// 本文件只保留 Flutter 实现层:
|
||
// · PangolinScheme — ThemeExtension(语义色映射 + light/dark 实例)
|
||
// · PangolinContext — BuildContext 扩展
|
||
// · PangolinFonts — 字族标签常量
|
||
// · PangolinText — 本地打包字型(生产 useBundled,离线;测试经 FontLoader)
|
||
// · 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';
|
||
|
||
/// 自定义容器(自带圆角/边框/聚焦态)里嵌 [TextField] 时用此装饰:
|
||
/// 彻底关掉主题 [inputDecorationTheme] 的边框/填充泄漏。
|
||
///
|
||
/// 坑:主题里设了 `focusedBorder: OutlineInputBorder(accent)` + `filled:true`。
|
||
/// 字段只写 `border: InputBorder.none` **不覆盖 focusedBorder** 时,聚焦瞬间主题的
|
||
/// 橙色 OutlineInputBorder + 填充会画到内层 TextField 上,与外层容器圆角错位/被裁
|
||
/// (登录/注册的邮箱·密码·验证码、节点页搜索框聚焦时左右各缺一截即此因)。
|
||
/// 聚焦指示交给外层容器自己负责(如 [Border.all(accent)])。
|
||
///
|
||
/// `filled: false`:同时关掉主题的 `filled:true / fillColor:bg`。否则内层 TextField
|
||
/// 会用 `bg` 自铺一层底,与外层容器底色(如搜索框 `bgSubtle`)不一致——图标与左右
|
||
/// 内边距露出容器色、中间是 `bg`,出现两端一色中间另一色的接缝。底色统一交给外层容器。
|
||
const InputDecoration kBareInput = InputDecoration(
|
||
isCollapsed: true,
|
||
filled: false,
|
||
border: InputBorder.none,
|
||
enabledBorder: InputBorder.none,
|
||
focusedBorder: InputBorder.none,
|
||
disabledBorder: InputBorder.none,
|
||
errorBorder: InputBorder.none,
|
||
focusedErrorBorder: InputBorder.none,
|
||
);
|
||
|
||
/// ── 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 ───────────────────────────────────────────────────
|
||
/// 生产用本地打包字体([useBundled]=true,pubspec fonts: 段,离线);保留的
|
||
/// [useBundled]=false 分支走 google_fonts 运行时加载,仅作回退/对照。测试态由
|
||
/// flutter_test_config 经 FontLoader 注册同名 family。封装函数是 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:用本地打包 ttf(pubspec 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 locally-bundled fonts via [PangolinFonts] (production `useBundled=true`,
|
||
/// offline; tests register fonts via FontLoader). Members are static getters
|
||
/// (not const) so they route through the [PangolinFonts] wrapper functions.
|
||
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),
|
||
),
|
||
),
|
||
// toast / SnackBar:跟随明暗主题(surface 底 + fg1 字 + 描边圆角,与卡片同风格),
|
||
// 不再用 Material 默认的突兀黑底。
|
||
snackBarTheme: SnackBarThemeData(
|
||
backgroundColor: s.surface,
|
||
contentTextStyle: PangolinFonts.manrope(fontWeight: FontWeight.w600, fontSize: 14)
|
||
.copyWith(color: s.fg1),
|
||
behavior: SnackBarBehavior.floating,
|
||
elevation: 6,
|
||
shape: RoundedRectangleBorder(
|
||
side: BorderSide(color: s.border),
|
||
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
||
),
|
||
),
|
||
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),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|