From f35cf329ba9f75ea8e7dbc874bc2485cc931a94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E9=B2=81=E6=AD=A3?= <13780741225@163.com> Date: Thu, 10 Sep 2026 10:58:04 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=AF=BC=E8=88=AA=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1=E4=BF=AE=E8=AE=A2=EF=BC=88ledger=20R21/R22?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R21:移除全部 Task 的 installDebug(本机无模拟器/真机),Task 14 人工验收 明确标注「未执行 —— 无设备」,不得当成已通过 - R22:Task 7 的 5 条 Minor 折进其真正可观测的任务 —— Task 9 加 tabRouteFor 归属映射 + DETAIL_PATTERN 常量插值, Task 12 把 ?: MetricType.STEP 换成 requireNotNull 并补 RoutesTest(纯 JVM) --- .../plans/2026-09-10-ring-health-app-m0-m1.md | 131 ++++++++++++++++-- 1 file changed, 119 insertions(+), 12 deletions(-) diff --git a/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md b/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md index 997e98d..11596c1 100644 --- a/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md +++ b/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md @@ -34,6 +34,9 @@ - **注释规范**:每个类与公开函数上方写中文 KDoc,说明用途(团队规范要求)。 - **提交规范**:每个 Task 结束提交一次;提交信息用中文,前缀 `feat:` / `chore:` / `test:` / `docs:`。 - **Gitea 地址**:`http://120.192.101.56:3000/cuiluzheng/.git`(凭据助手已按主机配置好,可直接推送)。 +- **不做 `installDebug`**:本机没有模拟器镜像、也没有连接真机,各 Task 的构建步骤**只跑 `./gradlew assembleDebug`** + (ledger Ruling R21)。运行时的界面/交互验证统一顺延到 Task 14 Step 3 的人工验收清单,交人在有设备时执行。 + 执行 agent 不要尝试 `installDebug`,也不要临时下载模拟器镜像。 ## File Structure @@ -2101,7 +2104,7 @@ setContent { - [ ] **Step 7: 构建 + 安装验证** ```bash -cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug +cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug ``` Expected: 构建成功并装到设备/模拟器;打开 App 可见底部四个 Tab,点击可切换、互不崩溃。 @@ -2603,7 +2606,7 @@ private fun HomeContentPreview() { - [ ] **Step 8: 构建 + 单测 + 真机验证** ```bash -cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew installDebug +cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest ``` Expected: 测试全绿;真机上首页显示设备条、步数大环、六个指标卡(血压显示“未开启”),点击卡片进入详情占位页并可返回。 @@ -2884,7 +2887,7 @@ fun WorkoutHistoryScreen( } ``` -- [ ] **Step 5: 在 `RingHealthApp.kt` 中接入真实页面** +- [ ] **Step 5: 在 `RingHealthApp.kt` 中接入真实页面,并让子页保持所属 Tab 高亮** 将 `composable(Routes.WORKOUT)` 与 `composable(Routes.WORKOUT_HISTORY)` 替换为: @@ -2899,12 +2902,69 @@ fun WorkoutHistoryScreen( 并删除 `WorkoutHistoryPlaceholder` 的 import 与定义(文件 `WorkoutHistoryScreen.kt` 中不再保留占位函数)。 +**同时修正底栏的高亮归属(ledger Ruling R22)。** Task 7 的 `selected = currentRoute == tab.route` +在子页位于栈顶时会**四个 Tab 全部不高亮**:`NavDestination.route` 返回的是**路由模板** +(`"detail/{metric}"`),而不是填充后的 `"detail/STEP"`;`device/settings`、`workout/history` +又都不等于任何 Tab 路由。Material3 允许零个选中项,所以不会崩,但偏离了小米运动健康 +(父 Tab 应当保持点亮)。把 Route 的比较改成「先归到父 Tab,再比」: + +在 `RingHealthApp.kt` 中新增(放在 `RingHealthApp` 函数之前): + +```kotlin +/** + * 把当前路由归到它所属的底部 Tab。 + * + * 子页位于栈顶时,底栏应当保持父 Tab 点亮。这里必须与 `Routes.DETAIL_PATTERN` + * 比较 —— `NavDestination.route` 给出的是路由**模板**而非填充后的实际路由。 + * 本 App 的路由是扁平注册的(没有嵌套导航图),`destination.hierarchy` 帮不上忙, + * 只能显式映射。 + */ +private fun tabRouteFor(route: String?): String? = when (route) { + Routes.DETAIL_PATTERN -> Routes.HOME + Routes.DEVICE_SETTINGS -> Routes.DEVICE + Routes.WORKOUT_HISTORY -> Routes.WORKOUT + else -> route +} +``` + +再把 `RingHealthApp` 内的 + +```kotlin + val currentRoute = backStackEntry?.destination?.route +``` + +改为 + +```kotlin + val currentRoute = tabRouteFor(backStackEntry?.destination?.route) +``` + +这样 `selected = currentRoute == tab.route` 本身不用动。**三个子路由在这里一次性映射完** +(`DEVICE_SETTINGS` / `DETAIL_PATTERN` 分别由 Task 10 / Task 12 接入), +免得那两个 Task 再回来改同一个 `when`。 + +**顺带加固 `Routes.kt` 里的一处手工耦合(ledger Ruling R22):** + +```kotlin + /** 路由参数的键名。必须声明在 DETAIL_PATTERN 之前:模板引用它。 */ + const val DETAIL_ARG_METRIC = "metric" + + /** 指标详情路由模板,参数为 MetricType.name。 */ + const val DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}" +``` + +原来 `"detail/{metric}"` 与 `const val DETAIL_ARG_METRIC = "metric"` 是两个各自独立的字面量, +靠人手工保持一致;改了占位符名而没改常量,导航会静默走到 Task 12 的兜底分支而不报错。 +`const val` 允许引用另一个 `const val`(字符串模板里只含常量即为编译期常量), +零运行时开销。**注意 `DETAIL_ARG_METRIC` 必须声明在 `DETAIL_PATTERN` 之前**, +否则常量初始化顺序会导致编译失败。 + > 注意:`WorkoutViewModel` 在运动页与历史页各创建一次实例(导航到不同 destination 时独立作用域)。阶段一数据相同,可接受;M3 引入真实数据后如需共享,再提升作用域。 - [ ] **Step 6: 构建 + 验证** ```bash -cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug +cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug ``` Expected: 运动 Tab 显示“开始运动”卡片 + 5 条记录;“查看全部”跳转历史页。 @@ -3269,7 +3329,7 @@ fun DeviceSettingsScreen( - [ ] **Step 7: 构建 + 验证** ```bash -cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug +cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug ``` Expected: 设备 Tab 显示戒指卡片(电量 78%、固件 1.0.5);“更多设置”进入后开关可切换且返回后状态保持。 @@ -3439,7 +3499,7 @@ private fun ProfileEntry( - [ ] **Step 3: 构建 + 验证** ```bash -cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug +cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug ``` Expected: “我的”Tab 显示头像卡与两组设置入口。 @@ -3826,12 +3886,56 @@ private fun List.averageOrZero(): Double = if (isEmpty()) 0.0 else avera route = Routes.DETAIL_PATTERN, arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }), ) { entry -> - val raw = entry.arguments?.getString(Routes.DETAIL_ARG_METRIC).orEmpty() - val metric = MetricType.entries.firstOrNull { it.name == raw } ?: MetricType.STEP + // 参数无法解析属于编程错误,直接抛(ledger Ruling R22):这里原先是 + // `?: MetricType.STEP`,一旦路由模板与 Routes.detail() 拼串不一致, + // 页面会静默显示步数的数据而不报错 —— 那是「错数据」而不是「响亮失败」。 + val raw = requireNotNull(entry.arguments?.getString(Routes.DETAIL_ARG_METRIC)) { + "详情路由缺少参数 ${Routes.DETAIL_ARG_METRIC}" + } + val metric = requireNotNull(MetricType.entries.firstOrNull { it.name == raw }) { + "无法识别的指标:$raw" + } MetricDetailScreen(metric = metric, onBack = { navController.popBackStack() }) } ``` +**同时补一条不需要设备的 JVM 测试(ledger Ruling R22)**,把「路由模板」与 +`Routes.detail()` 的拼串锁在一起 —— Task 7 的审查指出这两者目前靠人手工同步, +而本 App 的路由是纯 Kotlin、不含任何 `android.*`,完全可以在 `src/test` 里断言: + +`app/src/test/java/com/placeholder/ringhealth/core/navigation/RoutesTest.kt`: + +```kotlin +package com.placeholder.ringhealth.core.navigation + +import com.placeholder.ringhealth.domain.model.MetricType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 路由表的一致性测试:模板与实际拼出的路由必须始终吻合(ledger Ruling R22)。 */ +class RoutesTest { + + @Test + fun `detail 拼出的路由能被详情模板匹配`() { + val expectedPrefix = Routes.DETAIL_PATTERN.substringBefore("{") + MetricType.entries.forEach { metric -> + val route = Routes.detail(metric) + assertTrue("$route 不以 $expectedPrefix 开头", route.startsWith(expectedPrefix)) + assertEquals(metric.name, route.removePrefix(expectedPrefix)) + } + } + + @Test + fun `占位符名与参数常量一致`() { + assertTrue(Routes.DETAIL_PATTERN.contains("{${Routes.DETAIL_ARG_METRIC}}")) + } +} +``` + +跑 `./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.core.navigation.RoutesTest"`, +Expected: `BUILD SUCCESSFUL`,2 项通过。 + 删除 `MetricDetailPlaceholder` 的定义与 import。 > 注意:详情页自带 `Scaffold` + `TopAppBar`,而外层 `RingHealthApp` 的 `Scaffold` 会传入 `innerPadding`(已用于 NavHost 的 `Modifier.padding`)。为避免底部导航重复遮罩,详情页在 NavHost 内会同时受外层 padding 影响——这是预期行为(底部栏保持可见)。 @@ -3839,7 +3943,7 @@ private fun List.averageOrZero(): Double = if (isEmpty()) 0.0 else avera - [ ] **Step 5: 构建 + 验证** ```bash -cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug +cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug ``` Expected: 从首页点任一指标卡进入详情页;顶部标题为指标中文名;近 7 天/近 30 天可切换,柱状图数量随之变化(7 根 / 30 根);**睡眠详情额外显示分期卡片**(深睡/浅睡/清醒分段条与图例、入睡/起床时间),其他指标不显示;返回键回到首页。 @@ -4015,9 +4119,12 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk - [ ] **Step 3: 真机/模拟器人工验收(逐项打勾)** -```bash -cd /d/cherry/repos/ring-health-app && ./gradlew installDebug -``` +> **本步在本机当前状态下无法执行(ledger Ruling R21)。** 本机没有任何模拟器镜像,也没有连接真机 +> (Android SDK 只装了 `platform-tools` / `platforms;android-36` / `build-tools;36.1.0`)。 +> 因此下面这份清单**要交给人在有设备时执行**,不要由 agent 尝试 `installDebug`、 +> 也不要临时去下模拟器镜像(约 1–2 GB)。执行 agent 在本步的动作是: +> 把清单原样抄进验收报告,明确标注「未执行 —— 无设备」,然后继续 Step 4。 +> 本条是**已知且有意的覆盖缺口**,不要在其他任何地方把它当成已通过。 在设备上逐项确认: