docs: 导航骨架复审修订(ledger R21/R22)

- R21:移除全部 Task 的 installDebug(本机无模拟器/真机),Task 14 人工验收
  明确标注「未执行 —— 无设备」,不得当成已通过
- R22:Task 7 的 5 条 Minor 折进其真正可观测的任务 ——
  Task 9 加 tabRouteFor 归属映射 + DETAIL_PATTERN 常量插值,
  Task 12 把 ?: MetricType.STEP 换成 requireNotNull 并补 RoutesTest(纯 JVM)
This commit is contained in:
2026-09-10 10:58:04 +08:00
parent 3d39fd3445
commit f35cf329ba
@@ -34,6 +34,9 @@
- **注释规范**:每个类与公开函数上方写中文 KDoc,说明用途(团队规范要求)。
- **提交规范**:每个 Task 结束提交一次;提交信息用中文,前缀 `feat:` / `chore:` / `test:` / `docs:`。
- **Gitea 地址**:`http://120.192.101.56:3000/cuiluzheng/<repo>.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<Double>.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<Double>.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。
> 本条是**已知且有意的覆盖缺口**,不要在其他任何地方把它当成已通过。
在设备上逐项确认: