Files
ring-health-docs/superpowers/specs/2026-09-10-ring-health-app-design.md
T
cuiluzheng 32b05acd6f 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 的边界
2026-09-10 16:49:28 +08:00

523 lines
36 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.
# 智能戒指健康 App 设计文档(阶段一)
- 日期:2026-09-10
- 状态:待评审
- 产品代号(占位):`ring-health`
- 相关仓库:`ring-health-app`(Android App)、`ring-health-api`(后端 API)
---
## 1. 背景与目标
我们要为自有的智能戒指/手环做一款手机 App,用于后续与设备联调、并最终面向用户。
交互参照**小米运动健康**(Mi Fitness),但**按我们戒指的真实能力裁剪**。
**阶段一目标(本文档范围)**:用原生 Android + Jetpack Compose 搭出完整可运行的
**静态页面骨架**,页面齐全、导航顺畅、真机可跑;所有数据来自本地 Mock。
**不在阶段一范围**:真实蓝牙连接、协议解析、本地数据库、账号登录、云端同步。
这些在 M2 及以后的里程碑中,通过预留的接口边界接入。
## 2. 现状:厂商 SDK 分析(已探明)
手上的 `SmartRingF01_SDK` **不是 `.aar` 库,而是厂商「鑫域隆 SmartRingF01/F02」的完整 Demo App**。
| 项 | 现状 |
|---|---|
| 工程名 / 包名 | `SmartRingDCLT_SDK` / `com.sr.smartring` |
| 可复用核心 | `BluetoothLeClass`(BLE 扫描/连接/GATT 收发,**仅依赖 Android 框架,零第三方依赖**) |
| 协议收发 | 写在厂商 `MainActivity` 的静态方法:`sendBleData` / `recvBleData` / `recvBleWriteData`,帧格式 `DC 0001 <cmd> <data> LT`(下行)、`DC 0002 …`(上报) |
| 辅助类 | `HEROUtils`(OTA 下载 + CRC32)、`Utils`、`BluetoothApplication`、`LeDeviceListAdapter` |
| 技术栈 | Java + Android **Support Library(非 AndroidX)** + EventBus 3 + Gradle 6.7.1 / AGP 4.2.2(很旧) |
| 协议文档 | `鑫域隆智能戒指协议.txt`(GBK 编码,含全部指令码) |
**戒指能力(来自协议文档,决定我们能做哪些页面)**:
计步、心率、血氧、睡眠、血压、体温、压力、运动检测、查找戒指、电量、佩戴/充电状态、OTA 升级、自动监测开关配置、时间同步。
**复用策略**:SDK 内容**不修改**。M2 接 BLE 时,把 `BluetoothLeClass` 等纯通讯类**原样拷贝**进
App 的 `vendor/` 包(保持 `com.sr.smartring` 包名与代码原样),我们只**新写一层适配器**去调用它。
UI 永远不直接依赖厂商代码。
## 3. 仓库与目录结构
遵循团队 `docs/` + `repos/` 惯例;仓库名 kebab-case,移动端 `-app`、后端 `-api`。
```
d:\cherry
├── SmartRingF01_SDK/ # 厂商原始工程(只读参考,不纳入任何 git 仓库)
├── docs/ # 项目文档根(独立 git 仓库)
│ └── superpowers/specs/ # 设计/技术文档
└── repos/
├── ring-health-app/ # Android App(Kotlin + Compose) ← 阶段一主战场
└── ring-health-api/ # 后端 API ← 阶段一仅脚手架 + 接口契约
```
- `ring-health-app`:阶段一全部实现在此。
- `ring-health-api`:阶段一**只放空骨架(README + gitignore)与 `docs/api-contract.md` 契约草稿**,
不写业务接口。后端技术栈(Spring Boot / .NET)待 M4 真正做云同步时再定。
## 4. App 技术选型
| 项 | 选择 | 理由 |
|---|---|---|
| 语言 | Kotlin | Compose 唯一舒适选择 |
| UI | Jetpack Compose + Material 3 | 写卡片/图表类仿小米页面效率最高 |
| 导航 | Navigation-Compose,单 Activity | 多 Tab + 深层子页标准做法 |
| 架构 | MVVM + `StateFlow`(`UiState`) | 官方推荐,易测试 |
| 异步 | Coroutines + Flow | — |
| 图表 | **Compose Canvas 手绘**(今日大环、柱状/折线占位) | 阶段一不引第三方图表库(YAGNI) |
| DI | 手动组合根(`Application` 中构造仓库) | 阶段一无需 Hilt,后续可平滑替换 |
| 构建 | **Gradle 8.14 + AGP 8.13.1** + JDK 17 + `libs.versions.toml` | 本机已缓存 Gradle 8.14,免联网下载。~~AGP 8.11.x~~ 已按 R5/R6 改为 **8.13.1**(见下方注记) |
| SDK | `minSdk 26` / `targetSdk 36` / `compileSdk 36`,build-tools **36.1.0** | 对齐厂商 minSdk(26);~~35~~ 已按 R5 改为 **36**,与本机已装 platform/build-tools 一致 |
> **注记(2026-09-10,M1 实施后回填):** 上表两行原写 AGP 8.11.x / compileSdk 35 / build-tools 35.0.0,
> 与最终交付的 `app/build.gradle.kts` 不符 —— **代码是对的,是本文档当时没跟着更新。**
> 实际工具链:AGP **8.13.1**、compileSdk/targetSdk **36**、buildToolsVersion **36.1.0**、minSdk 26、
> Kotlin 2.2.20。下调理由见 Ruling R5(工具链以本机已装版本为准)与 R6(越界依赖下调至工具链可接受的最新版本),
> 完整证据链写在 `gradle/libs.versions.toml` 顶部的注释里。
> 另外 `compose-bom` 用的是 **2026.06.01**(compose 1.11.4),不是 2026.09.00 —— 那组版本要
> compileSdk 37 + AGP 9.1.0,与本工具链互斥。
**阶段一依赖清单(刻意精简)**:`androidx.core:core-ktx`、`androidx.activity:activity-compose`、
`androidx.lifecycle:lifecycle-runtime-compose` / `lifecycle-viewmodel-compose`、
`androidx.navigation:navigation-compose`、`androidx.compose:compose-bom`(ui / material3 /
material-icons-extended / ui-tooling-preview)、`kotlinx-coroutines`。
**不引** Hilt、Room、Retrofit、第三方图表库。
## 5. App 架构分层(单 `:app` 模块 + 严格分包)
```
com.placeholder.ringhealth
├── RingHealthApplication.kt # 组合根:手动构造 Repository,注入 ViewModel
├── MainActivity.kt # 唯一 Activity,承载 Compose NavHost
├── core/
│ ├── designsystem/ # Theme/Color/Type/Shape + 通用组件
│ │ └── component/ # HealthCard、RingProgress、MetricTile、SectionHeader…
│ ├── common/ # 格式化与扩展(步数、时长、心率区间)
│ └── navigation/ # Route 常量与 NavGraph
├── domain/
│ ├── model/ # 纯 Kotlin 数据模型(无 Android 依赖)
│ └── repository/ # 仓库接口(UI 只依赖这里)
├── data/
│ └── mock/ # Mock 实现 + 造数器(阶段一)
│ # M2 将新增 data/sdk/ 作为厂商 SDK 适配层
├── feature/ # 每个 Tab / 子页一个包:Screen + ViewModel + UiState
│ ├── home/ workout/ device/ profile/ detail/
└── vendor/ # 预留:M2 原样存放厂商 com.sr.smartring 通讯类(不改一行)
```
**核心边界原则**:UI(`feature/*`)**只依赖 `domain/repository` 的接口**与 `domain/model`。
阶段一注入 Mock 实现;M2 新增 SDK 适配实现,接口不变则 UI 零改动。
### 5.1 关键接口(阶段一定稿)
```kotlin
// 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(节选)
// 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,
val firmwareVersion: String, val charging: Boolean,
)
data class HealthSummary( // 今日概览
val steps: Int, val stepGoal: Int, val calories: Int, val distanceKm: Double,
val heartRate: Int?, val sleepMinutes: Int?, val bloodOxygen: Int?,
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` 表示"该指标未开启/无数据",
UI 统一走"未开启占位态",不显示 0。
## 6. 页面地图与路由
底部四 Tab(仿小米运动健康),并按戒指能力裁剪:
| Tab | 页面内容 | 路由 |
|---|---|---|
| **① 健康·首页** | 顶部问候 + 设备连接状态条;今日大环(步数/卡路里/距离);指标卡片网格:**心率 / 睡眠 / 血氧 / 压力 / 体温**(血压显示"未开启/需校准"占位) | `home` |
| **② 运动** | "开始运动"入口(跑步/骑行/自由训练占位)+ 运动历史记录列表(静态) | `workout` |
| **③ 设备** | 戒指卡片(名称/电量/连接态/固件)+ 设置列表:健康自动监测开关(心率/血氧/血压/压力/睡眠,对应协议 0502 开关位)~~、查找戒指(050B)、智能提醒/闹钟占位、**固件升级占位**(OTA 17xx)、佩戴检测~~ | `device` |
| **④ 我的** | 头像/昵称(登录占位)、App 设置(单位/通知)、通知权限引导、关于/检查更新、帮助与反馈 | `profile` |
**详情子页**(全部静态 + Canvas 图表占位):
| 路由 | 内容 |
|---|---|
| `detail/step` | 今日步数详情:大环 + 7 天/月切换柱状图 + 说明文案 |
| `detail/heart_rate` | 心率详情:当日折线/区间统计(静息/运动)+ 历史区间 |
| `detail/sleep` | 睡眠详情:深睡/浅睡/清醒分段条 + 入睡/起床时间 + **睡眠时段图**(§6.2) |
| `detail/blood_oxygen` | 血氧详情:趋势折线 + 正常范围说明 |
| `device/settings` | 戒指功能开关详情(静态开关,M2 后写向设备) |
| `workout/history` | 运动历史列表 |
> **M1 实际交付与上表的差异(2026-09-10 回填,全分支终审发现):**
> 上面 `detail/*` 的**四条路由实际合并成了一条**:`detail/{metric}`(`Routes.DETAIL_PATTERN`,
> 参数为 `MetricType.name`),七个指标共用同一个模板 —— 柱状趋势图 + 周/月切换 + 平均/最高/最低。
> **仅睡眠保留了专属区块**(`SleepStagesCard`:深睡/浅睡/清醒分段条 + 入睡/起床时间)。
> **未实现的是:** 步数的大环、心率的折线/静息-运动区间、血氧的正常范围说明文案。
> **同日晚已关闭这条差异**:七个页面各自独立成文件并各自专属化,见 §6.1;睡眠另加时段图,见 §6.2。
> 收敛本身是有意的(避免七套几乎相同的页面代码),但当时没留下裁定记录 —— 这不妥,
> 此处补记,供后续决定是否回归为专属设计。**这是 M1 已知的、有意的范围收窄,不是漏做。**
>
> **该悬案已关闭(2026-09-10 晚,见下方「详情页专属化」,§6.1)。** 结论是**回归专属设计**,
> 但拆法与上表原来的设想不同:不是「一页一条路由」,而是共用一条路由、七个页面各自实现内容。
>
> **另一处当时没被记录的差异:首页顶部的「问候 + 日期」在 M1 根本没做。**
> 上表白纸黑字写了「顶部问候」,交付的首页却是从设备状态条直接开始的 ——
> 而且这条差异在终审时也没被发现,是后来做视觉改版时才补上的(`greetingResFor` /
> `weekdayResFor`,按 5/12/18/23 点四档给「早上好 / 下午好 / 晚上好 / 夜深了」)。
> 记在这里是因为**它暴露了验收方式的问题**:M1 的验收逐条核对了「页面在不在、能不能点」,
> 却没核对「页面上该有的东西在不在」—— 纯静态的文案块最容易这样漏掉。
**明确不做**(小米有但我们戒指不支持):跑步课程、商城、社区、多设备接入、体重秤/体脂秤。
### 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 其他
- 深色模式:阶段一**仅保证浅色**可用,深色留到 M5。
- 所有中文字符串进 `strings.xml`;**禁止硬编码文案**。多语言排在 M5(见下方注记 3)。
- 网格布局:首页六张指标卡用 `LazyVerticalGrid` 两列 —— 原先单列时每张卡被拉到整屏宽
而高只有 180px(宽高比约 6:1),横向大片留白、纵向被撑开,六个数字要滚三屏才看得完。
> **M1 实施状态注记(2026-09-10,全分支终审后回填)—— 与上文不符的实际交付:**
>
> 1. **设备页删减了四项:** 查找戒指(050B)、智能提醒/闹钟占位、固件升级占位(OTA 17xx)、佩戴检测,
> M1 **未实现**(见 Ruling R27,并已同步删除对应的 8 条字符串资源)。理由是阶段一没有真实协议可对接,
> 摆一排点不动的入口不如不摆。**这些能力仍属于产品范围,将随后续里程碑回归**;本文档上表已用删除线标注。
> 2. **详情页由「四个专属设计」收敛为一个模板** —— 详见**上方「详情子页」表下的注记**。
> 3. **多语言**:`strings.xml` 目前**只有中文**,没有 `values-en/`。本文档
> 「附英文」与 M5 把多语言排期两处自相矛盾;以 M5 为准,M1 不做。
> 4. **详情页仍缺三项已规划的内容**(§6 注记已列):步数的大环、心率的折线/静息-运动区间、
> 血氧的**正常范围说明**。本次改版给柱状图补了刻度与参考线,但「这个值算不算正常」
> 仍需按指标给出参考区间才能回答 —— 那属于领域数据(应来自 `domain/`,不该写死在
> UI 层),留到 M3 与真实数据一起做。
## 8. Mock 数据策略
- `data/mock/MockDataProvider.kt` 提供**确定性**演示数据(固定随机种子),保证每次运行截图一致、便于展示。
- Mock 仓库模拟轻微异步延迟(~200ms),让加载态/占位骨架在阶段一就能跑通。
- 开关类设置的状态用内存态(`StateFlow`)保存,重启重置;**阶段一不引入持久化**。
## 9. API 仓库(阶段一)
`ring-health-api` 阶段一只包含:
```
ring-health-api/
├── README.md # 说明:后端栈待定,M4 落地
├── .gitignore
└── docs/api-contract.md # 接口契约草稿(登录、设备绑定、健康数据同步的字段雏形)
```
契约草稿仅为对齐未来前后端边界,**不实现**。后端技术栈(Spring Boot / .NET)在 M4 前单独定。
## 10. 里程碑与验收
| 阶段 | 内容 | 验收标准 |
|---|---|---|
| **M0 基建** | 建 `docs/`、`repos/` 目录;初始化两个 git 仓库;gitignore/README | 两仓库结构就绪,符合命名规范 |
| **M1 静态 App(阶段一)** | Compose 工程骨架、主题、四 Tab 导航、本文档第 6 节全部页面、Mock 仓库 | 真机/模拟器可跑;四 Tab 与全部子页可切换;无崩溃;`assembleDebug` 通过 |
| **M2 SDK 接入** | `vendor/` 原样放厂商通讯类;新增 `data/sdk/` 适配层实现既有接口;BLE 权限、扫描、连接 | 真机扫描到戒指、连接成功、读到电量/固件 |
| **M3 数据打通** | 协议解析(帧 `DC…LT`)、Mock→真数据、ROOM 本地存储、真实图表 | 首页与详情显示真机数据 |
| **M4 云端/账号** | 定后端栈、实现登录与同步接口、App 接入 | 接口联调通过 |
| **M5 发布** | 签名、图标、深色模式、多语言、内测包 | 可分发 APK |
**本次实施范围 = M0 + M1。**
## 11. 风险与决策记录
| 风险/决策 | 说明与对策 |
|---|---|
| 厂商 SDK 老旧(Support Library / EventBus)与 Compose 工程共存 | `BluetoothLeClass` 零第三方依赖,风险低;M2 隔离在 `vendor/` 包内,仅当必需才考虑 jetifier |
| 占位命名需全局替换 | 替换清单见 §12;集中一处记录,替换时按清单执行 |
| BLE 权限(Android 12+) | M2 统一处理 `BLUETOOTH_SCAN/CONNECT` + 位置权限与引导 UI,阶段一不涉及 |
| 手绘 Canvas 图表工作量 | 阶段一只做环形 + 简单柱/折线,避免过度实现;复杂图表留 M3 |
| 后端栈未定 | 有意延迟到 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 与全部详情子页都做;⑤ 后端阶段一只留契约;⑥ 占位命名后续统一替换;
⑦ 视觉方向**不仿小米**(见上表,改版口径见 §7)。
## 12. 占位命名与替换清单
待正式产品名/域名确定后,需统一替换:
| 位置 | 占位值 |
|---|---|
| 仓库名 | `ring-health-app`、`ring-health-api`(含 git 服务器上名称) |
| `applicationId` / `namespace` | `com.placeholder.ringhealth` |
| 包路径 | `com/placeholder/ringhealth` 目录树 |
| `app_name`(strings.xml) | `RingHealth`(占位) |
| 文档与 README 中的产品代号 | `ring-health` |
替换为一次性全局操作,不涉及逻辑改动;执行时按上表逐项核对。