feat(ui): Flutter 正式深色皮肤(ThemeData 层)与显式偏好装配
- theme.dart 新增 PmonDarkColors:转录 CSS dark 块 30 键到 26 个常量 (含两处反相 text-on-accent #0B0E14 / snackbar-bg #F2F4F9), 28 个 Web 专属键登记 kDarkExemptTokens 豁免;几何/排印/动效/端侧派生/ E-Ink 六色不进 Dart,沿用 PmonSpace/PmonRadius/PmonText,不新增刻度 - buildPmonTheme() 重构为 _assemble(palette, brightness), buildPmonDarkTheme() 复用同一控件装配,只在 theme.dart 内 new ThemeData; 浅色分支 fromSeed 逐字段未变,深色分支 copyWith 使 colorScheme.primary 严格等于 #6E9CFF - main.dart 装 darkTheme + themeMode(顶层 ValueNotifier,默认 light, 不用 ThemeMode.system),采集页加最小切换入口 - AppConfig.themeMode 持久化(零依赖 JSON,值域 light|dark,默认不预写键) - 护栏:theme_guard C-15 改为 C-15'(允许深色但要求 58 键同名、 不污染 E-Ink、不新增刻度、浅色 18 原色未动);token_parity 新增深色 parity 组(覆盖 58 / 转录逐项相等 / 反向缺项 / 豁免缺席); 新增 client/test/dark_theme_test.dart 并登记进 scripts/test.sh 关键约束:深色 tertiary 保持 #8A93A3,显式不同步浅色点修值 #646D7D; 浅色 18 品牌原色与 Core 45 项一个不动;E-Ink 仍纯黑白 / 圆角 0 / 无动画, 看板以 Theme(data: einkTheme()) 局部覆盖,与 themeMode 解耦。 验证:Dart 全量 +627 All tests passed(基线 610,新增 17)、 定向 35/35、run-guards.sh 5 套件 PASS 5 / FAIL 0、dart analyze 9 issues (2 warning + 7 info,0 error,与基线一致)。 已知边界:本轮仅 ThemeData 层深色皮肤。页面自绘卡片/文字仍直接引用静态 PmonColors.* / PmonText.* / PmonCard.*(11 / 9 / 4 个文件),不走 ThemeData, 深色下整屏仍呈浅色;整屏页面级深色化需改为 Theme.of(context) 查询, 属独立后续阶段。Web localStorage 与 Flutter AppConfig 为两端独立持久化。
This commit is contained in:
1 parent
61f044a752
commit
0d332f6e9a
8 files changed
+748
-71
No files matched your search
@@ -77,6 +77,7 @@ class AppConfig {
|
||||
this.einkMode = false,
|
||||
this.einkRefreshSec = 10,
|
||||
this.einkLandscape = true,
|
||||
this.themeMode = 'light',
|
||||
this.configVersion = kConfigVersion,
|
||||
});
|
||||
|
||||
@@ -143,6 +144,17 @@ class AppConfig {
|
||||
/// 用户可随时在看板里「转屏」切换,选择会被记住。
|
||||
final bool einkLandscape;
|
||||
|
||||
/// 界面主题偏好,值域 `'light' | 'dark'`,默认 `'light'`。
|
||||
///
|
||||
/// 由应用内**显式切换**驱动([ThemeMode] 由它换算),**不跟随系统亮度** ——
|
||||
/// 与 Web 侧「`html[data-iw-theme]` 显式控制、不写 prefers-color-scheme」一致。
|
||||
///
|
||||
/// 持久化差异:Web 用 `localStorage['inkwatch.theme']`,Flutter 用本 AppConfig
|
||||
/// 的零依赖 JSON 配置,两者是不同端的独立持久化,不强行统一。
|
||||
/// 默认 `'light'` 时 [toJson] **不预写 `theme_mode` 键**(与 Web 侧默认不 set
|
||||
/// localStorage 同义),只有用户切到 dark 才落盘该键。
|
||||
final String themeMode;
|
||||
|
||||
/// 写这份文件时所用的配置格式版本(见 [kConfigVersion])。
|
||||
/// 磁盘上缺失该键时读作 0 → 触发一次迁移(见 `config_migration.dart`)。
|
||||
final int configVersion;
|
||||
@@ -162,6 +174,7 @@ class AppConfig {
|
||||
bool? einkMode,
|
||||
int? einkRefreshSec,
|
||||
bool? einkLandscape,
|
||||
String? themeMode,
|
||||
int? configVersion,
|
||||
}) {
|
||||
return AppConfig(
|
||||
@@ -180,6 +193,7 @@ class AppConfig {
|
||||
einkMode: einkMode ?? this.einkMode,
|
||||
einkRefreshSec: einkRefreshSec ?? this.einkRefreshSec,
|
||||
einkLandscape: einkLandscape ?? this.einkLandscape,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
configVersion: configVersion ?? this.configVersion,
|
||||
);
|
||||
}
|
||||
@@ -200,6 +214,8 @@ class AppConfig {
|
||||
'eink_mode': einkMode,
|
||||
'eink_refresh_sec': einkRefreshSec,
|
||||
'eink_landscape': einkLandscape,
|
||||
// 默认 light 不预写键(与 Web 侧 localStorage 默认不 set 同义)
|
||||
if (themeMode != 'light') 'theme_mode': themeMode,
|
||||
};
|
||||
|
||||
static AppConfig fromJson(Map<String, dynamic> j) => AppConfig(
|
||||
@@ -217,6 +233,7 @@ class AppConfig {
|
||||
einkMode: j['eink_mode'] as bool? ?? false,
|
||||
einkRefreshSec: (j['eink_refresh_sec'] as num?)?.toInt() ?? 10,
|
||||
einkLandscape: j['eink_landscape'] as bool? ?? true,
|
||||
themeMode: j['theme_mode'] as String? ?? 'light',
|
||||
// 缺失按 0:老版本写的文件没有这个键,正是要触发迁移的信号
|
||||
configVersion: (j['config_version'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
@@ -133,6 +133,14 @@ ConfigNormalization normalizeConfig(AppConfig raw, {int fromVersion = 0}) {
|
||||
einkSec = kDefaultEinkRefreshSec;
|
||||
}
|
||||
|
||||
// ── 主题偏好:值域严格 'light' | 'dark',非法值回落浅色 ──
|
||||
// 与 Web 侧 html[data-iw-theme] 的显式取值一致,不接受系统亮度或任意字符串。
|
||||
var themeMode = raw.themeMode;
|
||||
if (themeMode != 'light' && themeMode != 'dark') {
|
||||
fixes.add('主题偏好「$themeMode」不是 light/dark,已回落浅色');
|
||||
themeMode = 'light';
|
||||
}
|
||||
|
||||
// ── 身份类字段(account_id / 令牌 / 邮箱):**一律原样保留** ──
|
||||
// 它们的"坏"只会变成一次普通的鉴权失败(服务端会明确拒绝),
|
||||
// 而误清有效凭据的代价是让用户重新登录 + 重新认领。
|
||||
@@ -167,6 +175,7 @@ ConfigNormalization normalizeConfig(AppConfig raw, {int fromVersion = 0}) {
|
||||
lastViewedDeviceId: lastViewed,
|
||||
samplingIntervalMs: interval,
|
||||
einkRefreshSec: einkSec,
|
||||
themeMode: themeMode,
|
||||
configVersion: kConfigVersion,
|
||||
),
|
||||
fixes,
|
||||
|
||||
+59
-8
@@ -63,22 +63,60 @@ Future<void> main() async {
|
||||
});
|
||||
// 掉帧诊断(默认关;`--dart-define=PMON_LOG_JANK=true` 打开,见 platform/frame_jank_log.dart)
|
||||
installJankLogger();
|
||||
// 主题偏好是显式的应用内设置(不跟随系统):runApp 前读一次,
|
||||
// 避免首屏先闪一下浅色再跳到用户上次选的深色。ConfigStore.load()
|
||||
// 自带兜底、不抛异常,故不存在"卡在启动"的路径。
|
||||
final initialCfg = await ConfigStore.load();
|
||||
appThemeMode.value = themeModeFromConfig(initialCfg.themeMode);
|
||||
runApp(const PmonApp());
|
||||
}
|
||||
|
||||
/// 应用内**显式**主题偏好 —— 不跟随系统亮度。
|
||||
///
|
||||
/// 与 Web 侧「`html[data-iw-theme]` 显式控制、不写 prefers-color-scheme」一致:
|
||||
/// 这里不采用系统跟随模式(不取 ThemeMode 的 system 值)。默认 light,
|
||||
/// 切换后由 [setAppThemeMode] 落盘到 `AppConfig.themeMode`
|
||||
/// (零依赖 JSON 配置,不引入 shared_preferences)。
|
||||
final ValueNotifier<ThemeMode> appThemeMode =
|
||||
ValueNotifier<ThemeMode>(ThemeMode.light);
|
||||
|
||||
/// 把配置里的 `'light' | 'dark'` 换算成 [ThemeMode](不采用系统跟随模式)。
|
||||
ThemeMode themeModeFromConfig(String v) =>
|
||||
v == 'dark' ? ThemeMode.dark : ThemeMode.light;
|
||||
|
||||
/// 切换深浅并持久化。先读回磁盘最新值再 copyWith —— 避免把别的页刚写的字段
|
||||
/// 整份抹掉(与采集页 `_persist` / 查看页同一纪律)。
|
||||
Future<void> setAppThemeMode(ThemeMode mode) async {
|
||||
appThemeMode.value = mode;
|
||||
try {
|
||||
final onDisk = await ConfigStore.load();
|
||||
await ConfigStore.save(onDisk.copyWith(
|
||||
themeMode: mode == ThemeMode.dark ? 'dark' : 'light',
|
||||
));
|
||||
} catch (_) {
|
||||
// 存不下不该让"本次切换"失效
|
||||
}
|
||||
}
|
||||
|
||||
class PmonApp extends StatelessWidget {
|
||||
const PmonApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: '墨观',
|
||||
debugShowCheckedModeBanner: false,
|
||||
// 主题必须**只有一处装配**(ui/theme.dart):
|
||||
// 否则系统控件(输入框/菜单/对话框/SnackBar)与我们自绘的卡片会各是一套色与圆角,
|
||||
// 表现为"弹窗像另一个应用"。旧版这里是就地 new 了一个只有 seedColor 的 ThemeData。
|
||||
theme: buildPmonTheme(),
|
||||
home: const HomeShell(),
|
||||
return ValueListenableBuilder<ThemeMode>(
|
||||
valueListenable: appThemeMode,
|
||||
builder: (context, mode, _) => MaterialApp(
|
||||
title: '墨观',
|
||||
debugShowCheckedModeBanner: false,
|
||||
// 主题必须**只有一处装配**(ui/theme.dart):
|
||||
// 否则系统控件(输入框/菜单/对话框/SnackBar)与我们自绘的卡片会各是一套色与圆角,
|
||||
// 表现为"弹窗像另一个应用"。旧版这里是就地 new 了一个只有 seedColor 的 ThemeData。
|
||||
theme: buildPmonTheme(),
|
||||
darkTheme: buildPmonDarkTheme(),
|
||||
// 由应用内显式偏好驱动,不采用系统跟随模式。
|
||||
themeMode: mode,
|
||||
home: const HomeShell(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -771,6 +809,19 @@ class _ControlPanelPageState extends State<ControlPanelPage>
|
||||
titleSpacing: PmonSpace.lg,
|
||||
title: const Text('墨观 采集端'),
|
||||
actions: [
|
||||
// 主题切换(最小入口):应用内显式偏好,不跟随系统。
|
||||
// 图标随当前 brightness 取太阳/月亮;setAppThemeMode 会落盘到 AppConfig。
|
||||
IconButton(
|
||||
tooltip: '切换深色 / 浅色',
|
||||
icon: Icon(Theme.of(context).brightness == Brightness.dark
|
||||
? Icons.light_mode_outlined
|
||||
: Icons.dark_mode_outlined),
|
||||
onPressed: () => unawaited(setAppThemeMode(
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? ThemeMode.light
|
||||
: ThemeMode.dark,
|
||||
)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: PmonSpace.lg),
|
||||
child: Center(child: _badge(core.platformName)),
|
||||
|
||||
+189
-36
@@ -99,6 +99,70 @@ abstract final class PmonColors {
|
||||
static const gap = Color(0xFFB6BCC8);
|
||||
}
|
||||
|
||||
/// 深色皮肤语义色 —— 与管理台唯一真相源 `tokens.css` 的
|
||||
/// `html[data-iw-theme="dark"]` 块(05 §1.4,58 个同名键覆写)一一对应。
|
||||
///
|
||||
/// ── 只转录「Flutter 端确实会消费」的键 ─────────────────────────────
|
||||
/// 颜色 / 状态 / 图表 / 品牌 / 反馈里,凡 Web 专属的交互态与预混实色一律
|
||||
/// **保持缺省、不写进 Dart**,理由与浅色侧 `kWebOnlyTokens` 完全一致:
|
||||
/// · hover/active/五种 `*-border` 预混实色 → Flutter 用 Material `WidgetState`
|
||||
/// 处理交互态、用 `withValues(alpha:)` 动态算描边(见 [PmonCard.notice]);
|
||||
/// · `series-2/3/4`、`chart-area/grid/axis`、`*-gap-band/threshold-*` →
|
||||
/// `viewer_chart.dart` 只画单序列,无多序列 / 预混面积需求;
|
||||
/// · `logo-*` 四色 → Dart 侧没有消费点;
|
||||
/// · `mask` / `overlay-shadow` / `focus-ring` 是 CSS 复合值,Flutter 无对应概念。
|
||||
/// 未转录的 28 个键在 `test/token_parity_test.dart` 的深色豁免表里逐项登记
|
||||
/// (反向缺项判红),防止有人"好心"补一套用不到的常量。
|
||||
///
|
||||
/// 几何 / 排印 / 描边宽度 / 动效 / 端侧派生 / E-Ink 六色**本就不在 dark 块里**
|
||||
/// (05 §1.4「应保持缺省」),Flutter 已有 `PmonSpace` / `PmonRadius` /
|
||||
/// `PmonText` 语义档位,一套都不新增。
|
||||
///
|
||||
/// ⚠️ [textTertiary] 保持 `#8A93A3`(深底 5.02–6.24:1 达标),**显式不同步**
|
||||
/// 浅色点修值 `#646D7D` —— 同步会让深底对比跌到 2.98–3.70:1,制造新违规。
|
||||
abstract final class PmonDarkColors {
|
||||
// ── 中性面(--iw-page / surface / surface-muted / line)──────────
|
||||
static const page = Color(0xFF0B0E14);
|
||||
static const surface = Color(0xFF151A23);
|
||||
static const line = Color(0xFF333B4C);
|
||||
static const surfaceMuted = Color(0xFF1E2430);
|
||||
|
||||
// ── 文字(--iw-text-primary / secondary / tertiary / on-accent)──
|
||||
static const textPrimary = Color(0xFFF2F4F9);
|
||||
static const textSecondary = Color(0xFFB9C2D0);
|
||||
static const textTertiary = Color(0xFF8A93A3);
|
||||
/// D3 反相:亮蓝主色底上的文字改深墨色(7.23:1)
|
||||
static const textOnAccent = Color(0xFF0B0E14);
|
||||
|
||||
// ── 主色(--iw-primary / primary-soft / info-text)───────────────
|
||||
static const primary = Color(0xFF6E9CFF);
|
||||
static const primarySoft = Color(0xFF18243D);
|
||||
|
||||
// ── 状态(--iw-online / suspect / offline / danger 各 base+soft+text)──
|
||||
static const online = Color(0xFF5CE0A0);
|
||||
static const onlineSoft = Color(0xFF0D2117);
|
||||
static const onlineText = Color(0xFF9AEAC3);
|
||||
static const suspect = Color(0xFFD8790E);
|
||||
static const suspectSoft = Color(0xFF261B0D);
|
||||
static const suspectText = Color(0xFFF2A32C);
|
||||
static const offline = Color(0xFF9CA3AF);
|
||||
static const offlineText = Color(0xFFB9C2D0);
|
||||
static const unknownText = Color(0xFFB9C2D0);
|
||||
static const danger = Color(0xFFF87171);
|
||||
static const dangerSoft = Color(0xFF2E1518);
|
||||
static const dangerText = Color(0xFFFCA5A5);
|
||||
static const infoText = Color(0xFFA8C5FF);
|
||||
|
||||
// ── 图表(--iw-chart-line / series-1 / gap)─────────────────────
|
||||
/// 与 [primary] 同源(= `--iw-chart-line` / `--iw-series-1` 深值)
|
||||
static const chartLine = primary;
|
||||
static const gap = Color(0xFF6B7488);
|
||||
|
||||
// ── 反馈(--iw-snackbar-bg)────────────────────────────────────
|
||||
/// D3 反相:深色 Toast 底变浅,文字自动用深 [surface](15.85:1 AAA)
|
||||
static const snackbarBg = Color(0xFFF2F4F9);
|
||||
}
|
||||
|
||||
/// 间距刻度(只此七档;新增刻度前先问"是否真的需要")
|
||||
abstract final class PmonSpace {
|
||||
static const xs = 4.0;
|
||||
@@ -191,109 +255,198 @@ abstract final class PmonCard {
|
||||
|
||||
/// Material 主题装配。**只在这里 new ThemeData** —— 保证控件(输入框/菜单/SnackBar/
|
||||
/// 对话框)与自绘卡片是同一套颜色与圆角,不会出现"系统控件是旧蓝、自绘是新品"。
|
||||
ThemeData buildPmonTheme() {
|
||||
///
|
||||
/// 浅色 / 深色共用**同一段控件装配**([_assemble]),只传入不同的语义色板
|
||||
/// ([_lightPalette] / [_darkPalette])—— 不另写一套分叉的控件配置。
|
||||
/// `buildPmonDarkTheme()` 也走这里,故「只在 theme.dart 内 new ThemeData」的
|
||||
/// 规矩对深色同样成立(页面里就地 `ThemeData(` 由 theme_guard_test.dart 判红)。
|
||||
ThemeData buildPmonTheme() => _assemble(_lightPalette, Brightness.light);
|
||||
|
||||
/// 深色皮肤主题。色值来自 `tokens.css` 的 `html[data-iw-theme="dark"]`
|
||||
/// (05 §1.4 的 58 个同名键覆写),由 [PmonDarkColors] 逐项转录。
|
||||
///
|
||||
/// 装配结构与 [buildPmonTheme] 完全相同,仅语义色替换为深色板:
|
||||
/// 圆角 / 间距 / 字号刻度一律沿用 PmonSpace / PmonRadius / PmonText,
|
||||
/// 不新增任何刻度。E-Ink 看板走独立的 [einkTheme],不受本主题影响。
|
||||
ThemeData buildPmonDarkTheme() => _assemble(_darkPalette, Brightness.dark);
|
||||
|
||||
/// 控件装配用到的语义色子集。**不是新令牌** —— 只是把两套色板喂给同一段装配,
|
||||
/// 避免把 Web 的「预混实色 / 交互态」概念搬进 Dart。
|
||||
class _PmonPalette {
|
||||
const _PmonPalette({
|
||||
required this.page,
|
||||
required this.surface,
|
||||
required this.line,
|
||||
required this.surfaceMuted,
|
||||
required this.textPrimary,
|
||||
required this.textSecondary,
|
||||
required this.textTertiary,
|
||||
required this.textOnAccent,
|
||||
required this.primary,
|
||||
required this.primarySoft,
|
||||
required this.snackbarBg,
|
||||
required this.snackbarText,
|
||||
});
|
||||
|
||||
final Color page;
|
||||
final Color surface;
|
||||
final Color line;
|
||||
final Color surfaceMuted;
|
||||
final Color textPrimary;
|
||||
final Color textSecondary;
|
||||
final Color textTertiary;
|
||||
final Color textOnAccent;
|
||||
final Color primary;
|
||||
final Color primarySoft;
|
||||
final Color snackbarBg;
|
||||
final Color snackbarText;
|
||||
}
|
||||
|
||||
const _lightPalette = _PmonPalette(
|
||||
page: PmonColors.page,
|
||||
surface: PmonColors.surface,
|
||||
line: PmonColors.line,
|
||||
surfaceMuted: PmonColors.surfaceMuted,
|
||||
textPrimary: PmonColors.textPrimary,
|
||||
textSecondary: PmonColors.textSecondary,
|
||||
textTertiary: PmonColors.textTertiary,
|
||||
textOnAccent: Colors.white,
|
||||
primary: PmonColors.primary,
|
||||
primarySoft: PmonColors.primarySoft,
|
||||
// 浅色 Toast:深底白字(--iw-snackbar-bg 浅色 = textPrimary #111827)
|
||||
snackbarBg: PmonColors.textPrimary,
|
||||
snackbarText: Colors.white,
|
||||
);
|
||||
|
||||
const _darkPalette = _PmonPalette(
|
||||
page: PmonDarkColors.page,
|
||||
surface: PmonDarkColors.surface,
|
||||
line: PmonDarkColors.line,
|
||||
surfaceMuted: PmonDarkColors.surfaceMuted,
|
||||
textPrimary: PmonDarkColors.textPrimary,
|
||||
textSecondary: PmonDarkColors.textSecondary,
|
||||
textTertiary: PmonDarkColors.textTertiary,
|
||||
textOnAccent: PmonDarkColors.textOnAccent,
|
||||
primary: PmonDarkColors.primary,
|
||||
primarySoft: PmonDarkColors.primarySoft,
|
||||
// D3 反相:深色 Toast 底 = 浅色 #F2F4F9,文字改用深 surface(15.85:1 AAA)
|
||||
snackbarBg: PmonDarkColors.snackbarBg,
|
||||
snackbarText: PmonDarkColors.surface,
|
||||
);
|
||||
|
||||
ThemeData _assemble(_PmonPalette p, Brightness brightness) {
|
||||
final baseScheme = ColorScheme.fromSeed(
|
||||
seedColor: p.primary,
|
||||
brightness: brightness,
|
||||
surface: p.surface,
|
||||
);
|
||||
// 深底必须让主色严格落在语义令牌上:fromSeed 会把 primary 推到 tonal 近似值
|
||||
// (实测 #6E9CFF → #AFC6FF),FilledButton / 选中态就会用到非令牌蓝。
|
||||
// 浅色分支保持 fromSeed 原始结果,确保 buildPmonTheme() 逐字段不变。
|
||||
final colorScheme = brightness == Brightness.dark
|
||||
? baseScheme.copyWith(
|
||||
primary: p.primary,
|
||||
onPrimary: p.textOnAccent,
|
||||
onSurface: p.textPrimary,
|
||||
)
|
||||
: baseScheme;
|
||||
final base = ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: PmonColors.primary,
|
||||
brightness: Brightness.light,
|
||||
surface: PmonColors.surface,
|
||||
),
|
||||
scaffoldBackgroundColor: PmonColors.page,
|
||||
brightness: brightness,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: p.page,
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
);
|
||||
|
||||
return base.copyWith(
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: PmonColors.surface,
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: p.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
foregroundColor: PmonColors.textPrimary,
|
||||
foregroundColor: p.textPrimary,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: PmonText.title,
|
||||
titleTextStyle: PmonText.title.copyWith(color: p.textPrimary),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: PmonColors.line, thickness: 1, space: 1),
|
||||
dividerTheme: DividerThemeData(color: p.line, thickness: 1, space: 1),
|
||||
cardTheme: base.cardTheme.copyWith(
|
||||
color: PmonColors.surface,
|
||||
color: p.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(PmonRadius.md),
|
||||
side: const BorderSide(color: PmonColors.line),
|
||||
side: BorderSide(color: p.line),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: PmonColors.surface,
|
||||
fillColor: p.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: PmonSpace.md, vertical: PmonSpace.md),
|
||||
hintStyle: PmonText.caption,
|
||||
hintStyle: PmonText.caption.copyWith(color: p.textTertiary),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(PmonRadius.sm),
|
||||
borderSide: const BorderSide(color: PmonColors.line),
|
||||
borderSide: BorderSide(color: p.line),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(PmonRadius.sm),
|
||||
borderSide: const BorderSide(color: PmonColors.line),
|
||||
borderSide: BorderSide(color: p.line),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(PmonRadius.sm),
|
||||
borderSide: const BorderSide(color: PmonColors.primary, width: 1.5),
|
||||
borderSide: BorderSide(color: p.primary, width: 1.5),
|
||||
),
|
||||
),
|
||||
snackBarTheme: base.snackBarTheme.copyWith(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: PmonColors.textPrimary,
|
||||
contentTextStyle: const TextStyle(fontSize: 13, color: Colors.white),
|
||||
backgroundColor: p.snackbarBg,
|
||||
contentTextStyle: TextStyle(fontSize: 13, color: p.snackbarText),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(PmonRadius.sm)),
|
||||
),
|
||||
popupMenuTheme: base.popupMenuTheme.copyWith(
|
||||
color: PmonColors.surface,
|
||||
color: p.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(PmonRadius.sm),
|
||||
side: const BorderSide(color: PmonColors.line),
|
||||
side: BorderSide(color: p.line),
|
||||
),
|
||||
textStyle: PmonText.body,
|
||||
textStyle: PmonText.body.copyWith(color: p.textPrimary),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: PmonColors.surface,
|
||||
backgroundColor: p.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
// 选中态用主色浅底 + 主色图标/文字。
|
||||
// 不设的话 M3 会按 seed 自动生成一个偏紫的 secondaryContainer,
|
||||
// 与我们的蓝主色不搭(真机截图里底部导航是紫色的)。
|
||||
indicatorColor: PmonColors.primarySoft,
|
||||
indicatorColor: p.primarySoft,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) => TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: states.contains(WidgetState.selected)
|
||||
? PmonColors.primary
|
||||
: PmonColors.textTertiary,
|
||||
? p.primary
|
||||
: p.textTertiary,
|
||||
)),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) => IconThemeData(
|
||||
size: 22,
|
||||
color: states.contains(WidgetState.selected)
|
||||
? PmonColors.primary
|
||||
: PmonColors.textTertiary,
|
||||
? p.primary
|
||||
: p.textTertiary,
|
||||
)),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: PmonColors.primary,
|
||||
foregroundColor: p.primary,
|
||||
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
listTileTheme: const ListTileThemeData(
|
||||
iconColor: PmonColors.textTertiary,
|
||||
textColor: PmonColors.textPrimary,
|
||||
listTileTheme: ListTileThemeData(
|
||||
iconColor: p.textTertiary,
|
||||
textColor: p.textPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ REGISTERED_TESTS=(
|
||||
clock_test.dart
|
||||
config_change_test.dart
|
||||
config_migration_test.dart
|
||||
dark_theme_test.dart
|
||||
device_merge_notice_test.dart
|
||||
device_model_test.dart
|
||||
eink_board_test.dart
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/// 深色皮肤行为测试(本阶段新增,登记进 scripts/test.sh)
|
||||
///
|
||||
/// 运行:cd client && bash scripts/test.sh test/dark_theme_test.dart
|
||||
///
|
||||
/// ── 覆盖面 ────────────────────────────────────────────────────────
|
||||
/// ① buildPmonDarkTheme() 的 brightness / colorScheme / 关键色值 / 控件语义色
|
||||
/// ② E-Ink 主题不受深色影响(纯黑白、圆角 0、无动画、看板局部覆盖解耦)
|
||||
/// ③ 浅色 buildPmonTheme() 逐字段未变(重构只是把装配参数化)
|
||||
/// ④ 主题偏好持久化(AppConfig 值域 light|dark、默认 light 不预写键)
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pmon_client/host/app_config.dart';
|
||||
import 'package:pmon_client/host/config_migration.dart';
|
||||
import 'package:pmon_client/ui/theme.dart';
|
||||
|
||||
void main() {
|
||||
group('① 深色 ThemeData', () {
|
||||
test('brightness == Brightness.dark,colorScheme 与主色一致', () {
|
||||
final t = buildPmonDarkTheme();
|
||||
expect(t.brightness, Brightness.dark);
|
||||
expect(t.colorScheme.brightness, Brightness.dark);
|
||||
// 「与主色一致」:primary 严格落在语义令牌上(不是 fromSeed 的 tonal 近似值)
|
||||
expect(t.colorScheme.primary, PmonDarkColors.primary);
|
||||
expect(t.colorScheme.primary, const Color(0xFF6E9CFF));
|
||||
expect(t.colorScheme.surface, PmonDarkColors.surface);
|
||||
expect(t.colorScheme.onSurface, PmonDarkColors.textPrimary);
|
||||
expect(t.colorScheme.onPrimary, PmonDarkColors.textOnAccent);
|
||||
});
|
||||
|
||||
test('关键色值:page / surface / textPrimary / primary / tertiary / snackbar', () {
|
||||
final t = buildPmonDarkTheme();
|
||||
// page #0B0E14
|
||||
expect(t.scaffoldBackgroundColor, PmonDarkColors.page);
|
||||
expect(t.scaffoldBackgroundColor, const Color(0xFF0B0E14));
|
||||
// surface #151A23(AppBar / 卡片 / 弹窗 / 输入底)
|
||||
expect(t.appBarTheme.backgroundColor, const Color(0xFF151A23));
|
||||
expect(t.cardTheme.color, const Color(0xFF151A23));
|
||||
expect(t.popupMenuTheme.color, PmonDarkColors.surface);
|
||||
expect(t.inputDecorationTheme.fillColor, PmonDarkColors.surface);
|
||||
// textPrimary #F2F4F9(AppBar 标题文字)
|
||||
expect(t.appBarTheme.titleTextStyle?.color, PmonDarkColors.textPrimary);
|
||||
expect(PmonDarkColors.textPrimary, const Color(0xFFF2F4F9));
|
||||
// primary #6E9CFF
|
||||
expect(t.colorScheme.primary, const Color(0xFF6E9CFF));
|
||||
// tertiary 仍 #8A93A3 —— 显式不同步浅色点修值 #646D7D
|
||||
expect(PmonDarkColors.textTertiary, const Color(0xFF8A93A3));
|
||||
expect(PmonDarkColors.textTertiary, isNot(PmonColors.textTertiary));
|
||||
// snackbar 反相 #F2F4F9:浅底必须配深字,不能白字压浅底
|
||||
expect(t.snackBarTheme.backgroundColor, PmonDarkColors.snackbarBg);
|
||||
expect(t.snackBarTheme.backgroundColor, const Color(0xFFF2F4F9));
|
||||
expect(t.snackBarTheme.contentTextStyle?.color, PmonDarkColors.surface);
|
||||
expect(t.snackBarTheme.contentTextStyle?.color, isNot(Colors.white));
|
||||
});
|
||||
|
||||
test('控件语义色一并换深色(沿用同一套装配,只替换色板)', () {
|
||||
final t = buildPmonDarkTheme();
|
||||
expect(t.dividerTheme.color, PmonDarkColors.line);
|
||||
expect(t.popupMenuTheme.textStyle?.color, PmonDarkColors.textPrimary);
|
||||
expect(t.inputDecorationTheme.hintStyle?.color,
|
||||
PmonDarkColors.textTertiary);
|
||||
expect(t.inputDecorationTheme.focusedBorder?.borderSide.color,
|
||||
PmonDarkColors.primary);
|
||||
expect(t.listTileTheme.iconColor, PmonDarkColors.textTertiary);
|
||||
expect(t.listTileTheme.textColor, PmonDarkColors.textPrimary);
|
||||
expect(t.navigationBarTheme.backgroundColor, PmonDarkColors.surface);
|
||||
expect(t.navigationBarTheme.indicatorColor, PmonDarkColors.primarySoft);
|
||||
});
|
||||
});
|
||||
|
||||
group('② E-Ink 不受深色影响', () {
|
||||
test('einkTheme() 仍是纯黑白 / Brightness.light / 无 splash', () {
|
||||
final e = einkTheme();
|
||||
expect(e.colorScheme.brightness, Brightness.light);
|
||||
expect(e.scaffoldBackgroundColor, PmonEink.page);
|
||||
expect(e.scaffoldBackgroundColor, const Color(0xFFFFFFFF));
|
||||
expect(e.colorScheme.primary, PmonEink.ink);
|
||||
expect(e.colorScheme.primary, const Color(0xFF000000));
|
||||
expect(e.cardTheme.color, PmonEink.box);
|
||||
expect(e.appBarTheme.backgroundColor, PmonEink.page);
|
||||
expect(e.splashFactory, NoSplash.splashFactory);
|
||||
expect(e.snackBarTheme.backgroundColor, PmonEink.ink);
|
||||
expect(e.snackBarTheme.backgroundColor,
|
||||
isNot(PmonDarkColors.snackbarBg));
|
||||
});
|
||||
|
||||
test('E-Ink 看板以 einkTheme() 局部覆盖主题,不读 themeMode', () {
|
||||
final src = File('lib/ui/eink_board.dart').readAsStringSync();
|
||||
expect(src.length, greaterThan(200), reason: 'eink_board.dart 读不到内容?');
|
||||
expect(src.contains('Theme('), isTrue);
|
||||
expect(src.contains('data: einkTheme()'), isTrue,
|
||||
reason: '看板自带 einkTheme(),与 MaterialApp 的 themeMode 解耦');
|
||||
expect(src.contains('PmonDarkColors'), isFalse);
|
||||
expect(src.contains('buildPmonDarkTheme'), isFalse);
|
||||
expect(src.contains('PmonColors'), isFalse,
|
||||
reason: '看板只消费 PmonEink.* / PmonEinkText.*,不碰彩色令牌');
|
||||
expect(src.contains('PmonEinkText'), isTrue);
|
||||
expect(src.contains('statusGlyph'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('③ 浅色主题未变(深色只是新增装配)', () {
|
||||
test('buildPmonTheme() 仍是 Brightness.light,且色值与重构前基线一致', () {
|
||||
final t = buildPmonTheme();
|
||||
expect(t.brightness, Brightness.light);
|
||||
expect(t.colorScheme.brightness, Brightness.light);
|
||||
expect(t.scaffoldBackgroundColor, PmonColors.page);
|
||||
// 重构前 colorScheme 由 fromSeed 派生,primary 不是主色本身。
|
||||
// 用同一公式动态取基线,钉死「浅色分支没有被 copyWith 改动」。
|
||||
final fromSeedPrimary = ColorScheme.fromSeed(
|
||||
seedColor: PmonColors.primary,
|
||||
brightness: Brightness.light,
|
||||
).primary;
|
||||
expect(t.colorScheme.primary, fromSeedPrimary);
|
||||
expect(t.colorScheme.primary, isNot(PmonColors.primary));
|
||||
expect(t.appBarTheme.backgroundColor, PmonColors.surface);
|
||||
expect(t.cardTheme.color, PmonColors.surface);
|
||||
expect(t.snackBarTheme.backgroundColor, PmonColors.textPrimary);
|
||||
expect(t.snackBarTheme.contentTextStyle?.color, Colors.white);
|
||||
// 深浅确实是两套不同的主题
|
||||
expect(buildPmonDarkTheme().brightness, isNot(t.brightness));
|
||||
});
|
||||
});
|
||||
|
||||
group('④ 偏好持久化(AppConfig,值域 light|dark,默认 light 不预写键)', () {
|
||||
test('默认值 light 且 toJson 不预写 theme_mode 键', () {
|
||||
const cfg = AppConfig();
|
||||
expect(cfg.themeMode, 'light');
|
||||
expect(cfg.toJson().containsKey('theme_mode'), isFalse,
|
||||
reason: '默认 light 不预写键(与 Web localStorage 默认不 set 同义)');
|
||||
});
|
||||
|
||||
test('dark 往返不丢 / 切回 light 不写键', () {
|
||||
const dark = AppConfig(themeMode: 'dark');
|
||||
expect(dark.toJson()['theme_mode'], 'dark');
|
||||
expect(AppConfig.fromJson(dark.toJson()).themeMode, 'dark');
|
||||
expect(AppConfig.fromJson(const AppConfig().toJson()).themeMode, 'light');
|
||||
expect(const AppConfig(themeMode: 'light')
|
||||
.toJson()
|
||||
.containsKey('theme_mode'), isFalse);
|
||||
});
|
||||
|
||||
test('非法值经 normalizeConfig 回落 light(不跟随系统 / 任意串)', () {
|
||||
final r = normalizeConfig(const AppConfig(themeMode: 'system'),
|
||||
fromVersion: kConfigVersion);
|
||||
expect(r.config.themeMode, 'light');
|
||||
expect(r.changed, isTrue);
|
||||
// 合法值零修正(正常启动不制造"已修正"噪音)
|
||||
final ok = normalizeConfig(const AppConfig(), fromVersion: kConfigVersion);
|
||||
expect(ok.changed, isFalse);
|
||||
expect(ok.config.themeMode, 'light');
|
||||
// 深色合法值原样保留
|
||||
expect(
|
||||
normalizeConfig(const AppConfig(themeMode: 'dark'),
|
||||
fromVersion: kConfigVersion).config.themeMode,
|
||||
'dark');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -205,21 +205,140 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('C-15:本轮不引入 Flutter 深色皮肤装配', () {
|
||||
// C-15'(本阶段翻转):正式深色皮肤已授权落地(05 §1 / 13 报告)。
|
||||
// 旧断言「本轮不引入深色装配」作废,换成与新事实一致的四条护栏:
|
||||
// 允许深色、键名不越出 CSS dark 块、不污染 E-Ink、不新增刻度、浅色原色未动。
|
||||
test("C-15':允许正式深色皮肤装配(不跟随系统)", () {
|
||||
final src = File('lib/ui/theme.dart').readAsStringSync();
|
||||
for (final forbidden in [
|
||||
'buildPmonDarkTheme',
|
||||
for (final required in [
|
||||
'PmonDarkColors',
|
||||
'buildPmonDarkTheme',
|
||||
'Brightness.dark',
|
||||
]) {
|
||||
expect(src.contains(required), isTrue,
|
||||
reason: '深色皮肤是本阶段正式交付(替代旧的「本轮不引入」断言)');
|
||||
}
|
||||
final main = File('lib/main.dart').readAsStringSync();
|
||||
expect(main.contains('darkTheme: buildPmonDarkTheme()'), isTrue,
|
||||
reason: 'MaterialApp 必须装配深色主题');
|
||||
expect(main.contains('themeMode:'), isTrue,
|
||||
reason: 'themeMode 必须由应用内显式偏好驱动');
|
||||
expect(main.contains('ThemeMode.system'), isFalse,
|
||||
reason: '不跟随系统亮度(与 Web 侧不写 prefers-color-scheme 一致)');
|
||||
});
|
||||
|
||||
test("C-15':深色键名与 CSS dark 块同名(无新增键名、块恰好 58 键)", () {
|
||||
// 与 token_parity_test.dart 同口径读唯一真相源(cwd=client/)。
|
||||
// 「转录 + 豁免 = 58」的逐项覆盖由 token_parity_test.dart 深色 parity 组验证,
|
||||
// 此处守住「dark 块键数」与「Dart 侧不引用块外键名」两道结构护栏。
|
||||
const cssCandidates = [
|
||||
'../../server/business/admin/assets/tokens.css',
|
||||
'../server/business/admin/assets/tokens.css',
|
||||
];
|
||||
String? css;
|
||||
for (final p in cssCandidates) {
|
||||
final f = File(p);
|
||||
if (f.existsSync()) {
|
||||
css = f.readAsStringSync();
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(css, isNotNull, reason: '找不到 tokens.css');
|
||||
final clean = css!.replaceAll(RegExp(r'/\*[\s\S]*?\*/'), '');
|
||||
final darkBody = RegExp(r'html\[data-iw-theme="dark"\]\s*\{([\s\S]*?)\}')
|
||||
.firstMatch(clean)
|
||||
?.group(1) ??
|
||||
'';
|
||||
final darkKeys = RegExp(r'(--iw-[a-z0-9-]+)\s*:')
|
||||
.allMatches(darkBody)
|
||||
.map((m) => m.group(1)!)
|
||||
.toSet();
|
||||
expect(darkKeys.length, 58, reason: '05 §1.4 的 58 个同名键覆写');
|
||||
|
||||
final src = File('lib/ui/theme.dart').readAsStringSync();
|
||||
final body = RegExp(r'class PmonDarkColors\s*\{([\s\S]*?)\n\}')
|
||||
.firstMatch(src)
|
||||
?.group(1) ??
|
||||
'';
|
||||
expect(body.length, greaterThan(200), reason: 'PmonDarkColors 读不到内容?');
|
||||
final mentioned = RegExp(r'--iw-[a-z0-9-]+')
|
||||
.allMatches(body)
|
||||
.map((m) => m.group(0)!)
|
||||
.toSet();
|
||||
final stray = mentioned.where((k) => !darkKeys.contains(k)).toList();
|
||||
expect(stray, isEmpty,
|
||||
reason: 'PmonDarkColors 引用了 dark 块之外的键名(会漏进 E-Ink 分支):\n'
|
||||
'${stray.join('\n')}');
|
||||
});
|
||||
|
||||
test("C-15':不污染 E-Ink —— einkTheme() 仍是纯黑白 / 无动画独立主题", () {
|
||||
final src = File('lib/ui/theme.dart').readAsStringSync();
|
||||
final start = src.indexOf('ThemeData einkTheme()');
|
||||
expect(start, greaterThanOrEqualTo(0), reason: 'einkTheme() 找不到?');
|
||||
// einkTheme() 是文件最后一个顶层声明,取到末尾即可覆盖整段函数体。
|
||||
final body = src.substring(start);
|
||||
for (final forbidden in [
|
||||
'PmonDarkColors',
|
||||
'Brightness.dark',
|
||||
'buildPmonDarkTheme',
|
||||
]) {
|
||||
expect(body.contains(forbidden), isFalse,
|
||||
reason: 'E-Ink 是 256 灰阶物理设备,深色皮肤一个键都不许进来');
|
||||
}
|
||||
expect(body.contains('PmonEink.ink'), isTrue);
|
||||
expect(body.contains('NoSplash.splashFactory'), isTrue);
|
||||
expect(body.contains('Brightness.light'), isTrue);
|
||||
});
|
||||
|
||||
test("C-15':不新增圆角/间距/字号刻度 —— PmonDarkColors 只有颜色常量", () {
|
||||
final src = File('lib/ui/theme.dart').readAsStringSync();
|
||||
final body = RegExp(r'class PmonDarkColors\s*\{([\s\S]*?)\n\}')
|
||||
.firstMatch(src)
|
||||
?.group(1) ??
|
||||
'';
|
||||
final clean = body.replaceAll(RegExp(r'//[^\n]*'), '');
|
||||
expect(RegExp(r'TextStyle\(').hasMatch(clean), isFalse,
|
||||
reason: '深色不得新增字号档位(沿用 PmonText)');
|
||||
expect(RegExp(r'fontSize:').hasMatch(clean), isFalse);
|
||||
expect(RegExp(r'static const \w+\s*=\s*\d').hasMatch(clean), isFalse,
|
||||
reason: '深色不得新增间距/圆角数字刻度(沿用 PmonSpace / PmonRadius)');
|
||||
final defs = RegExp(r'static const (\w+)\s*=\s*([^;]+);')
|
||||
.allMatches(clean)
|
||||
.toList();
|
||||
expect(defs.length, greaterThan(20), reason: '深色常量集合不能缩水');
|
||||
for (final m in defs) {
|
||||
final rhs = m.group(2)!.trim();
|
||||
expect(
|
||||
src.contains(forbidden),
|
||||
isFalse,
|
||||
reason: '本轮只在浅色皮肤内修缺陷;深色装配是独立阶段(05 §3.6)',
|
||||
RegExp(r'^Color\(0xFF[0-9A-Fa-f]{6}\)$').hasMatch(rhs) ||
|
||||
RegExp(r'^\w+$').hasMatch(rhs), // 语义别名(chartLine = primary)
|
||||
isTrue,
|
||||
reason: 'PmonDarkColors.${m.group(1)} 不是颜色常量(=$rhs)',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("C-15':浅色 18 个品牌原色未被改动(深色只是同名键覆写)", () {
|
||||
// 18 原色 = PmonColors 里除 6 个 *-text 外的全部静态色值常量。
|
||||
expect(PmonColors.page, const Color(0xFFF6F7F9));
|
||||
expect(PmonColors.surface, const Color(0xFFFFFFFF));
|
||||
expect(PmonColors.line, const Color(0xFFE5E7EB));
|
||||
expect(PmonColors.surfaceMuted, const Color(0xFFF3F4F6));
|
||||
expect(PmonColors.textPrimary, const Color(0xFF111827));
|
||||
expect(PmonColors.textSecondary, const Color(0xFF4B5563));
|
||||
expect(PmonColors.textTertiary, const Color(0xFF646D7D));
|
||||
expect(PmonColors.primary, const Color(0xFF2563EB));
|
||||
expect(PmonColors.primarySoft, const Color(0xFFEFF4FF));
|
||||
expect(PmonColors.online, const Color(0xFF16A34A));
|
||||
expect(PmonColors.onlineSoft, const Color(0xFFEAF7EF));
|
||||
expect(PmonColors.suspect, const Color(0xFFD97706));
|
||||
expect(PmonColors.suspectSoft, const Color(0xFFFEF6E7));
|
||||
expect(PmonColors.offline, const Color(0xFF9CA3AF));
|
||||
expect(PmonColors.danger, const Color(0xFFDC2626));
|
||||
expect(PmonColors.dangerSoft, const Color(0xFFFDECEC));
|
||||
expect(PmonColors.chartLine, PmonColors.primary);
|
||||
expect(PmonColors.gap, const Color(0xFFB6BCC8));
|
||||
});
|
||||
|
||||
test('卡片装饰来自同一处(标准卡不用阴影,靠发丝线分层)', () {
|
||||
final d = PmonCard.standard();
|
||||
expect((d.border as Border).top.color, PmonColors.line);
|
||||
|
||||
@@ -9,14 +9,15 @@
|
||||
/// · CSS 改值 / 删键 → 拦住(不只是「Dart 改了要跟 CSS」);
|
||||
/// · Dart 改值 / 删常量 / 擅自补一套用不到的常量 → 同样拦住。
|
||||
///
|
||||
/// ── 三条纪律(05 §3.5 / C-16)────────────────────────────────────
|
||||
/// 1. 值比对**只读 `tokens.css` 第一个 `:root`(浅色 Core)**。深色 58 键是
|
||||
/// `html[data-iw-theme="dark"]` 的**同名覆写**,Dart 本轮不实现深色,
|
||||
/// 故其覆写值全部按 `webOnly` 处理、不进 parity(见最后一组断言)。
|
||||
/// ── 四条纪律(05 §3.5 / C-16 / 本阶段深色 parity)──────────────────
|
||||
/// 1. 浅色值比对**只读 `tokens.css` 第一个 `:root`(浅色 Core)**。
|
||||
/// 2. Web 专属键必须登记在 `kWebOnlyTokens` 白名单,且 Dart 侧**确实缺席**;
|
||||
/// 若有人「好心」补一套用不到的常量,判红(防止重演两处漂移)。
|
||||
/// 3. 文件读不到内容直接 fail(`expect(src.length, greaterThan(200))`)——
|
||||
/// 照抄 `theme_guard_test.dart:56` 的自检,避免路径写错静默通过。
|
||||
/// 4. **深色已落地**(本阶段):`html[data-iw-theme="dark"]` 的 58 个同名键
|
||||
/// 另起一组 parity —— 转录项逐项比 `PmonDarkColors`、未转录的 Web 专属键
|
||||
/// 登记 `kDarkExemptTokens` 豁免,两向缺项都判红(见 group ④)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -131,9 +132,8 @@ const List<ParityEntry> kTokenParity = [
|
||||
(cssKey: '--iw-fs-metric-unit', dartExpr: 'PmonText.metricUnit', kind: 'dartOnly'),
|
||||
];
|
||||
|
||||
/// Web 专属键白名单(05 §3.3 / C-16)。
|
||||
/// 注:深色 58 键是**同名覆写**(键名与本表 / `both` 项重合),其覆写值按
|
||||
/// C-16 全部视为 webOnly、不进 parity 值比对 —— 见「深色块」那组断言。
|
||||
/// 浅色 Web 专属键白名单(05 §3.3 / C-16)。
|
||||
/// 深色块的转录 / 豁免另见 [kDarkTranscribed] / [kDarkExemptTokens](group ④)。
|
||||
const Set<String> kWebOnlyTokens = {
|
||||
'--iw-surface-hover',
|
||||
'--iw-line-strong',
|
||||
@@ -176,11 +176,91 @@ const List<({String cssKey, String dartName})> kEinkFontParity = [
|
||||
(cssKey: '--iw-eink-fs-caption', dartName: 'caption'),
|
||||
];
|
||||
|
||||
// ── 深色 parity(本阶段新增):58 键 = 转录 30 + 豁免 28 ─────────────
|
||||
/// 转录项:CSS dark 块键 ↔ `PmonDarkColors` 常量名。
|
||||
/// 只列「Flutter 真的会消费」的键 —— 与浅色 PmonColors 同名语义,外加两处
|
||||
/// D3 反相(text-on-accent / snackbar-bg);别名复用
|
||||
/// (offline-soft / unknown-soft → surfaceMuted、unknown → offline、
|
||||
/// series-1 → chartLine)与浅色侧 `kTokenParity` 的写法一致。
|
||||
const List<({String cssKey, String dartName})> kDarkTranscribed = [
|
||||
(cssKey: '--iw-page', dartName: 'page'),
|
||||
(cssKey: '--iw-surface', dartName: 'surface'),
|
||||
(cssKey: '--iw-surface-muted', dartName: 'surfaceMuted'),
|
||||
(cssKey: '--iw-line', dartName: 'line'),
|
||||
(cssKey: '--iw-text-primary', dartName: 'textPrimary'),
|
||||
(cssKey: '--iw-text-secondary', dartName: 'textSecondary'),
|
||||
(cssKey: '--iw-text-tertiary', dartName: 'textTertiary'),
|
||||
(cssKey: '--iw-text-on-accent', dartName: 'textOnAccent'),
|
||||
(cssKey: '--iw-primary', dartName: 'primary'),
|
||||
(cssKey: '--iw-primary-soft', dartName: 'primarySoft'),
|
||||
(cssKey: '--iw-info-text', dartName: 'infoText'),
|
||||
(cssKey: '--iw-online', dartName: 'online'),
|
||||
(cssKey: '--iw-online-soft', dartName: 'onlineSoft'),
|
||||
(cssKey: '--iw-online-text', dartName: 'onlineText'),
|
||||
(cssKey: '--iw-suspect', dartName: 'suspect'),
|
||||
(cssKey: '--iw-suspect-soft', dartName: 'suspectSoft'),
|
||||
(cssKey: '--iw-suspect-text', dartName: 'suspectText'),
|
||||
(cssKey: '--iw-offline', dartName: 'offline'),
|
||||
(cssKey: '--iw-offline-soft', dartName: 'surfaceMuted'),
|
||||
(cssKey: '--iw-offline-text', dartName: 'offlineText'),
|
||||
(cssKey: '--iw-unknown', dartName: 'offline'),
|
||||
(cssKey: '--iw-unknown-soft', dartName: 'surfaceMuted'),
|
||||
(cssKey: '--iw-unknown-text', dartName: 'unknownText'),
|
||||
(cssKey: '--iw-danger', dartName: 'danger'),
|
||||
(cssKey: '--iw-danger-soft', dartName: 'dangerSoft'),
|
||||
(cssKey: '--iw-danger-text', dartName: 'dangerText'),
|
||||
(cssKey: '--iw-chart-line', dartName: 'chartLine'),
|
||||
(cssKey: '--iw-series-1', dartName: 'chartLine'),
|
||||
(cssKey: '--iw-gap', dartName: 'gap'),
|
||||
(cssKey: '--iw-snackbar-bg', dartName: 'snackbarBg'),
|
||||
];
|
||||
|
||||
/// 豁免项:dark 块里**未转录**的 Web 专属键(05 §1.4 / 浅色 kWebOnlyTokens
|
||||
/// 同一纪律)。逐项登记、Dart 侧保持缺省,理由见 `theme.dart` 的 PmonDarkColors
|
||||
/// 文件头:交互态用 `WidgetState`、描边用 `withValues(alpha:)` 动态算、
|
||||
/// 多序列 / Logo / 预混面积在 Flutter 端没有消费点。
|
||||
const Set<String> kDarkExemptTokens = {
|
||||
// Web 交互态 / 预混实色描边
|
||||
'--iw-surface-hover',
|
||||
'--iw-line-strong',
|
||||
'--iw-primary-hover',
|
||||
'--iw-primary-active',
|
||||
'--iw-info-border',
|
||||
'--iw-online-border',
|
||||
'--iw-suspect-border',
|
||||
'--iw-offline-border',
|
||||
'--iw-unknown-border',
|
||||
'--iw-danger-hover',
|
||||
'--iw-danger-active',
|
||||
'--iw-danger-border',
|
||||
// 图表:单序列之外 / 预混面积 / 网格 / 阈值带
|
||||
'--iw-chart-area',
|
||||
'--iw-chart-grid',
|
||||
'--iw-chart-axis',
|
||||
'--iw-chart-gap-band',
|
||||
'--iw-chart-threshold-band',
|
||||
'--iw-chart-threshold-line',
|
||||
'--iw-series-2',
|
||||
'--iw-series-3',
|
||||
'--iw-series-4',
|
||||
// 品牌(Dart 无消费点)
|
||||
'--iw-logo-bg',
|
||||
'--iw-logo-wave',
|
||||
'--iw-logo-accent',
|
||||
'--iw-logo-mono',
|
||||
// 反馈:CSS 复合值(Dart 无对应概念)
|
||||
'--iw-mask',
|
||||
'--iw-overlay-shadow',
|
||||
'--iw-focus-ring',
|
||||
};
|
||||
|
||||
// ── 解析结果(main 里一次性装载)──────────────────────────────────
|
||||
late Map<String, String> cssCore; // 第一个 :root(浅色 Core)
|
||||
late Map<String, String> cssEngineering; // 第二个 :root(工程化补充)
|
||||
late Map<String, String> cssDark; // html[data-iw-theme="dark"] 块
|
||||
late String cssClean;
|
||||
late Map<String, String> dartColors; // PmonColors: name → FFRRGGBB
|
||||
late Map<String, String> darkColors; // PmonDarkColors: name → FFRRGGBB
|
||||
late Map<String, String> einkColors; // PmonEink: name → FFRRGGBB
|
||||
// ⚠ PmonColors.page 与 PmonEink.page 同名,必须按 class 分段解析,
|
||||
// 否则后者会覆盖前者,`--iw-page` 会被误判成 #FFFFFF 漂移。
|
||||
@@ -262,6 +342,22 @@ String? resolveDart(String expr) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 深色侧专用:只查 PmonDarkColors。
|
||||
/// ⚠️ 两套色板字段名同名(page / surface / primary…),**绝不能**复用
|
||||
/// [resolveDart] —— 那样会取到浅色 PmonColors 的值,深色 parity 会假通过。
|
||||
String? resolveDark(String name) =>
|
||||
darkColors[name] ?? darkColors[dartAliases[name]];
|
||||
|
||||
/// kebab-case → camelCase(反推「豁免键若被误补,会叫什么 Dart 常量名」)。
|
||||
String _kebabToCamel(String s) {
|
||||
final parts = s.split('-');
|
||||
return parts.first +
|
||||
parts
|
||||
.skip(1)
|
||||
.map((p) => p.isEmpty ? '' : p[0].toUpperCase() + p.substring(1))
|
||||
.join();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
// 唯一可编辑真相源在管理台 assets 下。
|
||||
@@ -293,12 +389,20 @@ void main() {
|
||||
reason: 'tokens.css 应有浅色 Core + 工程化补充两个 :root 块');
|
||||
cssCore = _parseKv(roots[0].group(1)!);
|
||||
cssEngineering = _parseKv(roots[1].group(1)!);
|
||||
final darkBody = RegExp(r'html\[data-iw-theme="dark"\]\s*\{([\s\S]*?)\}')
|
||||
.firstMatch(cssClean)
|
||||
?.group(1) ??
|
||||
'';
|
||||
expect(darkBody.length, greaterThan(200),
|
||||
reason: 'tokens.css 的 html[data-iw-theme="dark"] 块读不到内容?');
|
||||
cssDark = _parseKv(darkBody);
|
||||
|
||||
const dartPath = 'lib/ui/theme.dart';
|
||||
final dartSrc = File(dartPath).readAsStringSync();
|
||||
expect(dartSrc.length, greaterThan(200), reason: '$dartPath 读不到内容?');
|
||||
dartClean = dartSrc.replaceAll(RegExp(r'//[^\n]*'), '');
|
||||
dartColors = _parseColors(_classBody(dartClean, 'PmonColors'));
|
||||
darkColors = _parseColors(_classBody(dartClean, 'PmonDarkColors'));
|
||||
einkColors = _parseColors(_classBody(dartClean, 'PmonEink'));
|
||||
dartAliases = <String, String>{};
|
||||
for (final m in RegExp(r'static const (\w+)\s*=\s*(\w+);')
|
||||
@@ -419,28 +523,89 @@ void main() {
|
||||
expect(einkFontSizes.length, 6, reason: 'E-Ink 字阶必须是完整的 6 档');
|
||||
});
|
||||
|
||||
test('深色 58 键是同名覆写:零新增键名、不进 parity 值比对', () {
|
||||
final darkBody = RegExp(r'html\[data-iw-theme="dark"\]\s*\{([\s\S]*?)\}')
|
||||
.firstMatch(cssClean)
|
||||
?.group(1) ??
|
||||
'';
|
||||
final darkKeys = RegExp(r'(--iw-[a-z0-9-]+)\s*:')
|
||||
.allMatches(darkBody)
|
||||
.map((m) => m.group(1)!)
|
||||
.toList();
|
||||
test('深色 58 键是同名覆写:零新增键名 + 层叠顺序', () {
|
||||
final darkKeys = cssDark.keys.toList();
|
||||
expect(darkKeys.length, 58, reason: '05 §1.4 的 58 个同名键覆写');
|
||||
expect(darkKeys.toSet().length, 58, reason: '深色块不得有重复键');
|
||||
for (final k in darkKeys) {
|
||||
expect(cssCore.containsKey(k), isTrue,
|
||||
reason: '$k 只在深色块出现 —— 新增键名会漏进 E-Ink 分支(B2)');
|
||||
}
|
||||
// 不进 parity:值比对的唯一数据源是首个 :root;Dart 侧本轮不实现深色
|
||||
expect(dartClean.contains('PmonDarkColors'), isFalse);
|
||||
expect(dartClean.contains('buildPmonDarkTheme'), isFalse);
|
||||
expect(dartClean.contains('Brightness.dark'), isFalse);
|
||||
// dark 块必须排在 E-Ink 块之前(G2 层叠顺序)
|
||||
expect(cssClean.indexOf('html[data-iw-theme="dark"]'),
|
||||
lessThan(cssClean.indexOf('html[data-iw-surface="eink"]')));
|
||||
// 深色**现在进 parity**(本阶段已落地 PmonDarkColors),逐项比对见 group ④。
|
||||
expect(dartClean.contains('PmonDarkColors'), isTrue);
|
||||
expect(dartClean.contains('buildPmonDarkTheme'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('④ 深色 parity:CSS dark 块 ↔ PmonDarkColors(双向)', () {
|
||||
test('转录 + 豁免恰好覆盖 58 键,且都在 CSS dark 块里(数量一致)', () {
|
||||
final covered = <String>{
|
||||
...kDarkTranscribed.map((e) => e.cssKey),
|
||||
...kDarkExemptTokens,
|
||||
};
|
||||
expect(covered.length, 58,
|
||||
reason: '转录项 + 豁免项必须合计 58(同名且数量一致)');
|
||||
expect(cssDark.keys.toSet().length, 58, reason: 'CSS dark 块恰好 58 键');
|
||||
final missing = cssDark.keys.where((k) => !covered.contains(k)).toList();
|
||||
expect(missing, isEmpty,
|
||||
reason: 'CSS dark 块新增了键却没登记进转录/豁免表:\n'
|
||||
'${missing.join('\n')}');
|
||||
final dangling = covered.where((k) => !cssDark.containsKey(k)).toList();
|
||||
expect(dangling, isEmpty,
|
||||
reason: '转录/豁免表里有 CSS dark 块不存在的键:\n'
|
||||
'${dangling.join('\n')}');
|
||||
});
|
||||
|
||||
test('转录项逐项值相等(CSS dark 覆写值 == PmonDarkColors)', () {
|
||||
final offenders = <String>[];
|
||||
var checked = 0;
|
||||
for (final e in kDarkTranscribed) {
|
||||
checked++;
|
||||
final cssVal = cssDark.containsKey(e.cssKey)
|
||||
? normalizeCss(cssDark[e.cssKey]!)
|
||||
: null;
|
||||
final dartVal = resolveDark(e.dartName);
|
||||
if (cssVal == null || dartVal == null || cssVal != dartVal) {
|
||||
offenders.add('${e.cssKey.padRight(30)} css=${cssVal ?? "<缺>"}'
|
||||
' PmonDarkColors.${e.dartName.padRight(16)} '
|
||||
'dart=${dartVal ?? "<缺>"}');
|
||||
}
|
||||
}
|
||||
expect(checked, kDarkTranscribed.length);
|
||||
expect(offenders, isEmpty,
|
||||
reason: '深色令牌漂移(cssKey / cssValue / dartName / dartValue):\n'
|
||||
'${offenders.join('\n')}');
|
||||
});
|
||||
|
||||
test('反向缺项:PmonDarkColors 每个颜色常量都必须已登记', () {
|
||||
final registered = kDarkTranscribed.map((e) => e.dartName).toSet();
|
||||
final missing = darkColors.keys
|
||||
.where((k) => !registered.contains(k))
|
||||
.toList();
|
||||
expect(missing, isEmpty,
|
||||
reason: 'theme.dart 新增了深色常量却没登记进转录表:\n'
|
||||
'${missing.join('\n')}');
|
||||
});
|
||||
|
||||
test('豁免项:Web 专属键已登记、未被误转录、Dart 侧确实缺席', () {
|
||||
final transcribed = kDarkTranscribed.map((e) => e.cssKey).toSet();
|
||||
final offenders = <String>[];
|
||||
for (final k in kDarkExemptTokens) {
|
||||
if (transcribed.contains(k)) {
|
||||
offenders.add('$k 既登记豁免又被转录(重复登记)');
|
||||
}
|
||||
final camel = _kebabToCamel(k.replaceFirst('--iw-', ''));
|
||||
if (darkColors.containsKey(camel)) {
|
||||
offenders.add('$k: PmonDarkColors 擅自补了 $camel');
|
||||
}
|
||||
}
|
||||
expect(kDarkExemptTokens.length, 58 - kDarkTranscribed.length,
|
||||
reason: '豁免项数量 = 58 − 转录项数量');
|
||||
expect(offenders, isEmpty,
|
||||
reason: '未转录的 Web 专属键必须「CSS 有 / Dart 无」:\n'
|
||||
'${offenders.join('\n')}');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user