Files
ring-health-docs/superpowers/plans/2026-09-10-ring-health-app-m0-m1-ledger.md
T

1148 lines
96 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M0+M1 实施裁定记录(ledger)
> 本文件是 `2026-09-10-ring-health-app-m0-m1.md` 那份实施计划的**执行期裁定记录**,
> 由 subagent-driven-development 流程的 ledger 原件(`progress.md`)原样归档而来。
>
> **为什么要归档而不是丢弃:** 交付的代码与文档里有 **14 个文件**(含 `Formatters.kt`、
> `MetricDetailScreen.kt`、`RingHealthApp.kt`、多份单测、设计文档与验收记录)以
> 「ledger Ruling R\<N\>」的形式引用这里的条目。流程本来的收尾动作是删掉这份 scratch,
> 但那样会让上面每一处引用**指向一个不存在的记录** —— 所以先归档、再删 scratch。
>
> 每条裁定的格式是 `Ruling: <决定了什么> — <为什么> — <错了要付什么代价>`。
> 编号 R13 起为计划执行期的裁定;R13 之前的几条(不建 worktree、`.superpowers/` 入 gitignore)
> 记在开头的「Setup rulings」里。**编号不连续是正常的**(R31/R35 等条目按主题并入了相邻章节)。
>
> 归档时间:2026-09-10。此后代码若再变,以代码为准;本文件只解释「当时为什么这么定」。
---
# SDD ledger — plan: d:/cherry/docs/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md
Spec: d:/cherry/docs/superpowers/specs/2026-09-10-ring-health-app-design.md (read, reachable)
Workspace: d:/cherry/docs/.superpowers/sdd/2026-09-10-ring-health-app-m0-m1/
## Setup rulings
Ruling: 不建 git worktree — 仓库尚不存在。Task 1 从零 `git init` 创建 `repos/ring-health-app`,
之后各任务直接提交到该仓库 `main`。Gitea 上的 `ring-health-app` 目前是空仓、无协作者,
计划 Task 1 Step 11 明确要求 `git push -u origin main`,用户已批准该计划。 — 代价:若判断错误,
需要把已有提交挪到新分支并强推空仓,成本很低。
Ruling: `.superpowers/` 加入 docs 仓库 `.gitignore`(ledger 属 git-ignored scratch)。 — 代价:无。
环境已验证(无阻塞):JDK 17.0.12 在 PATH(JAVA_HOME 未设,Gradle 可用 PATH 上的 java);
ANDROID_HOME 已设;build-tools 35.0.0 / 36.1.0;platforms 31–36.1;
Gradle dists 缓存含 `gradle-8.14-all`(`distributionUrl` 指向该 URL 可命中缓存,无需联网下载发行包)。
## Pre-flight conflict scan
### 跨任务:共享文件 / 接口
| 任务对 | 产出 → 消费 | 结论 |
|---|---|---|
| T1 ↔ T2 / T7 | T1 建 `MainActivity.kt`;T2、T7 先后改写 `setContent` | OK,顺序覆盖,T7 版本取代 T2 |
| T1 ↔ T4 | T1 建最小 `RingHealthApplication.kt`;T4 替换 | OK |
| **T4 ↔ T6** | T4 的 `DefaultAppContainer` 引用 `MockHealthDataRepository` / `MockRingDeviceRepository`,二者到 T6 才存在 | **冲突 → R1** |
| **T4 ↔ T5** | T5 的 `:app:testDebugUnitTest` 会编译整个 main source set(含 `AppContainer.kt`)→ 未解析引用,Step 4 期望的 PASS 拿不到 | **冲突 → R1** |
| **T4 ↔ T8/T9/T10** | T4 的 `AppViewModelProvider` import `HomeViewModel` / `WorkoutViewModel` / `DeviceViewModel`,分别到 T8/T9/T10 才存在 | **冲突 → R2** |
| **T5 ↔ T8/T12** | T5 的 `metricDisplayName` 把中文指标名硬编码在 Kotlin 里;T8 又把同一批名字加进 `strings.xml`。违反 Global Constraint「不硬编码用户可见文案」,且同一事实两处定义 | **冲突 → R3** |
| T7 ↔ T8 | `HomeScreen(onOpenDetail, modifier)` → T8 追加 `viewModel` 形参 | OK |
| T7 ↔ T9 | T7 `WorkoutScreen(modifier)` → T9 追加 `onOpenHistory`;T9 在同文件内用 `WorkoutHistoryScreen` 替换 `WorkoutHistoryPlaceholder` | OK |
| T7 ↔ T10 | T7 `DeviceScreen(modifier)` → T10 追加 `onOpenSettings`;同文件替换 `DeviceSettingsPlaceholder` | OK |
| T7 ↔ T12 | T7 `MetricDetailPlaceholder` → T12 `MetricDetailScreen`,同文件 | OK |
| T3 ↔ T8/T9/T10/T12 | 组件签名(`HealthCard`/`RingProgress`/`MetricTile`/`SectionHeader`/`MetricBarChart`/`StatBar`)在各调用点一致 | OK,除 T12 的 `Consumes` 列了 `StatBar` 却未使用 → R4 |
| T8/T9/T10/T11/T12 ↔ `strings.xml` | 五个任务都往同一文件追加 | OK,仅追加、key 互不重叠 |
| T13 ↔ 其他 | 无共享文件 | OK |
| T14 ↔ T1–T13 | 仅验收 | OK |
### 单任务:自身文本是否自洽
| 任务 | 结论 |
|---|---|
| T1 | 已修:`android { kotlin {} }` 嵌套块移到顶层(AGP 8.x 无此嵌套) |
| T2 | 自洽 |
| T3 | 已修:删掉不存在的 `androidx.compose.foundation.layout.weight` import |
| T4 | Step 5「暂不构建」与 Step 6 提交共存,但与 T5 的构建步骤矛盾 → R1/R2 |
| T5 | 测试期望 7 项含 `metricDisplayName` → R3 后为 6 项 |
| T6 | Step 7 期望全量构建通过,前提是 `AppContainer` 已存在 → R1 后成立 |
| T7 | 自洽 |
| T8 | 已修:`LazyColumn` 内调 `@Composable`;测试 `WhileSubscribed` 无订阅者 |
| T9 | 已修:`formatCalories` 加入 import 并实际使用 |
| T10 | 自洽 |
| T11 | 自洽 |
| T12 | 已修:`factory()` 两版写法只留正确一版 |
| T13 | 自洽 |
| T14 | 自洽(验收清单含「血压未开启」等关键态) |
### Rulings
Ruling R1: 把 `AppContainer.kt` 的创建与 `RingHealthApplication` 持容器这两步,从 T4 移到 T6
(Mock 类建好之后)。T4 只产出 `domain/model/HealthModels.kt` + `domain/repository/Repositories.kt`。
—— 理由:容器是「把 Mock 接进 App」的动作,天然属于 Mock 那一任务;这样 T4–T6 每一步都能编译通过。
—— 代价:若判断错误,T4 的产出变小、T6 变大,只是任务边界调整,无返工。
Ruling R2: 把 `AppViewModelProvider.kt` 的创建从 T4 移到 T8(第一个消费者)。
T8 建立文件并只注册 `HomeViewModel`;T9 追加 `WorkoutViewModel`,T10 追加 `DeviceViewModel`。
—— 理由:`viewModelFactory { initializer {} }` 可增量追加,每个任务只引用已存在的 ViewModel 类。
—— 代价:若判断错误,三处 initializer 分散在本文件,仍是单一文件、单一职责。
Ruling R3: 从 T5 删除 `metricDisplayName`(连同其测试用例,测试数 7 → 6)。
T12 详情页标题改用资源 id 映射(`metricTitleRes(type): Int` → `R.string.metric_*`),
复用 T8 已加入 `strings.xml` 的同一批字符串。
—— 理由:Global Constraint 明令不硬编码用户可见文案;同一批中文名不能既在 Kotlin 又在 strings.xml 各写一份。
—— 代价:若判断错误,只是详情页标题多一次 `when` 映射,改动 5 行。
Ruling R4: T12 增加「睡眠专属区块」——当 `metric == MetricType.SLEEP` 时渲染 `StatBar`
(深睡/浅睡/清醒静态占比)+ 入睡/起床时间。同时把 T12 的 `Consumes` 里 `StatBar` 落实为真实使用。
—— 理由:spec §6 的 `detail/sleep` 明确要求「深睡/浅睡/清醒分段条 + 入睡/起床时间」,
而 T12 原本只做通用详情页,是 spec 覆盖缺口;顺带让 spec §7 要求的 `StatBar` 组件有真实调用点。
—— 代价:若判断错误,多出一个仅在睡眠详情显示的静态区块,可随时删除。
Ruling R5: `compileSdk`/`targetSdk` 取 36、`buildToolsVersion` 取 "36.1.0"、AGP 8.13.1、Kotlin 2.2.20、
Gradle 8.14 —— 均为本机已装/已缓存版本。spec §4 表格里的 35/AGP 8.11.x 是早期值,
以本计划 Global Constraints 为准(spec §11 风险行已说明工具链钉死在已安装版本)。
—— 代价:若判断错误,改 `libs.versions.toml` 四处版本号即可。
## Progress
### Task 1 — 仓库初始化 + Gradle 工程骨架
BASE: 不适用 —— `repos/ring-health-app` 由本任务 `git init` 创建,无前序提交。
审查包将用空树对象作为 BASE:`git hash-object -t tree /dev/null`。
Dispatch: implementer, model=sonnet(全新仓库首次 Gradle 构建,环境摩擦概率最高)。
### Task 1 — 结果
Commit `2a90c18` chore: 初始化 Android 工程骨架。`assembleDebug` BUILD SUCCESSFUL (1m54s),产出 APK 18.6MB,
已推送 origin/main。审查包:`review-task1.diff`(BASE=空树 4b825dc6)。
Ruling R6: 接受依赖版本下调。brief 写死的 coreKtx 1.19.0 / lifecycle 2.11.0 / navigationCompose 2.10.1 /
composeBom 2026.09.00 要求 compileSdk 37 + AGP 9.1.0,与 R5 钉死的工具链(compileSdk 36 / AGP 8.13.1)互斥,
首次构建 32 条 checkDebugAarMetadata 冲突即此因。实现者据各 AAR 自带 aar-metadata.properties 实测,
下调为 coreKtx 1.18.0 / lifecycle 2.10.0 / navigationCompose 2.9.8 / composeBom 2026.06.01(仅 4 行)。
工具链优先——R5 的理由是本机已装、避免下载。 — 代价:若判断错误,装 compileSdk 37 + AGP 9.1.0 后回改 4 行。
Ruling R7: 接受 Gradle 发行包目录联接(junction)。本机缓存的 gradle-8.14-all 哈希目录来自腾讯镜像 URL,
与 brief 的 services.gradle.org URL 哈希不同,会导致重下 224MB。实现者建联接指向已解压发行包,
`gradle-wrapper.properties` 未改。 — 代价:无(换机器克隆时表现为正常下载)。
Ruling R8: 补 `.gitattributes`(`gradlew text eol=lf`)。`core.autocrlf=true` 下新克隆的 `gradlew` 可能带 CRLF
而无法执行——Windows 上的真实隐患。纳入 Task 1 修复轮处理。 — 代价:无。
### Task 1 — 审查结果(第 1 轮)
结论:**Needs fixes**。spec 合规逐项核过(23 文件与 brief 一一对应,`local.properties` 正确未入库,
厂商 SDK 三个拷贝文件 md5 与原件一致)。R6 被独立复核:逐个读缓存 AAR 的 aar-metadata.properties,
确认 brief 的 4 个版本确实要求 minCompileSdk 37 / minAGP 9.1.0,替换版本均为真实可解析 artifact。
修复轮 1 处理范围(裁定):
- 【Important-1】`MainActivity.kt:24` `Text("RingHealth")` 硬编码 → `Text(stringResource(R.string.app_name))`。
标注 plan-mandated:brief 的 Step 8 示例代码本身就这么写,**计划该改**。后续页面会照抄此模式,必须现在扳正。
- 【Important-2】`.gitignore` 补 `.kotlin/`。
- 【Minor-3】`git update-index --chmod=+x gradlew` + 新增 `.gitattributes`(`gradlew text eol=lf`、
`*.bat text eol=crlf`)。这就是 R8,与实现者自陈的隐患 4 是同一件事。
- 【Minor-6】`libs.versions.toml` 的降级注释补上裁定编号与验证方法,防止后来者"好心"改回去重蹈 32 条冲突。
- 裁定:一并修(都是廉价且防回归的),不只修 Important 两条。
Park(不在本轮修):
- 【Minor-4】`screenOrientation="portrait"` 在 targetSdk 36 上对 ≥600dp 大屏失效 —— brief 指定,
手机场景符合预期,记录为已知决策即可。
- 【Minor-5】`testInstrumentationRunner` 无对应 androidTest 依赖 —— 当前惰性,等真有仪器测试再说。
- 【Minor-7】`ic_launcher_background.xml` 里唯一一处字面色值 —— 交给 Task 2 的 designsystem 收编。
## 环境变更(2026-09-10,非计划内,但影响后续所有任务的构建)
C 盘曾 100% 占满(149G/151G,仅剩 1.3G),是 Task 1 首次构建慢的直接原因。处理结果:
- 清理 Temp 中 7 天以上文件 + npm/pnpm/NuGet/.dartServer/pip 纯缓存。
- **Gradle 用户目录已从 `C:\Users\10314\.gradle` 整体搬到 `D:\.gradle`**,并在 C 盘原路径留了
目录联接指回 D 盘(`LinkType=Junction`)。**任何进程无需设 `GRADLE_USER_HOME` 即可正常使用**——
子代理不继承新环境变量,靠联接才无感,后续不要"顺手"删掉它。
搬迁中 robocopy 首次以 exit 9 失败(native-platform.dll 被 Gradle daemon 占用),
已停 daemon 后补齐并收口,D 盘那份完整、发行包内部联接(services.gradle.org 哈希 → 已解压包)已重建。
- **Android SDK 未搬**(用户决定保留模拟器镜像与 NDK,且 C 盘已有 35G 可用)。
- 当前:C 盘 35G 可用 / D 盘 225G 可用。
教训:跨盘搬 Gradle 目录会因 daemon 加载 `native-platform*.dll` 而失败,须先 `--stop` 且确认无 java 进程;
robocopy 必须分两步(先 copy 验证完整,再删源建联接),不能直接 `/MOVE`。
## 事故:Android SDK 被永久删除(2026-09-10)
发现于 Task 1 修复轮的构建验证:`./gradlew :app:assembleDebug` 10 秒即 BUILD FAILED。
事实:
- `C:\Users\10314\AppData\Local\Android\` **整个目录消失**,非搬走。
- 全盘(仅 C: 与 D: 两个盘)搜索无 `adb.exe` / `android-36` / `build-tools` 残留;
回收站 13 项全是 9/7–9/8 的旧文件,**无 SDK** → 属永久删除(绕过回收站)。
- `ANDROID_HOME` 与 `local.properties` 仍指向已不存在的旧路径。
- Android Studio 本体仍在 `C:\Program Files\Android`,但无任何 SDK。
归因:无法确定。该窗口内对 C 盘做过清理的有两方——我(只删 Temp 中 >7 天的文件与
npm/pnpm/NuGet/.dartServer/pip 五个缓存目录)与 Task 1 实现者(自陈"排查中修复"过 C 盘)。
两方都无法排他性证明。**不深究归因,重点是恢复与防复发。**
恢复(用户裁定:装到 D 盘、只装必需组件):
- 经 `commandline-tools` 装 `platform-tools` + `platforms;android-36` + `build-tools;36.1.0` 到 `D:\Android\Sdk`。
- 改 `local.properties` 的 `sdk.dir` 与用户级 `ANDROID_HOME`/`ANDROID_SDK_ROOT` 指向新路径。
- 模拟器、system-images、NDK **不重装**(用户选择;真要用再装)。
防复发规则(后续任务一律遵守):
1. **不再清理 C 盘**。用户已明确"不用腾 C 盘空间了"。
2. 任何删除操作前必须先 `Get-Item` 确认目标,并优先"先复制验证、再删源"。
3. 绝不使用会把大目录卷入的通配路径删除。
### Task 1 — 修复轮 1 完成
Commit `3cbcafd` fix: 文案改用字符串资源,补齐 .gitignore/.gitattributes 与依赖降级依据。
`./gradlew :app:assembleDebug --no-daemon` → **BUILD SUCCESSFUL in 23s**(compileDebugKotlin 真实执行)。
落地核验(实现者自报,待复审确认):`git ls-files -s gradlew` = 100755;`gradlew` 首行以 LF 结尾;
`git check-ignore -v .kotlin` 命中 `.gitignore:7`。已推送 `2a90c18..3cbcafd`。
复审包:`review-task1-fix.diff`(BASE=2a90c18, HEAD=3cbcafd)。作用域内复审已派发。
### Task 1 — 完成 ✅
复审裁定:**全部四项 ADDRESSED,无新增 Critical/Important 破坏**。
- Important-1 已修:`MainActivity.kt:26` = `Text(stringResource(R.string.app_name))`,import 在 :14。
- Important-2 已修:`.gitignore:7` 含 `.kotlin/`。
- Minor-3 已修:diff 显示 `gradlew` `old mode 100644 / new mode 100755`(零行内容变更);
`.gitattributes` 确为新增,含 `gradlew text eol=lf` 与 `*.bat text eol=crlf`。
- Minor-6 已修:`libs.versions.toml:4-17` 补上勿改回警示、R5/R6 裁定编号、
以及「依据各 AAR 的 META-INF/.../aar-metadata.properties 实测」的验证方法。
Ruling R9: 修复计划文档本身的缺陷。复审查出 plan 第 398、634 行仍写着 `Text("RingHealth")` 字面量——
Task 1 只改了代码没改文档,而 Task 2 的 Step 4 正要照抄这段。已在计划中改为
`stringResource(R.string.app_name)`、补 `androidx.compose.ui.res.stringResource` import,
并在 Task 2 处加了一段明确警示(说明 Task 1 初版正因照抄此示例而引入缺陷)。
同时**从修订后的计划重新生成 task-2-brief.md**,确保实现者拿到的是修正版。
—— 理由:文档里的示例代码就是后续任务的权威来源,只修代码不修文档,缺陷必然复发。
—— 代价:无。
### Task 2 — 设计系统(颜色/字体/主题)
BASE: `3cbcafd`(Task 1 修复轮后的 HEAD)
Commit `8c245c0` feat: 新增设计系统(颜色/字体/浅色主题)。`assembleDebug` BUILD SUCCESSFUL in 23s,无告警。
4 文件(+80/-2):新增 `core/designsystem/{Color,Type,Theme}.kt`,改 `MainActivity.kt`。
实现者自陈两点小顾虑:Theme.kt 沿用 brief 的全限定 `androidx.compose.ui.graphics.Color.White` 未 import;
只做了静态构建验证,未做视觉核对(Task 3+ 才有意义)。→ 交审查者判定。
审查包:`review-task2.diff`。
### Task 2 — 完成 ✅
复审裁定:**Approved(规范符合,无 Critical/Important)**。14 个颜色十六进制、2 个字号、2 个字重、
4 个文件名、`RingHealthTheme` 签名——全部逐字节对照 brief 与计划核实,无静默改值。
R9 的硬编码文案先例保持住了(`MainActivity.kt:32` 仍是 `stringResource(R.string.app_name)`)。
BASE=`3cbcafd`,HEAD=`8c245c0`,审查包 `review-task2.diff`。
Ruling R10: 计划全局约束里的「常量用 UPPER_SNAKE_CASE」与设计系统 token 的 PascalCase
(`BrandGreen`/`MetricRed`/`AppBackground`/`MetricValueStyle`…)直接冲突。审查者指出这会**贯穿剩余
十个任务**、每轮审查都重复报同一条。已改计划:把该约束限定为 `const val` 编译期常量,并显式豁免
`core/designsystem/` 的设计 token(它们不是 `const val`,且 Tasks 3–12 按确切名字引用,改名即破坏接口契约)。
—— 理由:消除一处会让后续每轮审查都重复产出的假阳性,同时把"为什么可以不一致"写清楚。
—— 代价:无。
Park(不修,留给最终整分支审查):
- `Theme.kt:9` 用全限定 `androidx.compose.ui.graphics.Color.White` 而非 import —— 纯风格,无功能风险,
为它单开一轮构建+提交不划算。审查者指出它会成为 Tasks 7–12 的风格先例,记录备查。
- `Type.kt:9,16` 的 `MetricValueStyle`/`MetricTileValueStyle` 未注入 `RingHealthTypography` —— brief 要求如此。
- `MainActivity.kt:28` + `enableEdgeToEdge()` 在系统暗色模式下可能有状态栏对比度问题 ——
brief 指定,暗色明确推迟到 M5;`RingHealthTheme` 缺 `darkTheme: Boolean` 参数,M5 落地时各调用点会有签名变更。
- 报告 diffstat 把 `MainActivity.kt` 写成 +8/-2,实际 +6/-2 —— 仅报告误差,代码无误。
### Task 3 — 通用 UI 组件
BASE: `8c245c0`。6 个组件文件:`HealthCard` / `RingProgress` / `MetricTile` / `SectionHeader` /
`StatBar`(含 `StatSegment`)/ `MetricBarChart`。产出签名是 Tasks 7–12 全部页面的构件。
### Task 3 — 结果
Commit `75a6749` feat: 新增通用 UI 组件。`assembleDebug` BUILD SUCCESSFUL;`--rerun-tasks` 强制重编
零 Kotlin 告警。6 个新文件 330 行,未触碰任何既有文件。BASE=`8c245c0`,审查包 `review-task3.diff`。
Ruling R11: 接受实现者对**计划代码缺陷**的修正。计划的 `MetricBarChart` 用
`values.indexOf(maxValue)` 定位高亮柱,但 `maxValue` 经过 `coerceAtLeast(1f)` 抬高,
只要所有值都 < 1(如睡眠时长 0.5h)就返回 -1 → 高亮柱永不出现。改为
`values.indices.maxByOrNull { values[it] } ?: 0`。公开签名与默认值未变。
—— 理由:这是计划里的真 bug,不是风格分歧;审查者应判"修正正确"而非"偏离 brief"。
—— 代价:无。另两处非签名偏离(KDoc 措辞与 `highlightLast` 默认值不符、删掉未使用的 import)一并接受。
Ruling R12: **保留** brief 的 `MetricTile(onClick: () -> Unit = {})` 非空签名,不改为可空。
—— 理由:Task 8 的所有指标块都指向 Task 12 的指标详情页,不存在"无处可去"的块;
连"血压未开启"也应能点进去看到未开启说明(与小米运动健康一致)。改成可空是为不存在的情况加复杂度(YAGNI)。
—— 代价:若判断错误,改一处签名 + 各调用点补 `null`,约 5 行;届时若确需静默块再改。
### Task 3 — 审查
已派发任务审查者(general-purpose / sonnet),输入:`task-3-brief.md`、`task-3-report.md`、
`review-task3.diff`(BASE `8c245c0` / HEAD `75a6749`)、Global Constraints 原文,
并把 R11(MetricBarChart 高亮位置修正为已批准)与 R12(MetricTile onClick 保持非空)作为
**已裁定事项**写入派发词,避免被当成偏离 brief 的缺陷而触发无谓的修复轮。
Task 4 brief 已生成:`task-4-brief.md`(领域模型 + 仓库接口)。Task 4 的 BASE 待 Task 3 定稿后取
(若 Task 3 触发修复轮,BASE 顺延至修复提交)。
### Task 3 — 审查结论:**Approved**(无 Critical / 无 Important)
审查者逐条核对了 6 个签名与默认值(含参数顺序)→ 零漂移,Tasks 7–12 可按名调用。
几何自行重算而非采信报告:RingProgress 弧框 166dp + 7dp 内缩,描边 14dp 恰好贴合、四边不裁切,
sweep 0f→360f 且 `coerceIn` 已处理越界;MetricBarChart 槽宽 65%/35% 对称、底对齐、
空列表在除法之前就 return、两个 highlight 分支都恰好高亮一根柱。R11 的替换被判"正确且完整"。
Park(已批准,不在本任务修,留最终整分支审查):
- M1 `RingProgress.kt:61`:`progress = 0f` 时 sweep 为 0 且 Round cap,Skia 是否画出 14dp 圆点
未经运行验证(审查者自评 ~70% 置信)。**观察点**:若 Task 8/14 在 0 步数下真看到色点,
修法是 `progress > 0f` 才画进度弧(约 2 行)。
- M4 `SectionHeader.kt:29-36`:`SpaceBetween` + 未加权的标题与 trailing,超长标题(文案来自
strings.xml,长度不可控)可能把 trailing 挤没。签名中性的修法:标题加
`Modifier.weight(1f, fill = false)` 或 `maxLines = 1` + ellipsis。**观察点**:Task 8 起各页
实际标题长度。
- M2 NaN progress 会静默丢失进度弧(不崩、不画错);M3 全 <1 或全 0 序列下柱图高度利用率低/全空白;
M5 `MetricTile` 里单 Row 外包 `Column(Bottom)` 是空操作;M6 `StatBar` 里 `.fillMaxWidth()` 冗余;
M7 六个组件均无 `@Preview`。均为打磨项,不动。
### Ruling R13 — `core/common/Formatters.kt` 的「不硬编码文案」边界(派发 Task 5 前裁定)
起因:Task 5 brief 的代码把 `"$kcal 千卡"`、`"放松/正常/中等/偏高"`、`"7小时32分"` 直接写在 Kotlin 里,
与 Global Constraints「所有用户可见文字进 strings.xml」正面冲突;且 R3 已用**同一条理由**
(「会与 strings.xml 里的第二份形成两份事实来源」)把 `metricDisplayName` 排除在外 —— 计划自相矛盾。
裁定(已改计划,见 commit):
1. `formatCalories(kcal)` 只返回千分位数字;「千卡」由调用点用资源拼接。
依据:`R.string.home_calories_label` 已是「千卡」,硬编码即 R3 所说的两份事实来源。
`R.string.workout_detail_calories` 由 `消耗 %1$s` 改为 `消耗 %1$s 千卡`(唯一调用点,2742 行)。
2. `stressLevelLabel(stress): String` → `@StringRes stressLevelLabelRes(stress): Int`,
4 条中文描述进 strings.xml。依据:与 R3 对指标名的处理保持同一模式。
3. **豁免**:`formatSleepDuration`(`7小时32分`)与 `formatDuration`(`1小时05分`)保留。
依据:数字与单位**黏合成单一复合值**,设计系统没有独立单位槽位可放,拆开会把同一段 `when`
复制进每个调用点;且「小时/分」在 strings.xml 里没有第二份。豁免已写进 Global Constraints,
并写明判据(**该文字是否已有第二份资源** + **设计系统是否已提供独立槽位**),避免后续审查重复误报。
代价:Task 5 两处测试断言改写;strings.xml 新增 4 条;Task 8 首页 calories 由 `.toString()` 改走
`formatCalories()`(顺带消掉 Task 8 代码块里原本未被使用的 `formatCalories` import)。
若判断有误,回退范围约 4 个文件、10 行以内。
### Ruling R14 — Task 7 占位页的中文字面量(同批修正)
起因:Task 7 的 7 个占位 Composable 写成 `Text("健康首页")` / `Text("设备设置")` / `Text("详情:${metric.name}")`,
而**同一个 Task 的 Step 3 正好新增了 `tab_home`/`tab_workout`/`tab_device`/`tab_profile`** —— 资源就在手边没用。
这正是 Task 1 首轮审查判为 Important 的同一类缺陷(`Text("RingHealth")` 字面量),必然重复触发修复轮。
裁定(已改计划):四个 Tab 占位页改用 `stringResource(R.string.tab_*)`;三个子页占位新增
`placeholder_device_settings` / `placeholder_workout_history` / `placeholder_metric_detail`
(`详情:%1$s` 简化为固定文案「指标详情」,避免占位页依赖 Task 12 才有的 `metricTitleRes`);
7 个文件补齐 `stringResource` 与 `R` 的 import。函数名与签名不变,Tasks 9/10/12 的替换点不受影响。
代价:Task 7 多 3 条字符串资源;占位页文案由「健康首页」统一为「健康」。若判断有误,回退 7 个文件。
### Task 4 — 结果
Commit `ea8897f` feat: 新增领域模型与仓库接口。两文件 105 行,未触碰既有文件。
`assembleDebug` BUILD SUCCESSFUL(`compileDebugKotlin` 为 executed,非 up-to-date)。
BASE=`75a6749`,审查包 `review-task4.diff`。
### Task 4 — 审查结论:**Approved**(无 Critical / 无 Important)
审查者逐字段核对了 7 个数据类与 2 个接口:字段名、类型、声明顺序、可空性(6 个可选指标确实可空、
4 个常态字段确实非空)、参数名与返回类型全部与 brief 一致,零漂移;KDoc 覆盖到每个公开成员
(含接口属性);`domain/` 无 `android.*`/`androidx.*` import;R1/R2 的范围裁定被遵守,
没有偷建 `AppContainer.kt`/`AppViewModelProvider.kt`。另跑三项针对性编译风险检查(重名声明、
coroutines 传递依赖、行尾)均无阻塞。
Park(不在本任务修):
- `Repositories.kt:14-15` 的 `kotlinx.coroutines.flow.*` 只是**传递依赖**(经 compose-runtime 的
api variant),app 模块没显式声明 coroutines。今天能编,但 `domain/` 的编译类路径因此依赖 Compose ——
正是「domain 层可脱离 Android 独立编译」想避免的耦合。**M2 若把 `domain/` 拆成纯 Kotlin 模块,
必须同时加显式依赖**。现在不动:改 `libs.versions.toml`(带禁止回退告示)去引一个已被传递解析的
版本,冲突风险大于收益。
- `Repositories.kt:40` `setFunctionEnabled` 非 suspend,而 `refreshDeviceState` 是 suspend。
M2 的真实 BLE 写是异步的,届时适配器要么自建 scope 要么阻塞。brief 明确要求此签名,留给 M2。
---
### Task 5 — 派发
BASE: `ea8897f`。brief 已按 R13 重新生成(`task-5-brief.md`,182 行,含新的 `stressLevelLabelRes`
与 strings.xml 步骤)。派发词携带:真实源码根路径、`R` 全限定名 `com.placeholder.ringhealth.R`、
`@StringRes` 由 androidx.annotation 传递提供、R13 的裁定理由(避免实现者"好心"改回拼接单位)。
### Ruling R15 — Mock 数据层的中文字面量:**豁免**(派发 Task 6 前裁定)
Task 6 的 Mock 里有 `listOf("户外跑步", "健走", …)`、`name = "RingHealth 智能戒指"`、
`DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true)` 等中文。
裁定:**不进 `strings.xml`,保持原样**。
理由:这些是**设备侧数据的替身**,不是 App 的 UI 文案 —— M2 接入后设备名来自蓝牙广播/固件、
运动类型与功能项来自协议与适配层。塞进资源反而会逼 M2 的适配器把设备值反向查表映射回资源 id。
R13 定的两条判据在这里都不成立:strings.xml 里没有第二份,设计系统也没有对应槽位。
—— 代价:若 M2 的设备只给 id 不给展示名,适配层需自行从资源解析(那属于 M2 的改动,不在 Mock)。
**给 Task 6 审查者的说明**:这是已批准的决定,请勿作为"硬编码文案"上报。
### 计划通扫(R13/R14 之后的复检)
按 `Text("中文")` / `= "中文"` / `listOf("中文")` / `-> "中文"` 四种形态扫全计划,剩余的 Kotlin 中文字面量只有:
①`Formatters` 的 `formatSleepDuration`/`formatDuration` 及其测试(R13 明确豁免);
②Task 6 的 Mock 数据(R15 豁免);③第 656 行那句解释 R9 的说明性注释(非代码)。
其余全部已进 `strings.xml` 或改用资源引用。据此判断:Tasks 8–12 不应再出现 R13/R14 类缺陷。
### Ruling R16 — 工作直接落在 `main`,不做 worktree / 特性分支
现状:`/d/cherry/repos/ring-health-app` 当前分支 `main`,upstream 为 `origin/main`,本地领先 4 个提交
(`8c245c0` `75a6749` `ea8897f` `bf373a2`;origin 停在 `3cbcafd`)。
裁定:Task 1–14 全部直接提交到本地 `main`,不另开分支或 worktree。
理由:这是用户为本项目**新建的仓库**,Phase 1 从零开始,`main` 就是本项目的开发主线,
不存在需要保护的既有共享分支;SDD 的 ledger 以提交 SHA 记录 BASE/HEAD,线性历史正是它需要的。
**推送**(`git push`)属四类停止条件里的「对外动作」,但用户此前已明确给出仓库地址并要求推送
(`http://120.192.101.56:3000/cuiluzheng/ring-health-app.git`),该授权持续有效。
节奏:不在每个 Task 后推,留到审定节点(Task 5 审查通过后)成批推一次,保证 origin 不长期落后。
### Task 5 — 结果
Commit `bf373a2` feat: 新增数值格式化工具(含单元测试)。3 文件 +109 行。
测试 6/6 通过,输出无告警;RED 阶段确认真实(实现前 21 处 `Unresolved reference`,BUILD FAILED)。
`strings.xml` 为纯追加 4 条,未新建文件。BASE=`ea8897f`,审查包 `review-task5.diff`。
实现者提出的两点疑虑:`formatClock`/`formatDate` 无测试(brief 只指定 6 个测试,属 brief 规定范围),
以及 `Locale.CHINA` 固定分隔符 —— 均为已知取舍,转交审查者按 Minor 判定。
### Task 5 — 审查结论:**Approved**(无 Critical / 无 Important)
审查者把 6 条测试的期望值全部自行重算(含 `formatDistance(6.212)` 的舍入方向 —— 第三位是 2,
HALF_UP 与截断同解,无陷阱;`formatSleepDuration(480)` 走「整小时无分钟」分支;
`formatDuration(65)` 的 `%02d` 补零),并逐条验证无同义反复(打乱任一分支臂或返回常量 id 都会挂)。
R13 的豁免被**精确**使用且未扩大:实现里仅剩 小时/分 两处中文,`千卡` 只出现在 KDoc 里,
`放松/正常/中等/偏高` 在 Formatters.kt 中完全不出现。
RED 证据被判为真实:审查者按提交的测试文件反推 21 处编译错误的行:列,与报告**逐条吻合** ——
伪造的输出不会刚好落在每一列上,且报错点名了 `R.string.stress_*`,只有 strings.xml 未改动时才成立。
Park(不在本任务修):
- `Formatters.kt:48-50` 压力分档阈值未写进 KDoc(30→正常、60→中等、80→偏高 是开区间下界)。
- `formatSleepDuration`/`formatDuration` 传负数会得到 `"0分"`/`"-30分"` 这类无意义值;本领域不会出现负分钟。
- `formatDistance(NaN)` 会渲染出英文 `"NaN"`(Java Formatter 特例,不抛异常)。
- `formatClock`/`formatDate` 无测试(brief 只指定 6 个);将来补测时 `SimpleDateFormat` 用的是 JVM 默认时区,
必须显式固定时区,否则换机器会 flake。
- 每次调用各自 new 一个 `SimpleDateFormat`:无共享状态、无线程安全问题,仅是每次一个小分配。改成静态共享反而会引入线程安全 bug。
- 报告第 66 行说「8 个符号此时全部不存在」属叙述夸大(RED 日志只证明 6 个函数 + 4 个资源 id)。
### Ruling R17 — `MockDataProvider` 注入时间源(派发 Task 6 前裁定)
起因:本类 KDoc 承诺「所有方法都是确定性的(同参数返回同结果)」,但 `metricHistory`/`workouts`
直接读 `System.currentTimeMillis()`,`timestamp` 随之跳动。brief 的测试
`assertEquals(first, second)` 比较的是整个 `List<MetricPoint>`(含 timestamp)—— 两次调用只要跨过
一个毫秒边界就失败,是一条**间歇性 flaky 断言**(Windows 上 `currentTimeMillis` 的粒度更粗,
平时容易蒙混过关,换机器或负载变化就会现形)。
裁定:给 `MockDataProvider` 增加可注入时间源 `now: () -> Long = System::currentTimeMillis`,
内部一律用 `now()`;测试注入 `FIXED_NOW`,并补一条「时间戳严格递增」的断言
(顺带验证「每点间隔一天倒推」的符号方向没写反)。
—— 理由:这样 KDoc 的确定性承诺才成立,测试也才真正在验证「可重复」而不是在赌毫秒边界。
—— 代价:接口增补一个带默认值的参数,brief 的 `Produces` 行与测试各改一处;实现端零额外复杂度。
若判断有误(比如并不在意时间戳稳定性),回退就是删掉该参数并放宽断言,约 6 行。
### Task 6 — 结果
Commit `fda1040` feat: 新增 Mock 数据层与依赖容器(含单元测试)。6 文件 +283/-2。
5/5 `MockDataProviderTest` 通过,全量单测 11/11(Formatters 6 + Mock 5),`assembleDebug` SUCCESSFUL,输出无告警。
RED 已捕获:`Unresolved reference 'MockDataProvider'`(`MockDataProviderTest.kt:15:28`),其余 6 条错误均由其级联。
R1(AppContainer 落在本任务)、R15(Mock 中文留在 Kotlin)、R17(注入 now())均已执行。
`data/mock/` 与 `AppContainer.kt` 无 `android.*`/`androidx.*` import(已 grep 确认)。BASE=`bf373a2`。
### Ruling R18 — `override` 成员不重复写 KDoc(随 Task 6 审查一并提交)
`MockHealthDataRepository` / `MockRingDeviceRepository` 的 `override` 成员按 brief 原样、未加 KDoc,
实现者主动把这一点提出来询问。
裁定:**保持现状**。理由:KDoc 在 Kotlin 中沿继承链生效,接口 `Repositories.kt` 已为每个成员写了中文说明,
在实现处复制一遍只会产生两份需要同步维护的说明;真正属于实现细节的信息(「每次发射前 delay 200ms」、
「开关状态存内存、重启即重置」)已写在**类级** KDoc 上,位置正确。
—— 代价:若审查者按「每个公开函数上方都要有 KDoc」的字面要求上报,此为已批准决定,不再开修复轮。
### 交给 Task 6 审查者的待判问题(不预设结论)
`MockRingDeviceRepository.refreshDeviceState()` 用 `_deviceState.value = provider.deviceState()` 赋值,
而 `provider.deviceState()` 每次返回**相等**的 data class 实例 —— `MutableStateFlow` 按相等性抑制重发,
因此「刷新」不会产生任何可观察的发射。请审查者判定:这在 M1(数据恒定、无真实设备)下是合理行为,
还是应当视为缺陷(例如改为带时间戳的状态、或让 UI 的刷新反馈不依赖状态变化)。
### Ruling R19 — 修正 Task 7 的 `Produces` 契约(派发 Task 7 前裁定)
起因:Task 7 的 Interfaces 段写 `HomeScreen(onOpenDetail: (MetricType) -> Unit, onOpenWorkoutHistory: () -> Unit)`,
但**全计划搜不到 `onOpenWorkoutHistory` 的任何调用点**;Task 8 实际产出
`HomeScreen(onOpenDetail, modifier, viewModel)`。同时 `WorkoutScreen(onOpenHistory)` 与
`DeviceScreen(onOpenSettings)` 分别是 Task 9 / Task 10 才加入签名的(它们在各自 Step 里更新
`RingHealthApp.kt` 的调用点),Task 7 的占位页并没有这两个参数。
影响:本任务的实现者与审查者都以 Interfaces 段为准 —— 照它写会凭空多出一个没人用的参数,
照代码写又会被审查者判「缺少声明的参数」,两条路都要浪费一轮。
裁定:改计划,Interfaces 段改为描述**本任务实际产出**的占位签名,并写明
「DEVICE_SETTINGS / WORKOUT_HISTORY 本任务只注册、无人跳转,回调由 Task 9/10 加入」,
以及「`onOpenWorkoutHistory` 是笔误,已删除,不要加回来」。
—— 代价:无。若判断有误(比如后续确实需要首页跳运动历史),届时按需在 Task 8 添加即可。
### Task 6 — 审查结论:**Approved**(无 Critical / 无 Important)
审查者复算了确定性(`now()` 只读一次、`Random` 每次按同种子重建、逐点抽样序列与 `count` 无关)、
时间戳符号(count=3 时 index 0 = now-2天,确认「最早在最前」没写反)、种子防碰撞
(`ordinal*100 + range.ordinal` 在 7 指标 × 2 范围上两两不撞,`seed+7` 也不与它们冲突)、
`coerceIn` 的 7 个区间均升序、`setFunctionEnabled` 的 `map+copy` 语义、以及 DI 初始化顺序
(manifest 无 `<provider>`,`lateinit` 不可能被提前读取)。RED 的行:列同样被逐条反推吻合。
**待判问题裁定:`refreshDeviceState` 不产生发射是正确的 StateFlow 语义,不是缺陷** ——
相等值抑制正是 StateFlow 作为状态容器的定义行为,M2 蓝牙重读到相同电量时也不应churn UI;
刷新契约由 suspend 函数「返回」而非「发射」来表达,`delay(300)` 在 suspend 体内,
调用方 `launch { refresh(); isRefreshing = false }` 在 M1 与 M2 都成立。
但审查者指出一个**调用侧陷阱**:若有人把刷新反馈写成 `deviceState.drop(1).first()` 之类的
「等下一个不同发射」,在 M1 会永久挂起,且看起来像仓库的锅。已 grep 确认当前**没有任何 UI 调用
refreshDeviceState**(只有接口声明与 Mock 实现),所以这是预防性记录,留给将来加刷新入口时。
Park(不在本任务修):3 条「值合理」断言对当前实现无法证伪(`coerceIn` 蕴含区间、
`batteryPercent in 0..100` 是字面量 78 蕴含、`durationMinutes>0` 由 `25+` 蕴含),作为冒烟断言可接受;
`MockRingDeviceRepository` 整类无测试;`.gitattributes` 无 `*.kt` 规则(`core.autocrlf=true` 导致的
LF→CRLF 提示为既有行为,blob 仍归一化为 LF);`RingHealthApplication` 的 KDoc 比 brief 少了一句
「(M1 阶段只持有 Mock 容器)」;`metricHistory` 的 KDoc 说「波动」而公式实为自低到高的斜坡。
### Ruling R20 — 补一轮小修复:让 R17 的时钟注入可被证伪(Task 6 修复轮 1)
起因:审查者指出(Minor 1),R17 注入的时钟**没有被测试真正验证** ——
`assertEquals(first, second)` 的两次相邻调用几乎总落在同一毫秒,把 `now` 去掉、
退回 `System.currentTimeMillis()`,这条测试照样通过。也就是说我为消除 flaky 而做的改动,
本身处于无覆盖状态,且该测试无法在将来发现这次改动被回退。
裁定:开一轮小修复(不因它是 Minor 就放过 —— 它保护的是本次会话自己引入的改动):
①测试补两条绝对时间戳断言(`first.last().timestamp == FIXED_NOW`、
`first.first().timestamp == FIXED_NOW - 6 天`),使注入成为被测对象;
②顺带把审查者 Minor 4 的刷新语义写进 `MockRingDeviceRepository` 的类级 KDoc
(「以 suspend 返回表示完成,不靠发射新状态」),因为已经要动这个文件,且它能挡住那个调用侧陷阱。
—— 代价:一轮构建 + 一次范围化复审。若判断有误,回退就是删掉两条断言。
**修复轮 1 结果:** 提交 `2fba18b`(+11 行,仅 2 个文件:`MockDataProviderTest.kt` 与 `MockRingDeviceRepository.kt`)。
`MockDataProvider.kt` 相对 `fda1040` **零 diff**,`currentTimeMillis` 在 `data/mock/` 主源码中只剩构造参数默认值一处。
**装药检查(要求实现者真做,不是推理):** 把 `metricHistory` 内部退回挂钟后,测试失败
`expected:<1760000000000> but was:<1789008526157>` —— 而**同一次运行里旧的 `assertEquals(first, second)` 依然通过**。
这是对审查者诊断的实证:两次相邻调用确实落在同一毫秒,旧的相等断言在任何情况下都发现不了注入失效。
两条新断言是真正承重的。恢复后 5/5 通过;`--rerun-tasks` 强制重跑全量 11/11;`assembleDebug` 成功。
**范围化复审:CLEAN。** 复审者独立复算了时间戳(count=7,index 6 → 恰为 `now()`,index 0 → 恰为 6 天前,
`6L*24*60*60*1000` = 518,400,000 量级与符号均正确),确认 KDoc 的 `[refreshDeviceState]` 链接指向真实同类成员,
并逐条核验了实现者自述(零 diff、diffstat、`currentTimeMillis` 计数)—— 均属实。
### Task 6: complete — 提交 `fda1040` + 修复轮 `2fba18b`
### Ruling R21 — Task 7 跳过 `installDebug`,仅 `assembleDebug`
起因:brief 的 Step 7 写的是 `./gradlew assembleDebug && ./gradlew installDebug`,
`Expected` 还要求「打开 App 可见底部四个 Tab,点击可切换」。但本机**没有模拟器、也没有连接真机**
(CLAUDE.md 明载 Android SDK 只装了 platform-tools / platforms;android-36 / build-tools;36.1.0,无模拟器与 NDK)。
`installDebug` 必然失败,且失败原因与本任务代码无关 —— 若不预先裁定,实现者会把它当成自己的 bug 反复调试,
或者报 BLOCKED。裁定:Task 7 只跑 `assembleDebug`;「四个 Tab 可点、互不崩溃」这一条**降级为编译期验证**
(NavHost 路由全部注册、`Routes` 常量与 `composable(...)` 一一对应),运行时验证顺延到 Task 14 的端到端验收,
或在有设备时人工补做。这也顺带剔除了全部 Task 里所有 `installDebug` 步骤。
—— 代价:导航骨架在 Task 14 之前没有真机验证;若 Task 14 也无设备,则整个 M1 的运行时行为都只有编译期保证,
那时需要单独向人确认(是否装模拟器镜像,约 1–2 GB,落在 D 盘)。这条风险记在此处,不隐藏。
### Task 7: complete — 提交 `6d3cb1b`(4 Tab 导航骨架)
**审查结论:Approved with minor issues。** 无 Critical、无 Important。审查者独立复算了三处最容易出错的地方:
**路由模板与拼串是否吻合**(`"detail/{metric}"` 对 `"detail/STEP"`,逐段匹配后 arg=`"STEP"` → `MetricType.STEP`;
并核验 7 个枚举名全为 `[A-Z_]`,不含 `/` 或 `%`,永远不需要 URI 编码)、
**底栏回退栈语义**(`popUpTo(startDestination){saveState}` + `launchSingleTop` + `restoreState` 逐跳推演:
HOME→WORKOUT→DEVICE 不堆积;重复点已选中 Tab 时先 pop 再被 `launchSingleTop` 抑制,栈不变、不崩)、
**图标依赖**(从 `debugCompileClasspath` 实证 `material-icons-extended:1.7.8` 在线上,
`FitnessCenter` 只在 extended 里,这正是它被显式钉版本的原因)。
另外强制 `--rerun-tasks` 全量重编,0 warning,实现者自述属实。
**5 条 Minor 全部是「计划缺陷」而非「实现缺陷」**(brief 把这批代码逐字钉死了):
F1 `Routes.kt` 里模板占位符名、`DETAIL_ARG_METRIC`、`detail()` 拼串是三份手工同步的字面量;
F2 `BottomTab.labelRes: Int` 缺 `@StringRes`;F3 子页在栈顶时四个 Tab 全不高亮
(`destination.route` 返回的是**模板**,不等于任何 Tab 路由);F4 `?: MetricType.STEP` 会静默显示错数据;
F5 报告里「导航测试需要设备」的说法过宽 —— `Routes` 与 `MetricType` 是纯 Kotlin,JVM 测试完全够。
### Ruling R22 — 把 F1/F3/F4/F5 折进计划文本,不在 Task 7 开修复轮
裁定:**不开修复轮**。理由是这四条目前**全都不可观测**:没有任何代码导航到子路由
(`HomeScreen` 是占位、Task 9/10 才加 `onOpenHistory`/`onOpenSettings`),
此时改 `RingHealthApp.kt` 属于改一段测不到的死代码,改对了也验证不了。
④ 它们真正变得可见的时刻,正是 Task 9(第一个子路由导航接入)、Task 10、Task 12。
因此把修复写进**那些任务**的计划文本,让改动与它的验证同处一个提交:
①F1 的加固(`DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}"`,注意常量声明顺序)+
②F3 的 `tabRouteFor` 归属映射(三个子路由**一次性**映射完,免得 Task 10/12 回来改同一个 `when`)
两条并入 **Task 9 Step 5**;③F4 的 `requireNotNull` 替换静默兜底并入 **Task 12**;
④F5 的 `RoutesTest`(纯 JVM、不需要设备)一并并入 **Task 12**。
—— 代价:Task 7 的提交里留着一处已知不完美的 Tab 高亮,直到 Task 9 才修;若 Task 9 被取消或重排,
这三条会跟着丢失(已在本条与计划文本两处留痕,不会只剩口头记忆)。
F2(`@StringRes`)不值得单独动,Park 到 M2 统一收口。
### Ruling R21(补充执行)— 已把 `installDebug` 从全部 Task 的构建步骤中移除
除 Task 1(已完成的历史文本)外,Task 8–13 的 `./gradlew assembleDebug && ./gradlew installDebug`
一律改为只跑 `assembleDebug`;Task 14 Step 3 的人工验收清单加了三行醒目说明,
要求执行 agent 原样抄进报告并标注「未执行 —— 无设备」,**不得**当成已通过。
Global Constraints 也加了对应一条,让后续每个 Task 的 brief 都带上它。
### Ruling R23 — Task 8 的 `unit = "mmHg"` 是硬编码用户可见文案,改为字符串资源
派发前预读 brief 时发现:`metric_blood_pressure` 卡片的单位写成字面量 `"mmHg"`,
而同一个 `MetricCardSpec` 的兄弟单位 —— `次/分`、`%`、`°C` —— 全都规规矩矩走了 `strings.xml`。
`unit` 会被 `MetricTile` 直接渲染出来,所以 `"mmHg"` 是用户可见文字,按 Global Constraints 必须进资源;
**同一份 listOf 里六个卡片、五个合规一个不合规**,正是 R13 要消灭的那种自相矛盾。
若放过,它会成为「写死也行」的先例,而 Task 12 的详情页要复用同一批单位资源。
裁定:计划里改为 `stringResource(R.string.unit_mmhg)`,并在 Step 7 的字符串清单中补
`<string name="unit_mmhg">mmHg</string>`。
—— 代价:多一条字符串资源。若判断有误(有人认为纯符号单位不必进资源),删掉这一条即可,无其他影响。
另外把 Step 8 的标题与 Expected 从「真机验证」改为「构建 + 单测」,界面验证按 R21 顺延到 Task 14。
**派发前已逐项核验 Task 8 brief 引用的全部符号真实存在且签名一致**(这是最容易一编译就炸的地方):
`MetricGreen/Red/Blue/Cyan/Purple/Orange/Yellow`(Color.kt:17-23)、`TextSecondary`(:13)、
`MetricValueStyle`(Type.kt:9)、`HealthCard(modifier, onClick: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit)`、
`MetricTile(icon, iconTint, title, value, unit, modifier, onClick)`、
`RingProgress(progress, color, trackColor, modifier, ringSize, strokeWidth, content)`、
`SectionHeader(title, modifier, trailing)` —— 全部吻合。
`collectAsStateWithLifecycle` 所需的 `lifecycle-runtime-compose`、
`viewModel(factory=)` 所需的 `lifecycle-viewmodel-compose`、`viewModelFactory`/`initializer` 所需的
`lifecycle-viewmodel-ktx` **三者均已在 `app/build.gradle.kts:54-56` 声明**(2.10.0),Task 8 不需要新增依赖。
`androidx.compose.foundation.lazy.{item,items}` 的 import 路径正确,且 `foundation` 经
`HealthCard` 已在用的 `androidx.compose.foundation.clickable` 确认在编译类路径上。
### Ruling R24 — 我自己核验错了:`androidx.compose.foundation.lazy.item` 不可 import
派发 Task 8 前的预读里,我核验了设计系统的全部符号与依赖(都对),并**在派发词里写了
「`androidx.compose.foundation.lazy.{item,items}` 的 import 路径正确,不要改动」**。这句话是错的。
实现者没有照做,而是去 `javap` 反编译了实际解析到的产物
(compose-bom 2026.06.01 → foundation-android 1.11.4)核实,确认:
**`item` 是 `LazyListScope` 的成员函数,不是顶层扩展** —— 成员函数永远不能 import,
那一行单独就让编译失败(`HomeScreen.kt:15:41 Unresolved reference 'item'`);
只有 `items(List<T>)` 是 `LazyDsl` 里的顶层扩展,才需要 import。
实现者删掉那一行后构建通过,且 `item { }` 的调用点行为不变(改走成员分派)。
**这是本次执行中第一次「实现者推翻控制者的指令且实现者是对的」。** 处理方式:
照实现者的结论改计划(删掉该 import),并在计划里留下 R24 的显式警告,
避免后来者把这一行「补回去」;同时把这条记在这里 ——
我预读时把「这两个名字我都见过」当成了「这两个都能 import」,那是记忆而非核验,
而真正的核验(反编译看符号)只花了实现者一次 `javap`。
**教训写进流程:** 派发词里凡是我用「不要改动」这种强断言背书的符号,必须是我**实际反编译或编译验证过**的,
否则改为「若编译不过,以实现者实测为准并回报」。
顺带确认:全计划只有这一处该 import(Task 9/10/11 没有),已修。
**关于实现者的 concern 3(`formatSleepDuration` 返回硬编码中文)—— 这是已裁定事项,不是新缺陷。**
R13 已把它连同 `formatDuration` 一起写成 Global Constraints 里的**显式豁免**,判据有两条
(该文字是否已有第二份资源 + 设计系统是否已提供独立槽位),睡眠时长两条都不满足。
实现者不知道 R13 存在,报告里说「值得单开 issue」;**不开**,见 R13。
### Task 8: complete — 提交 `7eeb9f8` + 修复轮 `4805b02`
**审查结论:Approved with minor issues。** 无 Critical、无 Important。审查者用 `diff -u` 把 brief 的代码块
与落地文件逐字节比对(`HomeViewModel.kt`/`AppViewModelProvider.kt` **完全一致**,`HomeScreen.kt` 只差
那一行被删掉的 import,`strings.xml` 恰好新增指定的 20 条),并**独立确认了实现者删 import 是对的**。
还逐字段核对了 Preview 的位置参数没有错位、`HomeUiState` 的 nullable 契约(六个指标任一为 null
都渲染「未开启」而不是 `null` 或 `0`)、`state.summary` 的智能转换(编译通过本身就是证明,
因为下游签名是非空)、以及测试里 `backgroundScope` 收集**确实承重**(去掉它 `.value` 会停在
`initialValue`,`assertFalse(loading)` 必挂)。APK 字节数与报告一致。
6 条 Minor 里,只有 1 条是本次实施产生的(`HomeContentPreview` 缺 KDoc,brief 本身就漏了),
其余 5 条都是计划层面的前瞻性记录,已 Park:`loading` 字段由构造方式决定其冗余(恒为
`summary == null` 的等价物)、首页没有任何错误态(M2 接真 SDK 后上游抛异常会直达
默认 handler 崩进程)、`ringHealthApplication()` 的强转在非 `RingHealthApplication` 的
ViewModelStoreOwner 下会 NPE/CCE(官方文档就是这个写法,M1 唯一路径安全)、
`bloodPressure` 若 M2 用 `""` 表示无数据会渲染成空白卡片而非「未开启」、`@StringRes` 缺失。
**修复轮 1(`4805b02`):** 补上 `HomeContentPreview` 的一行中文 KDoc(与其余七个同类函数同风格),
并做了全 diff 的 KDoc 自查 —— 除它之外**再无遗漏**;同时确认属性与
`@Test`/`setUp`/`tearDown` 在本代码库的既有约定下本来就不写 KDoc(`FormattersTest` 全文件零 KDoc)。
`./gradlew :app:testDebugUnitTest` 12/12 通过。
### Ruling R25 — Task 9 派发前的四处修正
派发前预读 brief,发现四处需要改(前两处是「新写死代码」,性质比 Task 8 那条更重):
① **`workout_detail_duration`(`时长 %1$s`)与 `workout_history`(`全部记录`)是死资源。**
全计划 grep:两条**一个调用点都没有**。时长已经作为 `%1$s` 拼进 `workout_item_summary`;
运动历史页没有标题栏,用不上「全部记录」。这个 Task 原本会**新增**两条永远不被引用的字符串 ——
与 R13 要消灭的「两份事实来源」是同一类问题的反面。裁定:从 Step 4 的清单中删除,并写明「不要加回来」。
② **`WorkoutHistoryScreen` 先判空、不判 loading,会闪「暂无运动记录」。** Mock 仓库有 200ms 延迟,
`records` 在加载完成前就是 `emptyList` —— 于是页面先显示「暂无运动记录」,数据到了再跳成列表。
用户看到的是「明明有记录却说没有」。裁定:改为先判 `loading`(转圈),再判空,与
`WorkoutContent` 保持同一形状。
③ **`WorkoutViewModel` 没有测试,而 Task 8 的 `HomeViewModel` 有。** 两个 ViewModel 形状完全一样
(`flow.map{}.stateIn(WhileSubscribed)`),覆盖度不该一个测一个不测。裁定:补 `WorkoutViewModelTest`
(TDD 顺序,先写测试看 RED,再实现看 GREEN),因此 Task 9 的步骤整体重编号为 Step 1..9。
断言取 `records.size == 5` 与 `records.first().type == "户外跑步"` —— 后者验证的是
`MockDataProvider.workouts()` 的「倒序,最近的在最前」,比只数数量多一重意义。
④ **`"${formatDate(...)} ${formatClock(...)}"` 这处插值不算违反「不硬编码文案」,明确记录以免复审误报。**
两个值都由 `Formatters` 做本地化格式化(`SimpleDateFormat` + `Locale.CHINA`),中间的连接符是一个空格,
不含任何需要翻译的文字,也没有第二份资源与它重复 —— 满足 R13 的两条判据。裁定:保留原样。
另外自行核验了两条**不是**缺陷的疑似项,一并记录以免复审重复劳动:
`workout_item_summary` 内含「公里」而 `home_distance_label` 也是「公里」,看似触碰 R13 的
「是否已有第二份资源」判据,但两者语法角色不同(句内单位 vs 独立标签),译者完全可能给出不同译法,
Android 的惯例也正是把单位写进句子资源 —— 保留。
`WorkoutHistoryPlaceholder` 被删后 `placeholder_workout_history` 成为孤儿资源,
**不顺手删**:Task 10/12 会各自留下同样的孤儿,与其三个任务各删一次且互相看不见对方,
不如最后统一清扫。孤立字符串只占几字节。
**顺带:** Task 9 的 import 清单里没有 `androidx.compose.foundation.lazy.item`(R24 的坑只存在于 Task 8),
但 `item { }` 的调用点仍在(走成员分派),已确认不受影响。
### Task 9: 审查结论 — Approved with minor issues(3 条,全部是计划缺陷)
审查者做了这次执行里最深的一次验证:**反编译依赖来证明结论**,而不是读代码猜。
`javap -constants` 确认 `const val DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}"` 真的在编译期折成
`"detail/{metric}"`,三个消费方(`Routes.detail()`、`navArgument`、`tabRouteFor`)一致;
反编译 `navigation-common 2.10.1` 确认 `NavDestination.route` 返回的是**路由模板**而非填充值
(`NavDestinationBuilder.build()` 存的就是你传给 `composable()` 的那个串,没人回写),
所以 `tabRouteFor` 的映射是对的;还核对了 material3 1.4.0 的 `lightColorScheme()` 填充
`onSurfaceVariant` 的取值链。文件层面:两个页面与 brief **逐字节一致**,`RingHealthApp.kt`
只动了指定的两个 hunk,`WorkoutHistoryPlaceholder` 全模块 grep 已无残留。
### R26 — 从 Task 9 的三条 Minor 里提炼出两条全局约定
**① 次要文字统一用 `TextSecondary`,禁用 `MaterialTheme.colorScheme.onSurfaceVariant`。**
Task 9 的空态文字用了后者,是整个 App 里**唯一**一处:主题只覆写了 `surface`/`background`/`surfaceVariant`,
`onSurfaceVariant` 还是 Material 默认的 `#FF49454F`,而设计系统的 `TextSecondary` 是 `#FF8E8E93` ——
同一屏里两种深浅不同的灰。这正是 R23 的同一形状(六个合规一个不合规),
而且**编译器拦不住**:槽位不是字面量,不算「行内样式散落」。已 grep 全计划,
该槽位只出现在 Task 9 这一处,其余任务不受影响;已写进 Global Constraints。
**② 每个页面拆成「有状态外壳 + 无状态内容」并给内容加 `@Preview`。**
Task 8 建了这个形状,Task 9 的 brief 没有,于是出现「首页有 Preview、运动页没有」的不对称;
若不管,Task 10/11/12 会各自随机选一边。而 R21 决定了**本机没有任何设备**,
IDE Preview 是 Task 14 人工验收之前唯一能让人看见界面的手段 —— 这不是审美问题而是验证能力问题。
已写进 Global Constraints,Task 14 的人工清单也加了两条(Preview 可渲染、次要文字灰度一致)。
—— 代价:Task 9 多一次 Screen/Content 拆分,Task 10/11/12 各多一个 Preview。
若判断有误(有人认为 Preview 不值得维护),删掉 `@Preview` 函数即可,不影响任何运行时行为。
**关于「重按已高亮的父 Tab」:** 审查者推测 `popUpTo(start){saveState}` + `restoreState`
会**恢复到该 Tab 保存的子页栈**(即停在子页),置信度约 85%,但需要真机确认。
我没有把它当缺陷(是 Task 7 的既有代码,本次未改动),而是**转成 Task 14 人工清单里的一条实测项**,
并写明「若实测与产品预期不符,另记一个小改动,不算验收失败」—— 把不确定性交给能观测的人,而不是猜。
**另外两条 Observation 记录在案、不改:**
`Routes.detail()` 仍单独写 `"detail/"` 前缀,F1 的手工耦合从三处降到两处而非一处;
但前缀不一致会抛 `IllegalArgumentException`(导航目标找不到)而非静默走错页面,
属于「响亮失败」,风险低,且 Task 12 的 `RoutesTest` 会把它锁住。
「开始运动」卡片没有 `onClick`,全计划也没有任何任务给它接回调 —— 符合 brief,但测试者可能误读为 bug,
Task 14 清单不必新增条目(该卡片本来就不在验收要点里)。
### Task 9: 修复轮复审 — CLEAN(scoped re-review,`031aea0..58573f4`)
三条发现全部 ADDRESSED,无新增 Critical/Important 破坏,修复只动了 3 个文件(与 diff stat 一致)。
复审者按要求**没有采信**实现者的变异测试结论,而是自己去 `MockDataProvider.kt:69` 把输入钉死:
`startTime = current - (index + 1) * dayMillis` 随 index 严格递减,`types` 共 5 条、首尾差 4 天,
所以 `assertTrue(first.startTime > last.startTime)` 在整体倒序时必然为假 —— 鉴别力是真的。
还独立佐证了旧断言为何零鉴别力:`types[0] == types[4] == "户外跑步"`。
并 grep 全 `app/src` 确认 `onSurfaceVariant` 已无任何匹配。
**顺带核实的两件事:** `WorkoutHistoryScreen` 的公开外壳签名未变,`RingHealthApp.kt` 的零参调用点不受影响;
两处新 Preview 的样本数据具名参数与 `domain/model/HealthModels.kt:48-55` 完全对应。
**Parked observation(不修,留待终审):** 新断言只钉首尾时间戳,**中间三条被任意重排(端点不动)不会被发现**。
作为「替换掉一条零鉴别力断言」的修复已达标;若日后要钉全序,需改成整列表比对。
—— 代价:Mock 数据生成顺序若在中间段出错,本测试不会报警。判断为可接受:Mock 是确定性生成器,
中间段独立出错的可能性极低,而整列表比对会让测试与 Mock 实现细节耦合得更紧。
**→ Task 9 完成。** `031aea0` + `58573f4`。
### R27 — Task 10 派发前的计划修正(五条)
预读 `task-10-brief.md` 时发现,全部在派发前改掉:
**① 步骤编号断裂。** 原为 `1, 1b, 4, 3, 4, 5, 6, 7, 8`(「追加文案」被误标为 Step 4 且与「实现 DeviceSettingsScreen」重号)。
已在计划文件里重排为 `1 … 10`。
**② 没有 ViewModel 测试。** Task 8 / Task 9 各有一个 ViewModel 测试,Task 10 一个都没有 —— 同一个工程里
三个 ViewModel 两种覆盖度。补了 Step 1(`DeviceViewModelTest`,2 个用例:输出设备状态与五个开关、
`setFunctionEnabled` 只改目标项)与 Step 1b(跑测试确认 RED)。
断言写 `assertEquals(4, settings.count { it.enabled })` 而非只断言目标项 —— **只断言目标项无法发现
「整表被覆盖」这类回归**(R25 ③ 的同一形状)。已核对 Mock:5 项里 `stress`/`blood_pressure` 初始 false,
切换 `stress` 后恰好 4 项开启,数值对得上。
**③ 少了 `@Preview`(R26)。** 两个页面都补上。`DeviceScreen` 早已是「外壳 + private `DeviceContent`」,
只缺 Preview;`DeviceSettingsScreen` 则整个没拆,已拆成公开外壳 + `private DeviceSettingsContent(settings, onToggle)`
(回调签名用 `(String, Boolean)` 而非 `(DeviceFunctionSetting, Boolean)`:无状态层不该知道「怎么改」)。
两处 Preview 共用 `internal fun previewSettings()`,值抄自 `MockDataProvider.defaultFunctionSettings()`。
**④ 8 条死字符串。** 原清单 16 条里 `device_title`/`device_charging`/`device_find_ring*`/`device_firmware_update*`/
`device_wear_detection*` 全计划 grep 无调用点,删到 8 条并写明「不要加回来」。
**⑤ Step 9 的 `Expected` 描述了本机做不到的事。** 已按 R21 改写为「构建 + 单测」,界面部分顺延 Task 14,
并加注 `FROM-CACHE` 假绿的坑(Task 9 实测)。
—— 代价:Task 10 多两次测试、两个 Preview、一个 Screen/Content 拆分;若判断有误,删 Preview 与测试即可,
不动任何运行时行为。
### R28 — Task 11 派发前的计划修正(三条)
同样的预读,同样在派发前改掉:
**① 少了 `@Preview`(R26)。** `ProfileScreen(modifier)` 本身就是无状态的(没有 ViewModel),
所以**不需要**再拆外壳/内容,直接在它上面挂 Preview —— 这一点已写进 KDoc,免得审查者按 R26 的常规形状
误判为「没拆分」。
**② Step 3 的 `Expected` 描述了本机做不到的事。** 已按 R21 改写为「构建 + 单测」,并写明本任务不新增测试,
全量仍为 15 项。
**③ 版本号是一份手抄副本。** `profile_check_update_desc` 里的 `0.1.0` 与 `app/build.gradle.kts` 的
`versionName` 是两处独立字面量,改版本时要一起改。**不**改成 `BuildConfig.VERSION_NAME`:AGP 8.x 默认关闭
`buildConfig` 生成,为一个展示串开它不划算。已写明 Task 14 更新 README 版本时一并核对。
—— 注:Task 11 的 12 条字符串**全部有调用点**(已逐个 grep),不是 R25/R27 那类死资源。
—— 代价:近乎为零;③ 若漏核,表现是「检查更新」里显示的版本号偏旧,不影响功能。
### 两个 Task 并行的风险处置
Task 9 的 scoped 复审与 Task 10 的实现**同时派发**(复审只读,两者文件不重叠)。
为消除「Task 10 提交时把 Task 9 修复文件卷进去」的风险,Task 10 的派发词里明确要求
**不得 `git add -A`**,只 add 本任务的文件。Task 9 复审 CLEAN、无修复轮,该风险实际未发生。
### Task 10: 审查结论 — Approved(4 条 Minor,无 Critical/Important)
审查者把「状态保持」这条路径真的走通了:`DeviceScreen` 与 `DeviceSettingsScreen` 各拿到**不同的**
`DeviceViewModel` 实例(两个 NavBackStackEntry 各自的 ViewModelStore),但注入的是**同一个仓库单例** ——
它去 `AppContainer.kt:19` 确认了 `ringDeviceRepository` 是容器字段而非工厂方法,
所以设置页的切换会经 `MockRingDeviceRepository` 的 `MutableStateFlow` 回流到设备页。
「返回后状态保持」因此不是靠运气,是容器设计撑住的。也确认了 `Switch` 是受控组件、没有 `remember` 影子状态。
两处必要的计划偏离都被核对为真(补 `DeviceFunctionSetting` import、补 Preview 的中文 KDoc)。
### R30 — 采纳 Task 10 审查的两条 Minor,驳回另外两条
**采纳(已改计划 + 开修复轮):**
**① `SettingRow` 的 `internal` 是无谓的暴露面。** 消费者只有同文件的 `DeviceContent`,
而计划自己的注释还写着「`previewSettings()` 是唯一的例外」——自相矛盾。改回 `private`。
**② `DeviceViewModelTest` 第一个用例断言强度不足。** 它断言的三个值与 `stateIn` 的 `initialValue` **完全相同**
(`DeviceViewModel` 的 `initialValue` 直接取仓库当前值),所以**即使 `combine` 从未发射、`uiState` 恒为
initialValue,测试照样全绿**。这是 R25 ③ 的同一形状:一条永远会通过的断言。修法是**绕开 ViewModel 直接改仓库**
(`repository.setFunctionEnabled("stress", true)`)再断言状态跟着变 —— 只有 combine 真在发射才可能成立。
注意必须绕开 ViewModel:走 `viewModel.setFunctionEnabled(...)` 的话,即使 combine 从不发射,
状态也会因为别的路径变化,鉴别力就丢了。
**驳回(记录理由,不改):**
**③ `previewSettings()` 与 `MockDataProvider.defaultFunctionSettings()` 逐字重复。** 审查者自己也指出:
唯一的替代方案是让 `feature/*` import `data.mock`,那会破坏 CLAUDE.md 的「UI 不依赖厂商/数据实现」分层硬约束。
两害相权,重复 5 行 Preview 夹具是可接受的。**这是有意接受的重复,不是疏忽**;
若日后 `MockDataProvider` 改了数据,Preview 会与真机不一致 —— 代价明确且只影响预览观感。
**④ `placeholder_device_settings` 变成孤儿。** R25 早已裁定三条占位字符串留到 Task 14 一次性清扫,
所以此处**故意不删**。审查者提得对,只是时机不对 —— 见下面 R29 的 Task 14 补丁。
### R29 — Task 12 派发前的计划修正(五条)
预读时发现,全部在派发前改掉:
**① 步骤编号断裂:`1, 4, 3, 4, 5, 6`。** 已重排为 `1, 1b, 1c, 2 … 7`。
**② 没有 ViewModel 测试。** Task 8/9/10 各一个,Task 12 一个都没有 —— 而本任务的 ViewModel 逻辑**最重**:
`range` 变化要经 `flatMapLatest` 换掉上游流,是全 App 唯一一处会**重新订阅**数据源的地方。
补了 3 个用例。第二个用例刻意断言三件事(`range` / 点数 / `metric`):只断言点数无法发现
「上游没重新订阅、只是把旧数据换了标签」;只断言 `range` 无法发现 `flatMapLatest` 没生效。
**③ 没有 `@Preview`(R26)。** 拆成公开外壳 + `private MetricDetailContent(state, onBack, onSelectRange)`。
标题改取 `state.metric` 而非外层参数 —— 无状态层只该有一个数据来源,否则可能出现「标题写心率、数据画睡眠」。
补了**两个** Preview(睡眠分支 / 步数分支),因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」。
**④ `StatLine` 里的 `String.format(java.util.Locale.CHINA, "%.1f", value)`。** 全计划 grep 下来,
这会是**整个 App 唯一**一处绕过 `core/common/Formatters.kt` 的数值格式化 —— 而该模块存在的全部理由
就是「数值格式化集中在一处」(Task 5)。改为在 `Formatters.kt` 新增 `formatDecimal(value: Double): String`
并配一条测试。用途是详情页的「平均/最高/最低」。
—— 附带的口径决定:**所有指标一律一位小数**,步数因此显示成「平均 7512.3」,对连续量是对的、
对步数只是精度过剩,不算错;按指标分别定精度属 M3 的展示打磨。已记入 parked observations。
**⑤ 睡眠卡片的入睡/起床时间会跨 7 天。** 原写法拿 `points.first()` 当入睡、`points.last()` 当起床,
而 Mock 的睡眠历史是「每天一个点」(首点 7 天前、末点现在),于是卡片会显示「睡了 7 天」——
Task 14 的人工验收一眼就会当成 bug。改为以最后一个数据点为起床时刻、往前推 8 小时
(`DEMO_SLEEP_WINDOW_MILLIS`),一张卡片讲「一晚」。阶段一没有真实分期数据,这是演示值,已写明 M3 替换。
**⑥ 顺手删掉一个不存在的 import 用法**:计划里 `import androidx.compose.foundation.layout.Column`
在 Task 12 的代码里**从未被使用**(唯一匹配是 `LazyColumn(`),已删。
**同时补了 Task 14 的一处真实缺口:** R25 说三条占位字符串「留到最后一次性清扫」,
但**计划里原先没有任何一步真的去做这件清扫**。已加 Task 14 Step 1(`placeholder_workout_history` /
`placeholder_device_settings` / `placeholder_metric_detail` 三条,先 grep 确认无调用点再删),
并把 Task 14 后续步骤顺延为 2–7。
**Task 14 的测试数也修正了:** 原文写「Formatters 7 + MockDataProvider 5 + HomeViewModel 1 = 13」,
两处都错 —— `FormattersTest` 实际是 **6** 项(已 `grep -c '@Test'` 核对磁盘上的测试文件),
而 6+5+1=12 才是 Task 8 结束时的真实总数(与 Task 9 的 brief 自洽)。终值 **21 项**。
—— 教训与 R24 同类:**数字也要落到文件上核对,不能凭记忆或凭早先的转述**。
### 并行派发(第二次)
Task 10 的修复轮与 Task 11 的实现**同时派发**。两者文件不重叠
(`feature/device/DeviceScreen.kt` + `DeviceViewModelTest.kt` vs `feature/profile/ProfileScreen.kt` + `strings.xml`),
两边都被明确要求**不得 `git add -A`**、只 add 自己改的文件。
### Task 10: 修复轮复审 — CLEAN
复审 `20fe2bc..7170a49`(2 文件,12 插入 / 3 删除),两条 Minor 均 ADDRESSED:
- `SettingRow` 已收窄为 `private`(`DeviceScreen.kt:129`),全仓仅同文件两处命中,无跨文件调用点。
- `DeviceViewModelTest` 第一条用例的鉴别力**经代码级独立推导确认**(复审者没有采信报告里的
变异测试声明):`stateIn` 的 `initialValue` 在构造时就把当时的 `List` 引用冻结,
而 mock 的 `setFunctionEnabled` 走 `map`(返回新列表),旧引用不被改写 ——
所以「combine 恒不发射」时新 `assertTrue` 必然失败,而原三条 `assertEquals` 依然全绿。
新断言确有鉴别力。
修复 diff 未引入新问题;`placeholder_device_settings` 孤儿、`previewSettings()` 逐字重复均按先前裁定保留。
Task 10 **完成**。
**残留脆弱性(非阻塞,复审者提出,我采纳为待办):** 该鉴别力依赖
`MockRingDeviceRepository.setFunctionEnabled` 产出**新列表**。若日后有人改成原地改写同一个
`MutableList`,`initialValue` 会被连带改动,这条断言就退化成自我确认。
**裁定 R31:在 final review 之前不额外开修复轮去补这句 KDoc**(改动价值真实但很低,
且要再走一整轮派发/复审/推送,成本远高于收益);**改由 Task 14 的收尾步骤顺带补一句中文 KDoc**,
写明「必须返回新列表,测试的鉴别力依赖于此」。若 Task 14 时发现该文件已因其它原因被改动,则一并补上。
—— 错了的代价:这条注释晚一点落地,期间若有人原地改写该 mock,测试会静默失去鉴别力。
### R32:Task 13 的 `git add -A`(预读 brief 发现)
Task 13 Step 4 写的是 `git add -A`,与 Global Constraints 的「只 add 本任务的文件」字面冲突。
**裁定:本步是那条规则的唯一豁免,且是刻意的** —— `repos/ring-health-api` 是 `git init` 出来的空仓库,
工作区里每个文件都是本任务建的,`-A` 卷不进别人的东西;逐条列举反而更危险(`.gitignore` 是隐藏文件,
漏掉它之后 `build/` 之类将来会被误提交)。
**加固:** 在 `git commit` 之前插了一条 `git status --porcelain`,要求确认列出的正好是那三个路径 + `.gitignore`,
多出任何别的路径就停下来查。App 仓库不受此豁免影响。
—— 错了的代价:若这里真有意外文件被卷进提交,`git status` 那步会拦住,且 API 仓库是全新空仓,回退成本极低。
### 工作区位置记录(勘误,非缺陷)
`scripts/task-brief` / `review-package` 按 **cwd** 决定输出位置,于是本计划的产物落在了**两处**:
- **App 仓** `repos/ring-health-app/.superpowers/sdd/<plan>/` —— brief 与 review-package 的 diff
(该目录下有个 `.gitignore` 内容是 `*`,全部被忽略)
- **docs 仓** `docs/.superpowers/sdd/<plan>/` —— `progress.md`(本账本)与各 `task-N-report.md`
(docs 仓根 `.gitignore` 里有 `.superpowers/`,同样被忽略)
**已核实两处都没有进任何提交**(`git ls-files .superpowers` 在两仓均为 0)。
派发时一律用**绝对路径**,所以分裂不影响正确性,**不做搬迁**(搬迁要动已被引用的路径,纯风险无收益)。
Task 13 / Task 14 的 brief 统一从 `/d/cherry/docs` 这个 cwd 生成,落在 docs 仓。
### Task 11: 我的页 — 审查通过
`88e88cf`(2 文件:`feature/profile/ProfileScreen.kt` + `res/values/strings.xml`)。
**Spec compliance ✅ / Code quality Approved**,零 Critical、零 Important。
复审者做了独立核验而非采信报告:brief 代码块与提交文件逐字 diff、12 条 `profile_*` 字符串**双向**核对
(12 定义 ↔ 12 个不同调用点,无孤儿)、`--rerun-tasks` 确认 16/16 任务真正执行而非 `FROM-CACHE`、
提交形状(1 commit、2 文件、作者 `崔鲁正`)。两条 Minor 均为 brief 原文所要求(`ProfileEntry` 的
`modifier` 参数当前无供给方;5 处 `Icon` 用 `contentDescription = null` 是正确的装饰性用法),
不构成缺陷。**Task 11 完成。**
### 事故与裁定:stale brief(R33 前置)
**我犯了一个真实的错**:派发 Task 12 时,我把 brief 路径写成了
`docs/.superpowers/sdd/<plan>/task-12-brief.md` —— 那是**上一轮会话生成的、R29 之前的旧版**
(18.6 KB,无 `formatDecimal`、无 `MetricDetailViewModelTest`、还是 `points.first()/last()` 的旧睡眠逻辑),
而当前版本在 app 仓那份(29 KB)。
**Task 12 的实现者自己发现了这一点**(对比了两份并 diff 确认 app 仓那份与计划 Task 12 章节逐字一致),
按正确版本实现,并在报告里点名。**没有造成返工**。
**处置:** 已用 app 仓那份覆盖 docs 仓的旧 brief(覆盖后 `formatDecimal` 12 处、
`MetricDetailViewModelTest` 5 处,确认是 R29 版本)。
**教训(与 R24 同类):** 派发前要核验**自己指给 subagent 的那个路径**的内容,而不只是核验自己的记忆。
**根因**:`task-brief` 的输出位置随 cwd 变,本计划的产物因此分裂在两仓(见上方「工作区位置记录」)。
—— 错了的代价:这次为零(实现者兜住了),但换一个不较真的实现者就会照着旧版做完,
再被审查者整轮打回。
### R33:Task 12 睡眠区块的恒真判空
brief 的 `if (fallAsleep != null && wakeUp != null)` 中,`wakeUp != null` **恒真**
(`fallAsleep` 由 `wakeUp` 推出),Kotlin 报 `Condition is always 'true'` 告警
(实测位置 `MetricDetailScreen.kt:174:35`)。
**裁定:采纳实现者的改写** `if (wakeUp != null) { val fallAsleep = wakeUp - …; … }`。
**依据:** 按情形枚举,两者语义完全等价(wakeUp 为 null → 两者皆 null → 跳过;否则 fallAsleep 必然非空 → 渲染),
且这是全仓**唯一**的 Kotlin 告警,消掉它使 `assembleDebug` 零告警。
**计划文本已同步改正**(否则下一个照着抄的人会重新引入这个告警),改写理由作为注释留在代码里。
—— 错了的代价:零。等价变换,且 `MetricDetailScreen` 的两条测试与编译都覆盖了它。
### Task 14 预读发现的四处缺陷(派发前修正)
1. **路径错误(会直接报错):** Step 7 的核对循环写成 `git -C /d/cherry/repos/$r`,
且 `$r` 列表里含 `docs` —— 而 docs 仓库在 **`/d/cherry/docs`**,不是 `repos/docs`。
更要命的是紧跟在代码块**下面**的注释已经指出了这一点,但**代码块本身没改** ——
照抄的人会拿到 `fatal: not a git repository`。已把路径改成逐条显式写。
2. **`git add -A`:** Step 7 两处都用了 `-A`,违反 Global Constraints。已改为**逐条列出本任务改过的文件**
(app 仓 4 个:`README.md` / `docs/verification-m1.md` / `strings.xml` / `MockRingDeviceRepository.kt`;
docs 仓 1 个计划文件,设计文档按需)。Task 13 那处 `-A` 的豁免**不适用于这里**(不是空仓库)。
3. **步骤号写错:** Step 4 的说明结尾写「然后继续 Step 4」,应为 **Step 5**。
4. **验收记录里有一句不实陈述(最重要):** Step 5 的模板把
「IDE Preview:各页面的 `@Preview` 在 Android Studio 中可渲染」列在 **「自动化验证(已执行)」**
标题之下 —— 但本机没有 IDE GUI,agent **根本打不开 Preview 面板**,这一项**从未被执行**。
这正是这份模板当初被改写、要避免的那类粉饰(R21)。已改为显式的「**未执行**」条目,
并写明旧稿把它放在「已执行」段落等于声称验证过一件没做过的事。
### Task 13: API 仓库骨架 — 实现完成
提交 `fe5f686`(root-commit,3 文件 / 89 插入,提交者 `崔鲁正`),**推送成功**:
首次 `git push -u origin main` 即过(`* [new branch]`),未 `--force`、未提示密码,
并用 `git ls-remote origin` 独立确认远程 `HEAD` 与 `refs/heads/main` 都指向 `fe5f686`,与本地一致。
远程此前为空(只有一个 `main` 分支),故 R32 预案里的 non-fast-forward 情形未发生。
R32 的豁免前提守住了:提交前 `git status --porcelain` 恰为三个 `A` 条目
(`.gitignore` / `README.md` / `docs/api-contract.md`),无多余路径。
三份文件与 brief 代码块 `diff -u` 全部 MATCH。`repos/ring-health-app` 未触碰。
**待办(顺延到 Task 14):** 本机 `core.autocrlf=true`,`git add` 打了 3 条
"LF will be replaced by CRLF" 警告。已核对**入库 blob 与落盘文件哈希一致(仓库内是 LF)**,
仅未来 Windows checkout 时工作区会变 CRLF。**这不是缺陷,不改配置。**
### Task 12: 指标详情页 — 审查结果
`01689b2`(8 文件 +532/−8)。**Spec compliance ✅**,但 **Code quality: Changes requested** ——
问题**全在测试**,生产代码、注释、字符串、路由接线都被判为正确完整。进入修复轮 1。
复审核心结论:3 条 ViewModel 用例里**第 1、2 条强,第 3 条弱**:
- **Finding 1(Important):** 第 3 条只断言 `state.metric == SLEEP`,而 `metric` 本来就在
`stateIn` 的 `initialValue` 里 —— 数据管线**从不发射**时它照样绿。**这与 R30 是同一类错误**,
而 `7170a49` 刚刚才在 `DeviceViewModelTest` 上修过同类问题。
更糟的是它连唯一能杀的变异(`.map` 里写死 `metric = STEP`)都已被第 1、2 条覆盖 —— **净鉴别力为零**。
报告 §1 声称「没有任何一条断言的值与 initialValue 相等,数据管线不发射时必挂」—— **这句话对第 3 条是假的**。
- **Finding 2(Important):构造函数参数 `metric` 与实际查询的数据之间没有任何断言绑定。**
变异:把 `MetricDetailViewModel.kt:43` 改成 `observeMetricHistory(MetricType.STEP, currentRange)`,
**三条用例全绿**(`points.size` 只取决于 `range`;`state.metric` 来自构造参数),
而用户点开「心率」会看到步数。路由侧由 `requireNotNull` 守住了,**ViewModel→仓库这一侧完全敞开**。
- **Finding 3(Minor):** 报告 §1 高估了测试的鉴别力(同 Finding 1)。
- **Finding 4(Minor):** `previewPoints(base)` 是 `base + index * 12`:
步数预览 7500→7572,按 `value / maxValue` 定高后 7 根柱子全在 99%~100%,**视觉上是一排等高方块**;
且 KDoc 写「取值贴近区间下限」对两个预览都不成立(420 在 330~510 中部,7500 在 3000~12000 中部)。
**已独立核验(不采信转述,R24/R29 的教训):** `MockDataProvider.valueRange` 实测为
STEP 3000~12000 / HEART_RATE 58~96 / SLEEP 330~510 —— 复审给的区间数字正确。
`previewPoints` 的实现也已读盘确认。
### R34:Task 12 三条测试缺陷的处置
**裁定:修复轮 1 采纳 Finding 1/2/4,一并修掉 Finding 3 那句不实陈述。**
- 第 3 条用例重写为「`metric` 既进状态、也决定查哪组数据」:保留 `metric` 断言但**显式注释它单独没有鉴别力**,
加上 `assertEquals(7, state.points.size)`(堵住「管线不发射」,同时堵住 `all {}` 在空列表上恒真的洞)
与 `assertTrue(state.points.all { it.value in 330.0..510.0 })`(把构造参数与实际数据绑起来)。
- `previewPoints` 增加 `step` 参数:睡眠 360/24(360→504),步数 5200/1000(5200→11720),
两个预览都落在各自 Mock 区间内且柱子有可见高低差;KDoc 改为描述真实行为。
- **要求实现者做变异测试自证**:把 `observeMetricHistory` 的第一参数临时改成 `MetricType.STEP`,
确认重写后的用例**会挂**,再改回来确认**会过**,两个结果都要报。这正是上一轮缺失的证据。
- **计划文本已同步改正**(否则下一个照抄的人会重新引入同一批缺陷)—— 与 R27/R29/R30 同一处置方式。
—— 错了的代价:无。测试只增鉴别力,预览只改样例数据;21 项总数不变。
### Task 12: 修复轮 1 → 复审 CLEAN,任务完成
修复提交 `0e17502`(2 文件 +38/−11,未 amend `01689b2`)。**四条发现全部 ADDRESSED,未引入新问题。**
**复审者没有采信报告,自己重跑了变异测试**(这正是上一轮缺的证据):
把 `MetricDetailViewModel.kt:43` 改成 `observeMetricHistory(MetricType.STEP, currentRange)` →
`3 tests completed, 1 failed`,只有新的区间断言挂掉,报文
`SLEEP 详情页拿到了区间外的值:[3771.86, 3373.64, 5213.48, 8116.18, 9985.15, 9309.98, 11865.75]`;
**第 1、2 条用例在泄漏下依然全绿** —— 这从经验上坐实了 Finding 2 的判断。
复审者随后 `git checkout --` 还原并确认工作区干净。
三处值得记下的复核结论:
- **区间断言的范围比预想的更严。** 复审者对着 `MockDataProvider.valueRange` 逐条比:
除 SLEEP(330~510) 与 STEP(3000~12000) 外,其余六条区间**整体低于 330**
(HEART_RATE 58~96 / BLOOD_OXYGEN 95~99 / STRESS 20~70 / TEMPERATURE 36.1~36.9 / BLOOD_PRESSURE 110~130),
所以这一条能挡住**泄漏到任意其它六个指标**,不只是 STEP。且 `metricHistory` 对每个值都做
`coerceIn(min, max)`,区间成员性是**构造保证**的,不依赖随机种子抽得好。
- **空列表恒真的洞确实堵住了,且不是靠断言顺序侥幸。** `all {}` 之前在**同一次快照**的直线代码里,
`assertEquals(7, …)` 先抛,`all {}` 根本不会被求值(JUnit 顺序执行、`assertEquals` 立即抛)。
- **修复没有削弱任何东西:** 第 1、2 条用例在 diff 里只作为上下文出现、逐字未变;
用例数仍是 3(总数仍 21);两个 `@Preview` 都在、目的未变;没有新的硬编码文案、没有 `data.mock` 泄漏进
`feature/*` 主源、没有禁用导入;生产运行逻辑**一行没动**(Screen 的改动只在私有预览辅助函数与预览 Composable 里)。
**一处纯文字瑕疵(复审者明确不作为发现,我也认同不改):** STEP 预览注释写「柱高从约 27% 递增到 100%」,
实际画出来是 3200/11000 = **29.1%**(27% 是拿 3200 比区间顶 12000 得来的,而 `MetricBarChart` 是比 `maxValue`)。
注释带「约」字、无行为影响、梯度肉眼可见。**不改。**
**Task 12 完成。**
### Task 14: 端到端验收与文档收尾 — 完成
`9c233e5`(app 仓),**三个仓库均已推送且与远端同步**(`## main...origin/main`,无 `[ahead]`)。
App `4805b02..9c233e5`,docs `5c82c38..a18523f`,api 保持 `fe5f686`。**未使用 `--force`。**
21/21 全绿,分项与计划**逐项吻合**(Formatters 7 / MockDataProvider 5 / HomeViewModel 1 /
WorkoutViewModel 1 / DeviceViewModel 2 / MetricDetailViewModel 3 / RoutesTest 2);
`clean assembleDebug` 成功,APK 18,907,329 字节。孤儿字符串清扫命中预期的那三条
(外加 `app_name` —— 那是 manifest 引用的,不是孤儿),无漏接线的文案。
**docs 仓无新提交是正确行为,不是遗漏:** 计划文件在 Task 14 期间没有任何改动
(R29–R34 的修订早已逐条提交),`git add` 是货真价实的空操作。**实现者没有伪造空提交** —— 正确。
brief 的核对循环真正要求的是「把 13 个已存在的未推送 docs 提交推上去」,已完成。
**沿用 R21 的诚实口径:** 12 行人工验收**全部**记为「未执行 —— 无设备」,
`@Preview` 渲染同样留在未执行段落,验收记录带**覆盖缺口声明**。
#### 对实现者 concern 4 的独立更正(我核过,它的诊断是错的)
实现者报告「`ANDROID_HOME` 仍指向已删除的 SDK 路径,与 CLAUDE.md 矛盾」。**实测:**
| 来源 | 值 | 结论 |
|---|---|---|
| **用户级注册表** `HKCU\Environment`(真正的持久设置) | `ANDROID_HOME=D:\Android\Sdk`、`ANDROID_SDK_ROOT=D:\Android\Sdk` | **与 CLAUDE.md 一致,正确** |
| 本会话 shell 的进程环境 | `C:\Users\10314\AppData\Local\Android\Sdk` | **陈旧值**,来自注册表修改**之前**启动的父进程 |
| `local.properties`(构建实际采用) | `sdk.dir=D:\Android\Sdk` | 正确 |
**结论:CLAUDE.md 没有问题,不需要改。** 那只是**进程环境里的陈旧副本**,
凡是注册表改之前启动的进程(含本会话的 shell 及其派生的 gradle)都会看到旧值。
**实际影响为零** —— AGP 以 `local.properties` 的 `sdk.dir` 为准,优先于 `ANDROID_HOME`;
且 19 次构建全部成功。**若要彻底消除,重启终端/IDE 即可,不是代码问题。**
—— 这条记下来是为了避免将来有人照着那句错误诊断去"修"一个没坏的东西。
#### 「无设备」前提的独立复核(比实现者给的论据更强)
实现者用「AVD 指向已删除的 C 盘路径」来论证 AVD 不可用。**我复核后认为那个论据本身不够硬**
(AVD 的 `path=` 指向 `C:\Users\10314\.android\avd\*.avd`,而 `.android` 目录是**存在的**、
未被那次清理波及)。**但结论仍然成立,且有更直接的证据:**
`D:\Android\Sdk` 下**根本没有 `emulator` 目录、也没有 `system-images`** ——
只装了 `build-tools` / `cmdline-tools` / `licenses` / `platform-tools` / `platforms`。
**没有模拟器可执行文件,也没有任何系统镜像,AVD 状态如何都跑不起来。**
「无设备」前提成立,R21 的覆盖缺口声明口径正确。
### 全分支终审(opus,全仓 62 文件 / 19 提交)
**判定:Ready to merge? With fixes.** 终审做了机械核验而非目测:
`feature/**`→`data.mock` **0 处**;`domain/**`→`android.*`/`androidx.*`/`data.*` **0 处**;
`core/designsystem` 外的 `Color(0x…)` **0 处**;`com.sr.smartring`/厂商引用 **0 处**;
**65 条字符串资源双向核对全部通过**;并 `javap` 反编译 `material3-android:1.4.0` 的 `ScaffoldKt`
来判定一个**光读代码判不了**的运行时问题。
**明确肯定(摘):** 分层边界在七个并行作者、19 次提交之后**真的守住了**;
七个页面架构高度一致(统一 shell + `private fun XContent`、统一 `collectAsStateWithLifecycle`、
统一 `WhileSubscribed(5_000)`、统一 `TextSecondary`);中文 KDoc 是**有信息量的**而非应付
(多条记录的是开发中真实发现的 bug 与**被否决的备选方案**);
**没有发现任何幸存下来的「代码坏了测试还绿」用例** —— 之前几轮修的都真修住了。
验收记录因「诚实到自曝其短」而提高了终审对其余内容的信任度。
**Critical 1(已处置,R35):M2 交接面那句「唯一需要改动的既有文件」是假的。**
这是整个 M1 赖以立论的那句话(spec §102「接口不变则 UI 零改动」)。
终审逐文件核验后给出真实清单:`Repositories.kt`(无扫描/连接/绑定/地址/权限面)、
`setFunctionEnabled` 非 `suspend` 且返回 `Unit`(写开关是可能失败的 BLE 往返)、
`AppContainer.kt`(无参构造,SDK 适配器要 `Context`)、`AndroidManifest.xml`(权限,
**计划自己 30 行后就要求改它,与「唯一一个文件」自相矛盾**)、构建三件套(EventBus/Jetifier)、
`DeviceScreen.kt`(无任何扫描/连接 UI)。且 `refreshDeviceState()` **零调用点**。
**已按此改写计划的 M2 交接节**(保留原句作为被更正的引文),并加了「读取路径接缝做对了、
设备控制与发现这条路径压根没开缝」的结论。
**不做补救性改接口** —— 对着尚不存在的 SDK 改接口是投机设计。价值在于让下一个里程碑照着真实清单立项。
**已采纳的代码修复(派发中,见 task-15):** 详情页图表色不随指标走(违反 spec §164 色板,唯一的违约点);
空数据渲染成 `0.0/0.0/0.0`;血压卡片显示「未开启」却可点进有数据的详情页(自相矛盾);
`stressLevelLabelRes` 及其 4 条字符串是死代码(**改为接线而非删除**)。
**关于内边距(Fix 5)的裁定 —— 我核过代码后决定"修",但如实标注未经验证:**
外层 `Scaffold` 无 `topBar` ⇒ `innerPadding.top` = 状态栏高度,经 `Modifier.padding` 施加到 `NavHost`;
`Modifier.padding` **不消费 window insets**,所以详情页内层 `Scaffold` 的 `TopAppBar`
会**再加一次**状态栏内边距 ⇒ 顶栏被压低约两个状态栏,且上方留出一条背景色带。
**修法 `Modifier.padding(innerPadding).consumeWindowInsets(innerPadding)` 双向安全**:
诊断对则消除重复内边距;诊断错则内边距本就是 0,再消费一次是 no-op。
**因此收益不对称,选择修**;但**本机无设备无模拟器,这一条是「推理得出、未经设备验证」**,
必须原样写进验收记录,并列为首次上真机的**第一项**待查。
**Fix 6:** README 让读者跑 `installDebug`,而本机根本没有设备/模拟器 —— 加一句说明。
### R36:终审提出但我裁定"不改"的项(连同理由)
- **加 Compose UI 测试(终审的头号建议)—— 不做。** 本机**没有模拟器也没有真机**
(SDK 里没有 `emulator` 可执行文件、没有 `system-images`),`androidTest` 源集**永远跑不起来**。
现在加它,等于新造一批**从不执行的代码** —— 正是终审自己用来批评 `stressLevelLabelRes` 的那把尺子。
**改记为 M2/M3 的头号测试投资**,等有设备时第一件做。
- **`MockDataProvider` 里的中文硬编码(`"户外跑步"` 等)—— 不改。** 那些是**模型数据**
(设备上报的名称、运动类型),spec 有意把它们放进领域模型,与「UI 文案」不是一回事。
真正值得记的是终审的附带发现:`DeviceScreen.kt` 的预览夹具**手抄**了
`MockDataProvider.defaultFunctionSettings()`,且没有任何东西能发现二者漂移。**记为已知隐患。**
- **`DeviceScreen` 预览夹具漂移 —— 见上,记为隐患,本次不改**(改它要么让 `feature/*` 依赖 `data.mock`,
要么把 `title` 从 `String` 改成 `@StringRes Int` 动领域模型,两者都比问题本身贵)。
- **ViewModel 构造两种模式(6 个用 `AppViewModelProvider`,详情页用自带 `factory`)—— 不改。**
带参 ViewModel 有充分理由,且理由已写进注释。记为一致性上的小瑕疵。
- **`RingHealthApp.kt` 放在 `core/navigation` 却 import 全部 `feature.*` —— 不改。** 单模块内无害。
- **`RingDeviceState.charging` 写而无读、README 的 `installDebug`(已并入 Fix 6)等 Minor —— 记录不改。**
### R37:终审对计划/文档的批评 —— 全部采纳(这一类比代码缺陷更值钱)
1. **计划的 M2 交接错误** —— 已改(R35)。
2. **spec 与交付脱节** —— 已回填 spec 三处:工具链版本(AGP 8.11.x→8.13.1、compileSdk 35→36、
build-tools 35.0.0→36.1.0)、设备页四项被删(加删除线 + 注记)、详情页四路由收敛为 `detail/{metric}`、
多语言「附英文」与 M5 排期的自相矛盾。
3. **R27 的孤儿字符串检查口径有洞(终审指出,已采纳)**:R27 的检查是「grep 有没有调用点」,
而 **`src/test` 里的引用也算调用点** —— `stressLevelLabelRes` 就是这样蒙混过关的
(唯一调用者是它自己的测试)。**今后这类检查必须排除 test 源集。**
4. **`stressLevelLabelRes` 是计划层面的缺陷**(Task 5 造出函数+4 条字符串+测试,却没有任何任务负责调用它,
Task 8 的示例代码把压力卡渲染成裸数字)—— 终审说得对,这是**计划**的错,不是执行者的错。
处置:接线(见 Fix 4),使其获得真实消费者。
### 终审修复轮:`03a795e`
六项修复全部落地,范围守住(**没有**加依赖、**没有**加测试基础设施、**没有**顺手重构 `HomeScreen` 的配色)。
新增字符串 `detail_empty`(暂无数据)唯一调用点 `MetricDetailScreen.kt`;
`stress_relaxed/normal/medium/high` 四条**终于有了真实调用点**(经压力卡的 `unit`)。
21/21 全绿,`assembleDebug` 成功,强制重编译主源与单测源后 `w:`/`e:` **零行**。
**我追加的一轮细化(源于实现者自己提出的 concern 2 —— 提得对):**
空态原先**把周/月切换 chip 一起隐藏了**,于是用户在某个区间没数据时**没有任何办法切到另一个区间** ——
恰好反了:数据显示为空时,切换区间正是最自然的动作,而且 chip 也是「哪个区间是空的」的唯一线索。
已要求改为:非 loading 分支**始终渲染 chip**,只把 chip **下方**的区域(图表 + 统计摘要)换成空态。
该改动 amend 进 `03a795e`(未推送、且尚无任何审查针对它,amend 是干净的)。
**残留(记录,不再修):**
- Fix 5 的 `consumeWindowInsets` —— **推理得出、未经设备验证**(本机无设备无模拟器)。
- 空态这条路径**没有测试**(拒绝新增测试基础设施),只有代码推理保证;且血压卡改为不可点后,
该路径在当前 Mock 下基本不可达(`metricHistory` 恒返回 7 或 30 个点),属防御性代码。
#### 终审修复轮的定向复审:**clean**
复审逐条核对了 6 项修复 + 空态细化,全部 addressed,**未发现新引入的缺陷**:
- `metricTint`(`MetricDetailScreen.kt:267-275`)覆盖全部七个 `MetricType`,**无 `else`**,
作为表达式使用 —— 将来加第八个枚举值会**编译失败**而不是静默落到某个默认色。
与首页各卡的 tint 逐项一致;血压→黄取自色板自身定义(spec §7 未给血压定色,属未规定而非违背)。
- `MetricBarChart` 确实使用 `color` 画柱子(`MetricBarChart.kt:63`),不是接了个没人用的参数。
- `onClick` 可空后,`HealthCard` **只在非空时**附加 `Modifier.clickable` —— 是真正不可交互
(无涟漪、无点击语义、无无障碍 click action),不是挂了个空 lambda。全仓库只有一个调用点。
- 空态字符串 `detail_empty` 唯一调用点;**全量 60 条字符串复查,无新增孤儿**
(只剩 `app_name` 无 `R.string.*` 调用点,属 manifest 正常用法)。
- 空态细化确认:`RangeSelector` 是 `LazyColumn` 的第一个 item 且**无条件渲染**;
判空分支只追加一个 item 再 return,**任何非 loading 状态都无法压掉 chips**。
loading 分支仍在 `LazyColumn` **之前**早返回,不会在 200ms 延迟窗口里闪一下空态。
- 改动面 6 个文件,**无 gradle / 依赖 / 测试文件**改动 —— 范围守住了。
**Parked(记录在案,本轮不修)—— 均为 Ruling:**
- **`switchRange` 不置 `loading`**(`MetricDetailViewModel.kt:41-67`):切区间时旧数据留在屏上,
若旧区间为空、新区间有数据,那 ~200ms 会显示「暂无数据」。**M1 中不可达**
(Mock 的 `metricHistory` 恒返回 7 或 30 个点)。裁定:**不修** —— 修它要动 ViewModel 的
加载语义,而这是 M3 接真实数据时必须重新设计的地方;现在改等于给一段不可达路径写代码,
正是终审用来批评 `stressLevelLabelRes` 的同一把尺子。**代价:**若 M3 的仓库会返回空区间,
这个窗口会变成可见缺陷 —— 届时按真实加载语义一并处理。
- **`averageOrZero()` 的 `isEmpty` 分支已成为死代码**(`MetricDetailScreen.kt:316`):
空态提前 return 后,`StatLine` 只可能在非空列表上走到。裁定:**不删** —— 它是列表扩展函数,
语义完整(空列表取平均是 NaN),删掉反而让函数名与实现脱节。**代价:**一行永不执行的分支。
**此项判决:终审 findings 已全部关闭,无 load-bearing 残留。可以进入收尾。**
#### 收尾
- `77de780` docs: 补充终审修复轮的验证状态与 M2 交接接缝(app 仓库)
- 已推送:app `9c233e5..77de780`、docs `a18523f..c3eb0b5`;api 仓库无需改动(核对为 `## main...origin/main`,无未推送)
- **从未使用 `--force`。**
### Task 15: complete