Files
pangolin/client/lib/pangolin_theme.dart
T
wangjia 6249d5b2ea refactor(client): 抽公用 PangolinFieldBox 统一输入框 + 根治填充两色接缝
根因:主题 inputDecorationTheme 设了 filled:true/fillColor:bg,自定义容器里嵌
TextField 时内层自铺 bg 底,与容器底色(搜索框 bgSubtle / 登录 surface)不一致 →
两端一色中间另一色接缝(节点搜索框最明显,登录/兑换框也有隐患)。

- kBareInput 加 filled:false:内层 TextField 不再自铺底,底色统一交给外层容器。
- 新增 widgets/pangolin_field.dart:PangolinFieldBox(圆角容器 + 底色 + 可选描边/
  聚焦光环 + 标签 + 前置图标 + 后置 widget)+ bareInputDecoration() 助手。
- 三处输入框统一改用:节点搜索框、登录/注册字段(_field)、兑换码输入框。
- 重生成 auth_login(dark/light)+ desktop_redeem golden(均人工核对外观正确)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 12:29:53 +08:00

307 lines
14 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 — 本地打包字型(生产 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]=truepubspec 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:用本地打包 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 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),
),
),
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),
),
),
);
}
}