Compare commits

...
2 Commits
Author SHA1 Message Date
cuiluzheng 77de7808cb docs: 补充终审修复轮的验证状态与 M2 交接接缝 2026-09-10 13:37:15 +08:00
cuiluzhengandClaude Code 5fe0d4a420 fix: 修正终审发现的详情页配色、空态与血压卡可点击等问题
- 详情页柱状图改用指标语义色(新增 MetricType→色板映射),此前恒定绿色
- 详情页无数据时渲染空态文案,不再画全 0 柱状图与三行 0.0
- 首页「未开启」指标卡不再可点击,与详情页内容保持一致
- 压力卡单位位显示等级文案(如「34 正常」),接上原先无人调用的 stressLevelLabelRes
- NavHost 补 consumeWindowInsets,避免详情页 TopAppBar 重复叠加状态栏内边距
- README 注明 installDebug 需要设备/模拟器

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 13:18:51 +08:00
7 changed files with 128 additions and 7 deletions
+4 -1
View File
@@ -24,8 +24,11 @@
```bash ```bash
./gradlew assembleDebug # 产物在 app/build/outputs/apk/debug/ ./gradlew assembleDebug # 产物在 app/build/outputs/apk/debug/
./gradlew installDebug # 安装到已连接设备 ./gradlew installDebug # 安装到已连接设备(需接好设备或已启动模拟器)
./gradlew test # 单元测试 ./gradlew test # 单元测试
``` ```
> `installDebug` 需要一台已连接的设备或正在运行的模拟器。M1 使用的机器两者都没有,
> 因此本机实际可执行的是 `./gradlew assembleDebug` 与 `./gradlew :app:testDebugUnitTest`。
要求:JDK 17、Android SDK(platform 36 / build-tools 36.1.0)。 要求:JDK 17、Android SDK(platform 36 / build-tools 36.1.0)。
@@ -30,7 +30,8 @@ import com.placeholder.ringhealth.core.designsystem.TextSecondary
* @param value 主数值文本。 * @param value 主数值文本。
* @param unit 单位文本,如「步」「次/分」。 * @param unit 单位文本,如「步」「次/分」。
* @param modifier 外部修饰符。 * @param modifier 外部修饰符。
* @param onClick 点击回调,默认空实现(配合 HealthCard 的整卡可点击)。 * @param onClick 点击回调;为 null 表示卡片不可点击 —— 不挂 clickable,因此既没有水波纹
* 也不会触发导航,用于「未开启」这类没有数值的指标卡(有值的卡才可跳转详情)。
*/ */
@Composable @Composable
fun MetricTile( fun MetricTile(
@@ -40,7 +41,7 @@ fun MetricTile(
value: String, value: String,
unit: String, unit: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit = {}, onClick: (() -> Unit)? = null,
) { ) {
HealthCard(modifier = modifier, onClick = onClick) { HealthCard(modifier = modifier, onClick = onClick) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -1,5 +1,6 @@
package com.placeholder.ringhealth.core.navigation package com.placeholder.ringhealth.core.navigation
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FitnessCenter import androidx.compose.material.icons.filled.FitnessCenter
@@ -90,7 +91,12 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Routes.HOME, startDestination = Routes.HOME,
modifier = Modifier.padding(innerPadding), // consumeWindowInsets 必须跟着 padding 一起写:外层 Scaffold 没有 topBar,
// 它的 innerPadding.top 就是状态栏高度,若只 padding 不消费,这个 inset 会继续
// 往下传,详情页内层 Scaffold 的 TopAppBar 会再加一次状态栏内边距 ——
// AppBar 被推低约两个状态栏,上方露出一条背景色带,与四个 Tab 页明显不同。
// 消费后内层拿到的是已扣减的 inset,两个方向都安全(若外层早已消费,这里是 no-op)。
modifier = Modifier.padding(innerPadding).consumeWindowInsets(innerPadding),
) { ) {
composable(Routes.HOME) { composable(Routes.HOME) {
HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) }) HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) })
@@ -37,7 +37,12 @@ import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatClock import com.placeholder.ringhealth.core.common.formatClock
import com.placeholder.ringhealth.core.common.formatDecimal import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.designsystem.MetricBlue import com.placeholder.ringhealth.core.designsystem.MetricBlue
import com.placeholder.ringhealth.core.designsystem.MetricCyan
import com.placeholder.ringhealth.core.designsystem.MetricGreen import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.MetricOrange
import com.placeholder.ringhealth.core.designsystem.MetricPurple
import com.placeholder.ringhealth.core.designsystem.MetricRed
import com.placeholder.ringhealth.core.designsystem.MetricYellow
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard import com.placeholder.ringhealth.core.designsystem.component.HealthCard
@@ -75,6 +80,8 @@ fun MetricDetailScreen(
/** /**
* 无状态指标详情内容:范围切换 + 柱状趋势图 + 统计摘要(+ 睡眠分期)。 * 无状态指标详情内容:范围切换 + 柱状趋势图 + 统计摘要(+ 睡眠分期)。
* *
* 范围切换常驻;当前范围没有数据点时,只把「趋势图 + 统计摘要」换成一行空态文案。
*
* 标题取自 `state.metric` 而不是外面传进来的 `metric` —— 无状态层只有 `state` 一个数据来源, * 标题取自 `state.metric` 而不是外面传进来的 `metric` —— 无状态层只有 `state` 一个数据来源,
* 免得出现「标题写心率、数据画睡眠」这种两者不一致的可能。 * 免得出现「标题写心率、数据画睡眠」这种两者不一致的可能。
*/ */
@@ -118,13 +125,35 @@ private fun MetricDetailContent(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
// 范围切换常驻,空数据时反而更要留着:切到另一个范围是用户看到「暂无数据」后的
// 第一反应,chips 也是判断「哪个范围是空的」的唯一线索。它属于页头,不属于数据区。
item { RangeSelector(current = state.range, onSelect = onSelectRange) } item { RangeSelector(current = state.range, onSelect = onSelectRange) }
// 只有图与统计被替换为空态。判空必须在 loading 之后:Mock 有约 200ms 延迟,
// 加载期间 points 也是空的,先判空会在数据到达前闪一下空态(loading 分支已提前 return)。
if (state.points.isEmpty()) {
// 不再渲染「全 0 高度的柱状图 + 三行 0.0」,那看着像页面坏了而不是「没有数据」。
item {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.detail_empty),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
}
return@LazyColumn
}
item { item {
HealthCard { HealthCard {
SectionHeader(title = stringResource(R.string.detail_trend)) SectionHeader(title = stringResource(R.string.detail_trend))
MetricBarChart( MetricBarChart(
values = state.points.map { it.value.toFloat() }, values = state.points.map { it.value.toFloat() },
color = MetricGreen, color = metricTint(state.metric),
) )
} }
} }
@@ -228,6 +257,23 @@ private fun metricTitleRes(type: MetricType): Int = when (type) {
MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure
} }
/**
* 指标类型 → 语义色。
*
* 与 `HomeScreen` 里各指标卡的 tint 一一对应(步数=绿、心率=红、睡眠=蓝、血氧=青、
* 压力=紫、体温=橙、血压=黄)。色板的唯一来源是设计文档 §指标色板与
* `core/designsystem/Color.kt`,本函数只做映射,不在页面里另造颜色。
*/
private fun metricTint(type: MetricType): Color = when (type) {
MetricType.STEP -> MetricGreen
MetricType.HEART_RATE -> MetricRed
MetricType.SLEEP -> MetricBlue
MetricType.BLOOD_OXYGEN -> MetricCyan
MetricType.STRESS -> MetricPurple
MetricType.TEMPERATURE -> MetricOrange
MetricType.BLOOD_PRESSURE -> MetricYellow
}
/** 周/月切换。 */ /** 周/月切换。 */
@Composable @Composable
private fun RangeSelector( private fun RangeSelector(
@@ -42,6 +42,7 @@ import com.placeholder.ringhealth.core.common.formatCalories
import com.placeholder.ringhealth.core.common.formatDistance import com.placeholder.ringhealth.core.common.formatDistance
import com.placeholder.ringhealth.core.common.formatSleepDuration import com.placeholder.ringhealth.core.common.formatSleepDuration
import com.placeholder.ringhealth.core.common.formatSteps import com.placeholder.ringhealth.core.common.formatSteps
import com.placeholder.ringhealth.core.common.stressLevelLabelRes
import com.placeholder.ringhealth.core.designsystem.MetricBlue import com.placeholder.ringhealth.core.designsystem.MetricBlue
import com.placeholder.ringhealth.core.designsystem.MetricCyan import com.placeholder.ringhealth.core.designsystem.MetricCyan
import com.placeholder.ringhealth.core.designsystem.MetricGreen import com.placeholder.ringhealth.core.designsystem.MetricGreen
@@ -109,13 +110,16 @@ private fun HomeContent(
item { StepsCard(summary = summary, onClick = { onOpenDetail(MetricType.STEP) }) } item { StepsCard(summary = summary, onClick = { onOpenDetail(MetricType.STEP) }) }
item { SectionHeader(title = stringResource(R.string.home_metrics)) } item { SectionHeader(title = stringResource(R.string.home_metrics)) }
items(cards) { card -> items(cards) { card ->
// 没有数值(未开启)的指标卡不可点击:传 null 后 MetricTile/HealthCard 不挂 clickable,
// 无跳转也无水波纹。否则首页显示「未开启」、点进去却是一张完整图表,两页自相矛盾。
val openDetail = card.value?.let { { onOpenDetail(card.type) } }
MetricTile( MetricTile(
icon = card.icon, icon = card.icon,
iconTint = card.tint, iconTint = card.tint,
title = card.title, title = card.title,
value = card.value ?: notEnabledText, value = card.value ?: notEnabledText,
unit = if (card.value == null) "" else card.unit, unit = if (card.value == null) "" else card.unit,
onClick = { onOpenDetail(card.type) }, onClick = openDetail,
) )
} }
} }
@@ -239,7 +243,10 @@ private fun buildMetricCards(summary: HealthSummary): List<MetricCardSpec> = lis
tint = MetricPurple, tint = MetricPurple,
title = stringResource(R.string.metric_stress), title = stringResource(R.string.metric_stress),
value = summary.stress?.toString(), value = summary.stress?.toString(),
unit = "", // 单位位改放压力等级文案(数值落在哪个区间就是放松/正常/中等/偏高),
// 于是卡片读作「34 正常」而不是一个孤零零的数字。无值时留空(上面的 value 分支
// 会整卡退化成「未开启」,此时单位本来也会被清空)。
unit = summary.stress?.let { stringResource(stressLevelLabelRes(it)) } ?: "",
), ),
MetricCardSpec( MetricCardSpec(
type = MetricType.TEMPERATURE, type = MetricType.TEMPERATURE,
+1
View File
@@ -71,6 +71,7 @@
<string name="detail_max">最高</string> <string name="detail_max">最高</string>
<string name="detail_min">最低</string> <string name="detail_min">最低</string>
<string name="detail_trend">趋势</string> <string name="detail_trend">趋势</string>
<string name="detail_empty">暂无数据</string>
<string name="detail_sleep_stages">睡眠分期</string> <string name="detail_sleep_stages">睡眠分期</string>
<string name="detail_sleep_deep">深睡</string> <string name="detail_sleep_deep">深睡</string>
<string name="detail_sleep_light">浅睡</string> <string name="detail_sleep_light">浅睡</string>
+57
View File
@@ -39,6 +39,22 @@ List of devices attached
Preview 面板,**这一项与人工验收一样是未执行的** —— 不要把它写进「已执行」段落 Preview 面板,**这一项与人工验收一样是未执行的** —— 不要把它写进「已执行」段落
(旧稿把它列在上面这一节里,那等于声称验证过一件没做过的事)。 (旧稿把它列在上面这一节里,那等于声称验证过一件没做过的事)。
### 终审修复轮(`5fe0d4a`)各项改动的可验证性
终审后又有一轮修复,共 6 处改动。**不要把这 6 处一律当成「已验证」** —— 它们能验到什么程度
差别很大,逐项列明:
| 改动 | 实际验到了什么 |
|---|---|
| 详情页按指标取色(原先一律绿) | `when` 穷尽七个 `MetricType`,代码可确认;**配色是否好看/是否与首页一致,未经渲染验证** |
| 详情页空数据态(原先渲染全 0 柱状图 + 三行 `0.0`) | **无测试覆盖**,仅代码推理。且血压卡改为不可点后,该路径在当前 Mock 下实际不可达(`metricHistory` 恒返回 7 或 30 个点)—— 属防御性代码 |
| 血压卡不可点击(原先显示「未开启」却能点进一张有数据的图表) | `onClick` 改为可空并传 `null`,代码可确认;**点击涟漪是否真的消失,未经渲染验证** |
| 压力卡 `unit` 接线(4 条字符串此前无调用点) | 调用点已存在,代码可确认;**文字是否在卡片里溢出,未经渲染验证** |
| `RingHealthApp` NavHost 补 `consumeWindowInsets(innerPadding)` | **推理得出,未经设备验证** —— 见下方人工验收第 1 项 |
| README 注明 `installDebug` 需设备 | 文档,无需验证 |
结论不变:**这几处里凡带「视觉」二字的,本机都没验过。**
## 人工验收(未执行 —— 无设备) ## 人工验收(未执行 —— 无设备)
下表**全部未打勾**。这份清单已原样保留,交给有设备的人执行;不要在没有实测的情况下填入结果。 下表**全部未打勾**。这份清单已原样保留,交给有设备的人执行;不要在没有实测的情况下填入结果。
@@ -60,6 +76,14 @@ List of devices attached
### 需在有设备时重点确认的项(原清单细目,逐条保留) ### 需在有设备时重点确认的项(原清单细目,逐条保留)
- **【第 1 优先】内边距是否正确(终审 fix 5,本表最可能出问题的一项)**:
`RingHealthApp` 的 NavHost 现为 `Modifier.padding(innerPadding).consumeWindowInsets(innerPadding)`。
原因是详情页/设备设置页**自带** `Scaffold` + `TopAppBar`,而外层的 `Modifier.padding`
**不消费** window insets,于是状态栏高度被上下叠加两次。补 `consumeWindowInsets` 是标准修法,
但**这一改动从未在设备上看过一眼**。请优先确认两件事:
① 详情页、设备设置页的返回箭头与标题**没有被状态栏遮住**;
② 顶部也**没有多出一条无意义的空白**。
若仍有多余留白,问题在 insets 的消费方式而不在这行代码本身
- 应用启动无崩溃,底部四个 Tab 显示为 健康 / 运动 / 设备 / 我的 - 应用启动无崩溃,底部四个 Tab 显示为 健康 / 运动 / 设备 / 我的
- 四个 Tab 可自由切换,切换后状态保留、无闪烁 - 四个 Tab 可自由切换,切换后状态保留、无闪烁
- **重按已高亮的父 Tab 的行为**:实现为 `popUpTo(start){saveState}` + `restoreState`, - **重按已高亮的父 Tab 的行为**:实现为 `popUpTo(start){saveState}` + `restoreState`,
@@ -82,6 +106,35 @@ List of devices attached
- 睡眠分期条与入睡/起床时间是演示值(固定比例 + 最后一个数据点往前 8 小时),无真实分期数据 - 睡眠分期条与入睡/起床时间是演示值(固定比例 + 最后一个数据点往前 8 小时),无真实分期数据
- 详情页统计摘要一律保留一位小数(步数因此显示为「平均 7512.3」这类精度过剩的形式) - 详情页统计摘要一律保留一位小数(步数因此显示为「平均 7512.3」这类精度过剩的形式)
- 仅浅色模式 - 仅浅色模式
- 血压卡**不可点击**(`value == null` 时 `onClick` 传 `null`)—— 阶段一没有血压数据,
点进去只会看到一张编出来的图表,不如不点
- 详情页的**周/月切换常驻**,空数据时只把切换条下方的图表与统计换成一行空态文案。
切换条属于页头而非数据区:数据显示为空时,切换区间正是用户的第一反应,它也是判断
「哪个区间是空的」的唯一线索
### 与设计文档 §6 的偏差(有意收窄,非遗漏)
- **设备页少了四项**:查找戒指(050B)、智能提醒/闹钟占位、固件升级占位(OTA 17xx)、佩戴检测。
阶段一没有真实协议可对接,摆一排点不动的入口不如不摆(ledger R27)。**这些能力仍属产品范围,
将于后续里程碑回归**;设计文档 §6 的设备页一行已用删除线标注。
- **详情页由「四个专属设计」收敛为一个模板**:`detail/step`、`detail/heart_rate`、`detail/sleep`、
`detail/blood_oxygen` 四条路由合并为 `detail/{metric}`,七个指标共用同一模板(柱状趋势图 +
周/月切换 + 平均/最高/最低)。**仅睡眠保留了专属区块**(`SleepStagesCard`)。
未实现的是:步数的大环、心率的折线/静息-运动区间、血氧的正常范围说明文案。
收敛本身是有意的(避免七套几乎相同的页面代码),详见设计文档 §6 下的注记。
### M2 接入时的已知接缝(交接用,本阶段不修)
`RingDeviceRepository` 目前**不足以承载 M2 的设备控制**:
- `refreshDeviceState()` **零调用者** —— 接口定义了,UI 从不触发。M2 需要接上「下拉刷新」或
进入设备页时刷新,否则真机断连后界面不会更新。
- `setFunctionEnabled` 是**非 `suspend`** 的 —— Mock 只改内存态所以无所谓,但 M2 写向 BLE
必然是挂起操作,接口签名届时需要改,连带 `DeviceViewModel` 的调用点。
- 预计 M2 的改动面:`domain/repository/Repositories.kt`、`data/mock/*`、`di/AppContainer.kt`、
`AndroidManifest.xml`(BLE 权限)、`app/build.gradle.kts` + `gradle/libs.versions.toml`
(厂商 Java 依赖,**很可能需要 Jetifier**)、`feature/device/DeviceScreen.kt`。
完整清单见实施计划的「M2 交接提示」一节。
## 覆盖缺口声明 ## 覆盖缺口声明
@@ -95,3 +148,7 @@ List of devices attached
睡眠分期卡片是否只在睡眠指标下出现、次要文字灰度是否统一 —— 这些全部未经运行时检验。 睡眠分期卡片是否只在睡眠指标下出现、次要文字灰度是否统一 —— 这些全部未经运行时检验。
单元测试覆盖的是 `Formatters` 的纯函数、`MockDataProvider` 的数据形状,以及七个 ViewModel 单元测试覆盖的是 `Formatters` 的纯函数、`MockDataProvider` 的数据形状,以及七个 ViewModel
的状态流;**它们不经过 Compose 渲染管线,也不能发现布局、主题或导航层面的错误。** 的状态流;**它们不经过 Compose 渲染管线,也不能发现布局、主题或导航层面的错误。**
终审修复轮新改的几处**尤其如此**:按指标取色、血压卡不可点、空数据态、压力卡的单位接线、
以及 `consumeWindowInsets` —— 这五处**全部是界面行为**,而 21 项单测**一项都没有、也不可能**
碰到它们。它们的依据是代码阅读与 Compose 的既有语义,不是运行结果。