feat(design-system): 合入 V2 线并统一命名为 Inkwatch,补齐深色主题与状态符号图标化
Design System Guard / guard (push) Failing after 7m11s

本提交收口本轮四件事。三者改动在同一批文件里交织(改名触及 theme.dart、
深色化也在 theme.dart),按文件无法干净切分,故合为一个可审计的提交,
下面按事由分述。

一、命名统一:Pmon* → Inkwatch*(合入 V2 线后的收尾)
  · 包名 pmon_client → inkwatch_client,Rust crate pmon-core → inkwatch-core。
    改名后必须 flutter pub get 重建 package_config,否则 dart analyze 会报
    1688 个"找不到 package:pmon_client"的假错误。
  · 符号:PmonColors→InkwatchColors、PmonPalette→InkwatchPalette、
    PmonTextStyles→InkwatchTextStyles、PmonRadius→InkwatchRadius、
    PmonEinkText→InkwatchEinkText、PMON_LOG_*→INKWATCH_LOG_*。
  · 覆盖 server/ client/ rust-core/ tests/ 全树;已用
    git grep -In 'Pmon\|PMON\|pmon' 复核,零残留(tokens.css 里最后一处
    §2.8 的类名注释也在本提交修掉)。
  · rust-core/Cargo.lock 的变动只是包名变化导致 [[package]] 字典序重排。

二、深色主题:58 键同名覆写 + Flutter 整屏深色化
  · tokens.css 增 dark 块(58 键),键名与浅色**逐一相同**——
    这是 token_parity_test.dart 的深色 parity 组在守的约束。
  · theme.dart:新增 InkwatchDarkColors / _assemble(palette, brightness),
    把语义色从"常量"改为"随 brightness 装配"。
  · main.dart:darkTheme + 显式 themeMode(ValueNotifier 持久化,不用
    ThemeMode.system —— 水墨屏用户要的是自己定,不是跟系统)。
  · theme_guard_test.dart C-15 → C-15′:从"禁止出现深色"改为"允许深色,
    但必须 58 键同名",护栏跟着设计意图一起改,而不是删掉。
  · 新增 dark_theme_test.dart / dark_ui_test.dart 覆盖装配与整屏渲染。

三、状态符号图标化:Unicode 字形 → Material Icons
  · symbol_guard_test.dart 按**码位**扫 lib/(注释之外),命中 5 处:
    transfer_card.dart 的 ⚠️/✓/✓/⚠,viewer_page.dart 的 ✓。
  · 只删字形会留下不可见的 U+FE0F,源码看不出、界面渲染成豆腐块,
    且按 emoji 区间匹配的旧护栏看不见它 —— 故护栏按码位判,且判据选
    "扫注释之外全部字符"(错了只会多报,不会漏)。
  · 改法:状态图标单独一槽,不拼进文案。
    - transfer_card.dart:_offerHint 由 String? 改为
      ({IconData? icon, Color? tint, String text})?,渲染为 Row(Icon+Text);
      「已收下」结果块同理(check_circle / error_outline)。
      图标着色:warning_amber→_p.suspect,check_circle→_p.online。
    - viewer_page.dart:_lanSummary() 返回 ({IconData? icon, String text}),
      诊断行由 ('对端内网', _lanSummary(), null) 改为 (…, lan.text, lan.icon)。
      调用点提到 return 之前只算一次。
  · 理由不只是"好看":字符符号在水墨屏上会渲染成方框,且无法随主题着色。

验证
  · client/scripts/test.sh:674 项全过(+674,0 失败,23s)
  · tests/run-guards.sh(RUN_AXE=1):
      web-token-guard.js              PASS  21/0
      phase3b-static-guard.js         PASS  87/0
      dockerfile-coverage-test.js     PASS  53/0
      schema-consistency-test.js      PASS 116/0
      client Dart 全量                PASS  674 项
      admin-axe-audit.mjs             PASS  42/42 格 viol=0(inc=12 有台账)
    6 套件 PASS 6 / FAIL 0 / SKIPPED 0
  · dart analyze:9 issues / 0 error(全部为既有 info/warning,非本次引入)
  · 两处 tokens.css(仓库内 admin/assets 与设计系统交付目录)逐字节一致
    sha256 088873cf6ece8da78135f96ca4633a537ac7802ea5f0fc8bd9c9294bb8a7bcaa

已知未做(写下来,不藏)
  · flutter analyze 在本机因仓库路径含中文(水墨屏工具)触发 analysis_server
    的 LSP 分帧 UTF-8 错误(FormatException: Unterminated string)。
    绕法是用 dart analyze,或把工作树放到纯 ASCII 路径下。
  · 窄屏降级态 + 失败态的组合真机复核:仍未做。
  · VoiceOver / NVDA 实机验证:NOT RUN。
  · 管理台登录框仍缺持久 <label>(现靠 aria-labelledby 指向标题)。
This commit is contained in:
PMon Dev committed 2026-11-09 17:24:56 +08:00
1 parent 89260024ee
commit 1d76c3b8ac
19 files changed
+945 -851

No files matched your search

+59 -59
View File
@@ -20,7 +20,7 @@ import 'host/boot_report.dart';
import 'host/device_id.dart';
import 'host/link.dart';
import 'host/metric_format.dart';
import 'host/pmon_core.dart';
import 'host/inkwatch_core.dart';
import 'host/resident_service.dart';
import 'host/startup_log.dart';
import 'platform/android_foreground.dart';
@@ -61,14 +61,14 @@ Future<void> main() async {
'platform': Platform.operatingSystem,
'dir': ConfigStore.dir,
});
// 掉帧诊断(默认关;`--dart-define=PMON_LOG_JANK=true` 打开,见 platform/frame_jank_log.dart)
// 掉帧诊断(默认关;`--dart-define=INKWATCH_LOG_JANK=true` 打开,见 platform/frame_jank_log.dart)
installJankLogger();
// 主题偏好是显式的应用内设置(不跟随系统):runApp 前读一次,
// 避免首屏先闪一下浅色再跳到用户上次选的深色。ConfigStore.load()
// 自带兜底、不抛异常,故不存在"卡在启动"的路径。
final initialCfg = await ConfigStore.load();
appThemeMode.value = themeModeFromConfig(initialCfg.themeMode);
runApp(PmonApp());
runApp(InkwatchApp());
}
/// 应用内**显式**主题偏好 —— 不跟随系统亮度。
@@ -98,8 +98,8 @@ Future<void> setAppThemeMode(ThemeMode mode) async {
}
}
class PmonApp extends StatelessWidget {
const PmonApp({super.key});
class InkwatchApp extends StatelessWidget {
const InkwatchApp({super.key});
@override
Widget build(BuildContext context) {
@@ -111,8 +111,8 @@ class PmonApp extends StatelessWidget {
// 主题必须**只有一处装配**(ui/theme.dart):
// 否则系统控件(输入框/菜单/对话框/SnackBar)与我们自绘的卡片会各是一套色与圆角,
// 表现为"弹窗像另一个应用"。旧版这里是就地 new 了一个只有 seedColor 的 ThemeData。
theme: buildPmonTheme(),
darkTheme: buildPmonDarkTheme(),
theme: buildInkwatchTheme(),
darkTheme: buildInkwatchDarkTheme(),
// 由应用内显式偏好驱动,不采用系统跟随模式。
themeMode: mode,
home: HomeShell(),
@@ -139,7 +139,7 @@ class HomeShell extends StatefulWidget {
}
class _HomeShellState extends State<HomeShell> {
PmonPalette get _p => PmonPalette.of(context);
InkwatchPalette get _p => InkwatchPalette.of(context);
int _tab = 0;
bool _viewerEverOpened = false;
@@ -182,7 +182,7 @@ class _HomeShellState extends State<HomeShell> {
// body 里发生什么它一无所知。不订阅的话,副屏底下会一直挂着
// 「采集 / 查看」两个页签(真机反馈"底下的导航键没有隐藏")。
bottomNavigationBar: ValueListenableBuilder<bool>(
valueListenable: PmonEinkBoardMode.active,
valueListenable: InkwatchEinkBoardMode.active,
builder: (context, boardMode, _) => boardMode
? SizedBox.shrink()
: DecoratedBox(
@@ -223,14 +223,14 @@ class ControlPanelPage extends StatefulWidget {
class _ControlPanelPageState extends State<ControlPanelPage>
with WidgetsBindingObserver {
PmonPalette get _p => PmonPalette.of(context);
PmonTextStyles get _tx => PmonTextStyles.of(_p);
InkwatchPalette get _p => InkwatchPalette.of(context);
InkwatchTextStyles get _tx => InkwatchTextStyles.of(_p);
late final TextEditingController _urlCtrl;
late final TextEditingController _deviceIdCtrl;
ClientAgent? _agent;
/// 常驻采集的状态/动作(P0-5)。只做"调 pmon-install CLI + 显示结果",
/// 常驻采集的状态/动作(P0-5)。只做"调 inkwatch-install CLI + 显示结果",
/// 判据在 CLI 那一侧(单一真相源),本类不自己查进程/查文件。
final ResidentService _resident = ResidentService();
@@ -506,7 +506,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
_hardwareId = hwId;
});
// 启动诊断口(默认关,`--dart-define=PMON_LOG_BOOT=true` 才输出)。
// 启动诊断口(默认关,`--dart-define=INKWATCH_LOG_BOOT=true` 才输出)。
//
// 用途:**覆盖安装残留**类故障的唯一取证口 —— release 包不可 `adb run-as`,
// 读不到私有目录里的 `config.json` / `viewer_cache.json`,只能让应用自己报。
@@ -557,9 +557,9 @@ class _ControlPanelPageState extends State<ControlPanelPage>
/// 文案由 Rust 核心生成(`runtime_guidance`),避免在 Dart 侧再写一份。
Future<void> _loadAndroidGuidance() async {
try {
final plan = PmonCore.instance.planAutostart(
final plan = InkwatchCore.instance.planAutostart(
exePath: '', // Android 的自启是构建期清单声明,不需要可执行文件路径
label: 'com.pmon.pmon_client',
label: 'com.inkwatch.inkwatch_client',
);
final guidance =
(plan['runtime_guidance'] as List?)?.cast<String>() ?? const <String>[];
@@ -637,7 +637,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
///
/// ── 为什么 agent 路径取自 CLI 而不是本地推导 ───────────────────
/// 沙箱 GUI 里 `installBinDirFor(macOS, homeDir)` 算出的 `homeDir` 就是容器
/// 路径,拼出来的是"容器/…/bin/pmon-agent",用它 pgrep 永远匹配不到(更糟:
/// 路径,拼出来的是"容器/…/bin/inkwatch-agent",用它 pgrep 永远匹配不到(更糟:
/// 会得到一个看起来确定的 `notRunning`)。CLI 是非沙箱进程,`status --json`
/// 报的 `installed.agent_path` 才是 launchd 实际启动的那条。
///
@@ -650,7 +650,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
final lockHeld = await AgentLock.isHeldByOther();
var presence = AgentPresence.notApplicable;
if (currentPlatform == PmonPlatform.macOS && !lockHeld) {
if (currentPlatform == InkwatchPlatform.macOS && !lockHeld) {
// 锁已经命中就不必再花一次 CLI 往返;两层只要有一层说"有人在跑"就够
final st = await _resident.status();
if (st.agentPath.trim().isEmpty) {
@@ -788,7 +788,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
@override
Widget build(BuildContext context) {
final core = PmonCore.instance;
final core = InkwatchCore.instance;
// 用 Scaffold.appBar 而不是自绘深色容器:状态栏内边距由框架负责,
// 不再出现"标题被系统时间压住"(旧自绘 header 的真机缺陷)。
return Scaffold(
@@ -809,9 +809,9 @@ class _ControlPanelPageState extends State<ControlPanelPage>
// 首屏三分之一被它吃掉,而真正要看的指标与折线被挤到下面。
// 现在:标题与平台交给 AppBar(自动处理状态栏内边距),其余信息按重要性重新分配到卡片里;
// 口号与能力项降级成一行 caption —— 它们对用户是"看过一次就够"的信息。
PreferredSizeWidget _appBar(PmonCore core) {
PreferredSizeWidget _appBar(InkwatchCore core) {
return AppBar(
titleSpacing: PmonSpace.lg,
titleSpacing: InkwatchSpace.lg,
title: Text('墨观 采集端'),
actions: [
// 主题切换(最小入口):应用内显式偏好,不跟随系统。
@@ -828,7 +828,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
)),
),
Padding(
padding: EdgeInsets.only(right: PmonSpace.lg),
padding: EdgeInsets.only(right: InkwatchSpace.lg),
child: Center(child: _badge(core.platformName)),
),
],
@@ -840,7 +840,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _p.surfaceMuted,
borderRadius: BorderRadius.circular(PmonRadius.pill),
borderRadius: BorderRadius.circular(InkwatchRadius.pill),
),
child: Text(text,
style: TextStyle(
@@ -852,7 +852,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
/// 能力一行 caption(原先是深色块里的三个 pill + 一句口号)。
/// 只保留**用户能对应到行为**的三项;"采集核心独立于界面 / 断线按 seq 补传"
/// 属实现细节,留在 README 里,不占首屏。
Widget _capabilityLine(PmonCore core) => Text(
Widget _capabilityLine(InkwatchCore core) => Text(
'指标 ${core.metricKeys().length} 项'
' · 后台常驻${core.canRunInBackground ? "支持" : "不支持"}'
' · 守护检查 ${core.guardianCheckIntervalMs ~/ 1000}s',
@@ -874,13 +874,13 @@ class _ControlPanelPageState extends State<ControlPanelPage>
/// —— 那样用户会把"形状变了"误读成"出事了"。
Widget _skeletonScreen(String hint) {
return ListView(
padding: EdgeInsets.all(PmonSpace.lg),
padding: EdgeInsets.all(InkwatchSpace.lg),
children: [
LoadingHint(text: hint),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
SkeletonMetricGrid(cells: 4),
SizedBox(height: PmonSpace.lg),
SkeletonBox(height: 90, radius: PmonRadius.md),
SizedBox(height: InkwatchSpace.lg),
SkeletonBox(height: 90, radius: InkwatchRadius.md),
],
);
}
@@ -936,7 +936,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
// 是 launchd 拉起的非沙箱进程,读真实家目录那份。用户在下面两项里
// 改地址,只有界面内采集会跟着变,常驻采集不会 —— 不说清的话,
// 症状是"改了网关地址、后台那台还在往旧地址发",两边都不报错。
if (currentPlatform != PmonPlatform.android) ...[
if (currentPlatform != InkwatchPlatform.android) ...[
Text(
'上面两项只对「界面内采集」生效。常驻采集是另一个进程,'
'读的是安装器那份配置 —— 装好后在「常驻采集」卡里能看到它'
@@ -1060,7 +1060,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
/// ② 本版本未接入采集 —— GPU / 温度 / 磁盘 IO(V1.5 路径)
/// ③ 其它 —— 兜底提示
Widget _unavailableNote() {
final caps = PmonCore.instance.capabilities();
final caps = InkwatchCore.instance.capabilities();
if (caps.isEmpty) return SizedBox.shrink();
final missing = caps.entries.where((e) => e.value == false).map((e) => e.key).toSet();
if (missing.isEmpty) return SizedBox.shrink();
@@ -1129,7 +1129,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
Widget _configFixNotice() {
return Card(
child: Padding(
padding: EdgeInsets.all(PmonSpace.md),
padding: EdgeInsets.all(InkwatchSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1160,23 +1160,23 @@ class _ControlPanelPageState extends State<ControlPanelPage>
}
Widget _dashboard(ClientAgent agent) {
final core = PmonCore.instance;
final core = InkwatchCore.instance;
return ValueListenableBuilder<AgentStatus>(
valueListenable: _statusVn,
builder: (context, st, _) {
final f = st.lastFrame ?? const <String, dynamic>{};
return ListView(
padding: EdgeInsets.all(PmonSpace.lg),
padding: EdgeInsets.all(InkwatchSpace.lg),
children: [
if (_configFixes.isNotEmpty) ...[
_configFixNotice(),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
],
_statusRow(st, core),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
// 主角:实时指标与折线(首屏就要看到"在采什么")
_metricsGrid(f),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
_samplingCard(st),
SizedBox(height: 14),
// 常驻采集(P0-5):桌面端才有意义 —— 关掉窗口继续采,靠的是
@@ -1221,7 +1221,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
hardwareId: _hardwareId,
onClaimed: (id) => unawaited(_issueDeviceTokenFor(id)),
),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
// 设备间传文件:发出 / 接收两个区。与账号、配对同级 ——
// 它同样是"这台机器与我的其它机器之间"的事,不是采样参数。
// 把采集端会话接进来 → 免去手工粘贴领取码(不通时自动退回粘贴)。
@@ -1232,12 +1232,12 @@ class _ControlPanelPageState extends State<ControlPanelPage>
false,
incomingOffers: _agent?.incomingOffers,
),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
// 解释性内容后置:首屏留给"在采什么","为什么是 —"放到下面
_unavailableNote(),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
_counters(st),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
_logPanel(agent),
SizedBox(height: 20),
],
@@ -1247,7 +1247,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
}
/// 状态灯三态(方案 13.3:绿在线 / 黄心跳超时疑似离线 / 灰离线)
Widget _statusRow(AgentStatus st, PmonCore core) {
Widget _statusRow(AgentStatus st, InkwatchCore core) {
final (color, text) = switch (st.linkState) {
LinkState.online => (_p.online, '在线'),
LinkState.suspect => (_p.suspect, '疑似离线(心跳超时)'),
@@ -1259,7 +1259,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
final showHint = !st.connected && st.lastError != null && st.reconnectCount > 0;
return Card(
child: Padding(
padding: EdgeInsets.all(PmonSpace.lg),
padding: EdgeInsets.all(InkwatchSpace.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1268,7 +1268,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
Row(
children: [
_statusDot(color),
SizedBox(width: PmonSpace.md),
SizedBox(width: InkwatchSpace.md),
Expanded(
child: Text(text,
style: TextStyle(
@@ -1281,7 +1281,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
icon: Icon(Icons.refresh, size: 16),
label: Text('强制重连'),
),
SizedBox(width: PmonSpace.xs),
SizedBox(width: InkwatchSpace.xs),
TextButton.icon(
onPressed: _stop,
icon: Icon(Icons.stop_circle_outlined,
@@ -1291,18 +1291,18 @@ class _ControlPanelPageState extends State<ControlPanelPage>
),
],
),
SizedBox(height: PmonSpace.sm),
SizedBox(height: InkwatchSpace.sm),
// 只留"报给谁"这一件用户会去核对的事;device_id 在配置面板的
// 「设备标识」输入框里本来就有,状态卡再显示一遍是重复(信息密度)。
_kvLine('目标', _shortTarget(st.serverUrl)),
SizedBox(height: PmonSpace.sm),
SizedBox(height: InkwatchSpace.sm),
_capabilityLine(core),
if (showHint) ...[
SizedBox(height: PmonSpace.md),
SizedBox(height: InkwatchSpace.md),
Container(
padding: EdgeInsets.all(PmonSpace.md),
padding: EdgeInsets.all(InkwatchSpace.md),
decoration:
PmonCard.notice(_p.suspect, _p.suspectSoft),
InkwatchCard.notice(_p.suspect, _p.suspectSoft),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1310,7 +1310,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
children: [
Icon(Icons.tips_and_updates_outlined,
size: 15, color: _p.suspect),
SizedBox(width: PmonSpace.sm),
SizedBox(width: InkwatchSpace.sm),
Expanded(
child: Text('连不上网关(已重试 ${st.reconnectCount} 次)',
style: TextStyle(
@@ -1320,11 +1320,11 @@ class _ControlPanelPageState extends State<ControlPanelPage>
),
],
),
SizedBox(height: PmonSpace.sm),
SizedBox(height: InkwatchSpace.sm),
Text(st.lastError!,
style: TextStyle(
fontSize: 12, color: _p.textSecondary)),
SizedBox(height: PmonSpace.sm),
SizedBox(height: InkwatchSpace.sm),
Text(
'排查:\n'
'· 手机需与服务器在同一局域网;在服务器上执行 '
@@ -1448,12 +1448,12 @@ class _ControlPanelPageState extends State<ControlPanelPage>
if (f.isEmpty) {
final cells = kMetricDisplayCatalog.where((s) => s.primary).length;
return Padding(
padding: EdgeInsets.only(bottom: PmonSpace.sm),
padding: EdgeInsets.only(bottom: InkwatchSpace.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LoadingHint(text: '正在采集第一帧…'),
SizedBox(height: PmonSpace.md),
SizedBox(height: InkwatchSpace.md),
SkeletonMetricGrid(cells: cells),
],
),
@@ -1479,16 +1479,16 @@ class _ControlPanelPageState extends State<ControlPanelPage>
crossAxisCount: cols,
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
mainAxisSpacing: PmonSpace.md,
crossAxisSpacing: PmonSpace.md,
mainAxisSpacing: InkwatchSpace.md,
crossAxisSpacing: InkwatchSpace.md,
// 同上:固定高度,别用宽高比(窄屏会算矮导致文字被裁)
mainAxisExtent: cols == 1 ? 66 : 84,
children: [
for (final (label, value, key, icon) in cards)
Container(
padding: EdgeInsets.symmetric(
horizontal: PmonSpace.lg, vertical: PmonSpace.md),
decoration: PmonCard.standard(palette: _p),
horizontal: InkwatchSpace.lg, vertical: InkwatchSpace.md),
decoration: InkwatchCard.standard(palette: _p),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
@@ -1496,7 +1496,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
Row(
children: [
Icon(icon, size: 15, color: _p.textTertiary),
SizedBox(width: PmonSpace.sm - 2),
SizedBox(width: InkwatchSpace.sm - 2),
Expanded(
child: Text(label,
style: TextStyle(
@@ -1507,7 +1507,7 @@ class _ControlPanelPageState extends State<ControlPanelPage>
),
],
),
SizedBox(height: PmonSpace.sm),
SizedBox(height: InkwatchSpace.sm),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
+24 -7
View File
@@ -46,8 +46,8 @@ class AccountCard extends StatefulWidget {
}
class _AccountCardState extends State<AccountCard> {
PmonPalette get _p => PmonPalette.of(context);
PmonTextStyles get _tx => PmonTextStyles.of(_p);
InkwatchPalette get _p => InkwatchPalette.of(context);
InkwatchTextStyles get _tx => InkwatchTextStyles.of(_p);
late final TextEditingController _emailCtrl;
late final TextEditingController _secretCtrl;
@@ -670,6 +670,12 @@ class _AccountCardState extends State<AccountCard> {
Widget _loggedInView() {
final info = _info;
// 元信息两行共用同一档样式 —— 它们原先挤在同一行,拆开后必须长得一样,
// 否则「档位比配额重要 / 次要」这种视觉暗示会凭空出现
final metaStyle = TextStyle(
fontSize: 11.5,
color: _p.textSecondary,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -695,12 +701,23 @@ class _AccountCardState extends State<AccountCard> {
),
if (info != null) ...[
SizedBox(height: 2),
Text(
'${info.email} · 档位 ${info.plan} · 我的设备 ${info.ownedDeviceCount}/${info.deviceQuota} 台',
style: TextStyle(
fontSize: 11.5,
color: _p.textSecondary,
// ── 配额行 ─────────────────────────────────────────────
// **任一数字缺失就整行不渲染**:服务端没给却写「0/0 台」,用户会以为
// 自己被降级了 —— 而那是编出来的数字(解析层已不再填默认值)。
if (info.ownedDeviceCount != null && info.deviceQuota != null)
Text(
'我的设备 ${info.ownedDeviceCount}/${info.deviceQuota} 台'
'${info.ownedDeviceCount! >= info.deviceQuota! ? ' · 已达上限' : ''}',
style: metaStyle,
),
// ── 档位行 ─────────────────────────────────────────────
// 中文档位名**只认服务端给的 plan_name**,拿不到就说「未知档位」。
// ① 绝不回落到机器键字段(free / pro / team):用户看不懂;
// ② 绝不按配额反推档位:free_device_limit 支持实例级覆盖,反推必然算错。
// 这两条都有源码护栏:test/plan_text_guard_test.dart
Text(
'${info.planName ?? '未知档位'} · 查看三档权益',
style: metaStyle,
),
SizedBox(height: 6),
Row(
+2 -2
View File
@@ -50,8 +50,8 @@ class DeviceMembersCard extends StatefulWidget {
}
class _DeviceMembersCardState extends State<DeviceMembersCard> {
PmonPalette get _p => PmonPalette.of(context);
PmonTextStyles get _tx => PmonTextStyles.of(_p);
InkwatchPalette get _p => InkwatchPalette.of(context);
InkwatchTextStyles get _tx => InkwatchTextStyles.of(_p);
final _emailCtrl = TextEditingController();
List<MemberInfo> _members = const [];
+68 -52
View File
@@ -286,7 +286,11 @@ class EinkBoard extends StatelessWidget {
/// 宿主提供的诊断行(刷新间隔、已收帧数、最后刷新时刻…)。
/// MediaQuery 相关的行由看板自己补 —— 那是展示层的事。
final List<(String, String)> diagnostics;
///
/// 每行是 `(标签, 值, 值前面的图标?)`。图标单列一个槽位而不是把 `✓` 拼进值:
/// 状态符号一律走 Material Icons —— 字符符号在水墨屏上会渲染成方框,
/// 且无法随主题着色(与 `ui/theme.dart` 那条「样式只许一处定义」同源)。
final List<(String, String, IconData?)> diagnostics;
@override
Widget build(BuildContext context) {
@@ -296,22 +300,22 @@ class EinkBoard extends StatelessWidget {
return Theme(
data: einkTheme(),
child: Scaffold(
backgroundColor: PmonEink.page,
backgroundColor: InkwatchEink.page,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
PmonSpace.lg, PmonSpace.md, PmonSpace.lg, PmonSpace.sm),
InkwatchSpace.lg, InkwatchSpace.md, InkwatchSpace.lg, InkwatchSpace.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(),
const SizedBox(height: PmonSpace.sm),
const SizedBox(height: InkwatchSpace.sm),
Expanded(
child: showDiagnostics
? _diagnostics(context)
: (hasData ? _grid(specs) : _noData()),
),
const SizedBox(height: PmonSpace.sm),
const SizedBox(height: InkwatchSpace.sm),
_footer(),
],
),
@@ -327,18 +331,18 @@ class EinkBoard extends StatelessWidget {
/// —— 墨水屏上最省墨、最清楚的分区手段。
Widget _header() {
final abnormal = statusKey != 'online';
final st = statusStyle(statusKey, PmonPalette.eink);
final st = statusStyle(statusKey, InkwatchPalette.eink);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
// 形状而非颜色:实心/半实心/空心/虚线 与 在线/疑似/离线/未知 一一对应
Text(statusGlyph(statusKey), style: PmonEinkText.glyph),
const SizedBox(width: PmonSpace.sm),
Text(statusGlyph(statusKey), style: InkwatchEinkText.glyph),
const SizedBox(width: InkwatchSpace.sm),
Expanded(
child: Text(deviceName,
style: PmonEinkText.title,
style: InkwatchEinkText.title,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
@@ -346,10 +350,10 @@ class EinkBoard extends StatelessWidget {
// ● 已经说了在线,右下的刷新时刻已经说了数据在动。
// 只有**异常**才把状态与原因铺出来(那时它们才是有用信息)。
if (abnormal) ...[
const SizedBox(width: PmonSpace.sm),
const SizedBox(width: InkwatchSpace.sm),
Flexible(
child: Text('${st.label} · $agoText',
style: PmonEinkText.label,
style: InkwatchEinkText.label,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
@@ -357,7 +361,7 @@ class EinkBoard extends StatelessWidget {
],
),
const SizedBox(height: 6),
const Divider(height: 2, thickness: 2, color: PmonEink.rule),
const Divider(height: 2, thickness: 2, color: InkwatchEink.rule),
],
);
}
@@ -378,13 +382,13 @@ class EinkBoard extends StatelessWidget {
final fixedWidths = parts
.map((p) => p.$2.isEmpty
? 0.0
: unitGap + measureEinkText(' ${p.$2}', PmonEinkText.label).width)
: unitGap + measureEinkText(' ${p.$2}', InkwatchEinkText.label).width)
.toList();
// 单格**内容区**(扣掉内边距、标签行与标签下的间距)—— 用来定统一缩放比例
const padH = 12.0;
final cellW = c.maxWidth / cols - padH * 2;
final labelH = measureEinkText('内存使用率', PmonEinkText.label).height;
final labelH = measureEinkText('内存使用率', InkwatchEinkText.label).height;
// ★ 矮屏要**让位给数值**(2026-09-16 真机暴露):
// F50 横屏只有 ~366dp 高,标签 + 内边距吃掉了几乎整格,
@@ -403,12 +407,12 @@ class EinkBoard extends StatelessWidget {
texts: texts,
maxWidth: cellW,
maxHeight: cellH,
base: PmonEinkText.value,
base: InkwatchEinkText.value,
fixedWidths: fixedWidths,
);
// ★ 全屏共用**一个**数值字号(唯一允许改字号的地方,且是算出来的)
final valueStyle = PmonEinkText.value
.copyWith(fontSize: PmonEinkText.value.fontSize! * scale);
final valueStyle = InkwatchEinkText.value
.copyWith(fontSize: InkwatchEinkText.value.fontSize! * scale);
final rows2 = <Widget>[];
for (var r = 0; r < rows; r++) {
@@ -436,7 +440,7 @@ class EinkBoard extends StatelessWidget {
}
return Container(
decoration:
BoxDecoration(border: Border.all(color: PmonEink.rule, width: 1.5)),
BoxDecoration(border: Border.all(color: InkwatchEink.rule, width: 1.5)),
child: Column(children: rows2),
);
});
@@ -460,10 +464,10 @@ class EinkBoard extends StatelessWidget {
decoration: BoxDecoration(
border: Border(
right: rightBorder
? const BorderSide(color: PmonEink.rule, width: 1)
? const BorderSide(color: InkwatchEink.rule, width: 1)
: BorderSide.none,
bottom: bottomBorder
? const BorderSide(color: PmonEink.rule, width: 1)
? const BorderSide(color: InkwatchEink.rule, width: 1)
: BorderSide.none,
),
),
@@ -475,7 +479,7 @@ class EinkBoard extends StatelessWidget {
// key 只为让测试能**精确定位**数值与标签:
// "同屏字号必须一致"这条要求只有在能逐格取值时才可断言。
key: ValueKey('eink-label-${s.key}'),
style: PmonEinkText.label,
style: InkwatchEinkText.label,
maxLines: 1,
overflow: TextOverflow.ellipsis),
SizedBox(height: labelGap),
@@ -501,7 +505,7 @@ class EinkBoard extends StatelessWidget {
parts.$1,
key: ValueKey('eink-value-${s.key}'),
style: raw == null
? valueStyle.copyWith(color: PmonEink.inkSoft)
? valueStyle.copyWith(color: InkwatchEink.inkSoft)
: valueStyle,
maxLines: 1,
),
@@ -513,8 +517,8 @@ class EinkBoard extends StatelessWidget {
child: Text(
parts.$2,
key: ValueKey('eink-unit-${s.key}'),
style: PmonEinkText.label
.copyWith(color: PmonEink.inkSoft),
style: InkwatchEinkText.label
.copyWith(color: InkwatchEink.inkSoft),
maxLines: 1,
),
),
@@ -538,17 +542,17 @@ class EinkBoard extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.hourglass_empty, size: 40, color: PmonEink.ink),
const SizedBox(height: PmonSpace.md),
const Text('等待这台设备上报第一帧', style: PmonEinkText.title),
const SizedBox(height: PmonSpace.sm),
const Icon(Icons.hourglass_empty, size: 40, color: InkwatchEink.ink),
const SizedBox(height: InkwatchSpace.md),
const Text('等待这台设备上报第一帧', style: InkwatchEinkText.title),
const SizedBox(height: InkwatchSpace.sm),
Text(agoText,
style: PmonEinkText.caption,
style: InkwatchEinkText.caption,
textAlign: TextAlign.center,
maxLines: 2),
if (abnormal) ...[
const SizedBox(height: PmonSpace.xs),
Text(statusStyle(statusKey, PmonPalette.eink).label, style: PmonEinkText.caption),
const SizedBox(height: InkwatchSpace.xs),
Text(statusStyle(statusKey, InkwatchPalette.eink).label, style: InkwatchEinkText.caption),
],
],
),
@@ -569,18 +573,20 @@ class EinkBoard extends StatelessWidget {
// ★ 布局尺寸取自**约束**而不是 MediaQuery:
// 应用内旋转时 `RotatedBox` 会交换约束,于是两者**不相等** ——
// 这正好是最硬的证据(一眼看出"系统没转、是应用自己在转")。
final rows = <(String, String)>[
('窗口(物理)', '${mq.size.width.round()} × ${mq.size.height.round()} dp'),
('布局(看板用)', '${c.maxWidth.round()} × ${c.maxHeight.round()} dp'),
('设备像素比', mq.devicePixelRatio.toStringAsFixed(3)),
final rows = <(String, String, IconData?)>[
('窗口(物理)',
'${mq.size.width.round()} × ${mq.size.height.round()} dp', null),
('布局(看板用)',
'${c.maxWidth.round()} × ${c.maxHeight.round()} dp', null),
('设备像素比', mq.devicePixelRatio.toStringAsFixed(3), null),
('物理像素', '${(mq.size.width * mq.devicePixelRatio).round()} × '
'${(mq.size.height * mq.devicePixelRatio).round()}'),
('看板列数', '${einkGridColumns(c.maxWidth, c.maxHeight)} 列'),
'${(mq.size.height * mq.devicePixelRatio).round()}', null),
('看板列数', '${einkGridColumns(c.maxWidth, c.maxHeight)} 列', null),
...diagnostics,
];
return Container(
decoration:
BoxDecoration(border: Border.all(color: PmonEink.rule, width: 1.5)),
BoxDecoration(border: Border.all(color: InkwatchEink.rule, width: 1.5)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -591,7 +597,7 @@ class EinkBoard extends StatelessWidget {
decoration: BoxDecoration(
border: Border(
bottom: i < rows.length - 1
? const BorderSide(color: PmonEink.rule, width: 1)
? const BorderSide(color: InkwatchEink.rule, width: 1)
: BorderSide.none,
),
),
@@ -599,15 +605,25 @@ class EinkBoard extends StatelessWidget {
children: [
Expanded(
flex: 2,
child: Text(rows[i].$1, style: PmonEinkText.label),
child: Text(rows[i].$1, style: InkwatchEinkText.label),
),
Expanded(
flex: 3,
child: Text(rows[i].$2,
style: PmonEinkText.label
.copyWith(color: PmonEink.ink),
maxLines: 1,
overflow: TextOverflow.ellipsis),
child: Row(
children: [
if (rows[i].$3 != null) ...[
Icon(rows[i].$3, size: 16, color: InkwatchEink.ink),
const SizedBox(width: 4),
],
Flexible(
child: Text(rows[i].$2,
style: InkwatchEinkText.label
.copyWith(color: InkwatchEink.ink),
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
),
),
],
),
@@ -627,31 +643,31 @@ class EinkBoard extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(height: 2, thickness: 2, color: PmonEink.rule),
const SizedBox(height: PmonSpace.sm),
const Divider(height: 2, thickness: 2, color: InkwatchEink.rule),
const SizedBox(height: InkwatchSpace.sm),
Row(
children: [
Expanded(
child: Text('$lastRefreshText · $refreshNote',
style: PmonEinkText.caption,
style: InkwatchEinkText.caption,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: PmonSpace.sm),
const SizedBox(width: InkwatchSpace.sm),
OutlinedButton(onPressed: onRefresh, child: const Text('刷新')),
if (onToggleOrientation != null) ...[
const SizedBox(width: PmonSpace.sm),
const SizedBox(width: InkwatchSpace.sm),
OutlinedButton(
onPressed: onToggleOrientation, child: const Text('转屏')),
],
if (onToggleDiagnostics != null) ...[
const SizedBox(width: PmonSpace.xs),
const SizedBox(width: InkwatchSpace.xs),
TextButton(
onPressed: onToggleDiagnostics,
child: Text(showDiagnostics ? '返回' : '自检')),
],
if (onExit != null) ...[
const SizedBox(width: PmonSpace.xs),
const SizedBox(width: InkwatchSpace.xs),
TextButton(onPressed: onExit, child: const Text('退出')),
],
],
+13 -13
View File
@@ -25,8 +25,8 @@ import 'package:flutter/material.dart';
import 'theme.dart';
/// 全局:当前是否处于水墨屏模式。见文件头(含"必须自己触发重建"的约定)。
class PmonEinkMode {
PmonEinkMode._();
class InkwatchEinkMode {
InkwatchEinkMode._();
static bool enabled = false;
@@ -36,15 +36,15 @@ class PmonEinkMode {
/// 全局:**看板是否正在前台全屏显示**。
///
/// ── 为什么它必须是"可监听"的,而 [PmonEinkMode] 不必 ──────────────
/// `PmonEinkMode` 只在整棵树重建时被读一次,静态 bool 够用;
/// ── 为什么它必须是"可监听"的,而 [InkwatchEinkMode] 不必 ──────────────
/// `InkwatchEinkMode` 只在整棵树重建时被读一次,静态 bool 够用;
/// 但**主壳要据此收起自己的底部导航** —— 看板渲染在 `IndexedStack` 的 body 里,
/// 而主壳的 `bottomNavigationBar` 是一个**独立区域**,它不感知 body 里发生了什么。
/// 不发通知的话,副屏底下会一直挂着"采集 / 查看"两个页签(实测反馈)。
///
/// 故这里用 `ValueNotifier`,主壳用 `ValueListenableBuilder` 订阅。
class PmonEinkBoardMode {
PmonEinkBoardMode._();
class InkwatchEinkBoardMode {
InkwatchEinkBoardMode._();
static final ValueNotifier<bool> active = ValueNotifier<bool>(false);
@@ -66,14 +66,14 @@ class WaitIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (PmonEinkMode.enabled) {
if (InkwatchEinkMode.enabled) {
// 静止:实心方块 + 文字。不用 Icon 的 spinner(那些也是动画)
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: size * 0.6, height: size * 0.6, color: PmonEink.fill),
Container(width: size * 0.6, height: size * 0.6, color: InkwatchEink.fill),
SizedBox(width: 6),
Text(label, style: PmonEinkText.caption),
Text(label, style: InkwatchEinkText.caption),
],
);
}
@@ -97,13 +97,13 @@ class EinkAwareSkeleton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final p = PmonPalette.of(context);
if (PmonEinkMode.enabled) {
final p = InkwatchPalette.of(context);
if (InkwatchEinkMode.enabled) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
border: Border.all(color: PmonEink.rule, width: 1),
border: Border.all(color: InkwatchEink.rule, width: 1),
),
);
}
@@ -112,7 +112,7 @@ class EinkAwareSkeleton extends StatelessWidget {
height: height,
decoration: BoxDecoration(
color: p.surfaceMuted,
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
),
);
}
+1 -1
View File
@@ -62,7 +62,7 @@ class PairingCard extends StatefulWidget {
}
class _PairingCardState extends State<PairingCard> {
PmonPalette get _p => PmonPalette.of(context);
InkwatchPalette get _p => InkwatchPalette.of(context);
late final PairingApi _api;
Timer? _tick;
+23 -23
View File
@@ -2,12 +2,12 @@
///
/// ── 这张卡要解决什么 ──────────────────────────────────────────────
/// 关窗即停:`main.dart` 的 `dispose()` 会 `_agent?.stop()`,而"关掉窗口继续采"
/// 需要常驻进程 + 自启注册。`bin/pmon_agent.dart`(常驻进程)与
/// `bin/pmon_install.dart`(注册/卸载/状态)早就就绪,但 GUI **一个入口都没有** ——
/// 需要常驻进程 + 自启注册。`bin/inkwatch_agent.dart`(常驻进程)与
/// `bin/inkwatch_install.dart`(注册/卸载/状态)早就就绪,但 GUI **一个入口都没有** ——
/// 用户得自己敲命令,等于这个能力不存在。
///
/// ── 三条设计纪律 ─────────────────────────────────────────────────
/// 1. **判据只有一处**:状态来自 `pmon-install status --json`
/// 1. **判据只有一处**:状态来自 `inkwatch-install status --json`
/// (见 `host/resident_service.dart`),本卡不自己 stat 文件、不自己 pgrep。
/// 2. **不在 GUI 里做 launchctl/systemd**:自启产物内容由 Rust 生成、落盘由
/// 非沙箱 CLI 执行(沙箱写不了 LaunchAgents)。GUI 再写一套就是第二套自启逻辑。
@@ -54,8 +54,8 @@ class ResidentCard extends StatefulWidget {
}
class _ResidentCardState extends State<ResidentCard> {
PmonPalette get _p => PmonPalette.of(context);
PmonTextStyles get _tx => PmonTextStyles.of(_p);
InkwatchPalette get _p => InkwatchPalette.of(context);
InkwatchTextStyles get _tx => InkwatchTextStyles.of(_p);
ResidentStatus _st = ResidentStatus.unknown;
bool _busy = false;
@@ -186,22 +186,22 @@ class _ResidentCardState extends State<ResidentCard> {
return Card(
child: Padding(
padding: EdgeInsets.all(PmonSpace.lg),
padding: EdgeInsets.all(InkwatchSpace.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.memory_outlined, size: 18, color: _p.primary),
SizedBox(width: PmonSpace.sm),
SizedBox(width: InkwatchSpace.sm),
Text('常驻采集', style: _tx.title),
Spacer(),
Container(
padding: EdgeInsets.symmetric(
horizontal: PmonSpace.sm, vertical: PmonSpace.xs),
horizontal: InkwatchSpace.sm, vertical: InkwatchSpace.xs),
decoration: BoxDecoration(
color: _p.surfaceMuted,
borderRadius: BorderRadius.circular(PmonRadius.pill),
borderRadius: BorderRadius.circular(InkwatchRadius.pill),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Container(
@@ -209,33 +209,33 @@ class _ResidentCardState extends State<ResidentCard> {
height: 8,
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
),
SizedBox(width: PmonSpace.sm),
SizedBox(width: InkwatchSpace.sm),
Text(label, style: _tx.caption.copyWith(color: dot)),
]),
),
],
),
SizedBox(height: PmonSpace.sm),
SizedBox(height: InkwatchSpace.sm),
Text(
'由系统自启托管:**关掉这个窗口也会继续采集**。'
'本卡片只调安装器 CLI 显示与操作,不在这里重写自启逻辑。',
style: _tx.caption,
),
SizedBox(height: PmonSpace.md),
SizedBox(height: InkwatchSpace.md),
_statusLine(),
if (effectiveConfig != null) ...[
SizedBox(height: PmonSpace.sm),
SizedBox(height: InkwatchSpace.sm),
effectiveConfig,
],
if (_error != null) ...[
SizedBox(height: PmonSpace.sm),
SizedBox(height: InkwatchSpace.sm),
_errorBox(_error!),
],
if (_note != null && _error == null) ...[
SizedBox(height: PmonSpace.sm),
SizedBox(height: InkwatchSpace.sm),
Text(_note!, style: _tx.caption),
],
SizedBox(height: PmonSpace.md),
SizedBox(height: InkwatchSpace.md),
Row(
children: [
if (_st.state != ResidentState.running)
@@ -253,7 +253,7 @@ class _ResidentCardState extends State<ResidentCard> {
? '安装并启动'
: '重新安装并启动'),
),
if (_st.state != ResidentState.running) SizedBox(width: PmonSpace.sm),
if (_st.state != ResidentState.running) SizedBox(width: InkwatchSpace.sm),
if (_st.state != ResidentState.notInstalled)
OutlinedButton(
onPressed: _busy
@@ -262,7 +262,7 @@ class _ResidentCardState extends State<ResidentCard> {
child: Text('卸载'),
),
if (_busy) ...[
SizedBox(width: PmonSpace.md),
SizedBox(width: InkwatchSpace.md),
SizedBox(
width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
],
@@ -278,7 +278,7 @@ class _ResidentCardState extends State<ResidentCard> {
///
/// ── 为什么必须显示它(而不是显示界面自己那份)────────────────────
/// macOS GUI 是**真沙箱**(`com.apple.security.app-sandbox`,`HOME` 指向
/// `~/Library/Containers/com.pmon.pmonClient/Data`),它读写的 `ConfigStore`
/// `~/Library/Containers/com.inkwatch.inkwatchClient/Data`),它读写的 `ConfigStore`
/// 在应用容器里;而常驻采集端由 launchd 拉起(**非沙箱**),读真实家目录。
/// 于是磁盘上**两份 config 同时活着**:用户在界面里改网关地址,只改了容器那份,
/// 采集端看到的还是旧的 —— 而两边都不会报错。这比"读不到配置"更隐蔽。
@@ -318,7 +318,7 @@ class _ResidentCardState extends State<ResidentCard> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('采集端当前读到:$effective', style: _tx.caption),
SizedBox(height: PmonSpace.xs),
SizedBox(height: InkwatchSpace.xs),
if (mismatch)
Text(
'与上方输入框($wanted)不一致:常驻采集读的是安装器那份配置。'
@@ -365,10 +365,10 @@ class _ResidentCardState extends State<ResidentCard> {
Widget _errorBox(String msg) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(PmonSpace.md),
padding: EdgeInsets.all(InkwatchSpace.md),
decoration: BoxDecoration(
color: _p.dangerSoft,
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -376,7 +376,7 @@ class _ResidentCardState extends State<ResidentCard> {
Text('操作失败(原因如下,来自安装器输出)',
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: _p.danger)),
SizedBox(height: PmonSpace.xs),
SizedBox(height: InkwatchSpace.xs),
// 原样显示 CLI 的失败输出:哪条命令失败、错在哪,都在这里
SelectableText(msg,
style: TextStyle(
+9 -9
View File
@@ -26,7 +26,7 @@ class SkeletonBox extends StatelessWidget {
this.width,
this.height = 12,
// 圆角走既有刻度,不写就地 6:与 viewer_chart 的字号收敛同一纪律
this.radius = PmonRadius.sm,
this.radius = InkwatchRadius.sm,
});
final double? width;
@@ -35,7 +35,7 @@ class SkeletonBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
final p = PmonPalette.of(context);
final p = InkwatchPalette.of(context);
return Container(
width: width,
height: height,
@@ -56,18 +56,18 @@ class SkeletonMetricCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final p = PmonPalette.of(context);
final p = InkwatchPalette.of(context);
return Container(
height: height,
padding: EdgeInsets.symmetric(
horizontal: PmonSpace.lg, vertical: PmonSpace.md),
decoration: PmonCard.standard(palette: p),
horizontal: InkwatchSpace.lg, vertical: InkwatchSpace.md),
decoration: InkwatchCard.standard(palette: p),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: const [
SkeletonBox(width: 64, height: 11), // 标签位置
SizedBox(height: PmonSpace.md),
SizedBox(height: InkwatchSpace.md),
SkeletonBox(width: 88, height: 18), // 数值位置
],
),
@@ -93,8 +93,8 @@ class SkeletonMetricGrid extends StatelessWidget {
crossAxisCount: cols,
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
mainAxisSpacing: PmonSpace.md,
crossAxisSpacing: PmonSpace.md,
mainAxisSpacing: InkwatchSpace.md,
crossAxisSpacing: InkwatchSpace.md,
mainAxisExtent: cols == 1 ? 66 : cellHeight,
children: List.generate(cells, (_) => SkeletonMetricCard()),
);
@@ -110,7 +110,7 @@ class LoadingHint extends StatelessWidget {
@override
Widget build(BuildContext context) {
final tx = PmonTextStyles.of(PmonPalette.of(context));
final tx = InkwatchTextStyles.of(InkwatchPalette.of(context));
return Text(text, style: tx.caption);
}
}
+218 -218
View File
@@ -12,7 +12,7 @@
///
/// 1. **颜色/间距/圆角/字号只在这里定义**。页面里出现 `Color(0x…)` 即判红
/// (由 `test/theme_guard_test.dart` 源码结构断言锁定)。
/// 2. **语义优先于色值**:写 `PmonColors.online` 而不是 `Colors.green` ——
/// 2. **语义优先于色值**:写 `InkwatchColors.online` 而不是 `Colors.green` ——
/// 否则"在线"换成别的色时,要满地找哪几处是它。
/// 3. **只提供必要刻度**,不做无限可调的间距体系(4/8/12/16/20/24 够用)。
/// 刻度一多,同一个视觉决策又会有三种写法。
@@ -26,7 +26,7 @@ library;
import 'package:flutter/material.dart';
/// 色板与语义色
abstract final class PmonColors {
abstract final class InkwatchColors {
// ── 中性(浅色极简的骨架)──────────────────────────────────────
/// 页面底色(比卡片略深,让白卡片浮起来 —— 用明度差代替阴影)
static const page = Color(0xFFF6F7F9);
@@ -106,7 +106,7 @@ abstract final class PmonColors {
/// 颜色 / 状态 / 图表 / 品牌 / 反馈里,凡 Web 专属的交互态与预混实色一律
/// **保持缺省、不写进 Dart**,理由与浅色侧 `kWebOnlyTokens` 完全一致:
/// · hover/active/五种 `*-border` 预混实色 → Flutter 用 Material `WidgetState`
/// 处理交互态、用 `withValues(alpha:)` 动态算描边(见 [PmonCard.notice]);
/// 处理交互态、用 `withValues(alpha:)` 动态算描边(见 [InkwatchCard.notice]);
/// · `series-2/3/4`、`chart-area/grid/axis`、`*-gap-band/threshold-*` →
/// `viewer_chart.dart` 只画单序列,无多序列 / 预混面积需求;
/// · `logo-*` 四色 → Dart 侧没有消费点;
@@ -115,12 +115,12 @@ abstract final class PmonColors {
/// (反向缺项判红),防止有人"好心"补一套用不到的常量。
///
/// 几何 / 排印 / 描边宽度 / 动效 / 端侧派生 / E-Ink 六色**本就不在 dark 块里**
/// (05 §1.4「应保持缺省」),Flutter 已有 `PmonSpace` / `PmonRadius` /
/// `PmonText` 语义档位,一套都不新增。
/// (05 §1.4「应保持缺省」),Flutter 已有 `InkwatchSpace` / `InkwatchRadius` /
/// `InkwatchText` 语义档位,一套都不新增。
///
/// ⚠️ [textTertiary] 保持 `#8A93A3`(深底 5.02–6.24:1 达标),**显式不同步**
/// 浅色点修值 `#646D7D` —— 同步会让深底对比跌到 2.98–3.70:1,制造新违规。
abstract final class PmonDarkColors {
abstract final class InkwatchDarkColors {
// ── 中性面(--iw-page / surface / surface-muted / line)──────────
static const page = Color(0xFF0B0E14);
static const surface = Color(0xFF151A23);
@@ -164,7 +164,7 @@ abstract final class PmonDarkColors {
}
/// 间距刻度(只此七档;新增刻度前先问"是否真的需要")
abstract final class PmonSpace {
abstract final class InkwatchSpace {
static const xs = 4.0;
static const sm = 8.0;
static const md = 12.0;
@@ -174,7 +174,7 @@ abstract final class PmonSpace {
}
/// 圆角刻度
abstract final class PmonRadius {
abstract final class InkwatchRadius {
static const sm = 8.0;
static const md = 12.0;
static const lg = 16.0;
@@ -182,64 +182,64 @@ abstract final class PmonRadius {
}
/// 文字样式(三级 + 标题 + 数值)
abstract final class PmonText {
abstract final class InkwatchText {
/// 页面/卡片大标题
static const title = TextStyle(
fontSize: 17, fontWeight: FontWeight.w600, color: PmonColors.textPrimary, height: 1.3);
fontSize: 17, fontWeight: FontWeight.w600, color: InkwatchColors.textPrimary, height: 1.3);
/// 分区标题(卡片头)
static const section = TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: PmonColors.textPrimary, height: 1.35);
fontSize: 14, fontWeight: FontWeight.w600, color: InkwatchColors.textPrimary, height: 1.35);
/// 正文
static const body = TextStyle(
fontSize: 13, color: PmonColors.textPrimary, height: 1.5);
fontSize: 13, color: InkwatchColors.textPrimary, height: 1.5);
/// 正文(次要)
static const bodyMuted = TextStyle(
fontSize: 13, color: PmonColors.textSecondary, height: 1.5);
fontSize: 13, color: InkwatchColors.textSecondary, height: 1.5);
/// 说明文字(脚注、解释、诚实边界文案)
static const caption = TextStyle(
fontSize: 12, color: PmonColors.textTertiary, height: 1.5);
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: PmonColors.textTertiary, height: 1.4);
fontSize: 11, color: InkwatchColors.textTertiary, height: 1.4);
/// 指标数值(等宽数字,避免跳动)
static const metric = TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: PmonColors.textPrimary,
color: InkwatchColors.textPrimary,
height: 1.15,
fontFeatures: [FontFeature.tabularFigures()]);
/// 指标单位/后缀
static const metricUnit = TextStyle(
fontSize: 12, fontWeight: FontWeight.w500, color: PmonColors.textTertiary);
fontSize: 12, fontWeight: FontWeight.w500, color: InkwatchColors.textTertiary);
/// 小统计块数值
static const stat = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: PmonColors.textPrimary,
color: InkwatchColors.textPrimary,
fontFeatures: [FontFeature.tabularFigures()]);
}
/// 卡片与容器装饰(**统一在这里造**,页面不要自己拼 BoxDecoration)
abstract final class PmonCard {
abstract final class InkwatchCard {
/// 标准卡片:面 + 发丝线 + 大圆角。不用阴影(极简取向,且阴影在深色/低端屏上更难控)
///
/// [palette] 缺省时回退 [PmonPalette.light](与改造前逐字段一致),
/// 页面自绘一律传 `PmonPalette.of(context)` —— 深色下卡片才真正变深。
static BoxDecoration standard({Color? tint, PmonPalette? palette}) {
final p = palette ?? PmonPalette.light;
/// [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(PmonRadius.md),
borderRadius: BorderRadius.circular(InkwatchRadius.md),
border: Border.all(color: p.line),
);
}
@@ -248,16 +248,16 @@ abstract final class PmonCard {
/// [base] / [soft] 由调用方按当前色板传入(见 [statusStyle])。
static BoxDecoration notice(Color base, Color soft) => BoxDecoration(
color: soft,
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
border: Border.all(color: base.withValues(alpha: 0.28)),
);
/// 选中态容器(主色底 + 主色描边)
static BoxDecoration selected({PmonPalette? palette}) {
final p = palette ?? PmonPalette.light;
static BoxDecoration selected({InkwatchPalette? palette}) {
final p = palette ?? InkwatchPalette.light;
return BoxDecoration(
color: p.primarySoft,
borderRadius: BorderRadius.circular(PmonRadius.md),
borderRadius: BorderRadius.circular(InkwatchRadius.md),
border: Border.all(color: p.primary, width: 1.5),
);
}
@@ -268,26 +268,26 @@ abstract final class PmonCard {
///
/// 浅色 / 深色共用**同一段控件装配**([_assemble]),只传入不同的语义色板
/// ([_lightPalette] / [_darkPalette])—— 不另写一套分叉的控件配置。
/// `buildPmonDarkTheme()` 也走这里,故「只在 theme.dart 内 new ThemeData」的
/// `buildInkwatchDarkTheme()` 也走这里,故「只在 theme.dart 内 new ThemeData」的
/// 规矩对深色同样成立(页面里就地 `ThemeData(` 由 theme_guard_test.dart 判红)。
ThemeData buildPmonTheme() => _assemble(_lightPalette, Brightness.light)
ThemeData buildInkwatchTheme() => _assemble(_lightPalette, Brightness.light)
.copyWith(
extensions: const <ThemeExtension<dynamic>>[PmonPalette.light]);
extensions: const <ThemeExtension<dynamic>>[InkwatchPalette.light]);
/// 深色皮肤主题。色值来自 `tokens.css` 的 `html[data-iw-theme="dark"]`
/// (05 §1.4 的 58 个同名键覆写),由 [PmonDarkColors] 逐项转录。
/// (05 §1.4 的 58 个同名键覆写),由 [InkwatchDarkColors] 逐项转录。
///
/// 装配结构与 [buildPmonTheme] 完全相同,仅语义色替换为深色板:
/// 圆角 / 间距 / 字号刻度一律沿用 PmonSpace / PmonRadius / PmonText,
/// 装配结构与 [buildInkwatchTheme] 完全相同,仅语义色替换为深色板:
/// 圆角 / 间距 / 字号刻度一律沿用 InkwatchSpace / InkwatchRadius / InkwatchText,
/// 不新增任何刻度。E-Ink 看板走独立的 [einkTheme],不受本主题影响。
ThemeData buildPmonDarkTheme() => _assemble(_darkPalette, Brightness.dark)
ThemeData buildInkwatchDarkTheme() => _assemble(_darkPalette, Brightness.dark)
.copyWith(
extensions: const <ThemeExtension<dynamic>>[PmonPalette.dark]);
extensions: const <ThemeExtension<dynamic>>[InkwatchPalette.dark]);
/// 控件装配用到的语义色子集。**不是新令牌** —— 只是把两套色板喂给同一段装配,
/// 避免把 Web 的「预混实色 / 交互态」概念搬进 Dart。
class _PmonPalette {
const _PmonPalette({
class _InkwatchPalette {
const _InkwatchPalette({
required this.page,
required this.surface,
required this.line,
@@ -316,39 +316,39 @@ class _PmonPalette {
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,
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: PmonColors.primary,
primarySoft: PmonColors.primarySoft,
primary: InkwatchColors.primary,
primarySoft: InkwatchColors.primarySoft,
// 浅色 Toast:深底白字(--iw-snackbar-bg 浅色 = textPrimary #111827)
snackbarBg: PmonColors.textPrimary,
snackbarBg: InkwatchColors.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,
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: PmonDarkColors.snackbarBg,
snackbarText: PmonDarkColors.surface,
snackbarBg: InkwatchDarkColors.snackbarBg,
snackbarText: InkwatchDarkColors.surface,
);
ThemeData _assemble(_PmonPalette p, Brightness brightness) {
ThemeData _assemble(_InkwatchPalette p, Brightness brightness) {
final baseScheme = ColorScheme.fromSeed(
seedColor: p.primary,
brightness: brightness,
@@ -356,7 +356,7 @@ ThemeData _assemble(_PmonPalette p, Brightness brightness) {
);
// 深底必须让主色严格落在语义令牌上:fromSeed 会把 primary 推到 tonal 近似值
// (实测 #6E9CFF → #AFC6FF),FilledButton / 选中态就会用到非令牌蓝。
// 浅色分支保持 fromSeed 原始结果,确保 buildPmonTheme() 逐字段不变。
// 浅色分支保持 fromSeed 原始结果,确保 buildInkwatchTheme() 逐字段不变。
final colorScheme = brightness == Brightness.dark
? baseScheme.copyWith(
primary: p.primary,
@@ -380,7 +380,7 @@ ThemeData _assemble(_PmonPalette p, Brightness brightness) {
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: PmonText.title.copyWith(color: p.textPrimary),
titleTextStyle: InkwatchText.title.copyWith(color: p.textPrimary),
),
dividerTheme: DividerThemeData(color: p.line, thickness: 1, space: 1),
cardTheme: base.cardTheme.copyWith(
@@ -389,7 +389,7 @@ ThemeData _assemble(_PmonPalette p, Brightness brightness) {
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PmonRadius.md),
borderRadius: BorderRadius.circular(InkwatchRadius.md),
side: BorderSide(color: p.line),
),
),
@@ -398,18 +398,18 @@ ThemeData _assemble(_PmonPalette p, Brightness brightness) {
filled: true,
fillColor: p.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: PmonSpace.md, vertical: PmonSpace.md),
hintStyle: PmonText.caption.copyWith(color: p.textTertiary),
horizontal: InkwatchSpace.md, vertical: InkwatchSpace.md),
hintStyle: InkwatchText.caption.copyWith(color: p.textTertiary),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
borderSide: BorderSide(color: p.line),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
borderSide: BorderSide(color: p.line),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
borderSide: BorderSide(color: p.primary, width: 1.5),
),
),
@@ -418,17 +418,17 @@ ThemeData _assemble(_PmonPalette p, Brightness brightness) {
backgroundColor: p.snackbarBg,
contentTextStyle: TextStyle(fontSize: 13, color: p.snackbarText),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm)),
borderRadius: BorderRadius.circular(InkwatchRadius.sm)),
),
popupMenuTheme: base.popupMenuTheme.copyWith(
color: p.surface,
surfaceTintColor: Colors.transparent,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderRadius: BorderRadius.circular(InkwatchRadius.sm),
side: BorderSide(color: p.line),
),
textStyle: PmonText.body.copyWith(color: p.textPrimary),
textStyle: InkwatchText.body.copyWith(color: p.textPrimary),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: p.surface,
@@ -472,13 +472,13 @@ ThemeData _assemble(_PmonPalette p, Brightness brightness) {
/// 故收敛成一条函数,新增状态只改这里。
///
/// 只扩到 **4 字段、不带 `border`**:Flutter 的语义描边由
/// `base.withValues(alpha: 0.28)` 动态生成(见 [PmonCard.notice]),
/// `base.withValues(alpha: 0.28)` 动态生成(见 [InkwatchCard.notice]),
/// 没有 Web 端「预混实色描边」这一需求,不要把 CSS 的实现概念搬进 Dart。
/// [palette] 缺省时回退 [PmonPalette.light](与改造前逐字段一致);
/// [palette] 缺省时回退 [InkwatchPalette.light](与改造前逐字段一致);
/// 页面自绘一律传当前色板,深色 / E-Ink 下状态色才跟着换。
({String label, Color color, Color soft, Color text}) statusStyle(
String key, [PmonPalette? palette]) {
final p = palette ?? PmonPalette.light;
String key, [InkwatchPalette? palette]) {
final p = palette ?? InkwatchPalette.light;
switch (key) {
case 'online':
return (label: '在线', color: p.online,
@@ -521,8 +521,8 @@ ThemeData _assemble(_PmonPalette p, Brightness brightness) {
///
/// 故墨水屏只用**三档明度**(纯黑 / 深灰 / 纯白),分层靠**字重与描边**而不是底色,
/// 状态靠**形状**而不是颜色(见 [statusGlyph])。
class PmonEink {
PmonEink._();
class InkwatchEink {
InkwatchEink._();
/// 底:纯白。墨水屏的"原生"是白纸,浅灰底只会浪费对比度
static const page = Color(0xFFFFFFFF);
@@ -555,33 +555,33 @@ class PmonEink {
///
/// 为什么全部加粗(最低 w600):墨水屏墨滴扩散会让细笔画发虚,
/// w400 正文在 6″ 300PPI 上远看会断笔。
class PmonEinkText {
PmonEinkText._();
class InkwatchEinkText {
InkwatchEinkText._();
/// 数值(**基准字号**)。实际字号 = 本档 × `einkUniformValueScale()`
/// 算出的全屏统一比例(只会缩、不会放)。
static const value = TextStyle(
fontSize: 40, fontWeight: FontWeight.w700, color: PmonEink.ink, height: 1.0);
fontSize: 40, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.0);
/// 状态形状(●/◐/○/◌)—— 与设备名同行,比它略大以便一眼可辨
static const glyph = TextStyle(
fontSize: 22, fontWeight: FontWeight.w700, color: PmonEink.ink, height: 1.0);
fontSize: 22, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.0);
/// 设备名
static const title = TextStyle(
fontSize: 20, fontWeight: FontWeight.w700, color: PmonEink.ink, height: 1.15);
fontSize: 20, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.15);
/// 指标标签 / 按钮文字(同号,语义不同则用字重区分,不靠字号)
static const label = TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: PmonEink.inkSoft, height: 1.2);
fontSize: 14, fontWeight: FontWeight.w600, color: InkwatchEink.inkSoft, height: 1.2);
/// 按钮文字:与 [label] **同号**,只提字重与颜色(不靠改字号来表达重要)
static const action = TextStyle(
fontSize: 14, fontWeight: FontWeight.w700, color: PmonEink.ink, height: 1.2);
fontSize: 14, fontWeight: FontWeight.w700, color: InkwatchEink.ink, height: 1.2);
/// 角落说明(刷新时刻、异常时的末帧提示)
static const caption = TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: PmonEink.inkSoft, height: 1.3);
fontSize: 12, fontWeight: FontWeight.w600, color: InkwatchEink.inkSoft, height: 1.3);
}
/// 状态 → **形状**的唯一映射(水墨屏用)。
@@ -616,21 +616,21 @@ String statusGlyph(String key) {
/// ── 为什么需要它(整屏深色化)────────────────────────────────────
/// [ThemeData] 只负责系统控件(输入框 / 菜单 / SnackBar / 对话框…),
/// 而本项目的卡片、状态灯、说明文字是**自绘**的(BoxDecoration / TextStyle /
/// Icon color)。它们过去直接引用 [PmonColors] 静态常量,于是深色下
/// Icon color)。它们过去直接引用 [InkwatchColors] 静态常量,于是深色下
/// 「系统控件变深了、卡片和文字仍是浅色」—— 整屏并没有真正深色。
///
/// 故把页面自绘会消费的全部语义色收进一份 [ThemeExtension],
/// 由 [buildPmonTheme] / [buildPmonDarkTheme] / [einkTheme] 各挂一份实例。
/// 页面统一用 [PmonPalette.of] 取当前色板:
/// · [light] 逐项等于 [PmonColors] —— 浅色视觉一字不变;
/// · [dark] 逐项等于 [PmonDarkColors] —— 深色整屏生效;
/// · [eink] 全部映射到 [PmonEink] 六色 —— 纯黑白、无彩色。
/// 由 [buildInkwatchTheme] / [buildInkwatchDarkTheme] / [einkTheme] 各挂一份实例。
/// 页面统一用 [InkwatchPalette.of] 取当前色板:
/// · [light] 逐项等于 [InkwatchColors] —— 浅色视觉一字不变;
/// · [dark] 逐项等于 [InkwatchDarkColors] —— 深色整屏生效;
/// · [eink] 全部映射到 [InkwatchEink] 六色 —— 纯黑白、无彩色。
///
/// 色值**定义源**仍是 [PmonColors] / [PmonDarkColors] / [PmonEink] 静态常量
/// 色值**定义源**仍是 [InkwatchColors] / [InkwatchDarkColors] / [InkwatchEink] 静态常量
/// (parity / 护栏测试依赖,一个不动);本类只做「按主题选取」,不新增色值、
/// 不新增圆角 / 间距 / 字号刻度。
class PmonPalette extends ThemeExtension<PmonPalette> {
const PmonPalette({
class InkwatchPalette extends ThemeExtension<InkwatchPalette> {
const InkwatchPalette({
required this.page,
required this.surface,
required this.surfaceMuted,
@@ -684,96 +684,96 @@ class PmonPalette extends ThemeExtension<PmonPalette> {
/// 当前主题的色板。裸 MaterialApp(widget 测试未挂 extensions)回退 [light],
/// 与改造前视觉一致,也不会因 extension 缺失而崩。
static PmonPalette of(BuildContext context) =>
Theme.of(context).extension<PmonPalette>() ?? light;
static InkwatchPalette of(BuildContext context) =>
Theme.of(context).extension<InkwatchPalette>() ?? light;
/// 浅色:逐项 = [PmonColors]。
static const light = PmonPalette(
page: PmonColors.page,
surface: PmonColors.surface,
surfaceMuted: PmonColors.surfaceMuted,
line: PmonColors.line,
textPrimary: PmonColors.textPrimary,
textSecondary: PmonColors.textSecondary,
textTertiary: PmonColors.textTertiary,
primary: PmonColors.primary,
primarySoft: PmonColors.primarySoft,
online: PmonColors.online,
onlineSoft: PmonColors.onlineSoft,
onlineText: PmonColors.onlineText,
suspect: PmonColors.suspect,
suspectSoft: PmonColors.suspectSoft,
suspectText: PmonColors.suspectText,
offline: PmonColors.offline,
offlineText: PmonColors.offlineText,
unknownText: PmonColors.unknownText,
danger: PmonColors.danger,
dangerSoft: PmonColors.dangerSoft,
dangerText: PmonColors.dangerText,
infoText: PmonColors.infoText,
chartLine: PmonColors.chartLine,
gap: PmonColors.gap,
/// 浅色:逐项 = [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,
);
/// 深色:逐项 = [PmonDarkColors]。
static const dark = PmonPalette(
page: PmonDarkColors.page,
surface: PmonDarkColors.surface,
surfaceMuted: PmonDarkColors.surfaceMuted,
line: PmonDarkColors.line,
textPrimary: PmonDarkColors.textPrimary,
textSecondary: PmonDarkColors.textSecondary,
textTertiary: PmonDarkColors.textTertiary,
primary: PmonDarkColors.primary,
primarySoft: PmonDarkColors.primarySoft,
online: PmonDarkColors.online,
onlineSoft: PmonDarkColors.onlineSoft,
onlineText: PmonDarkColors.onlineText,
suspect: PmonDarkColors.suspect,
suspectSoft: PmonDarkColors.suspectSoft,
suspectText: PmonDarkColors.suspectText,
offline: PmonDarkColors.offline,
offlineText: PmonDarkColors.offlineText,
unknownText: PmonDarkColors.unknownText,
danger: PmonDarkColors.danger,
dangerSoft: PmonDarkColors.dangerSoft,
dangerText: PmonDarkColors.dangerText,
infoText: PmonDarkColors.infoText,
chartLine: PmonDarkColors.chartLine,
gap: PmonDarkColors.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,
);
/// 水墨屏:全部映射到 [PmonEink] 六色(白 / 黑 / 深灰,R=G=B 无彩色)。
/// 水墨屏:全部映射到 [InkwatchEink] 六色(白 / 黑 / 深灰,R=G=B 无彩色)。
/// 看板状态靠字形([statusGlyph])而非颜色,此处只需保证绝无彩色。
static const eink = PmonPalette(
page: PmonEink.page,
surface: PmonEink.box,
surfaceMuted: PmonEink.page,
line: PmonEink.rule,
textPrimary: PmonEink.ink,
textSecondary: PmonEink.inkSoft,
textTertiary: PmonEink.inkSoft,
primary: PmonEink.ink,
primarySoft: PmonEink.page,
online: PmonEink.ink,
onlineSoft: PmonEink.page,
onlineText: PmonEink.ink,
suspect: PmonEink.ink,
suspectSoft: PmonEink.page,
suspectText: PmonEink.ink,
offline: PmonEink.inkSoft,
offlineText: PmonEink.inkSoft,
unknownText: PmonEink.inkSoft,
danger: PmonEink.ink,
dangerSoft: PmonEink.page,
dangerText: PmonEink.ink,
infoText: PmonEink.ink,
chartLine: PmonEink.ink,
gap: PmonEink.inkSoft,
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
PmonPalette copyWith({
InkwatchPalette copyWith({
Color? page,
Color? surface,
Color? surfaceMuted,
@@ -799,7 +799,7 @@ class PmonPalette extends ThemeExtension<PmonPalette> {
Color? chartLine,
Color? gap,
}) =>
PmonPalette(
InkwatchPalette(
page: page ?? this.page,
surface: surface ?? this.surface,
surfaceMuted: surfaceMuted ?? this.surfaceMuted,
@@ -827,9 +827,9 @@ class PmonPalette extends ThemeExtension<PmonPalette> {
);
@override
PmonPalette lerp(covariant PmonPalette? other, double t) {
InkwatchPalette lerp(covariant InkwatchPalette? other, double t) {
if (other == null) return this;
return PmonPalette(
return InkwatchPalette(
page: Color.lerp(page, other.page, t)!,
surface: Color.lerp(surface, other.surface, t)!,
surfaceMuted: Color.lerp(surfaceMuted, other.surfaceMuted, t)!,
@@ -858,29 +858,29 @@ class PmonPalette extends ThemeExtension<PmonPalette> {
}
}
/// 文字样式出口:把 [PmonText] 的档位(含浅色 color)按当前色板覆盖文字色。
/// 文字样式出口:把 [InkwatchText] 的档位(含浅色 color)按当前色板覆盖文字色。
///
/// 页面自绘统一走 `PmonTextStyles.of(p).caption`,避免在几十处散写
/// `PmonText.caption.copyWith(color: ...)` —— 那是同一份映射散落多处。
/// [PmonText] 静态常量本身**保留不动**(护栏 / parity 测试依赖它)。
class PmonTextStyles {
const PmonTextStyles(this.p);
/// 页面自绘统一走 `InkwatchTextStyles.of(p).caption`,避免在几十处散写
/// `InkwatchText.caption.copyWith(color: ...)` —— 那是同一份映射散落多处。
/// [InkwatchText] 静态常量本身**保留不动**(护栏 / parity 测试依赖它)。
class InkwatchTextStyles {
const InkwatchTextStyles(this.p);
final PmonPalette p;
final InkwatchPalette p;
static PmonTextStyles of(PmonPalette p) => PmonTextStyles(p);
static InkwatchTextStyles of(InkwatchPalette p) => InkwatchTextStyles(p);
TextStyle get title => PmonText.title.copyWith(color: p.textPrimary);
TextStyle get section => PmonText.section.copyWith(color: p.textPrimary);
TextStyle get body => PmonText.body.copyWith(color: p.textPrimary);
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 =>
PmonText.bodyMuted.copyWith(color: p.textSecondary);
TextStyle get caption => PmonText.caption.copyWith(color: p.textTertiary);
TextStyle get micro => PmonText.micro.copyWith(color: p.textTertiary);
TextStyle get metric => PmonText.metric.copyWith(color: p.textPrimary);
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 =>
PmonText.metricUnit.copyWith(color: p.textTertiary);
TextStyle get stat => PmonText.stat.copyWith(color: p.textPrimary);
InkwatchText.metricUnit.copyWith(color: p.textTertiary);
TextStyle get stat => InkwatchText.stat.copyWith(color: p.textPrimary);
}
/// 水墨屏专用 Material 主题。
@@ -888,64 +888,64 @@ class PmonTextStyles {
/// 与彩色版的三点不同,都是为了**减少刷新**:
/// 1. `splashFactory: NoSplash` —— 去掉点击水波纹(那是每帧重绘的动画);
/// 2. 全部 `elevation: 0` —— 阴影在墨水屏上只会变成一圈脏灰;
/// 3. 颜色走 [PmonEink] 三档明度。
/// 3. 颜色走 [InkwatchEink] 三档明度。
///
/// **只在 `theme.dart` 里 new ThemeData**(与彩色版同一条规矩)——
/// 页面里就地 `ThemeData(` 会被 `test/theme_guard_test.dart` 判红。
///
/// 挂 [PmonPalette.eink] 是必须的:看板内部若改用 [PmonPalette.of] 查询色板,
/// 挂 [InkwatchPalette.eink] 是必须的:看板内部若改用 [InkwatchPalette.of] 查询色板,
/// 缺了这一份会取到 null 而崩。
ThemeData einkTheme() => ThemeData(
useMaterial3: true,
extensions: const <ThemeExtension<dynamic>>[PmonPalette.eink],
scaffoldBackgroundColor: PmonEink.page,
extensions: const <ThemeExtension<dynamic>>[InkwatchPalette.eink],
scaffoldBackgroundColor: InkwatchEink.page,
colorScheme: ColorScheme.fromSeed(
seedColor: PmonEink.ink,
seedColor: InkwatchEink.ink,
brightness: Brightness.light,
).copyWith(
surface: PmonEink.page,
onSurface: PmonEink.ink,
primary: PmonEink.ink,
onPrimary: PmonEink.page,
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: PmonEink.page,
foregroundColor: PmonEink.ink,
backgroundColor: InkwatchEink.page,
foregroundColor: InkwatchEink.ink,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
),
cardTheme: const CardThemeData(
color: PmonEink.box,
color: InkwatchEink.box,
elevation: 0,
margin: EdgeInsets.zero,
),
dividerTheme: const DividerThemeData(
color: PmonEink.rule,
color: InkwatchEink.rule,
thickness: 1,
space: 1,
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: PmonEink.ink,
side: const BorderSide(color: PmonEink.rule, width: 1.5),
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: PmonEinkText.action,
textStyle: InkwatchEinkText.action,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: PmonEink.ink,
textStyle: PmonEinkText.action,
foregroundColor: InkwatchEink.ink,
textStyle: InkwatchEinkText.action,
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: PmonEink.ink,
contentTextStyle: PmonEinkText.label.copyWith(color: PmonEink.page),
backgroundColor: InkwatchEink.ink,
contentTextStyle: InkwatchEinkText.label.copyWith(color: InkwatchEink.page),
behavior: SnackBarBehavior.floating,
elevation: 0,
),
+68 -22
View File
@@ -67,8 +67,8 @@ class FileTransferCard extends StatefulWidget {
}
class _FileTransferCardState extends State<FileTransferCard> {
PmonPalette get _p => PmonPalette.of(context);
PmonTextStyles get _tx => PmonTextStyles.of(_p);
InkwatchPalette get _p => InkwatchPalette.of(context);
InkwatchTextStyles get _tx => InkwatchTextStyles.of(_p);
// ── 发出侧 ──
LanOutgoing? _offer;
@@ -81,7 +81,11 @@ class _FileTransferCardState extends State<FileTransferCard> {
/// 「已通过服务器通知对端」或「没通知成,请把码发过去」——
/// **这两句话必须分开说**:前者用户什么都不用做,后者要他去转发。
String? _offerHint;
///
/// 状态图标单独一槽(`icon` + `tint`),不拼进文本:
/// Unicode 的 `✓`/`⚠` 在水墨屏上会渲染成方框,且无法随主题着色;
/// `test/symbol_guard_test.dart` 按码位扫 lib/ 防止这类写法回归。
({IconData? icon, Color? tint, String text})? _offerHint;
// ── 免粘贴:对端主动送来的待领文件 ──
StreamSubscription<LanIncomingOffer>? _offerSub;
@@ -219,12 +223,25 @@ class _FileTransferCardState extends State<FileTransferCard> {
_code = encodeLanTicket(
endpoint: s.endpoint ?? '中继', token: o.token, name: o.name, size: o.size);
_offerHint = !pushed
? '没能通知对端,也开不了中继 —— 请把下面的码发给它。'
? (
icon: Icons.warning_amber,
tint: _p.suspect,
text: '没能通知对端,也开不了中继 —— 请把下面的码发给它。',
)
: viaRelay
? '⚠️ 已走**中继**(两台设备不在同一局域网)。'
'文件经服务器中转、不落盘,但会消耗流量包;'
'对方点「收下」后开始传输(限速 ${_fmtBps(relayBps)})。'
: '✓ 已走**局域网直连**(最快、且不消耗服务器流量)。对端会直接出现待领提示。';
? (
icon: Icons.warning_amber,
tint: _p.suspect,
text: '已走**中继**(两台设备不在同一局域网)。'
'文件经服务器中转、不落盘,但会消耗流量包;'
'对方点「收下」后开始传输(限速 ${_fmtBps(relayBps)})。',
)
: (
icon: Icons.check_circle,
tint: _p.online,
text: '已走**局域网直连**(最快、且不消耗服务器流量)。'
'对端会直接出现待领提示。',
);
_sendBusy = false;
});
} catch (e) {
@@ -416,7 +433,7 @@ class _FileTransferCardState extends State<FileTransferCard> {
Text(_sendBusy ? '处理中…' : '选择文件'),
]),
),
SizedBox(height: PmonSpace.sm),
SizedBox(height: InkwatchSpace.sm),
// ⚠️ 这句话必须在**点发送之前**就摆在界面上:跨网只能走服务器中继,
// 而中继当前有 1 MiB 单次上限。等到进度条不动再说,等于骗人。
Text('跨网(两台设备不在同一局域网)时走服务器中继,'
@@ -429,9 +446,26 @@ class _FileTransferCardState extends State<FileTransferCard> {
overflow: TextOverflow.ellipsis),
SizedBox(height: 6),
if (_offerHint != null) ...[
Text(_offerHint!,
style: TextStyle(
fontSize: 11.5, color: _p.textSecondary, height: 1.4)),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_offerHint!.icon != null) ...[
Padding(
padding: const EdgeInsets.only(top: 1.5),
child: Icon(_offerHint!.icon,
size: 14, color: _offerHint!.tint),
),
const SizedBox(width: 4),
],
Expanded(
child: Text(_offerHint!.text,
style: TextStyle(
fontSize: 11.5,
color: _p.textSecondary,
height: 1.4)),
),
],
),
SizedBox(height: 6),
],
// 中继上传进度。**只在真走中继时出现** —— 直连那条路是秒传的,
@@ -612,16 +646,28 @@ class _FileTransferCardState extends State<FileTransferCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_result!.verified
? '✓ 已收下并校验通过'
: '⚠ 已收下但校验未通过',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _result!.verified
? _p.online
: _p.danger),
Row(
children: [
// 状态图标走 Material Icons,不拼进文本:
// 字符符号在水墨屏上渲染成方框,也无法随主题着色
Icon(
_result!.verified
? Icons.check_circle
: Icons.error_outline,
size: 15,
color: _result!.verified ? _p.online : _p.danger,
),
const SizedBox(width: 4),
Flexible(
child: Text(
_result!.verified ? '已收下并校验通过' : '已收下但校验未通过',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _result!.verified ? _p.online : _p.danger),
),
),
],
),
SizedBox(height: 4),
Text('${fmtSize(_result!.bytes)} 保存于 ${_result!.path}',
+3 -3
View File
@@ -37,8 +37,8 @@ class ViewerChart extends StatelessWidget {
@override
Widget build(BuildContext context) {
final p = PmonPalette.of(context);
final tx = PmonTextStyles.of(p);
final p = InkwatchPalette.of(context);
final tx = InkwatchTextStyles.of(p);
final pts = _numericPoints();
if (pts.isEmpty) {
return SizedBox(height: height, child: _emptyHint(p, tx));
@@ -156,7 +156,7 @@ class ViewerChart extends StatelessWidget {
return '${v.toStringAsFixed(spec?.digits == 0 ? 0 : 1)}${spec?.unit ?? ''}';
}
Widget _emptyHint(PmonPalette p, PmonTextStyles tx) {
Widget _emptyHint(InkwatchPalette p, InkwatchTextStyles tx) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
+74 -62
View File
@@ -88,8 +88,8 @@ class ViewerPage extends StatefulWidget {
}
class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
PmonPalette get _p => PmonPalette.of(context);
PmonTextStyles get _tx => PmonTextStyles.of(_p);
InkwatchPalette get _p => InkwatchPalette.of(context);
InkwatchTextStyles get _tx => InkwatchTextStyles.of(_p);
ViewerSession? _session;
AppConfig _cfg = AppConfig();
@@ -106,7 +106,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
// ① 大字段版面(`ui/eink_board.dart`)—— 6 大格、一屏放下、状态用形状;
// ② **刷新节流**(下面的 `_einkTimer`)—— 数据每秒到,但只在间隔到点才重建
// (每次重建 = 一次屏刷新,墨水屏的成本全在这里);
// ③ 全局零动画(`PmonEinkMode.enabled`)—— 转圈/呼吸/水波纹全部静止。
// ③ 全局零动画(`InkwatchEinkMode.enabled`)—— 转圈/呼吸/水波纹全部静止。
bool _eink = false;
int _einkSec = 10;
Timer? _einkTimer;
@@ -221,7 +221,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
unawaited(AndroidForeground.setKeepScreenOn(false));
_applyEinkOrientation(null);
// 系统栏与主壳导航都要还回去,否则离开这一页会留下"没有导航栏"的副作用
PmonEinkBoardMode.active.value = false;
InkwatchEinkBoardMode.active.value = false;
unawaited(
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
@@ -492,7 +492,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
/// 前者全版本可用,且语义就是"把系统栏都还回来"。
void _syncBoardChrome() {
final boardMode = _eink && !_einkPaused;
PmonEinkBoardMode.active.value = boardMode;
InkwatchEinkBoardMode.active.value = boardMode;
unawaited(
boardMode
? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)
@@ -511,17 +511,24 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
/// ⚠️ 探测结果**不是实时推送的**:它在下一次节流刷新时反映到界面(最多 10 秒)。
/// 这是刻意的取舍 —— 为一个诊断行加一条推送通道要多一处要维护的接口,
/// 而看板本来就有节流刷新,够用。
String _lanSummary() {
/// 返回 `(icon, text)` 而不是拼好前缀的字符串 —— 状态图标一律走 Material Icons。
/// 水墨屏上 Unicode 字形与 emoji 会渲染成方框,且无法随主题着色;
/// `test/symbol_guard_test.dart` 按码位扫 lib/ 来防止回归。
({IconData? icon, String text}) _lanSummary() {
final s = _session;
if (s == null) return '无会话';
if (s.lanProbing) return '检测中…';
if (s == null) return (icon: null, text: '无会话');
if (s.lanProbing) return (icon: null, text: '检测中…');
final hit = s.lanReachable;
// 「可达」与「正在走」是两件事:探测通了但还没切过去(或在切换路上)
// 都要能看出来,否则用户无法判断省流量到底生效没有。
if (hit != null && s.lanMode) return '✓ 走内网 · $hit';
if (hit != null) return '可达(未切换)· $hit';
if (s.lanEndpoints.isEmpty) return '对端未上报(可能不在同一局域网)';
return '不可达 · 已试 ${s.lanEndpoints.join('、')}';
if (hit != null && s.lanMode) {
return (icon: Icons.check_circle, text: '走内网 · $hit');
}
if (hit != null) return (icon: null, text: '可达(未切换)· $hit');
if (s.lanEndpoints.isEmpty) {
return (icon: null, text: '对端未上报(可能不在同一局域网)');
}
return (icon: null, text: '不可达 · 已试 ${s.lanEndpoints.join('、')}');
}
/// 看板外壳:必要时套一层**应用内旋转**。
@@ -590,7 +597,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
/// 配置里的水墨屏设置 → 运行时。**唯一的应用点**(启动与用户切换都走它)。
void _applyEink(AppConfig cfg) {
final on = cfg.einkMode;
PmonEinkMode.enabled = on;
InkwatchEinkMode.enabled = on;
setState(() {
_eink = on;
_einkSec = cfg.einkRefreshSec;
@@ -743,12 +750,12 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
// 被系统时间压住)。链路状态放在 AppBar 右侧 —— 用户瞄一眼就知道"通不通"。
return Scaffold(
appBar: AppBar(
titleSpacing: PmonSpace.lg,
titleSpacing: InkwatchSpace.lg,
title: Text('墨观 查看端'),
actions: [
Center(child: _linkChip()),
_einkMenu(),
SizedBox(width: PmonSpace.xs),
SizedBox(width: InkwatchSpace.xs),
],
),
body: _body(),
@@ -856,6 +863,8 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
_st.activeDeviceName ??
(_series.deviceId.isEmpty ? '未选择设备' : _series.deviceId);
final lan = _lanSummary();
return EinkBoard(
deviceName: name,
statusKey: statusKey,
@@ -873,8 +882,10 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
}),
showDiagnostics: _einkDiag,
onToggleDiagnostics: () => setState(() => _einkDiag = !_einkDiag),
// 每行是 (标签, 值, 值前面的图标?) —— 图标单独一槽,符号不拼进文本
// (Material Icons 才能随主题着色;字符符号在水墨屏上会渲染成方框)
diagnostics: [
('转屏方式', _softRotate ? '应用内(请把设备横置)' : '系统'),
('转屏方式', _softRotate ? '应用内(请把设备横置)' : '系统', null),
// 局域网底座的状态。**"没开"和"开了但对面连不上"必须能分辨** ——
// 前者是 lastError,后者表现为"地址与端口都在、就是连不上"。
(
@@ -882,13 +893,14 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
LanService.instance.running
? '已开 · ${LanService.instance.endpoint}'
: (LanService.instance.lastError?.toString() ?? '未启动'),
null,
),
('对端内网', _lanSummary()),
('刷新间隔', _einkSec == 0 ? '仅手动' : '$_einkSec 秒'),
('已收帧数', '${_series.received}'),
('本会话断点', '${_series.gapCount}'),
('最后刷新', _einkAt),
('常驻内存', '${(ProcessInfo.currentRss / 1024 / 1024).round()} MB'),
('对端内网', lan.text, lan.icon),
('刷新间隔', _einkSec == 0 ? '仅手动' : '$_einkSec 秒', null),
('已收帧数', '${_series.received}', null),
('本会话断点', '${_series.gapCount}', null),
('最后刷新', _einkAt, null),
('常驻内存', '${(ProcessInfo.currentRss / 1024 / 1024).round()} MB', null),
],
);
}
@@ -917,7 +929,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: style.soft,
borderRadius: BorderRadius.circular(PmonRadius.pill),
borderRadius: BorderRadius.circular(InkwatchRadius.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -930,7 +942,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
shape: BoxShape.circle,
),
),
SizedBox(width: PmonSpace.sm),
SizedBox(width: InkwatchSpace.sm),
Text(
label,
style: TextStyle(
@@ -972,7 +984,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
size: 16,
color: on ? _p.primary : _p.textTertiary,
),
SizedBox(width: PmonSpace.sm),
SizedBox(width: InkwatchSpace.sm),
Text(text),
],
),
@@ -1058,12 +1070,12 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
// 切到查看端时先给"内容正在就位"的形状,而不是一个转圈 + 空白页:
// 用户能看懂在等什么,页面也不会在数据到达时突然变样(观感更连贯)。
return ListView(
padding: EdgeInsets.all(PmonSpace.lg),
padding: EdgeInsets.all(InkwatchSpace.lg),
children: const [
LoadingHint(text: '正在连接网关并读取设备列表…'),
SizedBox(height: PmonSpace.lg),
SkeletonBox(height: 120, radius: PmonRadius.md),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
SkeletonBox(height: 120, radius: InkwatchRadius.md),
SizedBox(height: InkwatchSpace.lg),
SkeletonMetricGrid(cells: 6),
],
);
@@ -1075,10 +1087,10 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
final membersDev = _membersTarget();
return ListView(
padding: EdgeInsets.fromLTRB(
PmonSpace.lg,
PmonSpace.lg,
PmonSpace.lg,
PmonSpace.xxl,
InkwatchSpace.lg,
InkwatchSpace.lg,
InkwatchSpace.lg,
InkwatchSpace.xxl,
),
children: [
if (_st.lastError != null) _errorCard(_st.lastError!),
@@ -1087,13 +1099,13 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
// 只留一行诊断信息;"需对方授权 / 服务器时间轴 / 断点不插值"这三句承诺
// 移进设备卡的脚注 —— 它们解释的是"这份列表",贴在它旁边才成立
_connCaption(),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
// 主角提前:用户来这一页是为了"看设备",不是看登录状态
_pairedDevicesCard(),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
if (_st.subscribed && _series.deviceId.isNotEmpty) ...[
..._dashboard(),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
],
// 登录与配对:设备列表里已逐条标注「我的 / 他人授权」,
// 故这里不再是"解释列表来源的必要前提",可以放到数据后面
@@ -1109,7 +1121,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
// `/members`(还要再查一次归属人),首屏就变成十几个并发请求。
// 而"谁还能看这台设备"这个问题,用户问的对象永远是**手里正在看的那台**。
if (membersDev != null) ...[
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
DeviceMembersCard(
api: _api,
deviceId: membersDev.deviceId,
@@ -1118,9 +1130,9 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
deviceLabel: membersDev.deviceName,
),
],
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
_pairingCard(),
SizedBox(height: PmonSpace.lg),
SizedBox(height: InkwatchSpace.lg),
// 设备间传文件(与采集端页同一张卡片)——
// 接收这一侧在查看端更常用:"把另一台机器上的文件拉过来"。
FileTransferCard(
@@ -1214,7 +1226,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
Widget _pairedDevicesCard() {
return Card(
child: Padding(
padding: EdgeInsets.all(PmonSpace.lg),
padding: EdgeInsets.all(InkwatchSpace.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1225,7 +1237,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
size: 18,
color: _p.textSecondary,
),
SizedBox(width: PmonSpace.sm),
SizedBox(width: InkwatchSpace.sm),
Text('可查看的设备', style: _tx.section),
Spacer(),
if (!_st.presenceKnown && _st.devicesLoaded)
@@ -1233,7 +1245,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
],
),
// 操作按钮从旧深色 header 搬到这里:它们本就作用于"这份列表"
SizedBox(height: PmonSpace.md),
SizedBox(height: InkwatchSpace.md),
Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -1245,12 +1257,12 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.symmetric(
horizontal: PmonSpace.md,
vertical: PmonSpace.sm,
horizontal: InkwatchSpace.md,
vertical: InkwatchSpace.sm,
),
),
),
SizedBox(width: PmonSpace.sm),
SizedBox(width: InkwatchSpace.sm),
OutlinedButton.icon(
onPressed: _st.subscribed ? _stopViewing : null,
icon: Icon(Icons.stop_circle_outlined, size: 16),
@@ -1258,14 +1270,14 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.symmetric(
horizontal: PmonSpace.md,
vertical: PmonSpace.sm,
horizontal: InkwatchSpace.md,
vertical: InkwatchSpace.sm,
),
),
),
],
),
SizedBox(height: PmonSpace.md),
SizedBox(height: InkwatchSpace.md),
if (!_st.devicesLoaded)
Padding(
padding: EdgeInsets.symmetric(vertical: 6),
@@ -1297,7 +1309,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
// 三句承诺贴在它们解释的对象旁边(原来是页面顶部独立一行,
// 离设备列表很远,还占掉首屏一行)
Padding(
padding: EdgeInsets.only(top: PmonSpace.sm),
padding: EdgeInsets.only(top: InkwatchSpace.sm),
child: Text('需对方授权 · 服务器时间轴 · 断点不插值', style: _tx.caption),
),
],
@@ -1393,7 +1405,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
),
),
),
SizedBox(width: PmonSpace.sm),
SizedBox(width: InkwatchSpace.sm),
Text(
label,
style: TextStyle(
@@ -1410,7 +1422,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
Row(
children: [
_relationBadge(d),
SizedBox(width: PmonSpace.sm),
SizedBox(width: InkwatchSpace.sm),
Expanded(
child: Text(
'${d.platform} · ${d.deviceId}'
@@ -1786,13 +1798,13 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
if (_range != 'live') unawaited(_loadHistory());
}
: null,
borderRadius: BorderRadius.circular(PmonRadius.md),
borderRadius: BorderRadius.circular(InkwatchRadius.md),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: PmonSpace.md,
vertical: PmonSpace.md,
horizontal: InkwatchSpace.md,
vertical: InkwatchSpace.md,
),
decoration: selected ? PmonCard.selected(palette: _p) : PmonCard.standard(palette: _p),
decoration: selected ? InkwatchCard.selected(palette: _p) : InkwatchCard.standard(palette: _p),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -1806,7 +1818,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
? _p.primary
: _p.textTertiary,
),
SizedBox(width: PmonSpace.xs + 2),
SizedBox(width: InkwatchSpace.xs + 2),
Expanded(
child: Text(
spec?.label ?? key,
@@ -1822,7 +1834,7 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
),
],
),
SizedBox(height: PmonSpace.sm),
SizedBox(height: InkwatchSpace.sm),
// 数值走令牌里的"指标数值"样式:等宽数字 + 固定字重,
// 数值变化时不会左右跳动(一排卡片同时刷新时尤其明显)
// 自适应缩放:数组型指标("16 核 · 峰 87%")在窄卡里放不下会被截断,
@@ -2175,7 +2187,7 @@ class _PulsePlaceholder extends StatefulWidget {
class _PulsePlaceholderState extends State<_PulsePlaceholder>
with SingleTickerProviderStateMixin {
PmonPalette get _p => PmonPalette.of(context);
InkwatchPalette get _p => InkwatchPalette.of(context);
late final AnimationController _c = AnimationController(
vsync: this,
@@ -2188,7 +2200,7 @@ class _PulsePlaceholderState extends State<_PulsePlaceholder>
// ⚠️ 水墨屏上**绝不能** repeat:无限动画 = 永不停止的局部刷新
// (残影累积、发花、耗电,而它唯一的作用只是"告知在等")。
// 墨水屏的等待用静止图标表达就够了 —— 见 `ui/eink_mode.dart`。
if (!PmonEinkMode.enabled) _c.repeat(reverse: true);
if (!InkwatchEinkMode.enabled) _c.repeat(reverse: true);
}
@override
@@ -2199,16 +2211,16 @@ class _PulsePlaceholderState extends State<_PulsePlaceholder>
@override
Widget build(BuildContext context) {
if (PmonEinkMode.enabled) {
if (InkwatchEinkMode.enabled) {
// 静止版:无动画、纯黑描边圆 —— 语义相同("在等对方"),代价为零
return Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: PmonEink.rule, width: 1.5),
border: Border.all(color: InkwatchEink.rule, width: 1.5),
),
child: Icon(Icons.sensors, size: 26, color: PmonEink.ink),
child: Icon(Icons.sensors, size: 26, color: InkwatchEink.ink),
);
}
return AnimatedBuilder(
+3
View File
@@ -50,6 +50,7 @@ REGISTERED_TESTS=(
device_merge_notice_test.dart
device_model_test.dart
eink_board_test.dart
event_reporter_test.dart
handover_test.dart
history_test.dart
host_ffi_test.dart
@@ -64,6 +65,7 @@ REGISTERED_TESTS=(
p2p_driver_test.dart
p2p_session_test.dart
pairing_card_test.dart
plan_text_guard_test.dart
relay_driver_test.dart
resident_card_test.dart
resident_service_test.dart
@@ -72,6 +74,7 @@ REGISTERED_TESTS=(
startup_log_test.dart
startup_path_test.dart
stun_test.dart
symbol_guard_test.dart
theme_guard_test.dart
token_parity_test.dart
transfer_card_test.dart
+49 -49
View File
@@ -3,9 +3,9 @@
/// 运行:cd client && bash scripts/test.sh test/dark_theme_test.dart
///
/// ── 覆盖面 ────────────────────────────────────────────────────────
/// ① buildPmonDarkTheme() 的 brightness / colorScheme / 关键色值 / 控件语义色
/// ① buildInkwatchDarkTheme() 的 brightness / colorScheme / 关键色值 / 控件语义色
/// ② E-Ink 主题不受深色影响(纯黑白、圆角 0、无动画、看板局部覆盖解耦)
/// ③ 浅色 buildPmonTheme() 逐字段未变(重构只是把装配参数化)
/// ③ 浅色 buildInkwatchTheme() 逐字段未变(重构只是把装配参数化)
/// ④ 主题偏好持久化(AppConfig 值域 light|dark、默认 light 不预写键)
library;
@@ -13,61 +13,61 @@ 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';
import 'package:inkwatch_client/host/app_config.dart';
import 'package:inkwatch_client/host/config_migration.dart';
import 'package:inkwatch_client/ui/theme.dart';
void main() {
group('① 深色 ThemeData', () {
test('brightness == Brightness.dark,colorScheme 与主色一致', () {
final t = buildPmonDarkTheme();
final t = buildInkwatchDarkTheme();
expect(t.brightness, Brightness.dark);
expect(t.colorScheme.brightness, Brightness.dark);
// 「与主色一致」:primary 严格落在语义令牌上(不是 fromSeed 的 tonal 近似值)
expect(t.colorScheme.primary, PmonDarkColors.primary);
expect(t.colorScheme.primary, InkwatchDarkColors.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);
expect(t.colorScheme.surface, InkwatchDarkColors.surface);
expect(t.colorScheme.onSurface, InkwatchDarkColors.textPrimary);
expect(t.colorScheme.onPrimary, InkwatchDarkColors.textOnAccent);
});
test('关键色值:page / surface / textPrimary / primary / tertiary / snackbar', () {
final t = buildPmonDarkTheme();
final t = buildInkwatchDarkTheme();
// page #0B0E14
expect(t.scaffoldBackgroundColor, PmonDarkColors.page);
expect(t.scaffoldBackgroundColor, InkwatchDarkColors.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);
expect(t.popupMenuTheme.color, InkwatchDarkColors.surface);
expect(t.inputDecorationTheme.fillColor, InkwatchDarkColors.surface);
// textPrimary #F2F4F9(AppBar 标题文字)
expect(t.appBarTheme.titleTextStyle?.color, PmonDarkColors.textPrimary);
expect(PmonDarkColors.textPrimary, const Color(0xFFF2F4F9));
expect(t.appBarTheme.titleTextStyle?.color, InkwatchDarkColors.textPrimary);
expect(InkwatchDarkColors.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));
expect(InkwatchDarkColors.textTertiary, const Color(0xFF8A93A3));
expect(InkwatchDarkColors.textTertiary, isNot(InkwatchColors.textTertiary));
// snackbar 反相 #F2F4F9:浅底必须配深字,不能白字压浅底
expect(t.snackBarTheme.backgroundColor, PmonDarkColors.snackbarBg);
expect(t.snackBarTheme.backgroundColor, InkwatchDarkColors.snackbarBg);
expect(t.snackBarTheme.backgroundColor, const Color(0xFFF2F4F9));
expect(t.snackBarTheme.contentTextStyle?.color, PmonDarkColors.surface);
expect(t.snackBarTheme.contentTextStyle?.color, InkwatchDarkColors.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);
final t = buildInkwatchDarkTheme();
expect(t.dividerTheme.color, InkwatchDarkColors.line);
expect(t.popupMenuTheme.textStyle?.color, InkwatchDarkColors.textPrimary);
expect(t.inputDecorationTheme.hintStyle?.color,
PmonDarkColors.textTertiary);
InkwatchDarkColors.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);
InkwatchDarkColors.primary);
expect(t.listTileTheme.iconColor, InkwatchDarkColors.textTertiary);
expect(t.listTileTheme.textColor, InkwatchDarkColors.textPrimary);
expect(t.navigationBarTheme.backgroundColor, InkwatchDarkColors.surface);
expect(t.navigationBarTheme.indicatorColor, InkwatchDarkColors.primarySoft);
});
});
@@ -75,16 +75,16 @@ void main() {
test('einkTheme() 仍是纯黑白 / Brightness.light / 无 splash', () {
final e = einkTheme();
expect(e.colorScheme.brightness, Brightness.light);
expect(e.scaffoldBackgroundColor, PmonEink.page);
expect(e.scaffoldBackgroundColor, InkwatchEink.page);
expect(e.scaffoldBackgroundColor, const Color(0xFFFFFFFF));
expect(e.colorScheme.primary, PmonEink.ink);
expect(e.colorScheme.primary, InkwatchEink.ink);
expect(e.colorScheme.primary, const Color(0xFF000000));
expect(e.cardTheme.color, PmonEink.box);
expect(e.appBarTheme.backgroundColor, PmonEink.page);
expect(e.cardTheme.color, InkwatchEink.box);
expect(e.appBarTheme.backgroundColor, InkwatchEink.page);
expect(e.splashFactory, NoSplash.splashFactory);
expect(e.snackBarTheme.backgroundColor, PmonEink.ink);
expect(e.snackBarTheme.backgroundColor, InkwatchEink.ink);
expect(e.snackBarTheme.backgroundColor,
isNot(PmonDarkColors.snackbarBg));
isNot(InkwatchDarkColors.snackbarBg));
});
test('E-Ink 看板以 einkTheme() 局部覆盖主题,不读 themeMode', () {
@@ -93,35 +93,35 @@ void main() {
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('InkwatchDarkColors'), isFalse);
expect(src.contains('buildInkwatchDarkTheme'), isFalse);
expect(src.contains('InkwatchColors'), isFalse,
reason: '看板只消费 InkwatchEink.* / InkwatchEinkText.*,不碰彩色令牌');
expect(src.contains('InkwatchEinkText'), isTrue);
expect(src.contains('statusGlyph'), isTrue);
});
});
group('③ 浅色主题未变(深色只是新增装配)', () {
test('buildPmonTheme() 仍是 Brightness.light,且色值与重构前基线一致', () {
final t = buildPmonTheme();
test('buildInkwatchTheme() 仍是 Brightness.light,且色值与重构前基线一致', () {
final t = buildInkwatchTheme();
expect(t.brightness, Brightness.light);
expect(t.colorScheme.brightness, Brightness.light);
expect(t.scaffoldBackgroundColor, PmonColors.page);
expect(t.scaffoldBackgroundColor, InkwatchColors.page);
// 重构前 colorScheme 由 fromSeed 派生,primary 不是主色本身。
// 用同一公式动态取基线,钉死「浅色分支没有被 copyWith 改动」。
final fromSeedPrimary = ColorScheme.fromSeed(
seedColor: PmonColors.primary,
seedColor: InkwatchColors.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.colorScheme.primary, isNot(InkwatchColors.primary));
expect(t.appBarTheme.backgroundColor, InkwatchColors.surface);
expect(t.cardTheme.color, InkwatchColors.surface);
expect(t.snackBarTheme.backgroundColor, InkwatchColors.textPrimary);
expect(t.snackBarTheme.contentTextStyle?.color, Colors.white);
// 深浅确实是两套不同的主题
expect(buildPmonDarkTheme().brightness, isNot(t.brightness));
expect(buildInkwatchDarkTheme().brightness, isNot(t.brightness));
});
});
+139 -139
View File
@@ -3,25 +3,25 @@
/// 运行:cd client && bash scripts/test.sh test/dark_ui_test.dart
///
/// ── 覆盖面 ────────────────────────────────────────────────────────
/// ① 三份 PmonPalette 字段映射:light 逐项 == PmonColors、dark 逐项 ==
/// PmonDarkColors、eink 全部 R=G=B 无彩色;statusStyle 按色板分叉。
/// ① 三份 InkwatchPalette 字段映射:light 逐项 == InkwatchColors、dark 逐项 ==
/// InkwatchDarkColors、eink 全部 R=G=B 无彩色;statusStyle 按色板分叉。
/// ② 三个 ThemeData(含 einkTheme)都挂了对应 extension —— E-Ink 不得为 null。
/// ③ 浅 / 深 / E-Ink 三主题下渲染关键自绘组件(指标卡 / 状态徽标 /
/// 标准卡片 / 提示条 / 说明文字),断言实际 BoxDecoration.color /
/// TextStyle.color / statusStyle 返回值分别等于三套色板。
/// ④ E-Ink 独立性:einkTheme() 与深色外壳下看板仍纯黑白、圆角 0,
/// 且 Theme(data: einkTheme()) 局部覆盖里 PmonPalette.of(ctx) 取到 eink。
/// 且 Theme(data: einkTheme()) 局部覆盖里 InkwatchPalette.of(ctx) 取到 eink。
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/host/metric_format.dart';
import 'package:pmon_client/ui/eink_board.dart';
import 'package:pmon_client/ui/skeleton.dart';
import 'package:pmon_client/ui/theme.dart';
import 'package:inkwatch_client/host/metric_format.dart';
import 'package:inkwatch_client/ui/eink_board.dart';
import 'package:inkwatch_client/ui/skeleton.dart';
import 'package:inkwatch_client/ui/theme.dart';
/// 把色板拆成「字段名 → 颜色」,便于逐项比对三份实例。
Map<String, Color> _fields(PmonPalette p) => <String, Color>{
Map<String, Color> _fields(InkwatchPalette p) => <String, Color>{
'page': p.page,
'surface': p.surface,
'surfaceMuted': p.surfaceMuted,
@@ -49,57 +49,57 @@ Map<String, Color> _fields(PmonPalette p) => <String, Color>{
};
Map<String, Color> _lightRef() => <String, Color>{
'page': PmonColors.page,
'surface': PmonColors.surface,
'surfaceMuted': PmonColors.surfaceMuted,
'line': PmonColors.line,
'textPrimary': PmonColors.textPrimary,
'textSecondary': PmonColors.textSecondary,
'textTertiary': PmonColors.textTertiary,
'primary': PmonColors.primary,
'primarySoft': PmonColors.primarySoft,
'online': PmonColors.online,
'onlineSoft': PmonColors.onlineSoft,
'onlineText': PmonColors.onlineText,
'suspect': PmonColors.suspect,
'suspectSoft': PmonColors.suspectSoft,
'suspectText': PmonColors.suspectText,
'offline': PmonColors.offline,
'offlineText': PmonColors.offlineText,
'unknownText': PmonColors.unknownText,
'danger': PmonColors.danger,
'dangerSoft': PmonColors.dangerSoft,
'dangerText': PmonColors.dangerText,
'infoText': PmonColors.infoText,
'chartLine': PmonColors.chartLine,
'gap': PmonColors.gap,
'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,
};
Map<String, Color> _darkRef() => <String, Color>{
'page': PmonDarkColors.page,
'surface': PmonDarkColors.surface,
'surfaceMuted': PmonDarkColors.surfaceMuted,
'line': PmonDarkColors.line,
'textPrimary': PmonDarkColors.textPrimary,
'textSecondary': PmonDarkColors.textSecondary,
'textTertiary': PmonDarkColors.textTertiary,
'primary': PmonDarkColors.primary,
'primarySoft': PmonDarkColors.primarySoft,
'online': PmonDarkColors.online,
'onlineSoft': PmonDarkColors.onlineSoft,
'onlineText': PmonDarkColors.onlineText,
'suspect': PmonDarkColors.suspect,
'suspectSoft': PmonDarkColors.suspectSoft,
'suspectText': PmonDarkColors.suspectText,
'offline': PmonDarkColors.offline,
'offlineText': PmonDarkColors.offlineText,
'unknownText': PmonDarkColors.unknownText,
'danger': PmonDarkColors.danger,
'dangerSoft': PmonDarkColors.dangerSoft,
'dangerText': PmonDarkColors.dangerText,
'infoText': PmonDarkColors.infoText,
'chartLine': PmonDarkColors.chartLine,
'gap': PmonDarkColors.gap,
'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,
};
/// 无彩色 = R=G=B(纯黑 / 纯白 / 中性灰都算,带色相的彩色不算)。
@@ -112,19 +112,19 @@ class _PaletteProbe extends StatelessWidget {
@override
Widget build(BuildContext context) {
final p = PmonPalette.of(context);
final p = InkwatchPalette.of(context);
final s = statusStyle('online', p);
return SingleChildScrollView(
child: Column(
children: [
const SkeletonMetricCard(), // 标准卡片 / 指标卡(PmonCard.standard)
const SkeletonMetricCard(), // 标准卡片 / 指标卡(InkwatchCard.standard)
const SkeletonBox(width: 40, height: 10), // 次级表面色块
const LoadingHint(text: '正在加载…'), // 说明文字(PmonTextStyles.caption)
const LoadingHint(text: '正在加载…'), // 说明文字(InkwatchTextStyles.caption)
Container(
key: const Key('notice'),
width: 120,
height: 20,
decoration: PmonCard.notice(p.suspect, p.suspectSoft), // 提示条
decoration: InkwatchCard.notice(p.suspect, p.suspectSoft), // 提示条
),
Container(
key: const Key('badge'),
@@ -149,16 +149,16 @@ Future<void> _pumpProbe(WidgetTester tester, ThemeData theme) async {
}
/// 断言探针里每个自绘点都取当前色板 p 的对应值。
void _expectProbe(WidgetTester tester, PmonPalette p) {
void _expectProbe(WidgetTester tester, InkwatchPalette p) {
// 标准卡片:白面 + 发丝线
final card = tester.widget<Container>(find
.descendant(
of: find.byType(SkeletonMetricCard), matching: find.byType(Container))
.first);
final cardDeco = card.decoration! as BoxDecoration;
expect(cardDeco.color, p.surface, reason: 'PmonCard.standard().color');
expect(cardDeco.color, p.surface, reason: 'InkwatchCard.standard().color');
expect((cardDeco.border as Border).top.color, p.line,
reason: 'PmonCard.standard().border');
reason: 'InkwatchCard.standard().border');
// 次级表面色块
final box = tester.widget<Container>(find
@@ -173,12 +173,12 @@ void _expectProbe(WidgetTester tester, PmonPalette p) {
.descendant(of: find.byType(LoadingHint), matching: find.byType(Text))
.first);
expect(hint.style?.color, p.textTertiary,
reason: 'PmonTextStyles.caption 的 textTertiary');
reason: 'InkwatchTextStyles.caption 的 textTertiary');
// 提示条(notice 的软底)
final notice = tester.widget<Container>(find.byKey(const Key('notice')));
expect((notice.decoration! as BoxDecoration).color, p.suspectSoft,
reason: 'PmonCard.notice 的软底');
reason: 'InkwatchCard.notice 的软底');
// 状态徽标:软底与文字色都来自 statusStyle(key, p)
final s = statusStyle('online', p);
@@ -266,57 +266,57 @@ void _expectBoardMonochrome(WidgetTester tester) {
void main() {
group('① 三份 palette 的字段映射', () {
test('light 逐项 == PmonColors(浅色视觉一字不变,24 字段全覆盖)', () {
final got = _fields(PmonPalette.light);
test('light 逐项 == InkwatchColors(浅色视觉一字不变,24 字段全覆盖)', () {
final got = _fields(InkwatchPalette.light);
final ref = _lightRef();
expect(got.keys.toSet(), ref.keys.toSet());
for (final k in ref.keys) {
expect(got[k], ref[k],
reason: 'PmonPalette.light.$k 必须等于 PmonColors.$k');
reason: 'InkwatchPalette.light.$k 必须等于 InkwatchColors.$k');
}
});
test('dark 逐项 == PmonDarkColors(深色整屏生效的根)', () {
final got = _fields(PmonPalette.dark);
test('dark 逐项 == InkwatchDarkColors(深色整屏生效的根)', () {
final got = _fields(InkwatchPalette.dark);
final ref = _darkRef();
expect(got.keys.toSet(), ref.keys.toSet());
for (final k in ref.keys) {
expect(got[k], ref[k],
reason: 'PmonPalette.dark.$k 必须等于 PmonDarkColors.$k');
reason: 'InkwatchPalette.dark.$k 必须等于 InkwatchDarkColors.$k');
}
});
test('深色关键色值:page/surface/textPrimary/primary/tertiary', () {
expect(PmonPalette.dark.page, const Color(0xFF0B0E14));
expect(PmonPalette.dark.surface, const Color(0xFF151A23));
expect(PmonPalette.dark.textPrimary, const Color(0xFFF2F4F9));
expect(PmonPalette.dark.primary, const Color(0xFF6E9CFF));
expect(InkwatchPalette.dark.page, const Color(0xFF0B0E14));
expect(InkwatchPalette.dark.surface, const Color(0xFF151A23));
expect(InkwatchPalette.dark.textPrimary, const Color(0xFFF2F4F9));
expect(InkwatchPalette.dark.primary, const Color(0xFF6E9CFF));
// tertiary 显式不同步浅色点修值 #646D7D
expect(PmonPalette.dark.textTertiary, const Color(0xFF8A93A3));
expect(PmonPalette.dark.textTertiary,
isNot(PmonPalette.light.textTertiary));
expect(InkwatchPalette.dark.textTertiary, const Color(0xFF8A93A3));
expect(InkwatchPalette.dark.textTertiary,
isNot(InkwatchPalette.light.textTertiary));
// 深浅确实是两套不同的色板
for (final k in _fields(PmonPalette.light).keys) {
for (final k in _fields(InkwatchPalette.light).keys) {
if (k == 'offline') continue; // offline 深浅同值(中性灰),语义如此
expect(_fields(PmonPalette.dark)[k],
isNot(_fields(PmonPalette.light)[k]),
expect(_fields(InkwatchPalette.dark)[k],
isNot(_fields(InkwatchPalette.light)[k]),
reason: 'dark.$k 不能仍是浅色值');
}
});
test('eink 全部字段 R=G=B(无彩色),且映射到 PmonEink 六色', () {
final got = _fields(PmonPalette.eink);
test('eink 全部字段 R=G=B(无彩色),且映射到 InkwatchEink 六色', () {
final got = _fields(InkwatchPalette.eink);
expect(got.length, 24);
for (final e in got.entries) {
expect(_isGray(e.value), isTrue,
reason: 'PmonPalette.eink.${e.key} 出现了彩色');
reason: 'InkwatchPalette.eink.${e.key} 出现了彩色');
}
expect(PmonPalette.eink.page, PmonEink.page);
expect(PmonPalette.eink.surface, PmonEink.box);
expect(PmonPalette.eink.line, PmonEink.rule);
expect(PmonPalette.eink.textPrimary, PmonEink.ink);
expect(PmonPalette.eink.primary, PmonEink.ink);
expect(PmonPalette.eink.chartLine, PmonEink.ink);
expect(InkwatchPalette.eink.page, InkwatchEink.page);
expect(InkwatchPalette.eink.surface, InkwatchEink.box);
expect(InkwatchPalette.eink.line, InkwatchEink.rule);
expect(InkwatchPalette.eink.textPrimary, InkwatchEink.ink);
expect(InkwatchPalette.eink.primary, InkwatchEink.ink);
expect(InkwatchPalette.eink.chartLine, InkwatchEink.ink);
});
test('statusStyle 按色板分叉(6 键),单参数旧签名仍回退浅色', () {
@@ -328,76 +328,76 @@ void main() {
'info',
'anything-else'
]) {
final l = statusStyle(key, PmonPalette.light);
final d = statusStyle(key, PmonPalette.dark);
final e = statusStyle(key, PmonPalette.eink);
final l = statusStyle(key, InkwatchPalette.light);
final d = statusStyle(key, InkwatchPalette.dark);
final e = statusStyle(key, InkwatchPalette.eink);
// label 与色板无关
expect(l.label, d.label);
expect(l.label, e.label);
// 颜色严格来自各自色板(钉死「不再写死浅色常量」)
expect(l.color, statusStyle(key, PmonPalette.light).color);
expect(l.color, statusStyle(key, InkwatchPalette.light).color);
expect(_isGray(e.color), isTrue, reason: '$key eink.color 有彩色');
expect(_isGray(e.soft), isTrue, reason: '$key eink.soft 有彩色');
expect(_isGray(e.text), isTrue, reason: '$key eink.text 有彩色');
}
expect(statusStyle('online', PmonPalette.light).color,
PmonColors.online);
expect(statusStyle('online', PmonPalette.dark).color,
PmonDarkColors.online);
expect(statusStyle('online', PmonPalette.dark).color,
isNot(statusStyle('online', PmonPalette.light).color));
expect(statusStyle('online', InkwatchPalette.light).color,
InkwatchColors.online);
expect(statusStyle('online', InkwatchPalette.dark).color,
InkwatchDarkColors.online);
expect(statusStyle('online', InkwatchPalette.dark).color,
isNot(statusStyle('online', InkwatchPalette.light).color));
// 单参数(theme_guard_test 仍在用)必须仍是浅色
expect(statusStyle('online').color, PmonColors.online);
expect(statusStyle('online').soft, PmonColors.onlineSoft);
expect(statusStyle('online').text, PmonColors.onlineText);
expect(statusStyle('online').color, InkwatchColors.online);
expect(statusStyle('online').soft, InkwatchColors.onlineSoft);
expect(statusStyle('online').text, InkwatchColors.onlineText);
});
});
group('② 三个 ThemeData 都挂了对应 palette', () {
test('light / dark / eink 各挂一份,E-Ink 绝不为 null', () {
expect(buildPmonTheme().extension<PmonPalette>(),
same(PmonPalette.light));
expect(buildPmonDarkTheme().extension<PmonPalette>(),
same(PmonPalette.dark));
expect(einkTheme().extension<PmonPalette>(), same(PmonPalette.eink));
expect(buildInkwatchTheme().extension<InkwatchPalette>(),
same(InkwatchPalette.light));
expect(buildInkwatchDarkTheme().extension<InkwatchPalette>(),
same(InkwatchPalette.dark));
expect(einkTheme().extension<InkwatchPalette>(), same(InkwatchPalette.eink));
});
test('PmonCard.standard()/selected() 缺省回退浅色、显式传板分叉', () {
final std = PmonCard.standard();
expect(std.color, PmonColors.surface);
expect((std.border as Border).top.color, PmonColors.line);
final sel = PmonCard.selected();
expect(sel.color, PmonColors.primarySoft);
expect((sel.border as Border).top.color, PmonColors.primary);
test('InkwatchCard.standard()/selected() 缺省回退浅色、显式传板分叉', () {
final std = InkwatchCard.standard();
expect(std.color, InkwatchColors.surface);
expect((std.border as Border).top.color, InkwatchColors.line);
final sel = InkwatchCard.selected();
expect(sel.color, InkwatchColors.primarySoft);
expect((sel.border as Border).top.color, InkwatchColors.primary);
final dStd = PmonCard.standard(palette: PmonPalette.dark);
expect(dStd.color, PmonDarkColors.surface);
expect((dStd.border as Border).top.color, PmonDarkColors.line);
final dSel = PmonCard.selected(palette: PmonPalette.dark);
expect(dSel.color, PmonDarkColors.primarySoft);
expect((dSel.border as Border).top.color, PmonDarkColors.primary);
final dStd = InkwatchCard.standard(palette: InkwatchPalette.dark);
expect(dStd.color, InkwatchDarkColors.surface);
expect((dStd.border as Border).top.color, InkwatchDarkColors.line);
final dSel = InkwatchCard.selected(palette: InkwatchPalette.dark);
expect(dSel.color, InkwatchDarkColors.primarySoft);
expect((dSel.border as Border).top.color, InkwatchDarkColors.primary);
final eStd = PmonCard.standard(palette: PmonPalette.eink);
final eStd = InkwatchCard.standard(palette: InkwatchPalette.eink);
expect(_isGray(eStd.color!), isTrue);
expect((eStd.border as Border).top.color, PmonEink.rule);
expect((eStd.border as Border).top.color, InkwatchEink.rule);
});
});
group('③ 三主题下渲染关键自绘组件', () {
testWidgets('浅色:自绘点全部 == PmonPalette.light', (tester) async {
await _pumpProbe(tester, buildPmonTheme());
_expectProbe(tester, PmonPalette.light);
testWidgets('浅色:自绘点全部 == InkwatchPalette.light', (tester) async {
await _pumpProbe(tester, buildInkwatchTheme());
_expectProbe(tester, InkwatchPalette.light);
});
testWidgets('深色:同一批自绘点全部 == PmonPalette.dark', (tester) async {
await _pumpProbe(tester, buildPmonDarkTheme());
_expectProbe(tester, PmonPalette.dark);
testWidgets('深色:同一批自绘点全部 == InkwatchPalette.dark', (tester) async {
await _pumpProbe(tester, buildInkwatchDarkTheme());
_expectProbe(tester, InkwatchPalette.dark);
});
testWidgets('E-Ink:同一批自绘点全部 == PmonPalette.eink 且无彩色',
testWidgets('E-Ink:同一批自绘点全部 == InkwatchPalette.eink 且无彩色',
(tester) async {
await _pumpProbe(tester, einkTheme());
_expectProbe(tester, PmonPalette.eink);
_expectProbe(tester, InkwatchPalette.eink);
// E-Ink 下探针里所有 BoxDecoration.color 都无彩色
for (final c in tester.widgetList<Container>(find.byType(Container))) {
final d = c.decoration;
@@ -421,29 +421,29 @@ void main() {
testWidgets('深色 MaterialApp 外壳下看板仍无彩色(内部 einkTheme 覆盖生效)',
(tester) async {
await _pumpBoard(tester,
outer: buildPmonDarkTheme(), mode: ThemeMode.dark);
outer: buildInkwatchDarkTheme(), mode: ThemeMode.dark);
_expectBoardMonochrome(tester);
});
testWidgets('Theme(data: einkTheme()) 局部覆盖里 PmonPalette.of(ctx) == eink',
testWidgets('Theme(data: einkTheme()) 局部覆盖里 InkwatchPalette.of(ctx) == eink',
(tester) async {
// 直接模拟看板 eink_board.dart:296 的局部覆盖,且外层故意是深色,
// 钉死「页面改成 extension 查询后,看板里取到 null 会崩」这个最大风险。
PmonPalette? seen;
InkwatchPalette? seen;
await tester.pumpWidget(MaterialApp(
theme: buildPmonDarkTheme(),
darkTheme: buildPmonDarkTheme(),
theme: buildInkwatchDarkTheme(),
darkTheme: buildInkwatchDarkTheme(),
themeMode: ThemeMode.dark,
home: Theme(
data: einkTheme(),
child: Builder(builder: (ctx) {
seen = PmonPalette.of(ctx);
seen = InkwatchPalette.of(ctx);
return const SizedBox.shrink();
}),
),
));
expect(seen, same(PmonPalette.eink));
expect(seen, isNot(same(PmonPalette.dark)));
expect(seen, same(InkwatchPalette.eink));
expect(seen, isNot(same(InkwatchPalette.dark)));
});
});
}
+67 -67
View File
@@ -22,7 +22,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/ui/theme.dart';
import 'package:inkwatch_client/ui/theme.dart';
/// 需要守住的界面文件(新增页面/组件请加到这里,否则它不受护栏保护)
const List<String> _uiFiles = [
@@ -46,7 +46,7 @@ const List<String> _uiFiles = [
'lib/ui/device_members_card.dart',
];
/// 本轮按 `PmonText` 档位收敛过的界面文件(05 §3.4)。
/// 本轮按 `InkwatchText` 档位收敛过的界面文件(05 §3.4)。
/// 这些文件里**一个** `fontSize: 数字` 都不许有 —— 与 E-Ink 分支
/// 「界面里不得写就地字号」同一纪律。新增已收敛文件务必登记到这里。
const List<String> _fontSizeCleanFiles = [
@@ -79,7 +79,7 @@ void main() {
offenders,
isEmpty,
reason:
'这些地方又写死了色值,请改用 PmonColors / PmonCard:\n${offenders.join('\n')}',
'这些地方又写死了色值,请改用 InkwatchColors / InkwatchCard:\n${offenders.join('\n')}',
);
});
@@ -99,15 +99,15 @@ void main() {
offenders,
isEmpty,
reason:
'中性色请用 PmonColors.textPrimary/textSecondary/textTertiary/line:\n'
'中性色请用 InkwatchColors.textPrimary/textSecondary/textTertiary/line:\n'
'${offenders.join('\n')}',
);
});
test('主题装配只有一处:MaterialApp 用 buildPmonTheme()', () {
test('主题装配只有一处:MaterialApp 用 buildInkwatchTheme()', () {
final main = File('lib/main.dart').readAsStringSync();
expect(
main.contains('buildPmonTheme()'),
main.contains('buildInkwatchTheme()'),
isTrue,
reason:
'MaterialApp 必须装配 ui/theme.dart 的主题,'
@@ -117,7 +117,7 @@ void main() {
expect(
RegExp(r'theme:\s*ThemeData\(').hasMatch(main),
isFalse,
reason: '不要在页面里就地 new ThemeData —— 主题只在 buildPmonTheme() 里装配',
reason: '不要在页面里就地 new ThemeData —— 主题只在 buildInkwatchTheme() 里装配',
);
});
@@ -138,7 +138,7 @@ void main() {
expect(
offenders,
isEmpty,
reason: '10 / 10.5 / 11.5 已收敛到 PmonText.micro / caption,'
reason: '10 / 10.5 / 11.5 已收敛到 InkwatchText.micro / caption,'
'要新尺寸请在 theme.dart 加语义档位:\n${offenders.join('\n')}',
);
});
@@ -153,42 +153,42 @@ void main() {
total,
lessThanOrEqualTo(_fontSizeBaseline),
reason: '现有 $_fontSizeBaseline 处是历史存量;新增字号一律走 '
'PmonText 语义档位,不许再写就地数字(当前 $total 处)',
'InkwatchText 语义档位,不许再写就地数字(当前 $total 处)',
);
});
});
group('② 令牌本身可用且自洽', () {
test('状态语义色映射齐全(6 行:online/suspect/offline/unknown/info/default,含文字色)', () {
expect(statusStyle('online').color, PmonColors.online);
expect(statusStyle('online').soft, PmonColors.onlineSoft);
expect(statusStyle('online').text, PmonColors.onlineText);
expect(statusStyle('online').color, InkwatchColors.online);
expect(statusStyle('online').soft, InkwatchColors.onlineSoft);
expect(statusStyle('online').text, InkwatchColors.onlineText);
expect(statusStyle('suspect').color, PmonColors.suspect);
expect(statusStyle('suspect').soft, PmonColors.suspectSoft);
expect(statusStyle('suspect').text, PmonColors.suspectText);
expect(statusStyle('suspect').color, InkwatchColors.suspect);
expect(statusStyle('suspect').soft, InkwatchColors.suspectSoft);
expect(statusStyle('suspect').text, InkwatchColors.suspectText);
expect(statusStyle('offline').color, PmonColors.offline);
expect(statusStyle('offline').soft, PmonColors.surfaceMuted);
expect(statusStyle('offline').text, PmonColors.offlineText);
expect(statusStyle('offline').color, InkwatchColors.offline);
expect(statusStyle('offline').soft, InkwatchColors.surfaceMuted);
expect(statusStyle('offline').text, InkwatchColors.offlineText);
// 未知**必须**是中性灰:把"不知道"染成告警色等于编造结论(诚实边界)
expect(statusStyle('unknown').color, PmonColors.offline);
expect(statusStyle('unknown').color, InkwatchColors.offline);
expect(statusStyle('unknown').label, '状态未知');
expect(statusStyle('unknown').soft, PmonColors.surfaceMuted);
expect(statusStyle('unknown').text, PmonColors.unknownText);
expect(statusStyle('unknown').soft, InkwatchColors.surfaceMuted);
expect(statusStyle('unknown').text, InkwatchColors.unknownText);
// info:提示态走主色系,文字色独立(primarySoft 底 6.73:1)
expect(statusStyle('info').label, '提示');
expect(statusStyle('info').color, PmonColors.primary);
expect(statusStyle('info').soft, PmonColors.primarySoft);
expect(statusStyle('info').text, PmonColors.infoText);
expect(statusStyle('info').color, InkwatchColors.primary);
expect(statusStyle('info').soft, InkwatchColors.primarySoft);
expect(statusStyle('info').text, InkwatchColors.infoText);
// default 与 unknown 同值,不能漏掉这条兜底分支
expect(statusStyle('anything-else').label, '状态未知');
expect(statusStyle('anything-else').color, PmonColors.offline);
expect(statusStyle('anything-else').soft, PmonColors.surfaceMuted);
expect(statusStyle('anything-else').text, PmonColors.unknownText);
expect(statusStyle('anything-else').color, InkwatchColors.offline);
expect(statusStyle('anything-else').soft, InkwatchColors.surfaceMuted);
expect(statusStyle('anything-else').text, InkwatchColors.unknownText);
});
test('offlineText / unknownText 语义独立命名(不借道 textSecondary)', () {
@@ -211,15 +211,15 @@ void main() {
test("C-15':允许正式深色皮肤装配(不跟随系统)", () {
final src = File('lib/ui/theme.dart').readAsStringSync();
for (final required in [
'PmonDarkColors',
'buildPmonDarkTheme',
'InkwatchDarkColors',
'buildInkwatchDarkTheme',
'Brightness.dark',
]) {
expect(src.contains(required), isTrue,
reason: '深色皮肤是本阶段正式交付(替代旧的「本轮不引入」断言)');
}
final main = File('lib/main.dart').readAsStringSync();
expect(main.contains('darkTheme: buildPmonDarkTheme()'), isTrue,
expect(main.contains('darkTheme: buildInkwatchDarkTheme()'), isTrue,
reason: 'MaterialApp 必须装配深色主题');
expect(main.contains('themeMode:'), isTrue,
reason: 'themeMode 必须由应用内显式偏好驱动');
@@ -256,18 +256,18 @@ void main() {
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\}')
final body = RegExp(r'class InkwatchDarkColors\s*\{([\s\S]*?)\n\}')
.firstMatch(src)
?.group(1) ??
'';
expect(body.length, greaterThan(200), reason: 'PmonDarkColors 读不到内容?');
expect(body.length, greaterThan(200), reason: 'InkwatchDarkColors 读不到内容?');
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'
reason: 'InkwatchDarkColors 引用了 dark 块之外的键名(会漏进 E-Ink 分支):\n'
'${stray.join('\n')}');
});
@@ -278,30 +278,30 @@ void main() {
// einkTheme() 是文件最后一个顶层声明,取到末尾即可覆盖整段函数体。
final body = src.substring(start);
for (final forbidden in [
'PmonDarkColors',
'InkwatchDarkColors',
'Brightness.dark',
'buildPmonDarkTheme',
'buildInkwatchDarkTheme',
]) {
expect(body.contains(forbidden), isFalse,
reason: 'E-Ink 是 256 灰阶物理设备,深色皮肤一个键都不许进来');
}
expect(body.contains('PmonEink.ink'), isTrue);
expect(body.contains('InkwatchEink.ink'), isTrue);
expect(body.contains('NoSplash.splashFactory'), isTrue);
expect(body.contains('Brightness.light'), isTrue);
});
test("C-15':不新增圆角/间距/字号刻度 —— PmonDarkColors 只有颜色常量", () {
test("C-15':不新增圆角/间距/字号刻度 —— InkwatchDarkColors 只有颜色常量", () {
final src = File('lib/ui/theme.dart').readAsStringSync();
final body = RegExp(r'class PmonDarkColors\s*\{([\s\S]*?)\n\}')
final body = RegExp(r'class InkwatchDarkColors\s*\{([\s\S]*?)\n\}')
.firstMatch(src)
?.group(1) ??
'';
final clean = body.replaceAll(RegExp(r'//[^\n]*'), '');
expect(RegExp(r'TextStyle\(').hasMatch(clean), isFalse,
reason: '深色不得新增字号档位(沿用 PmonText)');
reason: '深色不得新增字号档位(沿用 InkwatchText)');
expect(RegExp(r'fontSize:').hasMatch(clean), isFalse);
expect(RegExp(r'static const \w+\s*=\s*\d').hasMatch(clean), isFalse,
reason: '深色不得新增间距/圆角数字刻度(沿用 PmonSpace / PmonRadius)');
reason: '深色不得新增间距/圆角数字刻度(沿用 InkwatchSpace / InkwatchRadius)');
final defs = RegExp(r'static const (\w+)\s*=\s*([^;]+);')
.allMatches(clean)
.toList();
@@ -312,46 +312,46 @@ void main() {
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)',
reason: 'InkwatchDarkColors.${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));
// 18 原色 = InkwatchColors 里除 6 个 *-text 外的全部静态色值常量。
expect(InkwatchColors.page, const Color(0xFFF6F7F9));
expect(InkwatchColors.surface, const Color(0xFFFFFFFF));
expect(InkwatchColors.line, const Color(0xFFE5E7EB));
expect(InkwatchColors.surfaceMuted, const Color(0xFFF3F4F6));
expect(InkwatchColors.textPrimary, const Color(0xFF111827));
expect(InkwatchColors.textSecondary, const Color(0xFF4B5563));
expect(InkwatchColors.textTertiary, const Color(0xFF646D7D));
expect(InkwatchColors.primary, const Color(0xFF2563EB));
expect(InkwatchColors.primarySoft, const Color(0xFFEFF4FF));
expect(InkwatchColors.online, const Color(0xFF16A34A));
expect(InkwatchColors.onlineSoft, const Color(0xFFEAF7EF));
expect(InkwatchColors.suspect, const Color(0xFFD97706));
expect(InkwatchColors.suspectSoft, const Color(0xFFFEF6E7));
expect(InkwatchColors.offline, const Color(0xFF9CA3AF));
expect(InkwatchColors.danger, const Color(0xFFDC2626));
expect(InkwatchColors.dangerSoft, const Color(0xFFFDECEC));
expect(InkwatchColors.chartLine, InkwatchColors.primary);
expect(InkwatchColors.gap, const Color(0xFFB6BCC8));
});
test('卡片装饰来自同一处(标准卡不用阴影,靠发丝线分层)', () {
final d = PmonCard.standard();
expect((d.border as Border).top.color, PmonColors.line);
final d = InkwatchCard.standard();
expect((d.border as Border).top.color, InkwatchColors.line);
expect(d.boxShadow, isNull, reason: '极简取向:卡片靠留白与发丝线分层,不用阴影');
final s = PmonCard.selected();
expect((s.border as Border).top.color, PmonColors.primary);
final s = InkwatchCard.selected();
expect((s.border as Border).top.color, InkwatchColors.primary);
});
test('文字样式三级 + 数值样式齐全,且数值用等宽数字', () {
expect(PmonText.title.color, PmonColors.textPrimary);
expect(PmonText.caption.color, PmonColors.textTertiary);
expect(InkwatchText.title.color, InkwatchColors.textPrimary);
expect(InkwatchText.caption.color, InkwatchColors.textTertiary);
expect(
PmonText.metric.fontFeatures,
InkwatchText.metric.fontFeatures,
isNotNull,
reason: '指标数值必须等宽,否则一排卡片刷新时数字会左右跳动',
);
+115 -115
View File
@@ -16,7 +16,7 @@
/// 3. 文件读不到内容直接 fail(`expect(src.length, greaterThan(200))`)——
/// 照抄 `theme_guard_test.dart:56` 的自检,避免路径写错静默通过。
/// 4. **深色已落地**(本阶段):`html[data-iw-theme="dark"]` 的 58 个同名键
/// 另起一组 parity —— 转录项逐项比 `PmonDarkColors`、未转录的 Web 专属键
/// 另起一组 parity —— 转录项逐项比 `InkwatchDarkColors`、未转录的 Web 专属键
/// 登记 `kDarkExemptTokens` 豁免,两向缺项都判红(见 group ④)。
library;
@@ -35,101 +35,101 @@ typedef ParityEntry = ({String cssKey, String dartExpr, String kind});
/// · `dartOnly`—— 只 Dart 有;`cssKey` 是「不该存在的键名」,必须缺席
const List<ParityEntry> kTokenParity = [
// ── 颜色:两侧都有常量(01 §8.3「继」+ 05 §3.1 新增 6 个 *-text)──
(cssKey: '--iw-page', dartExpr: 'PmonColors.page', kind: 'both'),
(cssKey: '--iw-surface', dartExpr: 'PmonColors.surface', kind: 'both'),
(cssKey: '--iw-surface-muted', dartExpr: 'PmonColors.surfaceMuted', kind: 'both'),
(cssKey: '--iw-line', dartExpr: 'PmonColors.line', kind: 'both'),
(cssKey: '--iw-text-primary', dartExpr: 'PmonColors.textPrimary', kind: 'both'),
(cssKey: '--iw-text-secondary', dartExpr: 'PmonColors.textSecondary', kind: 'both'),
(cssKey: '--iw-text-tertiary', dartExpr: 'PmonColors.textTertiary', kind: 'both'),
(cssKey: '--iw-primary', dartExpr: 'PmonColors.primary', kind: 'both'),
(cssKey: '--iw-primary-soft', dartExpr: 'PmonColors.primarySoft', kind: 'both'),
(cssKey: '--iw-online', dartExpr: 'PmonColors.online', kind: 'both'),
(cssKey: '--iw-online-soft', dartExpr: 'PmonColors.onlineSoft', kind: 'both'),
(cssKey: '--iw-online-text', dartExpr: 'PmonColors.onlineText', kind: 'both'),
(cssKey: '--iw-suspect', dartExpr: 'PmonColors.suspect', kind: 'both'),
(cssKey: '--iw-suspect-soft', dartExpr: 'PmonColors.suspectSoft', kind: 'both'),
(cssKey: '--iw-suspect-text', dartExpr: 'PmonColors.suspectText', kind: 'both'),
(cssKey: '--iw-offline', dartExpr: 'PmonColors.offline', kind: 'both'),
(cssKey: '--iw-page', dartExpr: 'InkwatchColors.page', kind: 'both'),
(cssKey: '--iw-surface', dartExpr: 'InkwatchColors.surface', kind: 'both'),
(cssKey: '--iw-surface-muted', dartExpr: 'InkwatchColors.surfaceMuted', kind: 'both'),
(cssKey: '--iw-line', dartExpr: 'InkwatchColors.line', kind: 'both'),
(cssKey: '--iw-text-primary', dartExpr: 'InkwatchColors.textPrimary', kind: 'both'),
(cssKey: '--iw-text-secondary', dartExpr: 'InkwatchColors.textSecondary', kind: 'both'),
(cssKey: '--iw-text-tertiary', dartExpr: 'InkwatchColors.textTertiary', kind: 'both'),
(cssKey: '--iw-primary', dartExpr: 'InkwatchColors.primary', kind: 'both'),
(cssKey: '--iw-primary-soft', dartExpr: 'InkwatchColors.primarySoft', kind: 'both'),
(cssKey: '--iw-online', dartExpr: 'InkwatchColors.online', kind: 'both'),
(cssKey: '--iw-online-soft', dartExpr: 'InkwatchColors.onlineSoft', kind: 'both'),
(cssKey: '--iw-online-text', dartExpr: 'InkwatchColors.onlineText', kind: 'both'),
(cssKey: '--iw-suspect', dartExpr: 'InkwatchColors.suspect', kind: 'both'),
(cssKey: '--iw-suspect-soft', dartExpr: 'InkwatchColors.suspectSoft', kind: 'both'),
(cssKey: '--iw-suspect-text', dartExpr: 'InkwatchColors.suspectText', kind: 'both'),
(cssKey: '--iw-offline', dartExpr: 'InkwatchColors.offline', kind: 'both'),
// statusStyle('offline'/'unknown').soft 就是 surfaceMuted(01 §8.3 明列)
(cssKey: '--iw-offline-soft', dartExpr: 'PmonColors.surfaceMuted', kind: 'both'),
(cssKey: '--iw-offline-text', dartExpr: 'PmonColors.offlineText', kind: 'both'),
(cssKey: '--iw-offline-soft', dartExpr: 'InkwatchColors.surfaceMuted', kind: 'both'),
(cssKey: '--iw-offline-text', dartExpr: 'InkwatchColors.offlineText', kind: 'both'),
// --iw-unknown 是 offline 的同值别名(01 §8.3 括号里的说明)
(cssKey: '--iw-unknown', dartExpr: 'PmonColors.offline', kind: 'both'),
(cssKey: '--iw-unknown-soft', dartExpr: 'PmonColors.surfaceMuted', kind: 'both'),
(cssKey: '--iw-unknown-text', dartExpr: 'PmonColors.unknownText', kind: 'both'),
(cssKey: '--iw-danger', dartExpr: 'PmonColors.danger', kind: 'both'),
(cssKey: '--iw-danger-soft', dartExpr: 'PmonColors.dangerSoft', kind: 'both'),
(cssKey: '--iw-danger-text', dartExpr: 'PmonColors.dangerText', kind: 'both'),
(cssKey: '--iw-info-text', dartExpr: 'PmonColors.infoText', kind: 'both'),
(cssKey: '--iw-unknown', dartExpr: 'InkwatchColors.offline', kind: 'both'),
(cssKey: '--iw-unknown-soft', dartExpr: 'InkwatchColors.surfaceMuted', kind: 'both'),
(cssKey: '--iw-unknown-text', dartExpr: 'InkwatchColors.unknownText', kind: 'both'),
(cssKey: '--iw-danger', dartExpr: 'InkwatchColors.danger', kind: 'both'),
(cssKey: '--iw-danger-soft', dartExpr: 'InkwatchColors.dangerSoft', kind: 'both'),
(cssKey: '--iw-danger-text', dartExpr: 'InkwatchColors.dangerText', kind: 'both'),
(cssKey: '--iw-info-text', dartExpr: 'InkwatchColors.infoText', kind: 'both'),
// chartLine = primary(Dart 语义别名,解析器会解引用)
(cssKey: '--iw-chart-line', dartExpr: 'PmonColors.chartLine', kind: 'both'),
(cssKey: '--iw-series-1', dartExpr: 'PmonColors.chartLine', kind: 'both'),
(cssKey: '--iw-gap', dartExpr: 'PmonColors.gap', kind: 'both'),
// E-Ink 六色:01 §8.3「PmonEink.* ↔ --iw-eink-*」
(cssKey: '--iw-eink-page', dartExpr: 'PmonEink.page', kind: 'both'),
(cssKey: '--iw-eink-ink', dartExpr: 'PmonEink.ink', kind: 'both'),
(cssKey: '--iw-eink-ink-soft', dartExpr: 'PmonEink.inkSoft', kind: 'both'),
(cssKey: '--iw-eink-rule', dartExpr: 'PmonEink.rule', kind: 'both'),
(cssKey: '--iw-eink-box', dartExpr: 'PmonEink.box', kind: 'both'),
(cssKey: '--iw-eink-fill', dartExpr: 'PmonEink.fill', kind: 'both'),
(cssKey: '--iw-chart-line', dartExpr: 'InkwatchColors.chartLine', kind: 'both'),
(cssKey: '--iw-series-1', dartExpr: 'InkwatchColors.chartLine', kind: 'both'),
(cssKey: '--iw-gap', dartExpr: 'InkwatchColors.gap', kind: 'both'),
// E-Ink 六色:01 §8.3「InkwatchEink.* ↔ --iw-eink-*」
(cssKey: '--iw-eink-page', dartExpr: 'InkwatchEink.page', kind: 'both'),
(cssKey: '--iw-eink-ink', dartExpr: 'InkwatchEink.ink', kind: 'both'),
(cssKey: '--iw-eink-ink-soft', dartExpr: 'InkwatchEink.inkSoft', kind: 'both'),
(cssKey: '--iw-eink-rule', dartExpr: 'InkwatchEink.rule', kind: 'both'),
(cssKey: '--iw-eink-box', dartExpr: 'InkwatchEink.box', kind: 'both'),
(cssKey: '--iw-eink-fill', dartExpr: 'InkwatchEink.fill', kind: 'both'),
// ── 间距 / 圆角:两侧刻度相等(01 §8.3「继」)──
(cssKey: '--iw-space-xs', dartExpr: 'PmonSpace.xs', kind: 'both'),
(cssKey: '--iw-space-sm', dartExpr: 'PmonSpace.sm', kind: 'both'),
(cssKey: '--iw-space-md', dartExpr: 'PmonSpace.md', kind: 'both'),
(cssKey: '--iw-space-lg', dartExpr: 'PmonSpace.lg', kind: 'both'),
(cssKey: '--iw-space-xl', dartExpr: 'PmonSpace.xl', kind: 'both'),
(cssKey: '--iw-space-xxl', dartExpr: 'PmonSpace.xxl', kind: 'both'),
(cssKey: '--iw-radius-sm', dartExpr: 'PmonRadius.sm', kind: 'both'),
(cssKey: '--iw-radius-md', dartExpr: 'PmonRadius.md', kind: 'both'),
(cssKey: '--iw-radius-lg', dartExpr: 'PmonRadius.lg', kind: 'both'),
(cssKey: '--iw-radius-pill', dartExpr: 'PmonRadius.pill', kind: 'both'),
(cssKey: '--iw-space-xs', dartExpr: 'InkwatchSpace.xs', kind: 'both'),
(cssKey: '--iw-space-sm', dartExpr: 'InkwatchSpace.sm', kind: 'both'),
(cssKey: '--iw-space-md', dartExpr: 'InkwatchSpace.md', kind: 'both'),
(cssKey: '--iw-space-lg', dartExpr: 'InkwatchSpace.lg', kind: 'both'),
(cssKey: '--iw-space-xl', dartExpr: 'InkwatchSpace.xl', kind: 'both'),
(cssKey: '--iw-space-xxl', dartExpr: 'InkwatchSpace.xxl', kind: 'both'),
(cssKey: '--iw-radius-sm', dartExpr: 'InkwatchRadius.sm', kind: 'both'),
(cssKey: '--iw-radius-md', dartExpr: 'InkwatchRadius.md', kind: 'both'),
(cssKey: '--iw-radius-lg', dartExpr: 'InkwatchRadius.lg', kind: 'both'),
(cssKey: '--iw-radius-pill', dartExpr: 'InkwatchRadius.pill', kind: 'both'),
// ── 字阶:CSS 是 calc(基准 × scale),按基准值比(05 §3.4)──
(cssKey: '--iw-fs-metric', dartExpr: 'PmonText.metric', kind: 'derived'),
(cssKey: '--iw-fs-title', dartExpr: 'PmonText.title', kind: 'derived'),
(cssKey: '--iw-fs-stat', dartExpr: 'PmonText.stat', kind: 'derived'),
(cssKey: '--iw-fs-section', dartExpr: 'PmonText.section', kind: 'derived'),
(cssKey: '--iw-fs-body', dartExpr: 'PmonText.body', kind: 'derived'),
(cssKey: '--iw-fs-caption', dartExpr: 'PmonText.caption', kind: 'derived'),
(cssKey: '--iw-fs-micro', dartExpr: 'PmonText.micro', kind: 'derived'),
(cssKey: '--iw-fs-metric', dartExpr: 'InkwatchText.metric', kind: 'derived'),
(cssKey: '--iw-fs-title', dartExpr: 'InkwatchText.title', kind: 'derived'),
(cssKey: '--iw-fs-stat', dartExpr: 'InkwatchText.stat', kind: 'derived'),
(cssKey: '--iw-fs-section', dartExpr: 'InkwatchText.section', kind: 'derived'),
(cssKey: '--iw-fs-body', dartExpr: 'InkwatchText.body', kind: 'derived'),
(cssKey: '--iw-fs-caption', dartExpr: 'InkwatchText.caption', kind: 'derived'),
(cssKey: '--iw-fs-micro', dartExpr: 'InkwatchText.micro', kind: 'derived'),
// ── Web 专属(05 §3.3):CSS 有、Dart 不补;dartExpr 是「应缺席」的名字 ──
(cssKey: '--iw-surface-hover', dartExpr: 'PmonColors.surfaceHover', kind: 'webOnly'),
(cssKey: '--iw-line-strong', dartExpr: 'PmonColors.lineStrong', kind: 'webOnly'),
(cssKey: '--iw-text-on-accent', dartExpr: 'PmonColors.textOnAccent', kind: 'webOnly'),
(cssKey: '--iw-primary-hover', dartExpr: 'PmonColors.primaryHover', kind: 'webOnly'),
(cssKey: '--iw-primary-active', dartExpr: 'PmonColors.primaryActive', kind: 'webOnly'),
(cssKey: '--iw-info-border', dartExpr: 'PmonColors.infoBorder', kind: 'webOnly'),
(cssKey: '--iw-online-border', dartExpr: 'PmonColors.onlineBorder', kind: 'webOnly'),
(cssKey: '--iw-suspect-border', dartExpr: 'PmonColors.suspectBorder', kind: 'webOnly'),
(cssKey: '--iw-offline-border', dartExpr: 'PmonColors.offlineBorder', kind: 'webOnly'),
(cssKey: '--iw-unknown-border', dartExpr: 'PmonColors.unknownBorder', kind: 'webOnly'),
(cssKey: '--iw-danger-hover', dartExpr: 'PmonColors.dangerHover', kind: 'webOnly'),
(cssKey: '--iw-danger-active', dartExpr: 'PmonColors.dangerActive', kind: 'webOnly'),
(cssKey: '--iw-danger-border', dartExpr: 'PmonColors.dangerBorder', kind: 'webOnly'),
(cssKey: '--iw-chart-area', dartExpr: 'PmonColors.chartArea', kind: 'webOnly'),
(cssKey: '--iw-chart-grid', dartExpr: 'PmonColors.chartGrid', kind: 'webOnly'),
(cssKey: '--iw-chart-axis', dartExpr: 'PmonColors.chartAxis', kind: 'webOnly'),
(cssKey: '--iw-chart-gap-band', dartExpr: 'PmonColors.chartGapBand', kind: 'webOnly'),
(cssKey: '--iw-chart-threshold-band', dartExpr: 'PmonColors.chartThresholdBand', kind: 'webOnly'),
(cssKey: '--iw-chart-threshold-line', dartExpr: 'PmonColors.chartThresholdLine', kind: 'webOnly'),
(cssKey: '--iw-series-2', dartExpr: 'PmonColors.series2', kind: 'webOnly'),
(cssKey: '--iw-series-3', dartExpr: 'PmonColors.series3', kind: 'webOnly'),
(cssKey: '--iw-series-4', dartExpr: 'PmonColors.series4', kind: 'webOnly'),
(cssKey: '--iw-logo-bg', dartExpr: 'PmonColors.logoBg', kind: 'webOnly'),
(cssKey: '--iw-logo-wave', dartExpr: 'PmonColors.logoWave', kind: 'webOnly'),
(cssKey: '--iw-logo-accent', dartExpr: 'PmonColors.logoAccent', kind: 'webOnly'),
(cssKey: '--iw-logo-mono', dartExpr: 'PmonColors.logoMono', kind: 'webOnly'),
(cssKey: '--iw-mask', dartExpr: 'PmonColors.mask', kind: 'webOnly'),
(cssKey: '--iw-snackbar-bg', dartExpr: 'PmonColors.snackbarBg', kind: 'webOnly'),
(cssKey: '--iw-surface-hover', dartExpr: 'InkwatchColors.surfaceHover', kind: 'webOnly'),
(cssKey: '--iw-line-strong', dartExpr: 'InkwatchColors.lineStrong', kind: 'webOnly'),
(cssKey: '--iw-text-on-accent', dartExpr: 'InkwatchColors.textOnAccent', kind: 'webOnly'),
(cssKey: '--iw-primary-hover', dartExpr: 'InkwatchColors.primaryHover', kind: 'webOnly'),
(cssKey: '--iw-primary-active', dartExpr: 'InkwatchColors.primaryActive', kind: 'webOnly'),
(cssKey: '--iw-info-border', dartExpr: 'InkwatchColors.infoBorder', kind: 'webOnly'),
(cssKey: '--iw-online-border', dartExpr: 'InkwatchColors.onlineBorder', kind: 'webOnly'),
(cssKey: '--iw-suspect-border', dartExpr: 'InkwatchColors.suspectBorder', kind: 'webOnly'),
(cssKey: '--iw-offline-border', dartExpr: 'InkwatchColors.offlineBorder', kind: 'webOnly'),
(cssKey: '--iw-unknown-border', dartExpr: 'InkwatchColors.unknownBorder', kind: 'webOnly'),
(cssKey: '--iw-danger-hover', dartExpr: 'InkwatchColors.dangerHover', kind: 'webOnly'),
(cssKey: '--iw-danger-active', dartExpr: 'InkwatchColors.dangerActive', kind: 'webOnly'),
(cssKey: '--iw-danger-border', dartExpr: 'InkwatchColors.dangerBorder', kind: 'webOnly'),
(cssKey: '--iw-chart-area', dartExpr: 'InkwatchColors.chartArea', kind: 'webOnly'),
(cssKey: '--iw-chart-grid', dartExpr: 'InkwatchColors.chartGrid', kind: 'webOnly'),
(cssKey: '--iw-chart-axis', dartExpr: 'InkwatchColors.chartAxis', kind: 'webOnly'),
(cssKey: '--iw-chart-gap-band', dartExpr: 'InkwatchColors.chartGapBand', kind: 'webOnly'),
(cssKey: '--iw-chart-threshold-band', dartExpr: 'InkwatchColors.chartThresholdBand', kind: 'webOnly'),
(cssKey: '--iw-chart-threshold-line', dartExpr: 'InkwatchColors.chartThresholdLine', kind: 'webOnly'),
(cssKey: '--iw-series-2', dartExpr: 'InkwatchColors.series2', kind: 'webOnly'),
(cssKey: '--iw-series-3', dartExpr: 'InkwatchColors.series3', kind: 'webOnly'),
(cssKey: '--iw-series-4', dartExpr: 'InkwatchColors.series4', kind: 'webOnly'),
(cssKey: '--iw-logo-bg', dartExpr: 'InkwatchColors.logoBg', kind: 'webOnly'),
(cssKey: '--iw-logo-wave', dartExpr: 'InkwatchColors.logoWave', kind: 'webOnly'),
(cssKey: '--iw-logo-accent', dartExpr: 'InkwatchColors.logoAccent', kind: 'webOnly'),
(cssKey: '--iw-logo-mono', dartExpr: 'InkwatchColors.logoMono', kind: 'webOnly'),
(cssKey: '--iw-mask', dartExpr: 'InkwatchColors.mask', kind: 'webOnly'),
(cssKey: '--iw-snackbar-bg', dartExpr: 'InkwatchColors.snackbarBg', kind: 'webOnly'),
// 大屏专用字号,Flutter 端无大屏(05 §3.3)
(cssKey: '--iw-fs-display', dartExpr: 'PmonText.display', kind: 'webOnly'),
(cssKey: '--iw-fs-display', dartExpr: 'InkwatchText.display', kind: 'webOnly'),
// ── Dart 专属:Flutter 有语义档位、CSS 刻意不设同名键 ──
(cssKey: '--iw-fs-body-muted', dartExpr: 'PmonText.bodyMuted', kind: 'dartOnly'),
(cssKey: '--iw-fs-metric-unit', dartExpr: 'PmonText.metricUnit', kind: 'dartOnly'),
(cssKey: '--iw-fs-body-muted', dartExpr: 'InkwatchText.bodyMuted', kind: 'dartOnly'),
(cssKey: '--iw-fs-metric-unit', dartExpr: 'InkwatchText.metricUnit', kind: 'dartOnly'),
];
/// 浅色 Web 专属键白名单(05 §3.3 / C-16)。
@@ -166,7 +166,7 @@ const Set<String> kWebOnlyTokens = {
'--iw-fs-display',
};
/// E-Ink 六档字阶:CSS 是 `PmonEinkText` 的镜像,纳入双向比对(05 §3.3)
/// E-Ink 六档字阶:CSS 是 `InkwatchEinkText` 的镜像,纳入双向比对(05 §3.3)
const List<({String cssKey, String dartName})> kEinkFontParity = [
(cssKey: '--iw-eink-fs-value', dartName: 'value'),
(cssKey: '--iw-eink-fs-glyph', dartName: 'glyph'),
@@ -177,8 +177,8 @@ const List<({String cssKey, String dartName})> kEinkFontParity = [
];
// ── 深色 parity(本阶段新增):58 键 = 转录 30 + 豁免 28 ─────────────
/// 转录项:CSS dark 块键 ↔ `PmonDarkColors` 常量名。
/// 只列「Flutter 真的会消费」的键 —— 与浅色 PmonColors 同名语义,外加两处
/// 转录项:CSS dark 块键 ↔ `InkwatchDarkColors` 常量名。
/// 只列「Flutter 真的会消费」的键 —— 与浅色 InkwatchColors 同名语义,外加两处
/// D3 反相(text-on-accent / snackbar-bg);别名复用
/// (offline-soft / unknown-soft → surfaceMuted、unknown → offline、
/// series-1 → chartLine)与浅色侧 `kTokenParity` 的写法一致。
@@ -216,7 +216,7 @@ const List<({String cssKey, String dartName})> kDarkTranscribed = [
];
/// 豁免项:dark 块里**未转录**的 Web 专属键(05 §1.4 / 浅色 kWebOnlyTokens
/// 同一纪律)。逐项登记、Dart 侧保持缺省,理由见 `theme.dart` 的 PmonDarkColors
/// 同一纪律)。逐项登记、Dart 侧保持缺省,理由见 `theme.dart` 的 InkwatchDarkColors
/// 文件头:交互态用 `WidgetState`、描边用 `withValues(alpha:)` 动态算、
/// 多序列 / Logo / 预混面积在 Flutter 端没有消费点。
const Set<String> kDarkExemptTokens = {
@@ -259,15 +259,15 @@ 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 分段解析,
late Map<String, String> dartColors; // InkwatchColors: name → FFRRGGBB
late Map<String, String> darkColors; // InkwatchDarkColors: name → FFRRGGBB
late Map<String, String> einkColors; // InkwatchEink: name → FFRRGGBB
// ⚠ InkwatchColors.page 与 InkwatchEink.page 同名,必须按 class 分段解析,
// 否则后者会覆盖前者,`--iw-page` 会被误判成 #FFFFFF 漂移。
late Map<String, String> dartAliases; // name → 另一个 name(如 chartLine → primary)
late Map<String, String> dartNumbers; // PmonSpace / PmonRadius
late Map<String, String> dartFontSizes; // PmonText
late Map<String, String> einkFontSizes; // PmonEinkText
late Map<String, String> dartNumbers; // InkwatchSpace / InkwatchRadius
late Map<String, String> dartFontSizes; // InkwatchText
late Map<String, String> einkFontSizes; // InkwatchEinkText
late String dartClean;
Map<String, String> _parseKv(String body) {
@@ -330,11 +330,11 @@ String normalizeCss(String raw) {
String? resolveDart(String expr) {
if (expr.isEmpty) return null;
final name = expr.split('.').last;
final color = expr.startsWith('PmonEink.')
final color = expr.startsWith('InkwatchEink.')
? einkColors[name]
: (dartColors[name] ?? dartColors[dartAliases[name]]);
if (color != null) return color;
if (expr.startsWith('PmonEinkText.')) return einkFontSizes[name];
if (expr.startsWith('InkwatchEinkText.')) return einkFontSizes[name];
final n = dartNumbers[name];
if (n != null) return _num(n);
final fs = dartFontSizes[name];
@@ -342,9 +342,9 @@ String? resolveDart(String expr) {
return null;
}
/// 深色侧专用:只查 PmonDarkColors。
/// 深色侧专用:只查 InkwatchDarkColors。
/// ⚠️ 两套色板字段名同名(page / surface / primary…),**绝不能**复用
/// [resolveDart] —— 那样会取到浅色 PmonColors 的值,深色 parity 会假通过。
/// [resolveDart] —— 那样会取到浅色 InkwatchColors 的值,深色 parity 会假通过。
String? resolveDark(String name) =>
darkColors[name] ?? darkColors[dartAliases[name]];
@@ -401,9 +401,9 @@ void main() {
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'));
dartColors = _parseColors(_classBody(dartClean, 'InkwatchColors'));
darkColors = _parseColors(_classBody(dartClean, 'InkwatchDarkColors'));
einkColors = _parseColors(_classBody(dartClean, 'InkwatchEink'));
dartAliases = <String, String>{};
for (final m in RegExp(r'static const (\w+)\s*=\s*(\w+);')
.allMatches(dartClean)) {
@@ -414,8 +414,8 @@ void main() {
.allMatches(dartClean)) {
dartNumbers[m.group(1)!] = m.group(2)!;
}
dartFontSizes = _parseFontSizes(_classBody(dartClean, 'PmonText'));
einkFontSizes = _parseFontSizes(_classBody(dartClean, 'PmonEinkText'));
dartFontSizes = _parseFontSizes(_classBody(dartClean, 'InkwatchText'));
einkFontSizes = _parseFontSizes(_classBody(dartClean, 'InkwatchEinkText'));
});
group('① 双向值比对(只比浅色 Core)', () {
@@ -505,7 +505,7 @@ void main() {
});
group('③ E-Ink 字阶与深色边界', () {
test('--iw-eink-fs-* 六档与 PmonEinkText 逐档相等', () {
test('--iw-eink-fs-* 六档与 InkwatchEinkText 逐档相等', () {
final offenders = <String>[];
for (final e in kEinkFontParity) {
final cssVal = cssEngineering.containsKey(e.cssKey)
@@ -514,11 +514,11 @@ void main() {
final dartVal = einkFontSizes[e.dartName];
if (cssVal == null || dartVal == null || cssVal != _num(dartVal)) {
offenders.add('${e.cssKey} css=$cssVal '
'PmonEinkText.${e.dartName} dart=$dartVal');
'InkwatchEinkText.${e.dartName} dart=$dartVal');
}
}
expect(offenders, isEmpty,
reason: 'E-Ink 字阶是 PmonEinkText 的镜像,必须逐档相等:\n'
reason: 'E-Ink 字阶是 InkwatchEinkText 的镜像,必须逐档相等:\n'
'${offenders.join('\n')}');
expect(einkFontSizes.length, 6, reason: 'E-Ink 字阶必须是完整的 6 档');
});
@@ -533,13 +533,13 @@ void main() {
// 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);
// 深色**现在进 parity**(本阶段已落地 InkwatchDarkColors),逐项比对见 group ④。
expect(dartClean.contains('InkwatchDarkColors'), isTrue);
expect(dartClean.contains('buildInkwatchDarkTheme'), isTrue);
});
});
group('④ 深色 parity:CSS dark 块 ↔ PmonDarkColors(双向)', () {
group('④ 深色 parity:CSS dark 块 ↔ InkwatchDarkColors(双向)', () {
test('转录 + 豁免恰好覆盖 58 键,且都在 CSS dark 块里(数量一致)', () {
final covered = <String>{
...kDarkTranscribed.map((e) => e.cssKey),
@@ -558,7 +558,7 @@ void main() {
'${dangling.join('\n')}');
});
test('转录项逐项值相等(CSS dark 覆写值 == PmonDarkColors)', () {
test('转录项逐项值相等(CSS dark 覆写值 == InkwatchDarkColors)', () {
final offenders = <String>[];
var checked = 0;
for (final e in kDarkTranscribed) {
@@ -569,7 +569,7 @@ void main() {
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)} '
' InkwatchDarkColors.${e.dartName.padRight(16)} '
'dart=${dartVal ?? "<缺>"}');
}
}
@@ -579,7 +579,7 @@ void main() {
'${offenders.join('\n')}');
});
test('反向缺项:PmonDarkColors 每个颜色常量都必须已登记', () {
test('反向缺项:InkwatchDarkColors 每个颜色常量都必须已登记', () {
final registered = kDarkTranscribed.map((e) => e.dartName).toSet();
final missing = darkColors.keys
.where((k) => !registered.contains(k))
@@ -598,7 +598,7 @@ void main() {
}
final camel = _kebabToCamel(k.replaceFirst('--iw-', ''));
if (darkColors.containsKey(camel)) {
offenders.add('$k: PmonDarkColors 擅自补了 $camel');
offenders.add('$k: InkwatchDarkColors 擅自补了 $camel');
}
}
expect(kDarkExemptTokens.length, 58 - kDarkTranscribed.length,
+9 -9
View File
@@ -39,6 +39,15 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "inkwatch-core"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"sysinfo",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -66,15 +75,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "inkwatch-core"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"sysinfo",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
+1 -1
View File
@@ -247,7 +247,7 @@
--iw-pairing-tracking: .12em;
--iw-fs-pairing: 24px; /* §4.4 配对码展示档 24px/600 */
/* E-Ink 字阶(§2.8 PmonEinkText,全分支最低 w600) */
/* E-Ink 字阶(§2.8 InkwatchEinkText,全分支最低 w600) */
--iw-eink-fs-value: 40px;
--iw-eink-fs-glyph: 22px;
--iw-eink-fs-title: 20px;