/// 视觉令牌(**浅色极简**)—— 全客户端界面样式的**唯一真相源** /// /// ── 为什么要有这个文件 ──────────────────────────────────────────── /// 改版前 UI 共约 3500 行,里面散着 **70+ 处硬编码 `Color(0xFF…)`**(十几种互不成体系的 /// 灰度与蓝),间距/圆角/字号也全是字面量。后果不是"丑",而是**同一类元素在不同卡片里 /// 长得不一样**:主按钮在 A 卡是 0xFF2563EB、在 B 卡是 0xFF3B82F6;说明文字一会儿 11 /// 一会儿 11.5;卡片内边距 9/12/16 混用。用户读到的是"这里好像没那么整齐", /// 但没人能说清哪里不一致 —— 因为**根本没有"一致"的定义**。 /// /// 这和本项目在其他地方反复踩的坑是同一个病(指标字段清单、配对码契约、显示目录、 /// 单位换算都各写过两遍)。故此处立**三条规矩**: /// /// 1. **颜色/间距/圆角/字号只在这里定义**。页面里出现 `Color(0x…)` 即判红 /// (由 `test/theme_guard_test.dart` 源码结构断言锁定)。 /// 2. **语义优先于色值**:写 `InkwatchColors.online` 而不是 `Colors.green` —— /// 否则"在线"换成别的色时,要满地找哪几处是它。 /// 3. **只提供必要刻度**,不做无限可调的间距体系(4/8/12/16/20/24 够用)。 /// 刻度一多,同一个视觉决策又会有三种写法。 /// /// ── 浅色极简的三条取向(与旧版深色 header 的差别)──────────────── /// · **层次靠留白与发丝线,不靠阴影**:卡片 = 白面 + 1px 描边,去掉浮夸投影; /// · **颜色只用于表意**:整屏以中性灰为底,彩色仅出现在状态、主操作、告警上; /// · **文字三级**(主/次/说明)代替"到处 11px 灰"。 library; import 'package:flutter/material.dart'; /// 色板与语义色 abstract final class InkwatchColors { // ── 中性(浅色极简的骨架)────────────────────────────────────── /// 页面底色(比卡片略深,让白卡片浮起来 —— 用明度差代替阴影) static const page = Color(0xFFF6F7F9); /// 卡片/表面 static const surface = Color(0xFFFFFFFF); /// 发丝线(描边、分隔)—— 只用这一种描边色,别再写第二种灰 static const line = Color(0xFFE5E7EB); /// 次级表面(chip 未选中、代码块、折叠块头) static const surfaceMuted = Color(0xFFF3F4F6); // ── 文字三级 ────────────────────────────────────────────────── /// 主要文字(标题、数值) static const textPrimary = Color(0xFF111827); /// 次要文字(正文说明) static const textSecondary = Color(0xFF4B5563); /// 辅助文字(单位、时间戳、脚注) static const textTertiary = Color(0xFF646D7D); // ── 主色(唯一强调色:主操作 + 选中态 + 链接)────────────────── static const primary = Color(0xFF2563EB); /// 主色的浅底(选中项背景、信息条背景) static const primarySoft = Color(0xFFEFF4FF); // ── 状态语义色(**只用于表意**,不要拿它当装饰)──────────────── /// 在线 / 正常 static const online = Color(0xFF16A34A); static const onlineSoft = Color(0xFFEAF7EF); /// 疑似离线 / 需注意 static const suspect = Color(0xFFD97706); static const suspectSoft = Color(0xFFFEF6E7); /// 离线 / 未知(中性灰,**不是**危险色 —— 离线不等于出错) static const offline = Color(0xFF9CA3AF); /// 危险 / 破坏性操作(停止采集、解除授权) static const danger = Color(0xFFDC2626); static const dangerSoft = Color(0xFFFDECEC); // ── 状态文字色(软底上的字形/文字,对比度 AA 以上)────────────── /// 在线徽标文字(onlineSoft 底 4.68:1) static const onlineText = Color(0xFF117E39); /// 疑似离线徽标文字(suspectSoft 底 4.88:1) static const suspectText = Color(0xFFA45904); /// 危险提示文字(dangerSoft 底 5.67:1) static const dangerText = Color(0xFFB81E1E); /// 离线徽标文字。**与 [textSecondary] 同值也必须独立命名**: /// 语义优先于色值,将来离线文字换色不必满地找哪些地方借用了正文色。 static const offlineText = Color(0xFF4B5563); /// 未知徽标文字(同上,语义独立别名,不复用 offlineText 的名字) static const unknownText = Color(0xFF4B5563); /// 信息条 / 选中态文字(primarySoft 底 6.73:1) static const infoText = Color(0xFF124BC4); /// 图表主色(与主色同源,单独命名以便日后独立调整) static const chartLine = primary; /// 断点虚线与"数据缺口"标记(中性,**不是**告警 —— 缺口是事实,不是错误) 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:)` 动态算描边(见 [InkwatchCard.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 已有 `InkwatchSpace` / `InkwatchRadius` / /// `InkwatchText` 语义档位,一套都不新增。 /// /// ⚠️ [textTertiary] 保持 `#8A93A3`(深底 5.02–6.24:1 达标),**显式不同步** /// 浅色点修值 `#646D7D` —— 同步会让深底对比跌到 2.98–3.70:1,制造新违规。 abstract final class InkwatchDarkColors { // ── 中性面(--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 InkwatchSpace { static const xs = 4.0; static const sm = 8.0; static const md = 12.0; static const lg = 16.0; static const xl = 20.0; static const xxl = 24.0; } /// 圆角刻度 abstract final class InkwatchRadius { static const sm = 8.0; static const md = 12.0; static const lg = 16.0; static const pill = 999.0; } /// 文字样式(三级 + 标题 + 数值) abstract final class InkwatchText { /// 页面/卡片大标题 static const title = TextStyle( fontSize: 17, fontWeight: FontWeight.w600, color: InkwatchColors.textPrimary, height: 1.3); /// 分区标题(卡片头) static const section = TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: InkwatchColors.textPrimary, height: 1.35); /// 正文 static const body = TextStyle( fontSize: 13, color: InkwatchColors.textPrimary, height: 1.5); /// 正文(次要) static const bodyMuted = TextStyle( fontSize: 13, color: InkwatchColors.textSecondary, height: 1.5); /// 说明文字(脚注、解释、诚实边界文案) static const caption = TextStyle( fontSize: 12, color: InkwatchColors.textTertiary, height: 1.5); /// 图表轴标签 / Tooltip 的最小档(11px)。 /// 与 E-Ink 分支同纪律:界面里不写就地字号,需要小字就走这一档 /// (`viewer_chart.dart` 的 10 / 10.5 / 11.5 全部收敛到此处)。 static const micro = TextStyle( fontSize: 11, color: InkwatchColors.textTertiary, height: 1.4); /// 指标数值(等宽数字,避免跳动) static const metric = TextStyle( fontSize: 22, fontWeight: FontWeight.w700, color: InkwatchColors.textPrimary, height: 1.15, fontFeatures: [FontFeature.tabularFigures()]); /// 指标单位/后缀 static const metricUnit = TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: InkwatchColors.textTertiary); /// 小统计块数值 static const stat = TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: InkwatchColors.textPrimary, fontFeatures: [FontFeature.tabularFigures()]); } /// 卡片与容器装饰(**统一在这里造**,页面不要自己拼 BoxDecoration) abstract final class InkwatchCard { /// 标准卡片:面 + 发丝线 + 大圆角。不用阴影(极简取向,且阴影在深色/低端屏上更难控) /// /// [palette] 缺省时回退 [InkwatchPalette.light](与改造前逐字段一致), /// 页面自绘一律传 `InkwatchPalette.of(context)` —— 深色下卡片才真正变深。 static BoxDecoration standard({Color? tint, InkwatchPalette? palette}) { final p = palette ?? InkwatchPalette.light; return BoxDecoration( color: tint ?? p.surface, borderRadius: BorderRadius.circular(InkwatchRadius.md), border: Border.all(color: p.line), ); } /// 信息条(提示/错误/成功)—— 语义色浅底 + 同色描边。 /// [base] / [soft] 由调用方按当前色板传入(见 [statusStyle])。 static BoxDecoration notice(Color base, Color soft) => BoxDecoration( color: soft, borderRadius: BorderRadius.circular(InkwatchRadius.sm), border: Border.all(color: base.withValues(alpha: 0.28)), ); /// 选中态容器(主色底 + 主色描边) static BoxDecoration selected({InkwatchPalette? palette}) { final p = palette ?? InkwatchPalette.light; return BoxDecoration( color: p.primarySoft, borderRadius: BorderRadius.circular(InkwatchRadius.md), border: Border.all(color: p.primary, width: 1.5), ); } } /// Material 主题装配。**只在这里 new ThemeData** —— 保证控件(输入框/菜单/SnackBar/ /// 对话框)与自绘卡片是同一套颜色与圆角,不会出现"系统控件是旧蓝、自绘是新品"。 /// /// 浅色 / 深色共用**同一段控件装配**([_assemble]),只传入不同的语义色板 /// ([_lightPalette] / [_darkPalette])—— 不另写一套分叉的控件配置。 /// `buildInkwatchDarkTheme()` 也走这里,故「只在 theme.dart 内 new ThemeData」的 /// 规矩对深色同样成立(页面里就地 `ThemeData(` 由 theme_guard_test.dart 判红)。 ThemeData buildInkwatchTheme() => _assemble(_lightPalette, Brightness.light) .copyWith( extensions: const >[InkwatchPalette.light]); /// 深色皮肤主题。色值来自 `tokens.css` 的 `html[data-iw-theme="dark"]` /// (05 §1.4 的 58 个同名键覆写),由 [InkwatchDarkColors] 逐项转录。 /// /// 装配结构与 [buildInkwatchTheme] 完全相同,仅语义色替换为深色板: /// 圆角 / 间距 / 字号刻度一律沿用 InkwatchSpace / InkwatchRadius / InkwatchText, /// 不新增任何刻度。E-Ink 看板走独立的 [einkTheme],不受本主题影响。 ThemeData buildInkwatchDarkTheme() => _assemble(_darkPalette, Brightness.dark) .copyWith( extensions: const >[InkwatchPalette.dark]); /// 控件装配用到的语义色子集。**不是新令牌** —— 只是把两套色板喂给同一段装配, /// 避免把 Web 的「预混实色 / 交互态」概念搬进 Dart。 class _InkwatchPalette { const _InkwatchPalette({ 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 = _InkwatchPalette( page: InkwatchColors.page, surface: InkwatchColors.surface, line: InkwatchColors.line, surfaceMuted: InkwatchColors.surfaceMuted, textPrimary: InkwatchColors.textPrimary, textSecondary: InkwatchColors.textSecondary, textTertiary: InkwatchColors.textTertiary, textOnAccent: Colors.white, primary: InkwatchColors.primary, primarySoft: InkwatchColors.primarySoft, // 浅色 Toast:深底白字(--iw-snackbar-bg 浅色 = textPrimary #111827) snackbarBg: InkwatchColors.textPrimary, snackbarText: Colors.white, ); const _darkPalette = _InkwatchPalette( page: InkwatchDarkColors.page, surface: InkwatchDarkColors.surface, line: InkwatchDarkColors.line, surfaceMuted: InkwatchDarkColors.surfaceMuted, textPrimary: InkwatchDarkColors.textPrimary, textSecondary: InkwatchDarkColors.textSecondary, textTertiary: InkwatchDarkColors.textTertiary, textOnAccent: InkwatchDarkColors.textOnAccent, primary: InkwatchDarkColors.primary, primarySoft: InkwatchDarkColors.primarySoft, // D3 反相:深色 Toast 底 = 浅色 #F2F4F9,文字改用深 surface(15.85:1 AAA) snackbarBg: InkwatchDarkColors.snackbarBg, snackbarText: InkwatchDarkColors.surface, ); ThemeData _assemble(_InkwatchPalette p, Brightness brightness) { final baseScheme = ColorScheme.fromSeed( seedColor: p.primary, brightness: brightness, surface: p.surface, ); // 深底必须让主色严格落在语义令牌上:fromSeed 会把 primary 推到 tonal 近似值 // (实测 #6E9CFF → #AFC6FF),FilledButton / 选中态就会用到非令牌蓝。 // 浅色分支保持 fromSeed 原始结果,确保 buildInkwatchTheme() 逐字段不变。 final colorScheme = brightness == Brightness.dark ? baseScheme.copyWith( primary: p.primary, onPrimary: p.textOnAccent, onSurface: p.textPrimary, ) : baseScheme; final base = ThemeData( useMaterial3: true, brightness: brightness, colorScheme: colorScheme, scaffoldBackgroundColor: p.page, splashFactory: InkSparkle.splashFactory, ); return base.copyWith( appBarTheme: AppBarTheme( backgroundColor: p.surface, surfaceTintColor: Colors.transparent, foregroundColor: p.textPrimary, elevation: 0, scrolledUnderElevation: 0, centerTitle: false, titleTextStyle: InkwatchText.title.copyWith(color: p.textPrimary), ), dividerTheme: DividerThemeData(color: p.line, thickness: 1, space: 1), cardTheme: base.cardTheme.copyWith( color: p.surface, surfaceTintColor: Colors.transparent, elevation: 0, margin: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(InkwatchRadius.md), side: BorderSide(color: p.line), ), ), inputDecorationTheme: InputDecorationTheme( isDense: true, filled: true, fillColor: p.surface, contentPadding: const EdgeInsets.symmetric( horizontal: InkwatchSpace.md, vertical: InkwatchSpace.md), hintStyle: InkwatchText.caption.copyWith(color: p.textTertiary), border: OutlineInputBorder( borderRadius: BorderRadius.circular(InkwatchRadius.sm), borderSide: BorderSide(color: p.line), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(InkwatchRadius.sm), borderSide: BorderSide(color: p.line), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(InkwatchRadius.sm), borderSide: BorderSide(color: p.primary, width: 1.5), ), ), snackBarTheme: base.snackBarTheme.copyWith( behavior: SnackBarBehavior.floating, backgroundColor: p.snackbarBg, contentTextStyle: TextStyle(fontSize: 13, color: p.snackbarText), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(InkwatchRadius.sm)), ), popupMenuTheme: base.popupMenuTheme.copyWith( color: p.surface, surfaceTintColor: Colors.transparent, elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(InkwatchRadius.sm), side: BorderSide(color: p.line), ), textStyle: InkwatchText.body.copyWith(color: p.textPrimary), ), navigationBarTheme: NavigationBarThemeData( backgroundColor: p.surface, surfaceTintColor: Colors.transparent, elevation: 0, // 选中态用主色浅底 + 主色图标/文字。 // 不设的话 M3 会按 seed 自动生成一个偏紫的 secondaryContainer, // 与我们的蓝主色不搭(真机截图里底部导航是紫色的)。 indicatorColor: p.primarySoft, labelTextStyle: WidgetStateProperty.resolveWith((states) => TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: states.contains(WidgetState.selected) ? p.primary : p.textTertiary, )), iconTheme: WidgetStateProperty.resolveWith((states) => IconThemeData( size: 22, color: states.contains(WidgetState.selected) ? p.primary : p.textTertiary, )), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( foregroundColor: p.primary, textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), ), listTileTheme: ListTileThemeData( iconColor: p.textTertiary, textColor: p.textPrimary, ), ); } /// 状态 → (文字, 颜色, 浅底, 文字色)的**唯一映射**。 /// /// 为什么单独抽出来:状态颜色曾在采集页/查看页/设备列表三处各写一套 if-else, /// 于是"疑似离线"在一处是橙、在另一处是黄。这类"同一语义两处判定"是本仓库的老病, /// 故收敛成一条函数,新增状态只改这里。 /// /// 只扩到 **4 字段、不带 `border`**:Flutter 的语义描边由 /// `base.withValues(alpha: 0.28)` 动态生成(见 [InkwatchCard.notice]), /// 没有 Web 端「预混实色描边」这一需求,不要把 CSS 的实现概念搬进 Dart。 /// [palette] 缺省时回退 [InkwatchPalette.light](与改造前逐字段一致); /// 页面自绘一律传当前色板,深色 / E-Ink 下状态色才跟着换。 ({String label, Color color, Color soft, Color text}) statusStyle( String key, [InkwatchPalette? palette]) { final p = palette ?? InkwatchPalette.light; switch (key) { case 'online': return (label: '在线', color: p.online, soft: p.onlineSoft, text: p.onlineText); case 'suspect': return (label: '疑似离线', color: p.suspect, soft: p.suspectSoft, text: p.suspectText); case 'offline': return (label: '离线', color: p.offline, soft: p.surfaceMuted, text: p.offlineText); case 'unknown': // 未知**必须**是中性灰:把"不知道"染成告警色等于编造结论(诚实边界) return (label: '状态未知', color: p.offline, soft: p.surfaceMuted, text: p.unknownText); case 'info': return (label: '提示', color: p.primary, soft: p.primarySoft, text: p.infoText); default: return (label: '状态未知', color: p.offline, soft: p.surfaceMuted, text: p.unknownText); } } // ══════════════════════════════════════════════════════════════════ // 水墨屏(E-Ink)令牌 —— iReader Neo 2 这类 6″ 黑白 256 灰阶设备 // ══════════════════════════════════════════════════════════════════ /// 水墨屏配色。 /// /// ── 为什么不能照搬上面的浅色主题(2026-09-15)────────────────────── /// 墨水屏的显示物理与彩色 LCD 完全不同,上面那套令牌搬过去会"糊": /// /// | 浅色主题的写法 | 在墨水屏上的后果 | /// |---|---| /// | 浅色辅助文字色(`textTertiary` 浅灰字) | 300PPI 墨水屏下也发虚,且**黑色残影**明显 | /// | `page = #F6F7F9` 浅灰底 | 大面积灰底要全屏重绘(慢),且灰底上再写灰字几乎看不清 | /// | `line = #E5E7EB` 发丝线 | 灰阶下与底几乎同色 → 等于没有分隔 | /// | `online` 绿 / `suspect` 橙 | **亮度接近**,灰阶化后两个状态长得一样 | /// | 任何动画(转圈、呼吸) | 持续局部刷新 → 残影、闪、发热、耗电(墨水屏最忌) | /// /// 故墨水屏只用**三档明度**(纯黑 / 深灰 / 纯白),分层靠**字重与描边**而不是底色, /// 状态靠**形状**而不是颜色(见 [statusGlyph])。 class InkwatchEink { InkwatchEink._(); /// 底:纯白。墨水屏的"原生"是白纸,浅灰底只会浪费对比度 static const page = Color(0xFFFFFFFF); /// 主文字:纯黑(不要用 #111827 —— 深灰在黑字旁边会显得发虚) static const ink = Color(0xFF000000); /// 次级文字/单位:深灰。**不要用浅灰** —— 那是 LCD 的习惯,墨水屏上会糊 static const inkSoft = Color(0xFF444444); /// 描边与分隔:黑细线(墨水屏上灰线等于没有) static const rule = Color(0xFF000000); /// 卡片/格子:白底 + 黑描边(不靠灰底分区) static const box = Color(0xFFFFFFFF); /// 需要"填充对比"的地方(条形/进度),与白底形成明确两级 static const fill = Color(0xFF000000); } /// 水墨屏文字样式 —— **全分支唯一的字号刻度**。 /// /// ── 两条纪律(2026-09-16 立,为了让"字号不一"不可能再出现)────────── /// 1. **界面里不得写就地字号**(`fontSize: 数字`)。要新尺寸就在下面加一档, /// 并写清它对应什么语义 —— 否则同一类元素很快会变成 13/13.5/14 三种写法 /// (彩色主题当年就是这样烂掉的,见 `theme.dart` 文件头)。 /// 2. **同一屏的数值必须共用同一字号**。数值缩放要按**全屏最长的那条**统一算 /// (`einkUniformValueScale`),不能用 `FittedBox` 逐格各自缩 —— /// 逐格缩的结果是 `310 kb/s` 比 `24.8%` 小一号,一屏里字号参差。 /// /// 为什么全部加粗(最低 w600):墨水屏墨滴扩散会让细笔画发虚, /// w400 正文在 6″ 300PPI 上远看会断笔。 class InkwatchEinkText { InkwatchEinkText._(); /// 数值(**基准字号**)。实际字号 = 本档 × `einkUniformValueScale()` /// 算出的全屏统一比例(只会缩、不会放)。 static const value = TextStyle( fontSize: 40, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.0); /// 状态形状(●/◐/○/◌)—— 与设备名同行,比它略大以便一眼可辨 static const glyph = TextStyle( fontSize: 22, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.0); /// 设备名 static const title = TextStyle( fontSize: 20, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.15); /// 指标标签 / 按钮文字(同号,语义不同则用字重区分,不靠字号) static const label = TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: InkwatchEink.inkSoft, height: 1.2); /// 按钮文字:与 [label] **同号**,只提字重与颜色(不靠改字号来表达重要) static const action = TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.2); /// 角落说明(刷新时刻、异常时的末帧提示) static const caption = TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: InkwatchEink.inkSoft, height: 1.3); } /// 状态 → **形状**的唯一映射(水墨屏用)。 /// /// 为什么必须有它:24 位色下"在线绿 / 疑似橙 / 离线灰"是三个可辨的颜色, /// 而 256 灰阶下 `#16A34A`(绿) 与 `#D97706`(橙) 的**相对亮度接近**(0.42 vs 0.45), /// 灰阶化后几乎同色 —— 界面就在说谎(用户分不清设备到底在不在线)。 /// /// 故同一语义在墨水屏上必须换一条**不依赖颜色**的表达通道: /// 实心=在线、半实心=疑似离线、空心=离线(与颜色版语义一一对应,由测试钉住)。 String statusGlyph(String key) { switch (key) { case 'online': return '●'; case 'suspect': return '◐'; case 'offline': return '○'; default: // 状态未知(服务端没告知在线态)—— 用"虚线圆"与"确定离线"的实线圆区分开。 // 这两者处置完全不同:未知要等/查,离线要叫对方开机。 return '◌'; } } // ══════════════════════════════════════════════════════════════════ // 页面自绘色板(ThemeExtension)—— 整屏深浅 / E-Ink 的唯一查询入口 // ══════════════════════════════════════════════════════════════════ /// 页面自绘语义色板。 /// /// ── 为什么需要它(整屏深色化)──────────────────────────────────── /// [ThemeData] 只负责系统控件(输入框 / 菜单 / SnackBar / 对话框…), /// 而本项目的卡片、状态灯、说明文字是**自绘**的(BoxDecoration / TextStyle / /// Icon color)。它们过去直接引用 [InkwatchColors] 静态常量,于是深色下 /// 「系统控件变深了、卡片和文字仍是浅色」—— 整屏并没有真正深色。 /// /// 故把页面自绘会消费的全部语义色收进一份 [ThemeExtension], /// 由 [buildInkwatchTheme] / [buildInkwatchDarkTheme] / [einkTheme] 各挂一份实例。 /// 页面统一用 [InkwatchPalette.of] 取当前色板: /// · [light] 逐项等于 [InkwatchColors] —— 浅色视觉一字不变; /// · [dark] 逐项等于 [InkwatchDarkColors] —— 深色整屏生效; /// · [eink] 全部映射到 [InkwatchEink] 六色 —— 纯黑白、无彩色。 /// /// 色值**定义源**仍是 [InkwatchColors] / [InkwatchDarkColors] / [InkwatchEink] 静态常量 /// (parity / 护栏测试依赖,一个不动);本类只做「按主题选取」,不新增色值、 /// 不新增圆角 / 间距 / 字号刻度。 class InkwatchPalette extends ThemeExtension { const InkwatchPalette({ required this.page, required this.surface, required this.surfaceMuted, required this.line, required this.textPrimary, required this.textSecondary, required this.textTertiary, required this.primary, required this.primarySoft, required this.online, required this.onlineSoft, required this.onlineText, required this.suspect, required this.suspectSoft, required this.suspectText, required this.offline, required this.offlineText, required this.unknownText, required this.danger, required this.dangerSoft, required this.dangerText, required this.infoText, required this.chartLine, required this.gap, }); final Color page; final Color surface; final Color surfaceMuted; final Color line; final Color textPrimary; final Color textSecondary; final Color textTertiary; final Color primary; final Color primarySoft; final Color online; final Color onlineSoft; final Color onlineText; final Color suspect; final Color suspectSoft; final Color suspectText; final Color offline; final Color offlineText; final Color unknownText; final Color danger; final Color dangerSoft; final Color dangerText; final Color infoText; final Color chartLine; final Color gap; /// 当前主题的色板。裸 MaterialApp(widget 测试未挂 extensions)回退 [light], /// 与改造前视觉一致,也不会因 extension 缺失而崩。 static InkwatchPalette of(BuildContext context) => Theme.of(context).extension() ?? light; /// 浅色:逐项 = [InkwatchColors]。 static const light = InkwatchPalette( page: InkwatchColors.page, surface: InkwatchColors.surface, surfaceMuted: InkwatchColors.surfaceMuted, line: InkwatchColors.line, textPrimary: InkwatchColors.textPrimary, textSecondary: InkwatchColors.textSecondary, textTertiary: InkwatchColors.textTertiary, primary: InkwatchColors.primary, primarySoft: InkwatchColors.primarySoft, online: InkwatchColors.online, onlineSoft: InkwatchColors.onlineSoft, onlineText: InkwatchColors.onlineText, suspect: InkwatchColors.suspect, suspectSoft: InkwatchColors.suspectSoft, suspectText: InkwatchColors.suspectText, offline: InkwatchColors.offline, offlineText: InkwatchColors.offlineText, unknownText: InkwatchColors.unknownText, danger: InkwatchColors.danger, dangerSoft: InkwatchColors.dangerSoft, dangerText: InkwatchColors.dangerText, infoText: InkwatchColors.infoText, chartLine: InkwatchColors.chartLine, gap: InkwatchColors.gap, ); /// 深色:逐项 = [InkwatchDarkColors]。 static const dark = InkwatchPalette( page: InkwatchDarkColors.page, surface: InkwatchDarkColors.surface, surfaceMuted: InkwatchDarkColors.surfaceMuted, line: InkwatchDarkColors.line, textPrimary: InkwatchDarkColors.textPrimary, textSecondary: InkwatchDarkColors.textSecondary, textTertiary: InkwatchDarkColors.textTertiary, primary: InkwatchDarkColors.primary, primarySoft: InkwatchDarkColors.primarySoft, online: InkwatchDarkColors.online, onlineSoft: InkwatchDarkColors.onlineSoft, onlineText: InkwatchDarkColors.onlineText, suspect: InkwatchDarkColors.suspect, suspectSoft: InkwatchDarkColors.suspectSoft, suspectText: InkwatchDarkColors.suspectText, offline: InkwatchDarkColors.offline, offlineText: InkwatchDarkColors.offlineText, unknownText: InkwatchDarkColors.unknownText, danger: InkwatchDarkColors.danger, dangerSoft: InkwatchDarkColors.dangerSoft, dangerText: InkwatchDarkColors.dangerText, infoText: InkwatchDarkColors.infoText, chartLine: InkwatchDarkColors.chartLine, gap: InkwatchDarkColors.gap, ); /// 水墨屏:全部映射到 [InkwatchEink] 六色(白 / 黑 / 深灰,R=G=B 无彩色)。 /// 看板状态靠字形([statusGlyph])而非颜色,此处只需保证绝无彩色。 static const eink = InkwatchPalette( page: InkwatchEink.page, surface: InkwatchEink.box, surfaceMuted: InkwatchEink.page, line: InkwatchEink.rule, textPrimary: InkwatchEink.ink, textSecondary: InkwatchEink.inkSoft, textTertiary: InkwatchEink.inkSoft, primary: InkwatchEink.ink, primarySoft: InkwatchEink.page, online: InkwatchEink.ink, onlineSoft: InkwatchEink.page, onlineText: InkwatchEink.ink, suspect: InkwatchEink.ink, suspectSoft: InkwatchEink.page, suspectText: InkwatchEink.ink, offline: InkwatchEink.inkSoft, offlineText: InkwatchEink.inkSoft, unknownText: InkwatchEink.inkSoft, danger: InkwatchEink.ink, dangerSoft: InkwatchEink.page, dangerText: InkwatchEink.ink, infoText: InkwatchEink.ink, chartLine: InkwatchEink.ink, gap: InkwatchEink.inkSoft, ); @override InkwatchPalette copyWith({ Color? page, Color? surface, Color? surfaceMuted, Color? line, Color? textPrimary, Color? textSecondary, Color? textTertiary, Color? primary, Color? primarySoft, Color? online, Color? onlineSoft, Color? onlineText, Color? suspect, Color? suspectSoft, Color? suspectText, Color? offline, Color? offlineText, Color? unknownText, Color? danger, Color? dangerSoft, Color? dangerText, Color? infoText, Color? chartLine, Color? gap, }) => InkwatchPalette( page: page ?? this.page, surface: surface ?? this.surface, surfaceMuted: surfaceMuted ?? this.surfaceMuted, line: line ?? this.line, textPrimary: textPrimary ?? this.textPrimary, textSecondary: textSecondary ?? this.textSecondary, textTertiary: textTertiary ?? this.textTertiary, primary: primary ?? this.primary, primarySoft: primarySoft ?? this.primarySoft, online: online ?? this.online, onlineSoft: onlineSoft ?? this.onlineSoft, onlineText: onlineText ?? this.onlineText, suspect: suspect ?? this.suspect, suspectSoft: suspectSoft ?? this.suspectSoft, suspectText: suspectText ?? this.suspectText, offline: offline ?? this.offline, offlineText: offlineText ?? this.offlineText, unknownText: unknownText ?? this.unknownText, danger: danger ?? this.danger, dangerSoft: dangerSoft ?? this.dangerSoft, dangerText: dangerText ?? this.dangerText, infoText: infoText ?? this.infoText, chartLine: chartLine ?? this.chartLine, gap: gap ?? this.gap, ); @override InkwatchPalette lerp(covariant InkwatchPalette? other, double t) { if (other == null) return this; return InkwatchPalette( page: Color.lerp(page, other.page, t)!, surface: Color.lerp(surface, other.surface, t)!, surfaceMuted: Color.lerp(surfaceMuted, other.surfaceMuted, t)!, line: Color.lerp(line, other.line, t)!, textPrimary: Color.lerp(textPrimary, other.textPrimary, t)!, textSecondary: Color.lerp(textSecondary, other.textSecondary, t)!, textTertiary: Color.lerp(textTertiary, other.textTertiary, t)!, primary: Color.lerp(primary, other.primary, t)!, primarySoft: Color.lerp(primarySoft, other.primarySoft, t)!, online: Color.lerp(online, other.online, t)!, onlineSoft: Color.lerp(onlineSoft, other.onlineSoft, t)!, onlineText: Color.lerp(onlineText, other.onlineText, t)!, suspect: Color.lerp(suspect, other.suspect, t)!, suspectSoft: Color.lerp(suspectSoft, other.suspectSoft, t)!, suspectText: Color.lerp(suspectText, other.suspectText, t)!, offline: Color.lerp(offline, other.offline, t)!, offlineText: Color.lerp(offlineText, other.offlineText, t)!, unknownText: Color.lerp(unknownText, other.unknownText, t)!, danger: Color.lerp(danger, other.danger, t)!, dangerSoft: Color.lerp(dangerSoft, other.dangerSoft, t)!, dangerText: Color.lerp(dangerText, other.dangerText, t)!, infoText: Color.lerp(infoText, other.infoText, t)!, chartLine: Color.lerp(chartLine, other.chartLine, t)!, gap: Color.lerp(gap, other.gap, t)!, ); } } /// 文字样式出口:把 [InkwatchText] 的档位(含浅色 color)按当前色板覆盖文字色。 /// /// 页面自绘统一走 `InkwatchTextStyles.of(p).caption`,避免在几十处散写 /// `InkwatchText.caption.copyWith(color: ...)` —— 那是同一份映射散落多处。 /// [InkwatchText] 静态常量本身**保留不动**(护栏 / parity 测试依赖它)。 class InkwatchTextStyles { const InkwatchTextStyles(this.p); final InkwatchPalette p; static InkwatchTextStyles of(InkwatchPalette p) => InkwatchTextStyles(p); TextStyle get title => InkwatchText.title.copyWith(color: p.textPrimary); TextStyle get section => InkwatchText.section.copyWith(color: p.textPrimary); TextStyle get body => InkwatchText.body.copyWith(color: p.textPrimary); TextStyle get bodyMuted => InkwatchText.bodyMuted.copyWith(color: p.textSecondary); TextStyle get caption => InkwatchText.caption.copyWith(color: p.textTertiary); TextStyle get micro => InkwatchText.micro.copyWith(color: p.textTertiary); TextStyle get metric => InkwatchText.metric.copyWith(color: p.textPrimary); TextStyle get metricUnit => InkwatchText.metricUnit.copyWith(color: p.textTertiary); TextStyle get stat => InkwatchText.stat.copyWith(color: p.textPrimary); } /// 水墨屏专用 Material 主题。 /// /// 与彩色版的三点不同,都是为了**减少刷新**: /// 1. `splashFactory: NoSplash` —— 去掉点击水波纹(那是每帧重绘的动画); /// 2. 全部 `elevation: 0` —— 阴影在墨水屏上只会变成一圈脏灰; /// 3. 颜色走 [InkwatchEink] 三档明度。 /// /// **只在 `theme.dart` 里 new ThemeData**(与彩色版同一条规矩)—— /// 页面里就地 `ThemeData(` 会被 `test/theme_guard_test.dart` 判红。 /// /// 挂 [InkwatchPalette.eink] 是必须的:看板内部若改用 [InkwatchPalette.of] 查询色板, /// 缺了这一份会取到 null 而崩。 ThemeData einkTheme() => ThemeData( useMaterial3: true, extensions: const >[InkwatchPalette.eink], scaffoldBackgroundColor: InkwatchEink.page, colorScheme: ColorScheme.fromSeed( seedColor: InkwatchEink.ink, brightness: Brightness.light, ).copyWith( surface: InkwatchEink.page, onSurface: InkwatchEink.ink, primary: InkwatchEink.ink, onPrimary: InkwatchEink.page, ), splashFactory: NoSplash.splashFactory, splashColor: const Color(0x00000000), highlightColor: const Color(0x00000000), appBarTheme: const AppBarTheme( backgroundColor: InkwatchEink.page, foregroundColor: InkwatchEink.ink, elevation: 0, scrolledUnderElevation: 0, centerTitle: false, ), cardTheme: const CardThemeData( color: InkwatchEink.box, elevation: 0, margin: EdgeInsets.zero, ), dividerTheme: const DividerThemeData( color: InkwatchEink.rule, thickness: 1, space: 1, ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( foregroundColor: InkwatchEink.ink, side: const BorderSide(color: InkwatchEink.rule, width: 1.5), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), textStyle: InkwatchEinkText.action, ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( foregroundColor: InkwatchEink.ink, textStyle: InkwatchEinkText.action, ), ), snackBarTheme: SnackBarThemeData( backgroundColor: InkwatchEink.ink, contentTextStyle: InkwatchEinkText.label.copyWith(color: InkwatchEink.page), behavior: SnackBarBehavior.floating, elevation: 0, ), );