docs: 补记睡眠时段图与功能开关联动修复(spec §6.2 / §6.3)

- §6.2 睡眠时段图:动因(用户原话「只有最高时间最低时间,我不知道一天内具体什么时候睡的觉」)、
  SleepSession 为何不复用 MetricPoint、横轴锚在 20:00 的理由(含 % 86400000 是 UTC 余数这个坑)、
  轴范围由数据算、只用一个颜色不假装知道分期位置、刻度文字为何画在 Canvas 里、
  以及睡眠页撤掉柱状图的理由
- §6.3 功能开关联动:现象与根因(两个互不引用的字段 + 「开关 id 与枚举长得像」这个巧合)、
  修法(MetricType.switchId、仓库订阅开关流、todaySummary 刻意不给默认值)、
  顺带纠正的两处(未开启不画图、血压卡恢复可点击),以及三项已知未修的结构性重复
- §5.1 接口补 functionSettings / observeSleepSessions / SleepSession / MetricType.switchId
- §6.1 睡眠页的「趋势图」改为「睡眠时段图」;§6 的 M1 差异注记标注同日已关闭
- §7.3 组件清单补 SleepWindowChart 及其与 MetricBarChart 的边界
This commit is contained in:
2026-09-10 16:49:28 +08:00
parent afc08b2d92
commit 32b05acd6f
@@ -115,17 +115,24 @@ com.placeholder.ringhealth
// domain/repository
interface RingDeviceRepository {
val deviceState: StateFlow<RingDeviceState> // 连接态/电量/型号/固件版本
val functionSettings: StateFlow<List<DeviceFunctionSetting>> // 功能开关(见 §6.3)
suspend fun refreshDeviceState()
fun setFunctionEnabled(id: String, enabled: Boolean)
}
interface HealthDataRepository {
fun observeTodaySummary(): Flow<HealthSummary>
fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>>
fun observeSleepSessions(range: DateRange): Flow<List<SleepSession>> // 见 §6.2
fun observeWorkouts(): Flow<List<WorkoutRecord>>
}
// domain/model(节选)
enum class MetricType { STEP, HEART_RATE, SLEEP, BLOOD_OXYGEN, STRESS, TEMPERATURE, BLOOD_PRESSURE }
// switchId 为 null 表示「常开」(计步、体温),见 §6.3
enum class MetricType(val switchId: String?) {
STEP(null), HEART_RATE("heart_rate"), SLEEP("sleep"), BLOOD_OXYGEN("blood_oxygen"),
STRESS("stress"), TEMPERATURE(null), BLOOD_PRESSURE("blood_pressure"),
}
data class RingDeviceState(
val connected: Boolean, val name: String, val batteryPercent: Int,
@@ -137,6 +144,12 @@ data class HealthSummary( // 今日概览
val stress: Int?, val temperature: Double?, val bloodPressure: String?, // 血压未开启时为 null
)
data class MetricPoint(val timestamp: Long, val value: Double)
/** 一晚睡眠。睡眠占的是一段区间,MetricPoint 装不下,所以单独建模(§6.2)。 */
data class SleepSession(
val wakeDate: Long, val sleepStart: Long, val sleepEnd: Long,
val deepMinutes: Int, val lightMinutes: Int, val awakeMinutes: Int,
)
```
约定:`ViewModel` 用 `stateIn` 把 Flow 转成 `StateFlow<XxxUiState>`;`null` 表示"该指标未开启/无数据",
@@ -159,7 +172,7 @@ UI 统一走"未开启占位态",不显示 0。
|---|---|
| `detail/step` | 今日步数详情:大环 + 7 天/月切换柱状图 + 说明文案 |
| `detail/heart_rate` | 心率详情:当日折线/区间统计(静息/运动)+ 历史区间 |
| `detail/sleep` | 睡眠详情:深睡/浅睡/清醒分段条 + 入睡/起床时间 |
| `detail/sleep` | 睡眠详情:深睡/浅睡/清醒分段条 + 入睡/起床时间 + **睡眠时段图**(§6.2) |
| `detail/blood_oxygen` | 血氧详情:趋势折线 + 正常范围说明 |
| `device/settings` | 戒指功能开关详情(静态开关,M2 后写向设备) |
| `workout/history` | 运动历史列表 |
@@ -169,28 +182,281 @@ UI 统一走"未开启占位态",不显示 0。
> 参数为 `MetricType.name`),七个指标共用同一个模板 —— 柱状趋势图 + 周/月切换 + 平均/最高/最低。
> **仅睡眠保留了专属区块**(`SleepStagesCard`:深睡/浅睡/清醒分段条 + 入睡/起床时间)。
> **未实现的是:** 步数的大环、心率的折线/静息-运动区间、血氧的正常范围说明文案。
> **同日晚已关闭这条差异**:七个页面各自独立成文件并各自专属化,见 §6.1;睡眠另加时段图,见 §6.2。
> 收敛本身是有意的(避免七套几乎相同的页面代码),但当时没留下裁定记录 —— 这不妥,
> 此处补记,供后续决定是否回归为专属设计。**这是 M1 已知的、有意的范围收窄,不是漏做。**
>
> **该悬案已关闭(2026-09-10 晚,见下方「详情页专属化」,§6.1)。** 结论是**回归专属设计**,
> 但拆法与上表原来的设想不同:不是「一页一条路由」,而是共用一条路由、七个页面各自实现内容。
>
> **另一处当时没被记录的差异:首页顶部的「问候 + 日期」在 M1 根本没做。**
> 上表白纸黑字写了「顶部问候」,交付的首页却是从设备状态条直接开始的 ——
> 而且这条差异在终审时也没被发现,是后来做视觉改版时才补上的(`greetingResFor` /
> `weekdayResFor`,按 5/12/18/23 点四档给「早上好 / 下午好 / 晚上好 / 夜深了」)。
> 记在这里是因为**它暴露了验收方式的问题**:M1 的验收逐条核对了「页面在不在、能不能点」,
> 却没核对「页面上该有的东西在不在」—— 纯静态的文案块最容易这样漏掉。
**明确不做**(小米有但我们戒指不支持):跑步课程、商城、社区、多设备接入、体重秤/体脂秤。
## 7. 设计系统与视觉(仿小米运动健康)
### 6.1 详情页专属化(2026-09-10 晚,关闭上面的悬案)
**动因。** M1 收敛成一套模板后,验收时的原话是「**每个模块点开都差不多,只有颜色不一样,
能不能有特点一点**」。这不是观感偏好 —— 七个页面共用「一张柱状图 + 平均/最高/最低」,
等于七个页面都只回答了同一个问题(「这几天怎么变的」),而**每个指标真正要回答的问题并不相同**。
**方案:一页一个文件,共用路由。**
```text
feature/detail/
├── MetricDetailScreen.kt # 骨架:TopAppBar + 周/月切换 + 空态 + 按 metric 分派
├── MetricDetailShared.kt # 只放「每页都用且不因指标而变」的东西(成色、数值口径、轴刻度)
├── MetricStats.kt # 统计口径纯函数(+ 单测)
└── pages/ # 七个指标各一个文件,每个是一页的全部内容
├── StepDetail.kt HeartRateDetail.kt SleepDetail.kt BloodOxygenDetail.kt
├── StressDetail.kt TemperatureDetail.kt BloodPressureDetail.kt
```
每个 `pages/*.kt` 导出 `internal fun LazyListScope.xxxDetail(state)`,自己决定往列表里放几张卡。
骨架里的 `when (metric)` 是唯一知道「有哪些页面」的地方。**改一页不必碰其他六页。**
**七个页面各自的「主张」**(这是本次改版的实质,不是换皮):
| 页面 | 这个指标真正要回答的问题 | 头图 | 趋势图 |
|---|---|---|---|
| 步数 | 今天走够了没有 | 进度环 + 完成度 % | 柱状图 + **目标线**(不是均值线) |
| 心率 | 这个数算不算正常 | 大字 + **区间条**(40–120 轴,正常带 60–100 高亮) | 柱状图 + **正常区间带** |
| 睡眠 | 昨晚这一觉睡得怎么样 / 我每天都几点睡 | 时长 + **分期条 + 各期时长** + 入睡/起床时刻 | **睡眠时段图**(每晚一行,横轴是钟点) |
| 血氧 | 有没有掉到线下 | 大字 + 达标/偏低判定 | **阈值带**(点阵 + 95% 阈值虚线,线下标红) |
| 压力 | 这几天是紧还是松 | 大字 + **等级名** | 柱状图**按等级着色** + 四色图例 |
| 体温 | 比自己的基线高了还是低了 | 大字 + **较基线 ±x.x°C** | **偏离条**(基线居中,上下偏离的棒棒糖) |
| 血压 | ——(阶段一该功能未开启) | 空态说明 | 不画图 |
为此新增三个 Canvas 图元(`core/designsystem/component/`):`MetricRangeBar`(区间条)、
`MetricThresholdStrip`(阈值带)、`MetricDeviationBar`(偏离条);`MetricBarChart` 增两个参数
`valueRange`(取景窗口)与 `bandRange`(正常区间带),`average` 泛化成 `referenceLine`。
**顺带修掉的两个真实缺陷**(都是截图时看出来的,不是预先想到的):
1. **窄区间指标的柱子全一样高。** `MetricBarChart` 原来按 `value / maxValue` 定高,
血氧(95–99 → 比值 0.96–1.0)与体温(36.1–36.9 → 0.978–1.0)的七根柱子因此齐平,
整张图没有任何信息量 —— 这不是「不好看」,是数据被抹平了。加 `valueRange` 取景窗口解决。
(代码里原本有一条预览注释已经指出过同类问题,但修复只应用到了预览数据,没到真实范围。)
2. **头图的「当前值」与趋势图最后一根柱子对不上。** 概览的 `todaySummary()` 是固定演示值、
历史点由另一套随机序列生成,于是出现「头图写心率 72、图上今天却是 96」这种自相矛盾,
而且两个数字看起来都很确定。改为 `metricHistory` 的**末点值取自 `todaySummary()`
(`HealthSummary.valueOf(metric)`)**,并加了单测钉住。
**血压页为什么不画图。** 血压是演示数据里唯一 `bloodPressure = null`(未开启)的指标,
首页那张卡据此渲染成「未开启」。若详情页画出一张完整趋势图,两页自相矛盾。
注意 `metricHistory(BLOOD_PRESSURE)` 仍会返回 110–130 的演示点 —— 本页刻意不读它。
M3 接入真实血压数据后,本页整体改为双值(收缩压/舒张压)详情页,
`HealthSummary.bloodPressure` 也从字符串改为结构化的两个数值。
**这张卡必须能点开(同日晚修正)。** 第一版为了堵上面那个矛盾,顺手让详情页按
「有没有数值」决定挂不挂 `clickable` —— 血压没有数值,于是不可点击。这堵过头了:
「未开启 + 怎么开启」的说明页因此**一个入口都没有,成了死代码**,用户也无从知道
该怎么把功能打开。矛盾的根源是详情页画了图,不是卡片可点击,所以改为**由本页不画图来解决**,
卡片恢复可点击;`enabled = false` 仍保留(数值位显示的是「未开启」三个字,不该用大号粗体,
看着比真实数值还重)。同理,本页不显示周/月切换 —— 那两个 chip 点了不会有任何东西跟着变。
### 6.2 睡眠时段图(2026-09-10 晚,追加)
**动因。** 睡眠页当时只有「最高/最低时长」两个数,用户的原话是
「**只有最高时间最低时间,但我不知道一天内具体什么时候睡的觉**」。
柱状图只画「睡了多久」,七根柱子一样高的时候看不出作息是不是一天天往后拖 ——
而「几点睡」恰恰是睡眠这个指标最直接的问题。
**方案:`SleepSession` 单独建模,不复用 `MetricPoint`。**
```kotlin
data class SleepSession(
val wakeDate: Long, val sleepStart: Long, val sleepEnd: Long,
val deepMinutes: Int, val lightMinutes: Int, val awakeMinutes: Int,
)
```
`MetricPoint` 只装得下一个 `Double`,而一晚睡眠占的是**一段区间**。硬塞进去的话
「入睡时刻」无处可放,只能像改版前那样拿「起床时刻往前推 [时长]」凑一个 ——
那份凑出来的时刻与时长永远自洽,因此永远看不出是凑的,但它不对。
`HealthDataRepository` 增加 `observeSleepSessions(range)`,与 `observeMetricHistory` 同口径
(WEEK 七晚 / MONTH 三十晚)。
**只有一个数据源。** `todaySummary().sleepMinutes` 取自最后一个会话,
`metricHistory(SLEEP)` 也由各会话折算成点(`value = sleepMinutes`)。
此前睡眠页上「头图 7 小时 32 分」和「图上另一根长度」是可以并存的 ——
同一屏里最显眼的两块内容各说各的。分期条的权重同样取自会话
(深睡 96 / 浅睡 356 / 清醒 22,合计即卧床时长),不再是写死的 25% / 65% / 10%。
**`SleepWindowChart`**(`core/designsystem/component/`):每晚一行,
一根圆角条从入睡横到醒来,横轴是钟点,左侧行标签(近 7 天用星期、近 30 天用日期)。
三个决定值得记下来:
1. **横轴锚在 20:00。** 一晚从 23:43 到 07:37,用「当天第几分钟」表示会得到 1423 → 457
这样一对反过来的数,画出来入睡点跑到起床点右边。`minutesSinceSleepAnchor`
以 20:00 为界重新起算,两个时刻都落在 0–960 内。**不能用 `timestamp % 86_400_000`** ——
那是 UTC 的余数,在东八区会把整张图平移八小时,而平移后每根条看着都还挺合理。
2. **轴范围由数据算,不写死。** 写死「20:00–12:00」的话,某晚熬到中午才睡,
那根条会被右端裁掉一截,看起来像睡得短。取整到最近的刻度线(2/3/4/6 小时,
取第一个能让刻度不超过 8 个的),刻度因此均匀铺满整条轴。
3. **只用一个颜色,不画分期。** 手头只有「深睡共 96 分钟」这样的累计时长,
没有「第 47 分钟进入深睡」这种分期序列。按比例铺成色块画的是**一段并不存在的分期位置**,
比不画更容易让人当真。M3 接入真实分期序列后再谈分期着色。
**刻度文字画在 Canvas 里(`TextMeasurer`),没有另起一行等宽 `Text`。**
等宽文本框的中心落在 `(i + 0.5) / n`,刻度线落在 `i / (n - 1)`,中间几档会差出小半个格。
量一次宽度再居中,文字和网格线用的是同一个 `xOf`。首尾两档贴边 —— 居中的话各有一半落在画布外。
**睡眠页不再画柱状图。** 柱子高度是时长,时段图的条长同样是时长,两张图讲同一件事。
「目标 8 小时」原本是柱状图那条参考线的附注,改挂到头图右侧 —— 目标本身仍有用,
不该跟着那张图一起消失。
**遗留:** 分期时长(深睡/浅睡/清醒)本身仍是 Mock 按比例生成的演示值,
但**生成的位置从渲染层挪到了数据层**(`MockDataProvider.sleepSessions`),
M3 接入真实分期时只需换掉数据源,UI 与统计口径一行不用改。
`HealthSummary.valueOf` 对 `BLOOD_PRESSURE` 返回 null,同样等结构化的血压字段。
### 6.3 功能开关与健康数据的联动(2026-09-10 晚,修 bug)
**现象。** 用户在「设备 - 功能设置」里打开「血压监测」,首页那张卡纹丝不动。
**根因不是漏了一个刷新,是两套系统。** `DefaultAppContainer` 里
`ringDeviceRepository` 与 `healthDataRepository` 是两个互不引用的字段,
健康数据由写死的常量生成,压根不认识开关。「开关 id 是 `blood_pressure`、
枚举是 `BLOOD_PRESSURE`」这个对应关系只存在于**字符串长得像**这个巧合里,
没有一行代码声明过它们是同一件事。同类症状还有一处:压力开关默认关着,
而 `todaySummary()` 一直给出 `stress = 34`。
**修法:让开关成为首页数据的唯一真相来源。**
- `MetricType` 显式带上 `switchId: String?`,**null 表示常开**(计步与体温由戒指持续采集,
没有「关掉」这个选项)。`enabledMetrics()` 由它算出「当前开着哪些指标」,
常开指标在这里兜底 —— 若按「找不到对应开关就算关」处理,一个空开关列表会让步数和体温
凭空变成「未开启」,而戒指一直在数着步。
- `MockHealthDataRepository` 订阅 `functionSettings` 这个 `StateFlow`,
`todaySummary` / `metricHistory` / `sleepSessions` 全部改为**接受已开启指标集**。
`todaySummary(enabledMetrics)` **刻意不给默认值** —— 默认「全都开着」会让漏传参数的调用方
看起来一切正常,而那正是这次要修的病。
- 关掉的指标在概览里是 `null`,首页那张卡显示「未开启」。
**顺带纠正的两处。**
- **详情页对未开启的指标不画图。** 此前未开启的指标只是首页显示「未开启」,
点进详情页照样画出七天的随机数据 —— 一段并不存在的监测记录,比空图更有误导性。
现在整页走共用的 `MetricDisabledCard`(图标与颜色取自 `MetricVisuals`,
与首页那张卡完全一致,用户才知道自己没点错),周/月切换一并隐藏 ——
两个点了不会有任何东西跟着变的 chip 比不显示更让人困惑。
- **血压卡片恢复可点击。** 第一版为了堵「首页说未开启、详情页画图」这个矛盾,
顺手让详情页按「有没有数值」决定挂不挂 `clickable`,于是血压页**一个入口都没有,成了死代码**。
矛盾的根源是详情页画了图,不是卡片可点击,所以改由「本页不画图」来解决。
**未修的(已知,本次不动):** 「设备」页与「更多设置」页渲染的是同一份 `functionSettings`
(同一批开关出现在两个地方,两个页面各自维护一套列表 UI);运动页的「最近运动」与
「运动历史」内容完全相同;「我的」页四个入口均为死链接。三项都已报告,未在本轮处理。
## 7. 设计系统与视觉
> **方向变更(2026-09-10,M1 真机验收后)。** 本节原始表述为「仿小米运动健康:浅色为主,
> 白底卡片 + 大圆角 + 轻阴影」。该方向在验收时被否,原话是「**我不想像小米我想更好看,
> 我觉得现在的页面不好看**」。**「像小米」从此不再是目标,也不再是验收标准**;
> 目标改为「用同一套视觉语言,做得比小米干净」。下面 §7.1–§7.4 是改版后的实际口径,
> 其中每一条都对应当时截图上肉眼可见的一个问题 —— 原表述没能防住它们。
### 7.1 视觉口径(改版后)
- **两层底色,不是一层。** 页面底 `#EEF1F6`(浅灰蓝),卡片 `#FFFFFF`,分隔线 `#EFEFF4`。
原表述只说了「白底卡片」,没说页面底是什么 —— 实现时页面底取了近白的 `#F5F6F8`,
与卡片几乎同色,卡片边界只剩一层几乎看不见的阴影在撑,整屏糊成一片白。
**底色必须与卡片拉开可见差距,卡片才立得起来。**
- **卡片用真阴影,不是 M3 `Card` 默认的 tonal elevation。** 后者的 elevation 走的是
「给表面叠一层主色调」而不是投影,在浅灰底上表现成一团脏灰。现改为
`Surface` + `Modifier.shadow(5dp, 20dp 圆角, ambient/spot 双色)`。
**彩色阴影只在 API 28+ 生效,minSdk 26 会退化成黑色 —— 可接受,不为此提高 minSdk。**
- **颜色是有含义的,不能当装饰用。** 每个健康指标一个专属色(§7.2);
非指标的次要读数(距离、千卡)一律深色数值 + 灰单位,**不上色** ——
否则用户会以为「蓝色」代表某种指标身份,点上去却什么也没有。
- **大数字用等宽数字(`fontFeatureSettings = "tnum"`)**,否则数值刷新时字宽跳动、整行抖动。
### 7.2 指标色板
步数=绿、心率=红、睡眠=蓝、血氧=青、压力=紫、体温=橙、血压=黄(`core/designsystem/Color.kt` 统一定义)。
**小字号绿字单列一色** `BrandGreenText #12894A`:品牌绿 `#34C759` 在 12sp 文字上对白底
只有约 **2.0:1** 对比度,远低于 WCAG AA 要求的 4.5:1 —— 小字用它读不清,故另立一色专供文字。
> **踩过的坑(M1 验收时被当成正常现象记录了下来):** Material 3 的 `secondaryContainer`
> **同时**是「底部导航选中态药丸底」和「FilterChip 选中底」两处的取色来源。
> 主题里不显式覆盖它,就会落到 M3 默认的淡紫 `#E8DEF8`,于是出现
> 「**淡紫底 + 绿色文字**」的撞色。现已在 `Theme.kt` 中显式指向浅绿 `NavIndicator`。
### 7.3 组件清单(`core/designsystem/component/`)
`HealthCard`(白卡 + 真阴影)、`RingProgress`(Canvas 环形进度)、
`MetricTile`(2 列网格用的紧凑指标卡:色底图标徽章 + 数值 + 单位)、
`IconBadge`(圆形色底图标,首页指标卡与「我的」页共用)、`SectionHeader`、
`StatBar`(睡眠分段条)、`MetricBarChart`(Canvas 柱状图)、`SubPageTopAppBar`(子页顶栏)。
详情页专属化(§6.1)新增四个,都是「某个指标要回答的问题」缺一个图形语言时补的:
| 组件 | 图形语言 | 用在 |
|---|---|---|
| `MetricRangeBar` | 横向轴 + 高亮的正常区间 + 一个带白环的当前值圆点 | 心率 |
| `MetricThresholdStrip` | 点阵 + 阈值虚线,越线的点加警示色光晕 | 血氧 |
| `MetricDeviationBar` | 基线居中的棒棒糖图,向上/向下分别着色 | 体温 |
| `ChartAxisLabels` | 不是图元,是三个图共用的 X 轴标签行 | 全部 |
> `ChartAxisLabels` 的存在理由:X 轴标签要和柱子**逐列对齐**,所以「标签数 == 柱数时按
> `weight` 等分、否则 `SpaceBetween`」这段判断每个图都得写一遍。抽出来后三个图只调一次,
> 也保证了三张图的日期对位规则不会各走各的。
睡眠时段图(§6.2)另加一个:
| 组件 | 图形语言 | 用在 |
|---|---|---|
| `SleepWindowChart` | 每晚一行,圆角条从入睡横到醒来,横轴是钟点(锚在 20:00,跨零点) | 睡眠 |
> 它**不复用 `MetricBarChart`**:后者的横轴是「第几天」,槽位等分、每根柱居中,
> 而时段图的横轴是**连续的钟点**,一根条的位置本身就是数据。两者只有配色可以共用。
> 唯一的例外是刻度文字 —— 时段图的刻度画在 Canvas 内(`TextMeasurer` 量宽后居中),
> 因为它必须和竖向网格线共用同一个坐标换算,见 §6.2。
**四条组件级约定,都是改版时踩出来的:**
- **子页顶栏底色取 `background`,不取 `surface`。** M3 `TopAppBar` 的默认容器色是
`surface`(本应用里是纯白),而页面底是浅灰 —— 两者不同色时,顶栏会在浅灰页面上
糊出一条白色横带,同时状态栏那一条仍是页底色,顶部于是出现「**灰、白、灰**」三层。
三个子页统一改用 `SubPageTopAppBar`。
- **`MetricBarChart` 必须画基线、均值参考线与 X 轴刻度。** 改版前它只有一个
`SectionHeader` 加一排浮空的胶囊柱:**没有基线**(柱子悬空)、**没有刻度**
(看不出哪根是哪天)、**没有参照**(读不出「今天比平时高还是低」)。三者补齐后这张图
才开始承载信息。参考线用**中性灰**而非柱色 —— 跟着柱色走会被当成「第 8 根柱子」。
- **窄区间指标必须给 `valueRange`,否则柱子会被抹平。** 柱子按 `value / max` 定高,
血氧(95–99)与体温(36.1–36.9)的七根柱子因此几乎齐平,**整张图没有信息量**。
这不是审美问题,是数据被压没了。`valueRange` 把取景窗口收窄到数据的实际活动范围;
但**睡眠与步数不能收**——「睡了 3 小时」相对「睡了 8 小时」确实只有 3/8 高,
这个比例本身有含义,所以它们的下界仍是 0。
- **统计数值的小数位按指标分别定。** 此前一律走 `"%.1f"`,心率显示成「78.0」、
步数显示成「8426.0」—— 尾随的 `.0` 是**假精度**,看着像仪器精度比实际高一个数量级。
现在只有体温保留一位(36.5 与 36 的差别有实际意义),其余取整并补上单位。
### 7.4 其他
- 浅色为主,白底卡片 + 大圆角 + 轻阴影;大号数字(步数等)用等宽感强的字重。
- 指标色板:步数=绿、心率=红/粉、睡眠=蓝/靛、血氧=青、压力=紫、体温=橙(在 `designsystem/Color.kt` 统一定义)。
- 通用组件沉淀到 `core/designsystem/component/`:`HealthCard`、`RingProgress`(Canvas 环形进度)、
`MetricTile`(图标+数值+单位小卡)、`SectionHeader`、`StatBar`(睡眠分段条/柱状图)。
- 深色模式:阶段一**仅保证浅色**可用,深色留到 M5。
- 所有中文字符串进 `strings.xml`(默认中文,附英文),支持后续多语言;**禁止硬编码文案**。
- 所有中文字符串进 `strings.xml`;**禁止硬编码文案**。多语言排在 M5(见下方注记 3)。
- 网格布局:首页六张指标卡用 `LazyVerticalGrid` 两列 —— 原先单列时每张卡被拉到整屏宽
而高只有 180px(宽高比约 6:1),横向大片留白、纵向被撑开,六个数字要滚三屏才看得完。
> **M1 实施状态注记(2026-09-10,全分支终审后回填)—— 两条与上文不符的实际交付,均为有意为之:**
> **M1 实施状态注记(2026-09-10,全分支终审后回填)—— 与上文不符的实际交付:**
>
> 1. **设备页删减了四项:** 查找戒指(050B)、智能提醒/闹钟占位、固件升级占位(OTA 17xx)、佩戴检测,
> M1 **未实现**(见 Ruling R27,并已同步删除对应的 8 条字符串资源)。理由是阶段一没有真实协议可对接,
> 摆一排点不动的入口不如不摆。**这些能力仍属于产品范围,将随后续里程碑回归**;本文档上表已用删除线标注。
> 2. **详情页由「四个专属设计」收敛为一个模板** —— 详见**上方「详情子页」表下的注记**。
> 3. **多语言**:`strings.xml` 目前**只有中文**,没有 `values-en/`。本文档
> 「附英文」与 §198 把多语言排在 **M5** 两处自相矛盾;以 M5 为准,M1 不做。
> 「附英文」与 M5 把多语言排期两处自相矛盾;以 M5 为准,M1 不做。
> 4. **详情页仍缺三项已规划的内容**(§6 注记已列):步数的大环、心率的折线/静息-运动区间、
> 血氧的**正常范围说明**。本次改版给柱状图补了刻度与参考线,但「这个值算不算正常」
> 仍需按指标给出参考区间才能回答 —— 那属于领域数据(应来自 `domain/`,不该写死在
> UI 层),留到 M3 与真实数据一起做。
## 8. Mock 数据策略
@@ -235,8 +501,11 @@ ring-health-api/
| 后端栈未定 | 有意延迟到 M4,避免两仓库空转 |
| 本机构建环境版本 | 已探明:JDK 17、Android SDK(platform 34/35/36)、build-tools **仅 35.0.0 / 36.1.0**、Gradle 缓存 8.12/8.14、无全局 gradle。故工具链**钉死在已安装版本**,避免构建时联网拉取缺失组件 |
| 视觉方向定为「**不仿小米**」 | 2026-09-10 M1 真机验收后由需求方否决原「仿小米运动健康」方向。**「像小米」不再是目标,也不是验收标准**。改版后的实际口径见 §7,视觉审查一律以 §7 为准 |
**已确认决策**:① 前后端分离,两个仓库;② Jetpack Compose;③ 阶段一单 `:app` 分层模块;
④ 四个 Tab 与全部详情子页都做;⑤ 后端阶段一只留契约;⑥ 占位命名后续统一替换。
④ 四个 Tab 与全部详情子页都做;⑤ 后端阶段一只留契约;⑥ 占位命名后续统一替换;
⑦ 视觉方向**不仿小米**(见上表,改版口径见 §7)。
## 12. 占位命名与替换清单