Compare commits

...
5 Commits
Author SHA1 Message Date
cuiluzheng 6af1078ca5 fix: 首页三件套字号 30sp 改 26sp,label 含单位避免窄格里换行
第一版 value 行 + 单位同行 + 30sp 字号在窄格里换行成两截:「8,426」一行 + 「步」一行,「327」+「千」+「卡」也各换一行,「47」+「分钟」同样问题 —— 整张图被切成 6 段,视觉上不像三件套、像六个数。

改法:label 含单位(卡路里(千卡)、活动(分钟)),value 行只摆数字,字号降到 26sp —— 三件套格子约 110dp 宽,26sp + 5 位数字 100,000 装得下,再留余量给目标那行小字。

KDoc 注释一并更新,标注 M1 验证阶段肉眼发现的坑。
2026-09-11 09:52:09 +08:00
cuiluzheng 841f9f1542 feat: 四个 Tab 收口 — 首页三件套 / 运动类型网格 / 设备空态 / 我的四个真子页
PROGRESS.md §3.2 报告的三个结构性问题(设备页 + 更多设置重复、运动页两个入口同数据、「我的」四个死链接)本批一并收口。用户原话:「现在就是要 APP 页面完整成熟,后期再做后端接口,我的任务只是前端」。

首页(HomeScreen)
- HealthSummary 加 activeMinutes: Int(M3 接 SDK 后由真实活动汇总接口填入)
- MockDataProvider 演示值 47 分钟
- 删 StepsCard(步数大环 + 距离/千卡 横排),换为 TodaySummaryCard:横排 3 列「步数 / 卡路里 / 活动时长」
- 三列等宽,无主导项,步数下挂目标;步数一格整格可点进详情
- 三件套单格内 30sp + tnum,宽字符数字不会跳
- 三列之间 1dp 浅灰竖线(不用 core 的 DividerColor,那个是卡片间分割线)

运动页(WorkoutScreen)
- 加 SportType 枚举(户外跑步 / 健走 / 户外骑行 / 室内跑步 / 跳绳 / 自由训练)
- 删 StartWorkoutCard,换为 SportTypeGrid:横排 6 个运动类型入口,可滑
- 每格:80dp 圆角方块 + 浅 MetricGreen 底 + 深 MetricGreen 图标 + 类型名下方
- 点任意类型仍弹 Snackbar「将在接入戒指后开放」(一致行为)
- 图标背景用 MetricGreen.copy(alpha=0.12f) 而不是小米那种饱和度极高的大色块 —— spec §7.1「颜色是有含义的不能当装饰用」

设备页(DeviceScreen)
- DeviceContent 加分支:!state.device.connected 时显示 DeviceEmptyState
- 空态:圆形 NFC 图标 + 「未连接戒指」+ 「连接戒指后即可查看电量、固件与监测设置」+ 「扫一扫」+ 「按型号添加」两按钮
- 已连态完全保留
- 加 Preview @Preview(name = "未连") 验证空态
- Mock 始终 connected,所以生产路径仍走已连;M2 接 SDK 后未连时自动落空态

我的(ProfileScreen + 4 个新子页)
- 4 个死链接(单位设置 / 通知权限 / 检查更新 / 帮助与反馈)全部挂上 onClick
- 加 4 条路由:PROFILE_SETTINGS / PROFILE_NOTIFICATIONS / PROFILE_ABOUT / PROFILE_HELP
- 新增 4 个子页:
  - SettingsScreen:单位卡(距离/体重,只读)+ 通知卡(健康/设备/营销三个 Switch,演示值)
  - NotificationsScreen:权限状态徽章 + 引导文案 + 「请求权限」+ 「前往系统设置」按钮
  - AboutScreen:App 版本 / 固件版本 / 设备型号 + 「检查更新」按钮(弹 Snackbar「已是最新版本」)
  - HelpScreen:4 条 FAQ 折叠卡(整卡可点切换展开)+ 「联系我们」邮箱卡
- tabRouteFor 把 4 条新路由归到 PROFILE Tab,子页栈顶时底栏仍高亮「我的」

校验
- ./gradlew :app:testDebugUnitTest → 44 个用例全绿
- ./gradlew :app:assembleDebug → APK 出包成功
- 装机截屏:PROGRESS.md 提到的 ringtest AVD 已不存在,现存的 Medium_Phone AVD 配错系统镜像路径(指向 android-36.1 但本机只装了 android-36),按 ledger Ruling R21 跳过运行时验证,交人工

未做(已记录的边界)
- 通知开关不持久化:阶段一切换只走 remember,离页即丢;M2 接 SDK 时换 StateFlow
- 「请求权限」不真发系统弹窗:阶段一按下后只切演示状态;M2 接 SDK 时用 NotificationManagerCompat.requestPermissions
- 「前往系统设置」不真跳:同上,阶段一只占位

——共 14 文件,+10 修改 / +4 新建。
2026-09-11 08:55:38 +08:00
cuiluzheng fba26fc372 feat: 睡眠详情页支持按分期染色(演示仅昨晚带分期序列)
- SleepSession 增加可选 stages: List<SleepSegment>?;为 null 走原行为
- SleepSegment(stage, startOffsetMinute, endOffsetMinute) —— 相对 sleepStart 的分钟偏移
- MockDataProvider.demoLastNight() 填入真实分期序列,深睡/浅睡/清醒各期时长与累计值严格对齐
- SleepWindowChart 新增 StageTints(deep, light, awake, track);stages 非空时画轨道 + 子矩形
- AWAKE 跳出 MetricBlue 色族用暖粉 Color(0xFFE8B4A8),中途醒显眼;track 用极浅蓝作 rail
- 头图分期条 / 图例自动跟着 AWAKE_TINT 走,两处口径一致
- 仅昨晚一条带 stages,其他 6 晚单色原样保留 ——
  这是为了演示视觉效果,不是生产数据形态;M2 接入真机后逐步补全其他夜晚
2026-09-10 17:25:48 +08:00
cuiluzheng 514e5ec9e2 feat: 详情页专属化 + 功能开关联动 + 睡眠时段图
三块改动同批落地,互相咬合(详情页未开启分支依赖开关联动,睡眠页依赖前两者),
拆开提交会得到编译不过的中间态,故合并为一次。

详情页专属化(spec §6.1)
- feature/detail/pages/ 七个指标各一个文件,各自决定放几张卡;
  MetricDetailScreen 只剩页头 + 周/月切换 + 按 metric 分派
- 新增 MetricRangeBar(心率区间条)、MetricThresholdStrip(血氧阈值带)、
  MetricDeviationBar(体温偏离条)、ChartAxisLabels(三图共用的 X 轴标签行)
- MetricBarChart 增 valueRange / bandRange,均值参考线泛化为 referenceLine
- 修:窄区间指标(血氧 95–99、体温 36.1–36.9)柱子被抹平成一样高,整张图无信息量
- 修:头图当前值与趋势图末点各生成各的,改为末点取自 todaySummary

功能开关联动(spec §6.3)
- MetricType 显式带 switchId,null 表示常开(计步、体温)
- MockHealthDataRepository 订阅 functionSettings,概览/历史/睡眠全部由开关算出
- 未开启的指标在概览里为 null;详情页整页走共用的 MetricDisabledCard 且不画图
- 修:血压卡因「未开启」不可点击,说明页成为死代码,用户无从知道怎么开启
- 修:压力开关默认关着而 todaySummary 一直给出 stress = 34

睡眠时段图(spec §6.2)
- 新增 SleepSession:MetricPoint 只装得下一个 Double,装不下「几点到几点」
- 新增 SleepWindowChart:每晚一行,圆角条从入睡横到醒来;横轴锚在 20:00 跨零点
  (不能用 timestamp % 86400000,那是 UTC 余数,东八区会整体平移八小时),
  轴范围由数据算出并取整到刻度线;只用一个颜色,不假装知道分期位置
- 睡眠页撤掉柱状图(条长即时长,与时段图重复),改画时段图 + 分期时长图例
- 今日睡眠时长、睡眠历史点、分期条全部由 SleepSession 派生,不再各生成各的

验证:44 个单测通过;装机核对近 7 天 / 近 30 天 / 关闭睡眠开关三种状态
2026-09-10 16:49:17 +08:00
cuiluzheng e5f935625e docs: 补录模拟器实测结果,关闭 M1 运行时验收缺口(含 consumeWindowInsets 像素级验证) 2026-09-10 14:14:18 +08:00
55 changed files with 5455 additions and 656 deletions
@@ -13,8 +13,18 @@ interface AppContainer {
val ringDeviceRepository: RingDeviceRepository
}
/** 默认容器:阶段一全部使用 Mock 数据。 */
/**
* 默认容器:阶段一全部使用 Mock 数据。
*
* 注意 [healthDataRepository] **依赖** [ringDeviceRepository] 的开关流,不是两个平行字段 ——
* 之前正是各建各的,导致功能开关与健康数据互不相干(详见 [MockHealthDataRepository])。
* 声明里保留具体类型,是为了把开关流传下去而不必向下转型。
*/
class DefaultAppContainer : AppContainer {
override val healthDataRepository: HealthDataRepository = MockHealthDataRepository()
override val ringDeviceRepository: RingDeviceRepository = MockRingDeviceRepository()
private val mockRingDeviceRepository = MockRingDeviceRepository()
override val ringDeviceRepository: RingDeviceRepository = mockRingDeviceRepository
override val healthDataRepository: HealthDataRepository =
MockHealthDataRepository(functionSettings = mockRingDeviceRepository.functionSettings)
}
@@ -2,7 +2,10 @@ package com.placeholder.ringhealth.core.common
import androidx.annotation.StringRes
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.domain.model.StressLevel
import com.placeholder.ringhealth.domain.model.stressLevelOf
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
@@ -15,6 +18,9 @@ fun formatDistance(km: Double): String = String.format(Locale.CHINA, "%.2f", km)
/** 卡路里:只做千分位,单位「千卡」由 UI 用字符串资源拼接(ledger Ruling R13)。 */
fun formatCalories(kcal: Int): String = String.format(Locale.CHINA, "%,d", kcal)
/** 活动累计分钟数:只取整,单位「分钟」由 UI 用字符串资源拼接。 */
fun formatActiveMinutes(minutes: Int): String = minutes.toString()
/** 睡眠时长:7小时32分 / 45分 / 8小时。 */
fun formatSleepDuration(minutes: Int): String {
val hours = minutes / 60
@@ -45,14 +51,119 @@ fun formatClock(timestamp: Long): String =
fun formatDate(timestamp: Long): String =
SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))
/** 数值 → 保留一位小数。详情页的统计摘要用(ledger Ruling R29)。 */
fun formatDecimal(value: Double): String = String.format(Locale.CHINA, "%.1f", value)
/** 时间戳 → 「9月10日」形式(首页顶部日期行左半部分;星期由 [weekdayResFor] 单独给)。 */
fun formatMonthDay(timestamp: Long): String =
SimpleDateFormat("M月d日", Locale.CHINA).format(Date(timestamp))
/** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */
/**
* 时间戳所在星期 → 字符串资源 id(「周四」这类简写)。
*
* 星期名属于**用户可见文案**,必须走 `strings.xml`(ledger Ruling R3),
* 所以这里只做 Calendar → 资源 id 的映射,不返回中文串本身。
* 注意 `Calendar.DAY_OF_WEEK` 是「周日 = 1」起算,与直觉的「周一 = 1」差一天。
*/
@StringRes
fun stressLevelLabelRes(stress: Int): Int = when {
stress < 30 -> R.string.stress_relaxed
stress < 60 -> R.string.stress_normal
stress < 80 -> R.string.stress_medium
else -> R.string.stress_high
fun weekdayResFor(timestamp: Long): Int = when (calendarField(timestamp, Calendar.DAY_OF_WEEK)) {
Calendar.MONDAY -> R.string.weekday_monday
Calendar.TUESDAY -> R.string.weekday_tuesday
Calendar.WEDNESDAY -> R.string.weekday_wednesday
Calendar.THURSDAY -> R.string.weekday_thursday
Calendar.FRIDAY -> R.string.weekday_friday
Calendar.SATURDAY -> R.string.weekday_saturday
else -> R.string.weekday_sunday
}
/**
* 时间戳所在时段 → 问候语字符串资源 id。
*
* 按时段取文案而不是拼字符串,文案本身仍全在 `strings.xml` 里(Ruling R3)。
* 分档:[GREETING_NIGHT_START]–[GREETING_MORNING_START] 为「夜深了」,
* 其余依次为早上/下午/晚上。
*/
@StringRes
fun greetingResFor(timestamp: Long): Int {
val hour = calendarField(timestamp, Calendar.HOUR_OF_DAY)
return when {
hour < GREETING_MORNING_START -> R.string.home_greeting_night
hour < GREETING_AFTERNOON_START -> R.string.home_greeting_morning
hour < GREETING_EVENING_START -> R.string.home_greeting_afternoon
hour < GREETING_NIGHT_START -> R.string.home_greeting_evening
else -> R.string.home_greeting_night
}
}
/** 一天 1440 分钟。[minutesOfDay] 与 [formatMinuteOfDay] 的取值范围。 */
private const val MINUTES_PER_DAY = 24 * 60
/** 5 点起算「早上好」。 */
private const val GREETING_MORNING_START = 5
/** 12 点起算「下午好」。 */
private const val GREETING_AFTERNOON_START = 12
/** 18 点起算「晚上好」。 */
private const val GREETING_EVENING_START = 18
/** 23 点起算「夜深了」。 */
private const val GREETING_NIGHT_START = 23
/** 取时间戳的某个日历字段。抽出来是为了 [weekdayResFor] / [greetingResFor] 共用一份 Calendar 构造。 */
private fun calendarField(timestamp: Long, field: Int): Int =
Calendar.getInstance().apply { timeInMillis = timestamp }.get(field)
/**
* 时间戳 → 当天 0 点起的分钟数(0–1439),按**设备本地时区**算。
*
* 睡眠时段图的横轴是「几点」而不是「哪天」,需要这个纯时间量。
* 不能用 `timestamp % 86_400_000 / 60_000` —— 那是 UTC 的余数,
* 在东八区会把 23:43 算成 15:43,整张图会平移八小时,
* 而且平移后每个条看起来都还挺合理,不会有人发现。
*/
fun minutesOfDay(timestamp: Long): Int =
calendarField(timestamp, Calendar.HOUR_OF_DAY) * 60 + calendarField(timestamp, Calendar.MINUTE)
/** 当天第几分钟(0–1439)→「HH:mm」。上一条的逆运算,给时段图的横轴刻度用。 */
fun formatMinuteOfDay(minuteOfDay: Int): String {
val normalized = ((minuteOfDay % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
return String.format(Locale.CHINA, "%02d:%02d", normalized / 60, normalized % 60)
}
/**
* 数值 → 保留 [decimals] 位小数。详情页的统计摘要用(ledger Ruling R29)。
*
* 小数位由调用方按指标给:体温 36.5 保留一位才有意义,而心率 78.0、步数 8426.0
* 这种尾随的 `.0` 只会让人以为精度很高,实际是假精度。
*/
fun formatDecimal(value: Double, decimals: Int = 1): String =
String.format(Locale.CHINA, "%.${decimals}f", value)
/**
* 数值 → 带正负号、保留 [decimals] 位小数,如 `+0.4` / `-0.3`。
*
* 体温页的「较基线」与「偏离量」用它:正负号是它一半的信息,
* 走 [formatDecimal] 的话 +0.4 会显示成「0.4」,看着像没有偏离。
* 零统一带正号(`+0.0`)—— `-0.0` 在浮点里存在,但它不是「低了 0.0」的意思。
*/
fun formatSignedDecimal(value: Double, decimals: Int = 1): String {
val text = String.format(Locale.CHINA, "%.${decimals}f", kotlin.math.abs(value))
return if (value < 0) "-$text" else "+$text"
}
/** 时间戳 → 「9/10」形式。详情页柱状图的 X 轴刻度用(月度 30 根柱时日期要尽量窄)。 */
fun formatMonthDayNumeric(timestamp: Long): String =
SimpleDateFormat("M/d", Locale.CHINA).format(Date(timestamp))
/**
* 压力等级 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。
*
* 分档阈值不在这里 —— 它属于领域判断,在 `domain/model/MetricReference.kt` 的
* [stressLevelOf] 里,本函数只负责「等级 → 文案」这一段映射。
*/
@StringRes
fun stressLevelLabelRes(level: StressLevel): Int = when (level) {
StressLevel.RELAXED -> R.string.stress_relaxed
StressLevel.NORMAL -> R.string.stress_normal
StressLevel.MEDIUM -> R.string.stress_medium
StressLevel.HIGH -> R.string.stress_high
}
/** [stressLevelLabelRes] 的便捷重载:直接吃压力值,省得调用方自己转一次等级。 */
@StringRes
fun stressLevelLabelRes(stress: Int): Int = stressLevelLabelRes(stressLevelOf(stress.toDouble()))
@@ -0,0 +1,29 @@
package com.placeholder.ringhealth.core.common
/**
* 睡眠时段的横轴锚点:**晚上 20:00**。
*
* 一晚睡眠横跨零点(23:43 入睡、07:37 起床),横轴不能直接用「当天第几分钟」——
* 那样 23:43 是 1423、07:37 是 457,画出来入睡点跑到了起床点右边,整条时段是反的。
* 以 20:00 为界重新起算,两个时刻都落在 0–960 这一段里,先后顺序就对了。
*/
const val SLEEP_ANCHOR_MINUTE_OF_DAY = 20 * 60
/** 一天 1440 分钟;[SLEEP_ANCHOR_MINUTE_OF_DAY] 加上它就是把时间推过零点。 */
private const val MINUTES_PER_DAY = 24 * 60
/**
* 时间戳 → 「距最近一次 20:00 过了多少分钟」,跨过零点继续累加(0–1439)。
*
* 20:00–24:00 之间是 0–239,次日 00:00–20:00 之间是 240–1439。
* 睡眠时段图与睡眠统计都靠它把「几点睡、几点起」变成可以比较、可以求平均的连续量 ——
* 直接对 `HH:mm` 求平均会把 23:00 和 01:00 平均成 12:00,正好是睡得最香的时候。
*/
fun minutesSinceSleepAnchor(timestamp: Long): Int {
val offset = minutesOfDay(timestamp) - SLEEP_ANCHOR_MINUTE_OF_DAY
return if (offset >= 0) offset else offset + MINUTES_PER_DAY
}
/** [minutesSinceSleepAnchor] 的逆运算:偏移量 → 「23:43」这样的时刻文案。 */
fun formatSleepAnchorOffset(offset: Int): String =
formatMinuteOfDay(SLEEP_ANCHOR_MINUTE_OF_DAY + offset)
@@ -2,17 +2,42 @@ package com.placeholder.ringhealth.core.designsystem
import androidx.compose.ui.graphics.Color
/** 品牌主色(健康绿,仿小米运动健康首页大环)。 */
/** 品牌主色(健康绿)。首页大环、主按钮与选中态都用它。 */
val BrandGreen = Color(0xFF34C759)
val BrandGreenDark = Color(0xFF248A3D)
/**
* 小字号绿:用于文字与图标。
*
* 与 [BrandGreen] 的区别是**对比度**而不是色相 —— `#34C759` 在 12sp 的文字上
* 对白底只有约 2.0:1,远低于 WCAG AA 要求的 4.5:1,读起来发虚。文字一律用这个更深的值。
*/
val BrandGreenText = Color(0xFF12894A)
/** 页面与卡片背景。 */
val AppBackground = Color(0xFFF5F6F8)
val AppBackground = Color(0xFFEEF1F6)
val CardSurface = Color(0xFFFFFFFF)
val TextPrimary = Color(0xFF1C1C1E)
val TextSecondary = Color(0xFF8E8E93)
val TextPrimary = Color(0xFF14161C)
val TextSecondary = Color(0xFF8A8F98)
val DividerColor = Color(0xFFEFEFF4)
/**
* 卡片阴影。
*
* 不用纯黑:纯黑阴影叠在冷灰背景上会发脏、发绿。这两个值是从背景色相推出来的深蓝灰,
* 低透明度(6% / 8%),叠出来是「卡片浮起来」而不是「卡片镶了黑边」。
*
* 注意:`Modifier.shadow` 的 ambientColor / spotColor 仅在 **Android 9(API 28)及以上**生效,
* 本应用 minSdk 26,在 26/27 上会退化成系统默认黑阴影 —— 属于可接受的降级,不影响布局。
*/
val CardShadowAmbient = Color(0x101B2A4A)
val CardShadowSpot = Color(0x141B2A4A)
/** 底部导航选中态:浅绿药丸底 + 深绿图标文字。 */
val NavIndicator = Color(0xFFDCF3E4)
val NavSelected = BrandGreenText
val NavUnselected = Color(0xFF9AA0A6)
/** 各健康指标的语义色,全应用统一引用。 */
val MetricGreen = Color(0xFF34C759) // 步数
val MetricRed = Color(0xFFFF3B30) // 心率
@@ -0,0 +1,49 @@
package com.placeholder.ringhealth.core.designsystem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.filled.Bloodtype
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Spa
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import com.placeholder.ringhealth.domain.model.MetricType
/**
* 指标的视觉身份:图标 + 语义色。
*
* **一个指标只有一处定义。** 此前这份对应关系抄了两份 —— 一份写在首页各指标卡的字面量里,
* 一份是详情页里一个从没被调用过的 `metricTint`。两份都在时不会报错,只会在改了一处之后
* 让同一个指标在首页和详情页显示成两种颜色,而且看不出是哪边错了。
*
* 放在 `core/designsystem` 而不是某个 `feature/` 下,是为了让首页和七个详情页都能直接用,
* 不必让 `feature/home` 去 import `feature/detail`。色板的唯一来源仍是设计文档 §7.2 与
* `Color.kt`,本文件只做映射,不新造颜色。
*/
/** 指标类型 → 图标。 */
fun metricIcon(type: MetricType): ImageVector = when (type) {
// 步数在首页不占指标卡(它有一张跨两列的进度环大卡),但详情页的「未开启」说明卡
// 需要它,映射因此保持完整 —— 七个指标一个不缺。
MetricType.STEP -> Icons.AutoMirrored.Filled.DirectionsWalk
MetricType.HEART_RATE -> Icons.Filled.Favorite
MetricType.SLEEP -> Icons.Filled.Bedtime
MetricType.BLOOD_OXYGEN -> Icons.Filled.Bloodtype
MetricType.STRESS -> Icons.Filled.Spa
MetricType.TEMPERATURE -> Icons.Filled.Thermostat
MetricType.BLOOD_PRESSURE -> Icons.Filled.WaterDrop
}
/** 指标类型 → 语义色(步数=绿、心率=红、睡眠=蓝、血氧=青、压力=紫、体温=橙、血压=黄)。 */
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
}
@@ -3,16 +3,22 @@ package com.placeholder.ringhealth.core.designsystem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val RingHealthColors = lightColorScheme(
primary = BrandGreen,
onPrimary = androidx.compose.ui.graphics.Color.White,
onPrimary = Color.White,
secondary = BrandGreenDark,
background = AppBackground,
onBackground = TextPrimary,
surface = CardSurface,
onSurface = TextPrimary,
surfaceVariant = DividerColor,
// secondaryContainer 必须显式覆盖:它在 Material 3 里同时是
// 「底部导航选中态药丸底」和「FilterChip 选中底」的取色来源。不写的话会落到 M3 默认的
// 淡紫 #E8DEF8 —— 于是出现「淡紫底 + 绿色文字」的撞色(M1 验收时被当成正常记录了下来)。
secondaryContainer = NavIndicator,
onSecondaryContainer = NavSelected,
)
/**
@@ -5,17 +5,35 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/** 大号数值样式:首页大环与详情页主数字。 */
/**
* 大号数值样式:首页大环与详情页主数字。
*
* `fontFeatureSettings = "tnum"` 打开等宽数字 —— 数值变化时每个字形宽度一致,
* 数字不会左右跳动。字体不支持时是空操作,无副作用。
* 负字距(-1sp)让多位数字排得更紧,是让大号数字「显贵」的关键一步。
*/
val MetricValueStyle = TextStyle(
fontSize = 40.sp,
fontSize = 44.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-1).sp,
fontFeatureSettings = "tnum",
color = TextPrimary,
)
/** 中等数值样式:指标卡主数字。 */
/** 中等数值样式:指标卡主数字。同样开等宽数字,避免网格里各卡数字宽度参差。 */
val MetricTileValueStyle = TextStyle(
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.3).sp,
fontFeatureSettings = "tnum",
color = TextPrimary,
)
/** 首页问候语:一屏里字号最大的文字,承担「这是哪一页」的身份作用。 */
val GreetingStyle = TextStyle(
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.4).sp,
color = TextPrimary,
)
@@ -0,0 +1,58 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import com.placeholder.ringhealth.core.designsystem.TextSecondary
/**
* 图表 X 轴刻度行。柱状图、阈值带、偏离条三张图共用,免得同一段对齐逻辑抄三份。
*
* 两种排布由 [labels] 与 [slotCount] 的关系决定:
* - 数量相等(周视图 7 根柱标 7 个星期)时按槽位等分,刻度正对着它那根柱子;
* - 数量更少(月视图 30 根柱只标 7 个日期)时两端对齐铺开 —— 此时槽宽只有约 10dp,
* 若也按槽位等分,每个文字会被压进 10dp 宽的格子里截断成一两个字。
*
* @param labels 刻度文字,从左到右。
* @param slotCount 数据槽位数量(柱数 / 点数)。
* @param modifier 外部修饰符。
*/
@Composable
internal fun ChartAxisLabels(
labels: List<String>,
slotCount: Int,
modifier: Modifier = Modifier,
) {
if (labels.isEmpty()) return
if (slotCount > 0 && labels.size == slotCount) {
Row(modifier = modifier.fillMaxWidth()) {
labels.forEach { label -> AxisLabel(text = label, modifier = Modifier.weight(1f)) }
}
} else {
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
labels.forEach { label -> AxisLabel(text = label) }
}
}
}
/**
* 单个刻度文字。居中、单行、不换行 ——
* 换行会把刻度行撑成两行高,柱子与刻度的对齐就散了。
*/
@Composable
private fun AxisLabel(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
modifier = modifier,
style = MaterialTheme.typography.labelSmall,
color = TextSecondary,
textAlign = TextAlign.Center,
maxLines = 1,
softWrap = false,
)
}
@@ -3,40 +3,73 @@ package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.designsystem.CardShadowAmbient
import com.placeholder.ringhealth.core.designsystem.CardShadowSpot
/** 卡片圆角。全应用统一,改这里就是改所有卡片。 */
val HealthCardShape = RoundedCornerShape(20.dp)
/** 卡片默认阴影高度。 */
private val CardElevation = 5.dp
/**
* 统一卡片容器:白底、大圆角、轻阴影。所有页面内容块都应包在它里面。
* 统一卡片容器:白底、大圆角、柔和阴影。所有页面内容块都应包在它里面。
*
* **为什么不用 `Card` + `CardDefaults.cardElevation`:**
* M3 的 `Card` 在 1~2dp 时的阴影几乎看不见,屏幕上就是一排纯白方块贴在灰底上,
* 没有纵深,整屏会「糊」成一片。这里改用 `Modifier.shadow` 并传入**带色相的深蓝灰**
* (见 [CardShadowAmbient] / [CardShadowSpot])而不是纯黑 —— 纯黑阴影叠在冷灰背景上会发脏。
* 同时 `tonalElevation = 0.dp` 关掉 M3 的 surface tint,保证卡片在任何高度都是纯白。
*
* 颜色取自主题的 surface(由 [com.placeholder.ringhealth.core.designsystem.RingHealthTheme]
* 映射为设计系统的 CardSurface),因此升级深色模式时无需改动本文件。
*
* @param modifier 外部修饰符,会在 fillMaxWidth 之前生效。
* @param onClick 非空时整卡可点击(如水/体重等可跳转的卡片);为空则纯展示。
* @param content 卡片内容,作用域为 ColumnScope,默认内边距 16dp。
* @param onClick 非空时整卡可点击(如可跳转的卡片);为空则纯展示。
* @param contentPadding 卡片内边距,默认 16dp;网格里的紧凑卡片可调小。
* @param content 卡片内容,作用域为 ColumnScope。
*/
@Composable
fun HealthCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
contentPadding: PaddingValues = PaddingValues(16.dp),
content: @Composable ColumnScope.() -> Unit,
) {
// 仅当传入点击回调时才挂 clickable,避免给纯展示卡片带来无谓的水波纹与无障碍焦点。
val clickModifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier
Card(
modifier = modifier.fillMaxWidth().then(clickModifier),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
Surface(
modifier = modifier
.fillMaxWidth()
// clip = false 让阴影画在卡片轮廓之外;卡片自身的形状由下面的 shape 参数裁剪。
.shadow(
elevation = CardElevation,
shape = HealthCardShape,
clip = false,
ambientColor = CardShadowAmbient,
spotColor = CardShadowSpot,
)
.then(clickModifier),
shape = HealthCardShape,
color = MaterialTheme.colorScheme.surface,
// 关掉 colorSurface 的 tonal 叠加,否则不同高度的卡片会呈现不同深浅的白。
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Column(modifier = Modifier.padding(16.dp), content = content)
Column(modifier = Modifier.padding(contentPadding), content = content)
}
}
/** 供需要显式指定阴影的场景复用(如自定义容器)。 */
internal val HealthCardElevation: Dp = CardElevation
@@ -0,0 +1,57 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** 徽章底色相对图标色的不透明度:淡到足以让图标本身仍是主体,又足以看出那是一块色底。 */
private const val BADGE_ALPHA = 0.14f
/**
* 圆形色底图标徽章:一层同色淡底 + 居中图标。
*
* 首页指标卡与「我的」页入口共用它。抽出来的直接原因是**两处原本不一致** ——
* 首页用色底圆形徽章、我的页用裸灰图标,同一套 App 里两种图标处理方式。
* 更实际的原因是:图标是纯装饰,给它配一块淡色底之后,一行「图标 + 文字」的入口
* 立刻有了视觉重心,而不必把图标本身调大调粗去抢注意力。
*
* @param icon 图标。
* @param tint 图标色,也是底色来源(底色取它的 [BADGE_ALPHA] 透明度)。
* @param modifier 外部修饰符。
* @param badgeSize 圆形底的直径。
* @param iconSize 图标本身的大小,需明显小于 [badgeSize] 才不会顶满圆。
*/
@Composable
fun IconBadge(
icon: ImageVector,
tint: Color,
modifier: Modifier = Modifier,
badgeSize: Dp = 30.dp,
iconSize: Dp = 17.dp,
) {
Box(
modifier = modifier
.size(badgeSize)
.clip(CircleShape)
.background(tint.copy(alpha = BADGE_ALPHA)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
// 徽章是纯装饰,语义由旁边的标题文字承担,避免无障碍重复朗读。
contentDescription = null,
tint = tint,
modifier = Modifier.size(iconSize),
)
}
}
@@ -1,6 +1,8 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
@@ -9,53 +11,149 @@ import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.designsystem.DividerColor
import com.placeholder.ringhealth.core.designsystem.TextSecondary
/** 柱状图基线相对灰线的粗细,1dp 在 420dpi 下是可见但不抢戏的一根细线。 */
private val BASELINE_WIDTH = 1.dp
/** 参考线的虚线节拍:6dp 实线 + 5dp 空白。 */
private val REFERENCE_DASH = floatArrayOf(6f, 5f)
/**
* 极简柱状图(Compose Canvas 手绘,无第三方依赖)。
* values 为空时不绘制;被高亮的一根用纯色,其余半透明。
* 柱状图(Compose Canvas 手绘,无第三方依赖)。
*
* 由三部分叠成:**基线** + **参考虚线** + **胶囊柱**,下方另有一行 X 轴刻度文字。
*
* 早先只有一排浮空的柱子 —— 没有基线,柱子像悬在半空;没有刻度,看不出哪根是哪天;
* 没有参照,也读不出「今天比平时高还是低」。三个都补上之后这张图才开始承载信息。
*
* 几何说明:画布横向按 values.size 等分得到每个槽位 slot,柱宽取槽位的 65%,
* 余下 35% 作为间隔,柱子因此在槽内水平居中;柱高 = 画布高 × (value / maxValue),
* 底边贴合画布底边(y = 画布高 - 柱高),圆角半径取柱宽一半,形成胶囊柱。
* maxValue 下限为 1f,避免「全为 0」时出现 0 除。
* 余下 35% 作为间隔,柱子因此在槽内水平居中;柱高 = 画布高 ×
* (value - 窗口下限) / (窗口上限 - 窗口下限),底边贴合基线(y = 画布高),
* 圆角半径取柱宽一半,形成胶囊柱。窗口见 [valueRange]。
*
* @param values 各柱数值(须为非负),按时间顺序从左到右。
* @param color 柱子颜色;高亮柱用纯色,其余用其 30% 透明度。
* @param modifier 外部修饰符。
* @param barHeight 画布高度,即最高柱的高度。
* @param highlightLast 为 true 时高亮最后一根(如「今天」),否则高亮数值最大的一根。
* @param referenceLine 参考线的位置(与 [values] 同单位),如均值或目标值;为 null 则不画。
* @param axisLabels X 轴刻度文字。数量与 [values] 一致时按槽位一一对齐;
* 数量更少时(月度 30 根柱只标 5 个刻度)改为两端对齐铺开。
* @param barColorOf 按数值决定单根柱子的颜色;返回 null 表示用 [color](高亮柱纯色、其余 30%)。
* 压力页用它按等级给柱子着色。
* @param valueRange 取景窗口(纵轴范围)。**窄区间指标必须传它** ——
* 见下方 KDoc 的说明,不传会让血氧/体温这类指标的柱子全部一样高。
* @param bandRange 正常区间带(与 [values] 同单位),在柱子后面铺一层极淡的指标色。
* 心率页用它标出 60–100 的正常静息区间 —— 柱子高过或低于这条带子,一眼就知道出没出界。
*/
@Composable
fun MetricBarChart(
values: List<Float>,
color: Color,
modifier: Modifier = Modifier,
barHeight: Dp = 120.dp,
barHeight: Dp = 170.dp,
highlightLast: Boolean = true,
referenceLine: Float? = null,
axisLabels: List<String> = emptyList(),
barColorOf: ((Float) -> Color?)? = null,
valueRange: ClosedFloatingPointRange<Float>? = null,
bandRange: ClosedFloatingPointRange<Float>? = null,
) {
Canvas(modifier = modifier.fillMaxWidth().height(barHeight)) {
if (values.isEmpty()) return@Canvas
val maxValue = values.max().coerceAtLeast(1f)
val gapRatio = 0.35f
val slot = size.width / values.size
val barWidth = slot * (1f - gapRatio)
// 注意:maxValue 已被 coerceAtLeast(1f) 抬高,当所有值都小于 1 时
// values.indexOf(maxValue) 会返回 -1 导致没有任何柱子被高亮,
// 因此这里改为直接取最大值的下标。
val highlightIndex = if (highlightLast) values.lastIndex else values.indices.maxByOrNull { values[it] } ?: 0
Column(modifier = modifier.fillMaxWidth()) {
Canvas(modifier = Modifier.fillMaxWidth().height(barHeight)) {
if (values.isEmpty()) return@Canvas
// 纵轴:默认从 0 到最大值,给了 valueRange 就按窗口取。
// 窄区间指标(血氧 95–99、体温 36.1–36.9)不传窗口时,所有柱子都落在满高的
// 96%~100% 之间,七根柱子一样高,整张图读不出任何东西 —— 这不是「观感差」,是数据被抹平了。
val low = valueRange?.start ?: 0f
val high = valueRange?.endInclusive ?: values.max().coerceAtLeast(1f)
// 窗口退化成一条线时直接不画:否则下面的 (value - low) / span 会除零得到 NaN。
val span = high - low
if (span <= 0f) return@Canvas
values.forEachIndexed { index, value ->
val ratio = (value / maxValue).coerceIn(0f, 1f)
val h = size.height * ratio
val isHighlight = index == highlightIndex
drawRoundRect(
color = if (isHighlight) color else color.copy(alpha = 0.3f),
topLeft = Offset(x = index * slot + (slot - barWidth) / 2f, y = size.height - h),
size = Size(width = barWidth, height = h),
cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f),
val gapRatio = 0.35f
val slot = size.width / values.size
val barWidth = slot * (1f - gapRatio)
// 注意:不能拿 `values.indexOf(maxValue)` 找高亮柱 —— 无窗口时 high 被
// coerceAtLeast(1f) 抬高过,当所有值都小于 1 时 indexOf 返回 -1,没有柱子会被高亮。
val highlightIndex = if (highlightLast) values.lastIndex else values.indices.maxByOrNull { values[it] } ?: 0
val baselineWidthPx = BASELINE_WIDTH.toPx()
// 基线先画,后画的柱子会压住它两端,视觉上柱子是「立在线上的」。
// y 取 height - 半线宽,否则 1dp 的线会有一半落在画布外被裁掉。
drawLine(
color = DividerColor,
start = Offset(x = 0f, y = size.height - baselineWidthPx / 2f),
end = Offset(x = size.width, y = size.height - baselineWidthPx / 2f),
strokeWidth = baselineWidthPx,
)
/** 值 → 画布纵坐标。窗口换算只写这一处,柱子/参考线/区间带共用。 */
fun yOf(v: Float): Float =
size.height - size.height * ((v - low) / span).coerceIn(0f, 1f)
// 正常区间带最先画,柱子压在上面。两层表达:极淡的填充 + 上下各一条边界线。
// 只有填充时,一整块横贯全宽的颜色块视觉重量会盖过柱子 —— 心率页试过 10% 的填充,
// 结果是「一堵粉墙,柱子贴在墙上」,看不出柱子是穿进带子里还是没够到。
// 加上两条边界线之后,带子的范围有了明确的边,「有没有出界」才读得出来。
if (bandRange != null) {
val top = yOf(bandRange.endInclusive)
val bottom = yOf(bandRange.start)
if (bottom > top) {
drawRect(
color = color.copy(alpha = 0.06f),
topLeft = Offset(0f, top),
size = Size(size.width, bottom - top),
)
listOf(top, bottom).forEach { y ->
drawLine(
color = color.copy(alpha = 0.35f),
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = BASELINE_WIDTH.toPx(),
)
}
}
}
// 参考线画在柱子下方:它是一条读数参照,不该盖住任何一根柱子。
// 用中性灰而不是柱色:参考线不是数据,跟着柱色走会被当成「第 8 根柱子」。
if (referenceLine != null) {
val y = yOf(referenceLine)
drawLine(
color = REFERENCE_LINE_COLOR,
start = Offset(x = 0f, y = y),
end = Offset(x = size.width, y = y),
strokeWidth = baselineWidthPx,
pathEffect = PathEffect.dashPathEffect(REFERENCE_DASH),
)
}
values.forEachIndexed { index, value ->
val h = size.height - yOf(value)
val isHighlight = index == highlightIndex
// 调用方指定了按值取色就优先用它;返回 null 表示「这根交回默认规则」。
val barColor = barColorOf?.invoke(value)
?: if (isHighlight) color else color.copy(alpha = 0.3f)
drawRoundRect(
color = barColor,
topLeft = Offset(x = index * slot + (slot - barWidth) / 2f, y = size.height - h),
size = Size(width = barWidth, height = h),
cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f),
)
}
}
if (axisLabels.isEmpty()) return@Column
Spacer(modifier = Modifier.height(6.dp))
ChartAxisLabels(labels = axisLabels, slotCount = values.size)
}
}
/** 参考虚线的颜色:中性灰,与任何指标色都不撞,一眼能分得出它是参照而非数据。 */
private val REFERENCE_LINE_COLOR = TextSecondary.copy(alpha = 0.65f)
@@ -0,0 +1,108 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.designsystem.TextSecondary
/** 画布高度。偏离条是纵向的,比柱状图矮一点就够 —— 半幅只有 ±1℃。 */
private val DEVIATION_HEIGHT = 132.dp
/** 每根「棒棒糖」的杆粗。 */
private val STEM_WIDTH = 2.dp
/** 杆顶圆点的直径。 */
private val DOT_SIZE = 9.dp
/** 基线的虚线节拍,与柱状图参考线一致。 */
private val BASELINE_DASH = floatArrayOf(6f, 5f)
/**
* 偏离条:一条基线 + 每天一根从基线出发的「棒棒糖」,向上偏高、向下偏低。
*
* 体温页的头图。体温的特殊之处在于**绝对值没有意义** —— 36.5 和 36.8 都属于正常,
* 真正要读的是「比自己的基线高还是低」。柱状图从 y=0 起画,36.1–36.9 的差别会被压成
* 一条平带(这正是改造前体温页的样子);即使加了取景窗口,柱子仍然是从底部往上长,
* 视觉上仍在暗示「越高越好」,而体温不是这样。
*
* 把基线画在正中间、让值向两侧偏离,极性才读得出来:偏上一片暖色、偏下一片冷色。
* 上方用 [aboveColor]、下方用 [belowColor] —— 这是一套**页内发散配色**,
* 与「一个指标一个专属色」的规则不冲突:它在同一张图里表达方向,不是另一个指标的身份。
*
* @param values 各天的值,按时间顺序从左到右。
* @param baseline 基线值(体温取 36.5),画在画布垂直中央。
* @param span 半幅,即基线到画布上下边缘代表多少(体温取 1.0℃)。
* @param aboveColor 高于基线的颜色。
* @param belowColor 低于基线的颜色。
* @param modifier 外部修饰符。
* @param axisLabels X 轴刻度文字。
*/
@Composable
fun MetricDeviationBar(
values: List<Double>,
baseline: Double,
span: Double,
aboveColor: Color,
belowColor: Color,
modifier: Modifier = Modifier,
axisLabels: List<String> = emptyList(),
) {
Column(modifier = modifier.fillMaxWidth()) {
Canvas(modifier = Modifier.fillMaxWidth().height(DEVIATION_HEIGHT)) {
if (values.isEmpty()) return@Canvas
if (span <= 0.0) return@Canvas
val radius = DOT_SIZE.toPx() / 2f
// 可用半高:留出一个圆点的半径,偏离到半幅的点才不会被画布边缘裁掉。
val halfHeight = size.height / 2f - radius
if (halfHeight <= 0f) return@Canvas
val centerY = size.height / 2f
val slot = size.width / values.size
// 基线:体温的参照就是它,必须画得比数据更「静」—— 中性灰 + 虚线。
drawLine(
color = TextSecondary.copy(alpha = 0.55f),
start = Offset(0f, centerY),
end = Offset(size.width, centerY),
strokeWidth = 1.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(BASELINE_DASH),
)
values.forEachIndexed { index, value ->
val cx = index * slot + slot / 2f
val deviation = value - baseline
// 超出半幅的值夹到边缘,而不是丢弃 —— 丢弃会让「那天没有数据」和
// 「那天偏得离谱」看起来一模一样。
val ratio = (deviation / span).coerceIn(-1.0, 1.0).toFloat()
val y = centerY - halfHeight * ratio
val tint = if (deviation >= 0.0) aboveColor else belowColor
// 杆从基线长到点,点画在杆顶。比单纯一个点更能读出「从基线偏出去多少」。
drawLine(
color = tint.copy(alpha = 0.45f),
start = Offset(cx, centerY),
end = Offset(cx, y),
strokeWidth = STEM_WIDTH.toPx(),
cap = StrokeCap.Round,
)
drawCircle(color = tint, radius = radius, center = Offset(cx, y))
}
}
if (axisLabels.isEmpty()) return@Column
Spacer(modifier = Modifier.height(6.dp))
ChartAxisLabels(labels = axisLabels, slotCount = values.size)
}
}
@@ -0,0 +1,91 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.designsystem.CardSurface
import com.placeholder.ringhealth.core.designsystem.DividerColor
/** 轨道的粗细。10dp 在 420dpi 下是一条能看清、又不至于喧宾夺主的带子。 */
private val TRACK_HEIGHT = 10.dp
/** 当前值圆点的直径。比轨道粗一截,让人一眼找到「我在哪」。 */
private val MARKER_SIZE = 18.dp
/** 圆点外的白圈宽度。 */
private val MARKER_RING = 3.dp
/**
* 区间条:一根横轴 + 一段高亮区间 + 一个当前值圆点。
*
* 心率页的头图。它回答的是「这个数落在哪儿」,而柱状图回答的是「这几天怎么变的」——
* 前者是单值判读,后者是趋势。心率的单值判读基准是「正常静息 60–100」,
* 一根光秃秃的数字「72」看不出这件事,落在区间条上就一目了然。
*
* 几何说明:横轴按 [axis] 线性映射到画布宽度,但左右各内缩一个圆点半径 ——
* 否则贴边的值(比如 40 或 120)圆点会有一半落在画布外被裁掉。
*
* @param value 当前值,须与 [axis]、[normal] 同单位。
* @param axis 横轴范围,通常比 [normal] 左右各留余量(见 `MetricReference.HEART_RATE_AXIS`)。
* @param normal 正常区间,在轨道上高亮出来。
* @param color 指标色,用于高亮段与圆点。
* @param modifier 外部修饰符。
*/
@Composable
fun MetricRangeBar(
value: Double,
axis: ClosedFloatingPointRange<Double>,
normal: ClosedFloatingPointRange<Double>,
color: Color,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier.fillMaxWidth().height(MARKER_SIZE)) {
val span = (axis.endInclusive - axis.start).toFloat()
// 轴退化成一条线时直接不画,否则下面除法会得到 NaN。
if (span <= 0f) return@Canvas
val radius = MARKER_SIZE.toPx() / 2f
// 可用宽度左右各让出一个半径,圆点因此永远不会被画布边缘切掉。
val usable = size.width - radius * 2f
if (usable <= 0f) return@Canvas
/** 值 → 画布横坐标。 */
fun xOf(v: Double): Float =
radius + usable * ((v - axis.start).toFloat() / span).coerceIn(0f, 1f)
val centerY = size.height / 2f
val trackH = TRACK_HEIGHT.toPx()
val trackRadius = trackH / 2f
// 轨道:整条横轴的底
drawRoundRect(
color = DividerColor,
topLeft = Offset(radius, centerY - trackRadius),
size = Size(usable, trackH),
cornerRadius = CornerRadius(trackRadius, trackRadius),
)
// 正常区间高亮。宽度下限取一个轨道高,免得区间极窄时退化成看不见的一条。
val left = xOf(normal.start)
val width = (xOf(normal.endInclusive) - left).coerceAtLeast(trackH)
drawRoundRect(
color = color.copy(alpha = 0.30f),
topLeft = Offset(left, centerY - trackRadius),
size = Size(width, trackH),
cornerRadius = CornerRadius(trackRadius, trackRadius),
)
// 当前值圆点:先铺一圈与卡片同色的底,再叠实心点。
// 少了这圈底,点落在高亮段上时两个同色系会糊成一片,看不出点的边界。
val center = Offset(xOf(value), centerY)
drawCircle(color = CardSurface, radius = radius, center = center)
drawCircle(color = color, radius = radius - MARKER_RING.toPx(), center = center)
}
}
@@ -0,0 +1,113 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.designsystem.TextSecondary
/** 阈值带的高度。比柱状图矮 —— 它只承载一条窄区间,撑高只会留出空白。 */
private val STRIP_HEIGHT = 108.dp
/** 每天那个点的直径。 */
private val DOT_SIZE = 11.dp
/** 阈值虚线的节拍,与柱状图的参考线保持一致(6dp 实线 + 5dp 空白)。 */
private val THRESHOLD_DASH = floatArrayOf(6f, 5f)
/**
* 阈值带:一条窄区间 + 一条阈值虚线 + 每天一个点。
*
* 血氧页的头图。它和柱状图的区别是**回答的问题不同**:柱状图比的是「谁高谁矮」,
* 而血氧要判的是「有没有掉到线下」—— 一条 95% 的线画在那里,点在线下方就是偏低。
* 用柱状图表达这件事会很别扭:柱高按 0–100 画全是满格,按取景窗口画又读不出绝对位置。
*
* 点用圆点而不是细柱,是为了在 30 天的月视图里依然能分辨 —— 细柱互相挨着会连成一片色块。
*
* @param values 各天的值,按时间顺序从左到右。
* @param axis 纵轴范围(注意这是**纵向**映射),见 `MetricReference.BLOOD_OXYGEN_FLOOR`。
* @param threshold 阈值线的位置,低于它的点用 [warningColor] 画。
* @param color 达标点的颜色,通常是血氧的指标色。
* @param warningColor 低于阈值时的警示色。
* @param modifier 外部修饰符。
* @param axisLabels X 轴刻度文字,数量与 [values] 一致时按槽位对齐,否则两端铺开。
*/
@Composable
fun MetricThresholdStrip(
values: List<Double>,
axis: ClosedFloatingPointRange<Double>,
threshold: Double,
color: Color,
warningColor: Color,
modifier: Modifier = Modifier,
axisLabels: List<String> = emptyList(),
) {
Column(modifier = modifier.fillMaxWidth()) {
Canvas(modifier = Modifier.fillMaxWidth().height(STRIP_HEIGHT)) {
if (values.isEmpty()) return@Canvas
val span = (axis.endInclusive - axis.start).toFloat()
// 轴退化成一条线时直接不画,否则下面除法会得到 NaN。
if (span <= 0f) return@Canvas
val radius = DOT_SIZE.toPx() / 2f
// 纵向可用高度上下各让出一个半径,贴轴的点才不会被裁掉。
val usableY = size.height - radius * 2f
if (usableY <= 0f) return@Canvas
val slot = size.width / values.size
/** 值 → 画布纵坐标(值越大越靠上)。 */
fun yOf(v: Double): Float =
radius + usableY * (1f - ((v - axis.start).toFloat() / span).coerceIn(0f, 1f))
// 达标区:阈值以上铺一层极淡的指标色,扫一眼就知道「上面这块是安全的」。
val thresholdY = yOf(threshold)
drawRoundRect(
color = color.copy(alpha = 0.10f),
topLeft = Offset(0f, 0f),
size = Size(size.width, thresholdY.coerceAtLeast(0f)),
cornerRadius = CornerRadius(6.dp.toPx(), 6.dp.toPx()),
)
// 阈值线用中性灰:它是判读基准,不是数据,跟着指标色走会被当成「第 N+1 个点」。
drawLine(
color = TextSecondary.copy(alpha = 0.65f),
start = Offset(0f, thresholdY),
end = Offset(size.width, thresholdY),
strokeWidth = 1.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(THRESHOLD_DASH),
)
values.forEachIndexed { index, value ->
val cx = index * slot + slot / 2f
val below = value < threshold
// 偏低的点额外套一圈警示色的淡晕,在月视图里连成一串时尤其醒目。
if (below) {
drawCircle(
color = warningColor.copy(alpha = 0.25f),
radius = radius * 2.1f,
center = Offset(cx, yOf(value)),
)
}
drawCircle(
color = if (below) warningColor else color,
radius = radius,
center = Offset(cx, yOf(value)),
)
}
}
if (axisLabels.isEmpty()) return@Column
Spacer(modifier = Modifier.height(6.dp))
ChartAxisLabels(labels = axisLabels, slotCount = values.size)
}
}
@@ -2,10 +2,11 @@ package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -19,17 +20,23 @@ import com.placeholder.ringhealth.core.designsystem.MetricTileValueStyle
import com.placeholder.ringhealth.core.designsystem.TextSecondary
/**
* 单个健康指标小卡:图标 + 标题 + 数值 + 单位。
* 单个健康指标小卡:色底图标徽章 + 标题 + 数值 + 单位。
*
* 为**两列网格**设计(首页 2×3),因此刻意做得紧凑:卡片内边距 14dp、图标 17dp。
* 图标外面套一层同色浅底的圆形徽章,是为了让网格里六张卡各自带一点颜色 —— 纯白卡片 +
* 灰色小图标排成网格会显得很素,色彩只能靠数字本身,而数字是黑白的。
*
* 文案全部由调用方传入(标题/数值/单位),组件内不出现任何用户可见字面量。
* 图标为纯装饰(contentDescription = null),语义由 title 承担,避免无障碍重复朗读。
*
* @param icon 指标图标,如 Icons.Filled.DirectionsWalk。
* @param icon 指标图标,如 Icons.Filled.Favorite。
* @param iconTint 图标着色,通常取设计系统中该指标的语义色。
* @param title 指标名(由调用方从字符串资源取得)。
* @param value 主数值文本。
* @param unit 单位文本,如「步」「次/分」。
* @param unit 单位文本,如「次/分」,可为空串。
* @param modifier 外部修饰符。
* @param enabled 为 false 时数值降级为普通灰字(用于「未开启」这类占位),
* 避免「未开启」三个字用大号粗体排出来,看着比真实数值还重。
* @param onClick 点击回调;为 null 表示卡片不可点击 —— 不挂 clickable,因此既没有水波纹
* 也不会触发导航,用于「未开启」这类没有数值的指标卡(有值的卡才可跳转详情)。
*/
@@ -41,23 +48,37 @@ fun MetricTile(
value: String,
unit: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: (() -> Unit)? = null,
) {
HealthCard(modifier = modifier, onClick = onClick) {
HealthCard(
modifier = modifier,
onClick = onClick,
contentPadding = PaddingValues(14.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(18.dp))
IconBadge(icon = icon, tint = iconTint)
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 6.dp),
modifier = Modifier.padding(start = 8.dp),
)
}
Column(modifier = Modifier.padding(top = 6.dp), verticalArrangement = Arrangement.Bottom) {
Spacer(modifier = Modifier.height(10.dp))
Column(verticalArrangement = Arrangement.Bottom) {
Row(verticalAlignment = Alignment.Bottom) {
Text(text = value, style = MetricTileValueStyle)
if (enabled) {
Text(text = value, style = MetricTileValueStyle)
} else {
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
Text(
text = unit,
style = MaterialTheme.typography.bodySmall,
@@ -0,0 +1,272 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.common.formatSleepAnchorOffset
import com.placeholder.ringhealth.core.common.minutesSinceSleepAnchor
import com.placeholder.ringhealth.core.designsystem.DividerColor
import com.placeholder.ringhealth.core.designsystem.MetricBlue
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.domain.model.SleepSegment
import com.placeholder.ringhealth.domain.model.SleepStage
/** 每行日期标签的宽度,够放下「周三」两个字。 */
private val ROW_LABEL_WIDTH = 40.dp
/** 刻度文字所占的高度(文字本身 + 与图表之间的空隙)。 */
private val AXIS_LABEL_BAND = 20.dp
/** 刻度文字与图表下沿的间距。 */
private val AXIS_LABEL_GAP = 6.dp
/** 竖向网格线的粗细。比柱图的基线还细一档:它是背景参照,不该和时段条抢注意力。 */
private val GRID_WIDTH = 1.dp
/**
* 时段图圆角条的默认「底色」(分期模式下的轨道)。极浅蓝,与 MetricBlue 同色系,
* 让深睡/浅睡子矩形自然浮起来;AWAKE 用另一族暖色,更显眼。给 [StageTints.track] 用作兜底值。
*/
internal val DefaultStageTrackTint: Color = MetricBlue.copy(alpha = 0.10f)
/** 一分钟对应的毫秒数。本地常量,不引用 `MockDataProvider` 那份以免引入循环依赖。 */
private const val MILLIS_PER_MINUTE = 60_000L
/** 分期 → 着色。单色模式下没有 `stageTints`,仍用 [color];分期模式按 stage 取色。 */
private fun colorForStage(stage: SleepStage, tints: StageTints?, fallback: Color): Color = when (stage) {
SleepStage.DEEP -> tints?.deep ?: fallback
SleepStage.LIGHT -> tints?.light ?: fallback
SleepStage.AWAKE -> tints?.awake ?: fallback
}
/**
* 时段图内单条睡眠条的分期配色。**为 null 表示该图不画分期**,所有夜晚都按单色画。
*
* 三色通常取自同一色系的两个深浅(DEEP / LIGHT)+ 一个跳出该色系的 AWAKE 暖色 ——
* 「中途醒」是用户最该被告知的信息之一,让它和浅睡同色系会让它和「睡得浅」混淆;
* 暖色与睡眠的冷色调形成对比,眼睛会先抓到这里。颜色同时驱动头图分期条与时段图。
*
* `track` 是时段图内圆角条的底色(rail),子矩形落在这条底色之上 —— 与 AWAKE 区分开来。
* 不传则用 [DefaultStageTrackTint]。
*/
data class StageTints(
val deep: Color,
val light: Color,
val awake: Color,
val track: Color = DefaultStageTrackTint,
)
/**
* 睡眠时段图:**每晚一行**,一根圆角条从入睡时刻横到醒来时刻,横轴是钟点。
*
* 它回答的是柱状图回答不了的那个问题:「我这一周都是几点睡的」。
* 柱状图只画「睡了多久」,七根一样高的柱子看不出作息是不是在往后拖;
* 时段条的长短同样是时长,但**左端的位置**是入睡时刻,连起来看就是一条作息曲线。
*
* 横轴以晚上 20:00 为锚点跨过零点:一晚从 23:43 到 07:37,用「当天第几分钟」表示会得到
* 1423 → 457 这样一对反过来的数,画出来入睡点跑到起床点右边。换算见 `minutesSinceSleepAnchor`。
*
* **分期染色**:[SleepSession.stages] 非空的那几晚,条会被拆成多个染色子矩形;
* 传 [stageTints] 启用这一支。不传则所有夜晚走单色原行为 —— 多数 Mock 数据没有分期序列,
* 这是默认状态;有这层数据的夜晚再单独着色。M2 接入真机后会逐步补全序列,
* 把每一晚都变成可分期着色的版本。
*
* @param sessions 睡眠时段,按时间正序。
* @param color 单色模式下的时段条颜色;分期模式下充当未指定分期的兜底色。
* @param rowLabel 每行左侧的标签(如「周三」)。由调用方从 [SleepSession.wakeDate] 取,
* 组件本身不做资源查找;标成 `@Composable` 是因为调用方多半要 `stringResource`。
* @param stageTints 为非 null 时启用分期染色;为 null 时全部夜晚走单色。
* @param modifier 外部修饰符。
* @param rowHeight 时段条的高度。
* @param rowGap 行距。
*/
@Composable
fun SleepWindowChart(
sessions: List<SleepSession>,
color: Color,
rowLabel: @Composable (SleepSession) -> String,
stageTints: StageTints? = null,
modifier: Modifier = Modifier,
rowHeight: Dp = 16.dp,
rowGap: Dp = 10.dp,
) {
if (sessions.isEmpty()) return
// 横轴范围由这批数据自己决定:写死「20:00–12:00」的话,某晚熬到中午才睡,
// 那根条会被右端裁掉一截,看起来像睡得短,而它其实只是被画短了。
val axis = remember(sessions) { SleepAxis.of(sessions) }
val measurer = rememberTextMeasurer()
val tickStyle: TextStyle = MaterialTheme.typography.labelSmall.copy(color = TextSecondary)
val labelStyle = MaterialTheme.typography.labelSmall
// 行高 + 行距的展开只写一次,左侧标签列与画布共用,两边的行才对得齐。
val pitch = rowHeight + rowGap
val chartHeight = pitch * sessions.size - rowGap
Row(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.width(ROW_LABEL_WIDTH)) {
sessions.forEach { session ->
Box(
modifier = Modifier.height(rowHeight),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = rowLabel(session),
style = labelStyle,
color = TextSecondary,
maxLines = 1,
)
}
Spacer(modifier = Modifier.height(rowGap))
}
}
Canvas(modifier = Modifier.weight(1f).height(chartHeight + AXIS_LABEL_BAND)) {
val chartPx = chartHeight.toPx()
val rowHeightPx = rowHeight.toPx()
val rowPitchPx = pitch.toPx()
/** 横轴偏移量(距锚点 20:00 的分钟数)→ 画布横坐标。 */
fun xOf(minute: Int): Float =
size.width * ((minute - axis.lo).toFloat() / axis.span.toFloat())
// 竖向网格线先画,时段条压在它上面。整张图共用一张画布而不是每晚一个,
// 网格线才能从上到下连成一条,而不是每晚断成一截。
axis.ticks.forEach { tick ->
val x = xOf(tick)
drawLine(
color = DividerColor,
start = Offset(x = x, y = 0f),
end = Offset(x = x, y = chartPx),
strokeWidth = GRID_WIDTH.toPx(),
)
}
sessions.forEachIndexed { index, session ->
val top = index * rowPitchPx
// 轴的上下界由这批数据算出,所以 coerce 在正常情况下不生效;
// 留着是防止将来有人把 axis 换成固定窗口后,某根条悄悄画到画布外面。
val start = xOf(minutesSinceSleepAnchor(session.sleepStart)).coerceIn(0f, size.width)
val end = xOf(minutesSinceSleepAnchor(session.sleepEnd)).coerceIn(0f, size.width)
if (end <= start) return@forEachIndexed
val stages = session.stages
if (stages.isNullOrEmpty() || stageTints == null) {
// 单色原行为:所有夜晚(默认)或没拿到分期数据的夜晚。
drawRoundRect(
color = color,
topLeft = Offset(x = start, y = top),
size = Size(width = end - start, height = rowHeightPx),
cornerRadius = CornerRadius(rowHeightPx / 2f, rowHeightPx / 2f),
)
return@forEachIndexed
}
// 分期模式:先画整条 track 浅色圆角条作底色(保住两端圆角 + 给未覆盖区域兜底),
// 再把每个分期子矩形用方角 `drawRect` 叠在上面 —— 子矩形落在圆角范围内,
// 视觉上左右两端仍是 pill 形。AWAKE 子矩形此时是独立暖色,从 track 上明显浮出,
// 不再是上一版的「看起来像渲染瑕疵的窄缝」。
val baseTint = stageTints.track
drawRoundRect(
color = baseTint,
topLeft = Offset(x = start, y = top),
size = Size(width = end - start, height = rowHeightPx),
cornerRadius = CornerRadius(rowHeightPx / 2f, rowHeightPx / 2f),
)
stages.forEach { segment ->
val segStartTs = session.sleepStart + segment.startOffsetMinute * MILLIS_PER_MINUTE
val segEndTs = session.sleepStart + segment.endOffsetMinute * MILLIS_PER_MINUTE
val segStart = xOf(minutesSinceSleepAnchor(segStartTs)).coerceIn(0f, size.width)
val segEnd = xOf(minutesSinceSleepAnchor(segEndTs)).coerceIn(0f, size.width)
if (segEnd <= segStart) return@forEach
drawRect(
color = colorForStage(segment.stage, stageTints, color),
topLeft = Offset(x = segStart, y = top),
size = Size(width = segEnd - segStart, height = rowHeightPx),
)
}
}
// 刻度文字画在画布内,而不是另起一行等宽 Text —— 等宽的文本框中心落在
// (i + 0.5) / n,刻度线落在 i / (n - 1),中间几档会差出小半个格。
// 在画布里量一次宽度再居中,刻度线和文字是同一个 xOf,对得严丝合缝。
val labelTop = chartPx + AXIS_LABEL_GAP.toPx()
axis.ticks.forEachIndexed { index, tick ->
val layout = measurer.measure(formatSleepAnchorOffset(tick), tickStyle)
val x = when (index) {
// 首尾两档贴边:居中的话各有一半落在画布外,会被裁掉半个字。
0 -> 0f
axis.ticks.last -> size.width - layout.size.width
else -> xOf(tick) - layout.size.width / 2f
}
drawText(textLayoutResult = layout, topLeft = Offset(x = x, y = labelTop))
}
}
}
}
/**
* 横轴窗口:`lo`–`hi` 是距锚点 20:00 的分钟数,[step] 是刻度间隔。
*
* 三者满足 `(hi - lo) % step == 0`,刻度因此均匀铺满整条轴 ——
* 首尾两档正好落在画布两端,中间每档等距,读起来不需要心算。
*/
private data class SleepAxis(val lo: Int, val hi: Int, val step: Int) {
val span: Int get() = hi - lo
/** 各刻度相对锚点的分钟数,从 [lo] 到 [hi] 递增。 */
val ticks: IntProgression get() = lo..hi step step
companion object {
/** 刻度间隔候选,从密到疏:2 小时、3 小时、4 小时、6 小时。 */
private val STEP_CANDIDATES = listOf(120, 180, 240, 360)
/** 刻度数量的上限。再多就该换更疏的间隔,否则刻度文字会挤成一团。 */
private const val MAX_TICKS = 8
/**
* 按数据选窗口:起点向下取整、终点向上取整到最近的刻度线。
*
* 取整是均匀刻度的前提 —— 直接拿最早入睡时刻当左端,第一个刻度就不在整点上,
* 后面每一档都跟着错位。
*/
fun of(sessions: List<SleepSession>): SleepAxis {
val rawLo = sessions.minOf { minutesSinceSleepAnchor(it.sleepStart) }
val rawHi = sessions.maxOf { minutesSinceSleepAnchor(it.sleepEnd) }
val step = STEP_CANDIDATES.firstOrNull { (rawHi - rawLo) / it + 3 <= MAX_TICKS }
?: STEP_CANDIDATES.last()
val lo = floorTo(rawLo, step)
// 所有夜晚长度都短于一个间隔时 hi 会等于 lo,轴退成一条线,除零得到 NaN。
// 至少留一格。
val hi = ceilTo(rawHi, step).let { if (it <= lo) lo + step else it }
return SleepAxis(lo = lo, hi = hi, step = step)
}
/** 向下取整到 [step] 的整数倍([value] 为负时也要正确,即 -60、180 → -180)。 */
private fun floorTo(value: Int, step: Int): Int = value - ((value % step) + step) % step
/** 向上取整到 [step] 的整数倍。 */
private fun ceilTo(value: Int, step: Int): Int = value + ((step - value % step) % step)
}
}
@@ -0,0 +1,52 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/**
* 子页顶栏:返回箭头 + 标题。
*
* 抽成组件而不是让三个子页各写一遍,是因为**底色这件事必须三个页面同时正确**:
* 顶栏默认容器色是 `surface`(本应用里是纯白),而页面底色是浅灰 `background` ——
* 两者不同色时,AppBar 会在浅灰页面上糊出一条白色横带,与下方内容形成硬分界线,
* 且状态栏那一条仍是页面底色,于是顶部出现「灰、白、灰」三层。
* 统一取 `background` 后顶栏与页面融为一体,页面只剩「浅灰底 + 白卡片」两层,干净得多。
*
* @param title 标题文案,由调用方从字符串资源取得(不在此处硬编码)。
* @param onBack 返回回调。
* @param modifier 外部修饰符。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SubPageTopAppBar(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
title = { Text(text = title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
}
@@ -11,6 +11,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -27,12 +28,19 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.NavIndicator
import com.placeholder.ringhealth.core.designsystem.NavSelected
import com.placeholder.ringhealth.core.designsystem.NavUnselected
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.feature.device.DeviceScreen
import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen
import com.placeholder.ringhealth.feature.detail.MetricDetailScreen
import com.placeholder.ringhealth.feature.home.HomeScreen
import com.placeholder.ringhealth.feature.profile.AboutScreen
import com.placeholder.ringhealth.feature.profile.HelpScreen
import com.placeholder.ringhealth.feature.profile.NotificationsScreen
import com.placeholder.ringhealth.feature.profile.ProfileScreen
import com.placeholder.ringhealth.feature.profile.SettingsScreen
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen
import com.placeholder.ringhealth.feature.workout.WorkoutScreen
@@ -62,6 +70,14 @@ private fun tabRouteFor(route: String?): String? = when (route) {
Routes.DETAIL_PATTERN -> Routes.HOME
Routes.DEVICE_SETTINGS -> Routes.DEVICE
Routes.WORKOUT_HISTORY -> Routes.WORKOUT
// 2026-09-10 晚:四个「我的」子页加进来,子页栈顶时底栏仍高亮「我的」。
// 之前一批 PROFILE_* 路由不存在,对应的「未开启」死链是 PROGRESS.md §3.2 报告的
// 已知结构性问题;本批一并收口。
Routes.PROFILE_SETTINGS,
Routes.PROFILE_NOTIFICATIONS,
Routes.PROFILE_ABOUT,
Routes.PROFILE_HELP,
-> Routes.PROFILE
else -> route
}
@@ -83,6 +99,16 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
onClick = { navController.navigateToTab(tab.route) },
icon = { Icon(imageVector = tab.icon, contentDescription = null) },
label = { Text(stringResource(tab.labelRes)) },
// 颜色显式指定,不吃主题默认值。M3 的默认选中底色来自
// secondaryContainer,本应用虽已在 Theme 里把它改成浅绿,但底栏是全应用
// 最显眼的一处选中态,写明确一点,避免将来有人改主题时无意改到这里。
colors = NavigationBarItemDefaults.colors(
selectedIconColor = NavSelected,
selectedTextColor = NavSelected,
indicatorColor = NavIndicator,
unselectedIconColor = NavUnselected,
unselectedTextColor = NavUnselected,
),
)
}
}
@@ -108,13 +134,33 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
}
composable(Routes.PROFILE) {
ProfileScreen()
ProfileScreen(
onOpenSettings = { navController.navigate(Routes.PROFILE_SETTINGS) },
onOpenNotifications = { navController.navigate(Routes.PROFILE_NOTIFICATIONS) },
onOpenAbout = { navController.navigate(Routes.PROFILE_ABOUT) },
onOpenHelp = { navController.navigate(Routes.PROFILE_HELP) },
)
}
// 「我的」四个子页:每个都带自己的 TopAppBar 与返回箭头,与详情页一致。
composable(Routes.PROFILE_SETTINGS) {
SettingsScreen(onBack = { navController.popBackStack() })
}
composable(Routes.PROFILE_NOTIFICATIONS) {
NotificationsScreen(onBack = { navController.popBackStack() })
}
composable(Routes.PROFILE_ABOUT) {
AboutScreen(onBack = { navController.popBackStack() })
}
composable(Routes.PROFILE_HELP) {
HelpScreen(onBack = { navController.popBackStack() })
}
// 两个子页都带自己的 TopAppBar 与返回箭头(原先没有,与详情页不一致 ——
// 详情页有 ←,这两页只能靠系统返回手势或点底部 Tab 离开)。
composable(Routes.DEVICE_SETTINGS) {
DeviceSettingsScreen()
DeviceSettingsScreen(onBack = { navController.popBackStack() })
}
composable(Routes.WORKOUT_HISTORY) {
WorkoutHistoryScreen()
WorkoutHistoryScreen(onBack = { navController.popBackStack() })
}
composable(
route = Routes.DETAIL_PATTERN,
@@ -14,6 +14,16 @@ object Routes {
const val DEVICE_SETTINGS = "device/settings"
const val WORKOUT_HISTORY = "workout/history"
// 「我的」子页:四张真页面替代了阶段一首批的四个死链接
// (PROGRESS.md §3.2 中报告的「未开启」占位状态)。
// 路由名按语义独立、不再嵌在 `profile/` 下 —— 子页与 Tab 用同一字符串前缀
// 容易让 `tabRouteFor` 误把它们归到「我的」Tab(实际上它们就是「我的」的内容,
// 这里归到「我的」才对,所以单独加分支,见 `RingHealthApp.tabRouteFor`)。
const val PROFILE_SETTINGS = "profile_settings"
const val PROFILE_NOTIFICATIONS = "profile_notifications"
const val PROFILE_ABOUT = "profile_about"
const val PROFILE_HELP = "profile_help"
/** 路由参数的键名。必须声明在 DETAIL_PATTERN 之前:模板引用它。 */
const val DETAIL_ARG_METRIC = "metric"
@@ -6,7 +6,13 @@ import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.model.SleepSegment
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.domain.model.SleepStage
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import com.placeholder.ringhealth.domain.model.valueOf
import java.util.Calendar
import kotlin.math.roundToInt
import kotlin.random.Random
/**
@@ -22,31 +28,68 @@ class MockDataProvider(
private val now: () -> Long = System::currentTimeMillis,
) {
/** 今日概览:固定演示值。血压为 null 以演示“未开启”占位态。 */
fun todaySummary(): HealthSummary = HealthSummary(
steps = 8426,
stepGoal = 10000,
calories = 327,
distanceKm = 6.21,
heartRate = 72,
sleepMinutes = 452,
bloodOxygen = 98,
stress = 34,
temperature = 36.5,
bloodPressure = null,
)
/**
* 今日概览:固定演示值,但**未开启的指标一律为 null**。
*
* @param enabledMetrics 当前开启的指标集合,来自功能开关(见 [DeviceFunctionSetting])。
* 关掉的指标在这里被置空,首页那张卡才会显示「未开启」——
* 否则用户在功能设置页翻了开关,首页纹丝不动,开关就是个摆设。
* **刻意不给默认值**:默认「全都开着」会让漏传参数的调用方看起来一切正常,
* 而那正是这次要修的病。
*
* 计步与体温没有开关([MetricType.switchId] 为 null),恒定给值。
*/
fun todaySummary(enabledMetrics: Set<MetricType>): HealthSummary =
HealthSummary(
steps = 8426,
stepGoal = 10000,
calories = 327,
distanceKm = 6.21,
heartRate = if (MetricType.HEART_RATE in enabledMetrics) 72 else null,
// 睡眠时长取自昨晚那条 [SleepSession],不另写一个常量:
// 头图写 452 分钟、时段图上昨晚那根条却是另一个长度,是同一屏里最刺眼的一种自相矛盾。
sleepMinutes = if (MetricType.SLEEP in enabledMetrics) demoLastNight().sleepMinutes else null,
bloodOxygen = if (MetricType.BLOOD_OXYGEN in enabledMetrics) 98 else null,
stress = if (MetricType.STRESS in enabledMetrics) 34 else null,
temperature = if (MetricType.TEMPERATURE in enabledMetrics) 36.5 else null,
// 血压在概览里是「收缩压/舒张压」字符串。开着给一个演示值,关着为 null ——
// 首页那张卡据此在「118/76」和「未开启」之间切换。
bloodPressure = if (MetricType.BLOOD_PRESSURE in enabledMetrics) DEMO_BLOOD_PRESSURE else null,
// 今日活动累计分钟数:与 steps/calories 同性质,**不属于任何一个 MetricType**,
// 因此不参与 enabledMetrics 的过滤 —— 它没有开关页可关,给 null 会变成「未开启」
// 跟步数一样荒谬。值是演示用,M3 接 SDK 后由真实活动汇总接口填入。
activeMinutes = DEMO_ACTIVE_MINUTES,
)
/**
* 指定指标的历史数据:WEEK 返回 7 个点,MONTH 返回 30 个点。
* 时间戳按“每点间隔一天”倒推,值在指标合理区间内波动。
*
* **最后一个点(时间戳 = 现在,即“今天”)的值取自 [todaySummary]**,不由随机序列生成。
* 详情页的头图显示概览里的当前值、趋势图的最后一根柱子显示今天的值,两者本该相同;
* 各生成各的会让页面出现「头图写心率 72、图上今天却是 96」这种矛盾,
* 而且两个数字看起来都很确定,用户无从判断该信哪个。
*
* **睡眠是例外:它的点由 [sleepSessions] 折算而来**,不走下面那套随机序列。
* 同一个指标有两套数据源,就会出现「头图写 7 小时 32 分、时段图上却是一根 6 小时的条」——
* 而这正是睡眠页最显眼的两块内容。
*/
fun metricHistory(metric: MetricType, range: DateRange): List<MetricPoint> {
fun metricHistory(
metric: MetricType,
range: DateRange,
enabledMetrics: Set<MetricType>,
): List<MetricPoint> {
if (metric == MetricType.SLEEP) {
return sleepSessions(range).map {
MetricPoint(timestamp = it.wakeDate, value = it.sleepMinutes.toDouble())
}
}
val count = if (range == DateRange.WEEK) 7 else 30
val random = Random(seed + metric.ordinal * 100L + range.ordinal)
val (min, max) = valueRange(metric)
val current = now()
val dayMillis = 24L * 60 * 60 * 1000
return (0 until count).map { index ->
val points = (0 until count).map { index ->
val progress = index.toFloat() / (count - 1).coerceAtLeast(1)
val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15
MetricPoint(
@@ -54,8 +97,93 @@ class MockDataProvider(
value = value.coerceIn(min, max),
)
}
// 指标未开启(概览里为 null)时保留随机序列:没有「今日值」可对齐。
// 这些点不会显示 —— 未开启的指标,详情页整页走「未开启」分支,不画图(见 MetricDetailScreen)。
val todayValue = todaySummary(enabledMetrics).valueOf(metric) ?: return points
return points.dropLast(1) + MetricPoint(timestamp = current, value = todayValue)
}
/**
* 睡眠时段:WEEK 返回最近 7 晚,MONTH 返回最近 30 晚,按时间正序(最后一晚是昨晚)。
*
* 与其他指标不同,这里的**时刻本身就是数据**,所以不能用「现在往前减 N 天」那种做法 ——
* 那会让每一晚的入睡时刻都等于「此刻减若干小时」,于是七根条在时段图上排成整齐的一列,
* 看不出作息差异,而「作息差在哪」正是这张图存在的理由。这里按日历把每晚上落到
* 早上 6–9 点之间的某个时刻,入睡时刻再由卧床时长倒推。
*
* 昨晚(daysAgo = 0)用固定值 [demoLastNight],与 [todaySummary] 给出的睡眠时长对齐。
*/
fun sleepSessions(range: DateRange): List<SleepSession> {
val count = if (range == DateRange.WEEK) 7 else 30
return (count - 1 downTo 0).map { daysAgo ->
if (daysAgo == 0) {
demoLastNight()
} else {
// 每晚一个独立种子:结果与生成顺序无关,多取几晚也不会让已有那几晚变样。
randomSession(Random(seed + SLEEP_SEED_OFFSET + daysAgo), daysAgo)
}
}
}
/**
* 昨晚(今天凌晨那次睡眠)。入睡 23:43、醒来 07:37,睡着 7 小时 32 分。
*
* 它是一个**写死的演示值**,和步数 8426、心率 72 同一性质;随机生成的那几晚负责
* 「作息有波动」的观感,昨晚这一条负责让第一屏的数字好看且固定。
* 时长(452 分钟)就是 `demoLastNight().sleepMinutes`,见 [todaySummary]。
*
* 此外**只有这一晚带 [SleepSession.stages]** —— 24 小时采样里 6 晚没有这层数据,
* 时段图按单色画;昨晚给了它,时段图把这一行拆成多个染色子矩形,让用户能直观看到
* 「夜里深睡 / 浅睡 / 中途醒是怎么交替的」。各段时长加起来正好等于上面的累计三件套,
* 否则头图分期条与时段图条内分期的颜色块会对不上。
*/
private fun demoLastNight(): SleepSession {
val wake = localTimeAt(daysAgo = 0, hour = DEMO_WAKE_HOUR, minute = DEMO_WAKE_MINUTE)
return SleepSession(
wakeDate = wake,
sleepStart = wake - DEMO_IN_BED_MINUTES * MILLIS_PER_MINUTE,
sleepEnd = wake,
deepMinutes = DEMO_DEEP_MINUTES,
lightMinutes = DEMO_LIGHT_MINUTES,
awakeMinutes = DEMO_AWAKE_MINUTES,
stages = DEMO_LAST_NIGHT_STAGES,
)
}
/** 随机构造一晚:睡着 350–500 分钟,夜里清醒 0–40 分钟,其中深睡占 18%–28%。 */
private fun randomSession(random: Random, daysAgo: Int): SleepSession {
val asleep = random.nextInt(350, 501)
val awake = random.nextInt(0, 41)
// 入睡时刻倒推:先定醒来时刻,再往前挪「卧床时长」。
// 醒来时刻在 6–9 点之间随机,作息差异才看得出来。
val wake = localTimeAt(daysAgo, hour = random.nextInt(6, 10), minute = random.nextInt(0, 60))
val deep = (asleep * random.nextDouble(DEEP_RATIO_MIN, DEEP_RATIO_MAX)).roundToInt()
return SleepSession(
wakeDate = wake,
sleepStart = wake - (asleep + awake) * MILLIS_PER_MINUTE,
sleepEnd = wake,
deepMinutes = deep,
lightMinutes = asleep - deep,
awakeMinutes = awake,
)
}
/**
* 时间戳:今天往前 [daysAgo] 天的 [hour]:[minute](本地时区)。
*
* 必须走 Calendar 而不是 `now() - daysAgo * 86400_000` —— 后者只会平移,改不了「几点」,
* 生成的每个时刻都带着当前这一秒的分钟数。跨夏令时的那种一天也不是 86400 秒。
*/
private fun localTimeAt(daysAgo: Int, hour: Int, minute: Int): Long =
Calendar.getInstance().apply {
timeInMillis = now()
add(Calendar.DAY_OF_YEAR, -daysAgo)
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
/** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */
fun workouts(): List<WorkoutRecord> {
val random = Random(seed + 7)
@@ -83,13 +211,40 @@ class MockDataProvider(
charging = false,
)
/** 五个功能开关,对应协议中的自动监测能力。 */
/**
* 五个功能开关,对应协议中的自动监测能力。
*
* id 一律取 [MetricType.switchId],不在这里手写字符串 —— 手写就等于把「开关 ↔ 指标」
* 这个对应关系又抄了一份,抄错一份不会报错,只会让某个开关悄悄失效。
*
* 默认值:血压关着,用来演示「未开启」占位态;其余开着。
* 压力此前默认是关的,但 [todaySummary] 里 `stress = 34` 一直有值 ——
* 那正是「开关与数据两套系统」留下的痕迹,现在两者由同一个开关决定,默认值必须自洽。
*/
fun defaultFunctionSettings(): List<DeviceFunctionSetting> = listOf(
DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true),
DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true),
DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true),
DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false),
DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false),
setting(MetricType.HEART_RATE, "自动心率监测", "每 10 分钟测量一次", enabled = true),
setting(MetricType.BLOOD_OXYGEN, "自动血氧监测", "夜间持续监测", enabled = true),
setting(MetricType.SLEEP, "睡眠监测", "自动识别睡眠时段", enabled = true),
setting(MetricType.STRESS, "压力监测", "全天候压力评估", enabled = true),
setting(MetricType.BLOOD_PRESSURE, "血压监测", "需在 App 内手动校准", enabled = false),
)
/**
* 构造一个开关项,id 从指标上取。
*
* 常开指标([MetricType.switchId] 为 null)不该出现在开关列表里,
* 传进来会立刻抛异常而不是生成一个 id 为 "null" 的幽灵开关。
*/
private fun setting(
metric: MetricType,
title: String,
subtitle: String,
enabled: Boolean,
): DeviceFunctionSetting = DeviceFunctionSetting(
id = requireNotNull(metric.switchId) { "$metric 是常开指标,不该出现在功能开关列表里" },
title = title,
subtitle = subtitle,
enabled = enabled,
)
/** 各指标的取值区间,用于生成合理的历史数据。 */
@@ -106,5 +261,74 @@ class MockDataProvider(
companion object {
/** 默认随机种子,保证演示数据稳定。 */
const val DEFAULT_SEED: Long = 20260910L
/** 血压开着时的演示读数(概览里是「收缩压/舒张压」形式)。 */
const val DEMO_BLOOD_PRESSURE: String = "118/76"
/**
* 今日活动累计分钟数演示值。
*
* 与 [DEMO_BLOOD_PRESSURE] 同一性质:固定写死,让第一屏有数字;M3 接 SDK 后整体替换。
* 47 分钟是「轻办公 + 通勤走路 + 一点通勤骑」的合理区间,比 0 像真人,比 120 不会让用户
* 误以为自己达标。
*/
const val DEMO_ACTIVE_MINUTES: Int = 47
/** 睡眠时段的随机种子偏移,避开 [metricHistory] 用的 `ordinal * 100`。 */
private const val SLEEP_SEED_OFFSET = 31L
private const val MILLIS_PER_MINUTE = 60_000L
/** 深睡占比区间。真实设备大约在这个范围,太低会让「深睡 3 分钟」这种数出现在演示里。 */
private const val DEEP_RATIO_MIN = 0.18
private const val DEEP_RATIO_MAX = 0.28
/** 昨晚的醒来时刻:07:37。 */
private const val DEMO_WAKE_HOUR = 7
private const val DEMO_WAKE_MINUTE = 37
/** 昨晚分期:深睡 96 + 浅睡 356 = 睡着 452 分钟,另有 22 分钟夜里清醒。 */
private const val DEMO_DEEP_MINUTES = 96
private const val DEMO_LIGHT_MINUTES = 356
private const val DEMO_AWAKE_MINUTES = 22
/** 昨晚卧床时长 = 睡着 452 + 清醒 22,由 07:37 倒推得入睡时刻 23:43。 */
private const val DEMO_IN_BED_MINUTES = DEMO_DEEP_MINUTES + DEMO_LIGHT_MINUTES + DEMO_AWAKE_MINUTES
/**
* 昨晚的分期序列(相对 `sleepStart` 的分钟偏移)。
*
* 三段时间加起来严格等于 [DEMO_DEEP_MINUTES] / [DEMO_LIGHT_MINUTES] / [DEMO_AWAKE_MINUTES]:
* 深睡 48 + 35 + 13 = 96
* 浅睡 30 + 60 + 60 + 44 + 57 + 105 = 356
* 清醒 5 + 5 + 7 + 5 = 22
* 否则头图分期条与时段图条内分期的色块会对不上。形态上模拟「入睡后两轮深睡 + 一段长浅睡
* (含 REM)+ 中途两段短暂清醒」的常见曲线。
*/
private val DEMO_LAST_NIGHT_STAGES: List<SleepSegment> = listOf(
// 入睡后短暂清醒 → 浅睡入睡
SleepSegment(SleepStage.AWAKE, 0, 5),
SleepSegment(SleepStage.LIGHT, 5, 35),
// 第一轮深睡
SleepSegment(SleepStage.DEEP, 35, 83),
// 第一轮长浅睡
SleepSegment(SleepStage.LIGHT, 83, 143),
// 第二轮深睡(比第一轮短)
SleepSegment(SleepStage.DEEP, 143, 178),
// 第二轮浅睡
SleepSegment(SleepStage.LIGHT, 178, 238),
// 第一次中途醒(5 分钟)
SleepSegment(SleepStage.AWAKE, 238, 243),
SleepSegment(SleepStage.LIGHT, 243, 287),
// 第三轮深睡(很短)
SleepSegment(SleepStage.DEEP, 287, 300),
SleepSegment(SleepStage.LIGHT, 300, 357),
// 第二次中途醒(7 分钟)
SleepSegment(SleepStage.AWAKE, 357, 364),
// 临近醒来的长浅睡(含 REM 密集期)
SleepSegment(SleepStage.LIGHT, 364, 469),
// 最终清醒
SleepSegment(SleepStage.AWAKE, 469, 474),
)
}
}
@@ -1,30 +1,57 @@
package com.placeholder.ringhealth.data.mock
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import com.placeholder.ringhealth.domain.model.enabledMetrics
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
/**
* Mock 健康数据仓库。模拟 200ms 网络/设备延迟,便于阶段一验证加载态。
*
* **它订阅功能开关。** 这是「开关能影响首页」的关键:原先健康数据与开关状态是
* `AppContainer` 里两个互不引用的字段,用户打开「血压监测」只翻动了开关自己那一行,
* 这边照样发射写死的概览,首页因而纹丝不动。现在数据由开关算出来,
* 开关一变就重发。
*
* @param functionSettings 功能开关流,来自 [RingDeviceRepository]。
* 注意依赖的是 `StateFlow`(会立刻给出当前值)而不是一个一次性读取 ——
* 否则开关变化传不过来,又会退回原样。
*/
class MockHealthDataRepository(
private val functionSettings: StateFlow<List<DeviceFunctionSetting>>,
private val provider: MockDataProvider = MockDataProvider(),
) : HealthDataRepository {
// flow { delay(...) } 先给出加载态,再 emitAll 接上开关流:初值延迟 200ms 到达
// (便于验证 loading),此后每次翻开关都立刻重发。
override fun observeTodaySummary(): Flow<HealthSummary> = flow {
delay(MOCK_DELAY_MILLIS)
emit(provider.todaySummary())
emitAll(functionSettings.map { provider.todaySummary(it.enabledMetrics()) })
}
override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>> = flow {
delay(MOCK_DELAY_MILLIS)
emit(provider.metricHistory(metric, range))
emitAll(functionSettings.map { provider.metricHistory(metric, range, it.enabledMetrics()) })
}
// 与 observeMetricHistory 同一口径:**照常发数据,判开关是页面的事**(见 MetricDetailScreen
// 的 enabled 分支)。不在这里按开关置空,是因为长度/取值是随 range 与种子固定的,
// 在这里分叉会让「关了再打开」拿到一段与之前不同的序列,而 Mock 承诺的是可复现。
override fun observeSleepSessions(range: DateRange): Flow<List<SleepSession>> = flow {
delay(MOCK_DELAY_MILLIS)
emitAll(functionSettings.map { provider.sleepSessions(range) })
}
override fun observeWorkouts(): Flow<List<WorkoutRecord>> = flow {
@@ -1,14 +1,26 @@
package com.placeholder.ringhealth.domain.model
/** 健康指标类型。路由与详情页以此区分。 */
enum class MetricType {
STEP,
HEART_RATE,
SLEEP,
BLOOD_OXYGEN,
STRESS,
TEMPERATURE,
BLOOD_PRESSURE,
/**
* 健康指标类型。路由与详情页以此区分。
*
* @param switchId 该指标对应 [DeviceFunctionSetting.id] 里的哪个开关;**为 null 表示常开** ——
* 计步与体温由戒指持续采集,没有「关掉」这个选项。
*
* 这个对应关系必须显式写出来,不能靠两边字符串长得像来对齐。原先它只存在于巧合里
* (开关 id 是 `"blood_pressure"`、枚举是 `BLOOD_PRESSURE`,撞得上),没有一行代码声明过
* 它们是同一件事 —— 于是开关状态和健康数据成了两个互不相干的系统:用户在功能设置页打开
* 「血压监测」,首页那张卡照样写「未开启」,因为首页读的数据根本不认识开关。
*/
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"),
}
/** 历史数据的时间范围。 */
@@ -27,6 +39,10 @@ data class RingDeviceState(
/**
* 今日健康概览。字段为 null 表示该指标未开启或暂无数据,UI 需展示“未开启”占位而非 0。
*
* @param activeMinutes 今日活动累计分钟数(高强度活动 + 中等强度活动合并)。
* 与 [steps] / [calories] 同属「每日总量」型数据,**不属于任何一个 [MetricType]**
* (首页三件套横排里展示,M3 接 SDK 后由真实活动汇总接口填入)。
*/
data class HealthSummary(
val steps: Int,
@@ -39,11 +55,98 @@ data class HealthSummary(
val stress: Int?,
val temperature: Double?,
val bloodPressure: String?,
val activeMinutes: Int = 0,
)
/**
* 取某个指标在今日概览里的值;该指标未开启或暂无数据时返回 null。
*
* 存在的理由是**让「当前值」与「历史点」用同一把尺子**:详情页的头图取概览里的当前值,
* 趋势图的最后一个点代表今天,两者说的本该是同一件事。数据源若各给各的,
* 页面就会出现「头图写心率 72、图上今天却是 96」这种自相矛盾 ——
* 用户没法判断该信哪个,而两个数字看起来都很确定。
*/
fun HealthSummary.valueOf(metric: MetricType): Double? = when (metric) {
MetricType.STEP -> steps.toDouble()
MetricType.HEART_RATE -> heartRate?.toDouble()
MetricType.SLEEP -> sleepMinutes?.toDouble()
MetricType.BLOOD_OXYGEN -> bloodOxygen?.toDouble()
MetricType.STRESS -> stress?.toDouble()
MetricType.TEMPERATURE -> temperature
// 血压在概览里是「收缩压/舒张压」形式的字符串,取不出单一数值。
// M3 把 bloodPressure 改成结构化的两个数值后再补上这一支。
MetricType.BLOOD_PRESSURE -> null
}
/** 一条历史数据点。 */
data class MetricPoint(val timestamp: Long, val value: Double)
/**
* 一段睡眠分期:起点到终点(相对于 [SleepSession.sleepStart] 的分钟数)+ 哪一期。
*
* 时段图渲染时按这一份序列把一根条拆成多个染色子矩形。没有这层序列时只能画
* 「累计深睡 96 分钟」这样的比例块 —— 那是一段并不存在的分期位置,比不画更容易让人当真
* (见 `SleepWindowChart` 的 KDoc)。有这份序列才能画「23:43–23:48 清醒、23:48–00:18 浅睡」这样的真顺序。
*
* @param stage 这一段是哪个分期。
* @param startOffsetMinute 起点相对 [SleepSession.sleepStart] 的分钟数。
* @param endOffsetMinute 终点相对 [SleepSession.sleepStart] 的分钟数。必须大于 [startOffsetMinute]。
*/
data class SleepSegment(
val stage: SleepStage,
val startOffsetMinute: Int,
val endOffsetMinute: Int,
)
/**
* 一晚睡眠。
*
* 睡眠与其他指标不一样:它在一天里占的是**一段区间**,不是一个数。
* [MetricPoint] 只装得下一个 `Double`,于是「睡了 7 小时 32 分」之外什么都说不了 ——
* 而「从几点睡到几点」恰恰是作息规律的关键,也是用户真正会问的问题
* (「我不知道一天内具体什么时候睡的觉」)。所以睡眠单独有这个类型。
*
* @param wakeDate 醒来的时刻;这条记录算在哪一天,以它为准。
* @param sleepStart 入睡时刻。
* @param sleepEnd 醒来时刻。
* @param deepMinutes 深睡时长。
* @param lightMinutes 浅睡时长。
* @param awakeMinutes 夜里清醒的累计时长(中途醒来)。
* @param stages 分期序列(相对 [sleepStart] 的分钟偏移)。**为 null 表示该晚不画分期染色**,
* 时段图按单色画这根条 —— 大多数 Mock 数据没有这层序列,M2 接入真机后会逐步补上。
*/
data class SleepSession(
val wakeDate: Long,
val sleepStart: Long,
val sleepEnd: Long,
val deepMinutes: Int,
val lightMinutes: Int,
val awakeMinutes: Int,
val stages: List<SleepSegment>? = null,
) {
/**
* 实际睡着的时长 = 深睡 + 浅睡,**不含夜里清醒**。
* 首页那张卡和详情页头图显示的都是这个数。
*/
val sleepMinutes: Int get() = deepMinutes + lightMinutes
/** 卧床时长 = 睡着 + 夜里清醒,等于 [sleepStart] 到 [sleepEnd] 的跨度。 */
val inBedMinutes: Int get() = sleepMinutes + awakeMinutes
}
/** 睡眠分期。头图的分期条与图例按它取色取文案。 */
enum class SleepStage { DEEP, LIGHT, AWAKE }
/**
* 某一期的时长(分钟)。注意各期时长**不表示先后顺序** ——
* 真实的分期是交替出现的,而这里只有累计时长。
*/
fun SleepSession.minutesOf(stage: SleepStage): Int = when (stage) {
SleepStage.DEEP -> deepMinutes
SleepStage.LIGHT -> lightMinutes
SleepStage.AWAKE -> awakeMinutes
}
/** 一条运动记录。 */
data class WorkoutRecord(
val id: String,
@@ -61,3 +164,29 @@ data class DeviceFunctionSetting(
val subtitle: String,
val enabled: Boolean,
)
/**
* 从开关列表算出「当前开着哪些指标」。
*
* 常开指标([MetricType.switchId] 为 null)永远算作开启 —— 它们不在开关列表里,
* 若按「找不到对应开关就算关」处理,计步和体温会凭空变成「未开启」。
*
* 开关 id 找不到对应指标时被忽略:那说明协议里有我们还没接入的功能,
* 不该让整个首页崩掉。
*/
fun List<DeviceFunctionSetting>.enabledMetrics(): Set<MetricType> =
MetricType.entries.filterTo(mutableSetOf()) { metric ->
val switchId = metric.switchId ?: return@filterTo true
any { it.id == switchId && it.enabled }
}
/**
* 该指标当前是否开启。
*
* 判据是「今日概览里有没有这个指标的值」—— 概览的字段由功能开关生成
* (见 `MockHealthDataRepository`),所以「概览里取不到值」与「开关是关的」是同一件事。
*
* 血压要单独判:它的值在概览里是「收缩压/舒张压」字符串,[valueOf] 按设计取不出单一数值。
*/
fun HealthSummary.isEnabled(metric: MetricType): Boolean =
if (metric == MetricType.BLOOD_PRESSURE) bloodPressure != null else valueOf(metric) != null
@@ -0,0 +1,63 @@
package com.placeholder.ringhealth.domain.model
/**
* 指标参考区间与判读基线。
*
* 这些是**领域数据**,不是展示细节 —— 「这个值算不算正常」的回答依据不该写死在 UI 层,
* 否则将来接真实数据、按用户画像下发区间时,得去七个页面里把常量一个个抠出来。
*
* 取值为通用成人临床参考值,**仅供演示,不构成医疗建议**。
* M3 接入真实数据后改为由数据层按用户画像提供。
*/
object MetricReference {
/** 成人静息心率正常区间(次/分)。 */
val HEART_RATE_NORMAL = 60.0..100.0
/**
* 心率区间条的横轴范围(次/分)。
*
* 比 [HEART_RATE_NORMAL] 左右各留出 20 的余量:若横轴恰好等于正常区间,
* 落在 60 或 100 附近的点会紧贴条的边缘,看不出它到底是「刚好在界内」还是「已经出界」。
*/
val HEART_RATE_AXIS = 40.0..120.0
/** 血氧饱和度下限(%)。低于此值的日子需要被单独标出并计数。 */
const val BLOOD_OXYGEN_MIN = 95.0
/**
* 血氧阈值带的下沿(%)。
*
* 比 [BLOOD_OXYGEN_MIN] 低的点仍要能画在带子上(否则低于阈值的点会集体贴边,
* 看不出低了多少),所以下沿再往下留 5。
*/
const val BLOOD_OXYGEN_FLOOR = 90.0
/** 体温基线(℃)。偏离条以它为中线。 */
const val TEMPERATURE_BASELINE = 36.5
/**
* 体温偏离条的半幅(℃),即纵轴范围是 基线 ± 本值。
*
* 取 1.0 而不是更大:演示数据的体温落在 36.1–36.9(偏离 ±0.4),
* 半幅 1.0 时点会落在中线到边缘的 40% 处,偏离看得出来;半幅 1.5 就被压到中间一小团了。
*/
const val TEMPERATURE_SPAN = 1.0
}
/** 压力等级。 */
enum class StressLevel { RELAXED, NORMAL, MEDIUM, HIGH }
/**
* 压力值 → 等级。
*
* 抽成独立的纯函数,是为了让「按等级给柱子着色」「统计放松天数」和「显示等级文案」
* 三处用的是**同一份**分档逻辑 —— 此前文案分档藏在 `Formatters.stressLevelLabelRes` 里,
* 一旦别处再写一份阈值,两边迟早对不上。
*/
fun stressLevelOf(stress: Double): StressLevel = when {
stress < 30.0 -> StressLevel.RELAXED
stress < 60.0 -> StressLevel.NORMAL
stress < 80.0 -> StressLevel.MEDIUM
else -> StressLevel.HIGH
}
@@ -6,6 +6,7 @@ import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@@ -20,6 +21,16 @@ interface HealthDataRepository {
/** 指定指标在指定时间范围内的历史数据。 */
fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>>
/**
* 睡眠时段:每晚的入睡/醒来时刻与各期时长,按时间正序。
*
* 睡眠无法复用 [observeMetricHistory] —— [MetricPoint] 只装得下一个 `Double`,
* 而「几点睡到几点」是这一段区间的全部意义(见 [SleepSession])。
*
* @param range 与 [observeMetricHistory] 同一套口径:WEEK 七晚,MONTH 三十晚。
*/
fun observeSleepSessions(range: DateRange): Flow<List<SleepSession>>
/** 运动记录列表。 */
fun observeWorkouts(): Flow<List<WorkoutRecord>>
}
@@ -1,6 +1,5 @@
package com.placeholder.ringhealth.feature.detail
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -8,51 +7,39 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatClock
import com.placeholder.ringhealth.core.common.formatDecimal
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.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.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.core.designsystem.component.StatBar
import com.placeholder.ringhealth.core.designsystem.component.StatSegment
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
import com.placeholder.ringhealth.data.mock.MockDataProvider
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.feature.detail.pages.bloodOxygenDetail
import com.placeholder.ringhealth.feature.detail.pages.bloodPressureDetail
import com.placeholder.ringhealth.feature.detail.pages.heartRateDetail
import com.placeholder.ringhealth.feature.detail.pages.sleepDetail
import com.placeholder.ringhealth.feature.detail.pages.stepDetail
import com.placeholder.ringhealth.feature.detail.pages.stressDetail
import com.placeholder.ringhealth.feature.detail.pages.temperatureDetail
/**
* 指标详情页(有状态外壳)。
@@ -78,9 +65,15 @@ fun MetricDetailScreen(
}
/**
* 无状态指标详情内容:范围切换 + 柱状趋势图 + 统计摘要(+ 睡眠分期)。
* 指标详情内容:**一具骨架**。
*
* 范围切换常驻;当前范围没有数据点时,只把「趋势图 + 统计摘要」换成一行空态文案。
* 这里只剩三件事:页头(返回 + 标题)、范围切换、以及把内容分派给对应指标。
* **七个指标的页面内容一行都不在本文件里** —— 它们各自待在 `feature/detail/pages/` 下自己的
* 文件里(`StepDetail.kt` / `HeartRateDetail.kt` / …)。
*
* 之所以这么拆:改造前七个指标共用「范围切换 + 一张柱状图 + 平均/最高/最低」这同一套模板,
* 页面之间只有柱子颜色不同 —— 打开哪个都像同一个页面。视觉上的雷同,根子是代码上的共用。
* 拆开之后,每个指标「要突出什么」写在它自己的文件里,改一页不必碰其他六页。
*
* 标题取自 `state.metric` 而不是外面传进来的 `metric` —— 无状态层只有 `state` 一个数据来源,
* 免得出现「标题写心率、数据画睡眠」这种两者不一致的可能。
@@ -97,16 +90,9 @@ private fun MetricDetailContent(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = { Text(text = stringResource(metricTitleRes(state.metric))) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
},
SubPageTopAppBar(
title = stringResource(metricTitleRes(state.metric)),
onBack = onBack,
)
},
) { innerPadding ->
@@ -127,12 +113,24 @@ private fun MetricDetailContent(
) {
// 范围切换常驻,空数据时反而更要留着:切到另一个范围是用户看到「暂无数据」后的
// 第一反应,chips 也是判断「哪个范围是空的」的唯一线索。它属于页头,不属于数据区。
item { RangeSelector(current = state.range, onSelect = onSelectRange) }
//
// 但「未开启」时不给:那一页没有任何数据,切换周/月不会有东西跟着变,
// 留两个能点但点了没反应的 chip 比不显示更让人困惑。
if (state.enabled) {
item { RangeSelector(current = state.range, onSelect = onSelectRange) }
}
// 只有图与统计被替换为空态。判空必须在 loading 之后:Mock 有约 200ms 延迟,
// 未开启的指标整页走说明卡,不画图。
// 判在 points 之前:Mock 对未开启的指标照常生成随机序列(见 MockDataProvider),
// 那些点画的是一段并不存在的监测数据,比空图更有误导性。
if (!state.enabled) {
item { MetricDisabledCard(metric = state.metric) }
return@LazyColumn
}
// 只有内容区被替换为空态。判空必须在 loading 之后:Mock 有约 200ms 延迟,
// 加载期间 points 也是空的,先判空会在数据到达前闪一下空态(loading 分支已提前 return)。
if (state.points.isEmpty()) {
// 不再渲染「全 0 高度的柱状图 + 三行 0.0」,那看着像页面坏了而不是「没有数据」。
item {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
@@ -148,132 +146,20 @@ private fun MetricDetailContent(
return@LazyColumn
}
item {
HealthCard {
SectionHeader(title = stringResource(R.string.detail_trend))
MetricBarChart(
values = state.points.map { it.value.toFloat() },
color = metricTint(state.metric),
)
}
}
item {
HealthCard {
val values = state.points.map { it.value }
StatLine(label = stringResource(R.string.detail_average), value = values.averageOrZero())
StatLine(label = stringResource(R.string.detail_max), value = values.maxOrNull() ?: 0.0)
StatLine(label = stringResource(R.string.detail_min), value = values.minOrNull() ?: 0.0)
}
}
if (state.metric == MetricType.SLEEP) {
item { SleepStagesCard(points = state.points) }
// 每个指标页自己决定往列表里放几张卡、放什么 —— 见各页文件的 KDoc。
when (state.metric) {
MetricType.STEP -> stepDetail(state)
MetricType.HEART_RATE -> heartRateDetail(state)
MetricType.SLEEP -> sleepDetail(state)
MetricType.BLOOD_OXYGEN -> bloodOxygenDetail(state)
MetricType.STRESS -> stressDetail(state)
MetricType.TEMPERATURE -> temperatureDetail(state)
MetricType.BLOOD_PRESSURE -> bloodPressureDetail(state)
}
}
}
}
/**
* 睡眠分期卡片:深睡/浅睡/清醒分段条 + 入睡/起床时间(对应 spec §6 的 `detail/sleep`)。
*/
@Composable
private fun SleepStagesCard(points: List<MetricPoint>, modifier: Modifier = Modifier) {
// 阶段一没有真实分期数据,按固定比例演示;M3 接入真实睡眠数据后改为按实际时段计算。
val segments = listOf(
StatSegment(weight = 0.25f, color = MetricBlue),
StatSegment(weight = 0.65f, color = MetricBlue.copy(alpha = 0.45f)),
StatSegment(weight = 0.10f, color = MetricBlue.copy(alpha = 0.2f)),
)
HealthCard(modifier = modifier) {
SectionHeader(title = stringResource(R.string.detail_sleep_stages))
StatBar(segments = segments)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
StageLegend(label = stringResource(R.string.detail_sleep_deep), tint = MetricBlue)
StageLegend(label = stringResource(R.string.detail_sleep_light), tint = MetricBlue.copy(alpha = 0.45f))
StageLegend(label = stringResource(R.string.detail_sleep_awake), tint = MetricBlue.copy(alpha = 0.2f))
}
// 入睡/起床演示值(ledger Ruling R29):Mock 的睡眠历史点是「每天一个」,首点是 7 天前、
// 末点是现在。直接拿 first/last 当入睡/起床会显示成「睡了 7 天」,在验收时看着像 bug。
// 这里改成以最后一个数据点为起床时刻、往前推 8 小时作为入睡时刻 —— 一张卡片讲「一晚」。
// M3 接入真实睡眠数据后改为读取实际分期时段。
val wakeUp = points.lastOrNull()?.timestamp
if (wakeUp != null) {
// 只有拿到起床时刻才渲染这一行;入睡时刻由它推出,所以判空只需判 wakeUp
// (brief 原文写的是 `fallAsleep != null && wakeUp != null`,Kotlin 会因其恒真而告警,
// 语义与此处完全等价)。
val fallAsleep = wakeUp - DEMO_SLEEP_WINDOW_MILLIS
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "${stringResource(R.string.detail_sleep_fall_asleep)} ${formatClock(fallAsleep)}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Text(
text = "${stringResource(R.string.detail_sleep_wake_up)} ${formatClock(wakeUp)}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
/** 阶段一演示用的「一晚」时长:8 小时。M3 接入真实睡眠数据后删除。 */
private const val DEMO_SLEEP_WINDOW_MILLIS = 8L * 60 * 60 * 1000
/** 分期图例:小色块 + 名称。 */
@Composable
private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.clip(RoundedCornerShape(5.dp))
.background(tint),
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(start = 6.dp),
)
}
}
/** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */
@Composable
private fun metricTitleRes(type: MetricType): Int = when (type) {
MetricType.STEP -> R.string.metric_step
MetricType.HEART_RATE -> R.string.metric_heart_rate
MetricType.SLEEP -> R.string.metric_sleep
MetricType.BLOOD_OXYGEN -> R.string.metric_blood_oxygen
MetricType.STRESS -> R.string.metric_stress
MetricType.TEMPERATURE -> R.string.metric_temperature
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
private fun RangeSelector(
@@ -298,53 +184,90 @@ private fun RangeSelector(
}
}
/** 一行统计摘要。 */
@Composable
private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
// 数值格式化一律走 core/common/Formatters.kt(Step 1c),页面里不写 String.format:
// 那是格式串副本,改口径时要满仓库找。
Text(text = formatDecimal(value), style = MaterialTheme.typography.bodyMedium)
}
}
/** 空列表时返回 0.0,避免 NaN。 */
private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
/** Preview 用的今日概览。 */
private val PreviewSummary = HealthSummary(
steps = 8426,
stepGoal = 10000,
calories = 327,
distanceKm = 6.21,
heartRate = 72,
sleepMinutes = 452,
bloodOxygen = 98,
stress = 34,
temperature = 36.5,
bloodPressure = null,
)
/**
* Preview 用的样例数据:7 个点、每天一个,取值从 `base` 起每点递增 `step`。
* 构造一份预览状态;各指标页的 Preview 共用它,避免七个预览各写一遍 HealthSummary。
*
* `base` 与 `step` 由调用方按**所在指标的取值区间**给出:`base` 取区间低位,
* `step` 取区间跨度的约 1/7,使 7 个点铺满区间的大半。这样柱状图才有可见的高低差
* (`MetricBarChart` 按 `value / maxValue` 定高,点值若都挤在一起,7 根柱子会齐平,
* 预览就看不出渲染问题),统计摘要的「平均/最高/最低」三行也不会几乎相等。
* @param enabled 该指标是否已开启。默认 true 对应 [PreviewSummary] 里给全了的那些值;
* 血压是未开启的,它的预览显式传 false。
* @param sleepSessions 只有睡眠预览会传。睡眠时段图要的是「几点到几点」,
* 而入睡时刻是**本地时钟上的位置**,随手编一个时间戳会让整张图平移几个钟头,
* 所以这里直接向 [MockDataProvider] 要一份演示数据,不另写一套日历换算。
*/
private fun previewPoints(base: Double, step: Double): List<MetricPoint> {
val dayMillis = 24L * 60 * 60 * 1000
val start = 1_700_000_000_000L
return List(7) { index -> MetricPoint(timestamp = start + index * dayMillis, value = base + index * step) }
}
private fun previewState(
metric: MetricType,
base: Double,
step: Double,
enabled: Boolean = true,
sleepSessions: List<SleepSession> = emptyList(),
): MetricDetailUiState =
MetricDetailUiState(
loading = false,
metric = metric,
range = DateRange.WEEK,
points = previewPoints(base = base, step = step),
summary = PreviewSummary,
enabled = enabled,
sleepSessions = sleepSessions,
)
/**
* 睡眠指标的详情页预览:走 `metric == SLEEP` 分支,带分期卡片。
* 取 SLEEP 不是随便挑的 —— 睡眠是唯一有专属区块的指标,预览它才能看见 `SleepStagesCard`。
* 七个指标的预览。
*
* 一个 Preview 一个指标,是因为它们现在真的是七个不同的页面 ——
* 改造前「预览步数页」看不出别的页有没有坏,现在也看不出,但至少每个页面都有一张自己
* 能对照的图。数值区间按各指标的真实量程给(见 [previewPoints] 的说明)。
*/
@Preview(showBackground = true)
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun MetricDetailContentSleepPreview() {
private fun StepDetailPreview() {
RingHealthTheme {
MetricDetailContent(
state = MetricDetailUiState(
loading = false,
// 步数:3200→11000,落在 Mock 的 3000~12000 区间内,七根柱子高度递增且都在目标线附近。
state = previewState(MetricType.STEP, base = 3200.0, step = 1300.0),
onBack = {},
onSelectRange = {},
)
}
}
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun HeartRateDetailPreview() {
RingHealthTheme {
// 心率:58→96,正好跨过正常带的下沿(60)并逼近上沿(100),区间带与柱子的关系看得出来。
MetricDetailContent(
state = previewState(MetricType.HEART_RATE, base = 58.0, step = 6.3),
onBack = {},
onSelectRange = {},
)
}
}
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun SleepDetailPreview() {
RingHealthTheme {
// 睡眠时长(分钟):330→480,跨过 8 小时目标线。
MetricDetailContent(
state = previewState(
metric = MetricType.SLEEP,
range = DateRange.WEEK,
// 睡眠时长(分钟):330→480,落在 Mock 的 330~510 区间内,跨度 150 分钟,
// 柱高从约 69% 递增到 100%,肉眼能看出梯度。
points = previewPoints(base = 330.0, step = 25.0),
base = 330.0,
step = 25.0,
sleepSessions = MockDataProvider().sleepSessions(DateRange.WEEK),
),
onBack = {},
onSelectRange = {},
@@ -352,26 +275,76 @@ private fun MetricDetailContentSleepPreview() {
}
}
/**
* 非睡眠指标的详情页预览:走「只有趋势图 + 统计摘要」的分支。
* 两个 Preview 各覆盖一个分支,是因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」这类回归。
*/
@Preview(showBackground = true)
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun MetricDetailContentStepsPreview() {
private fun BloodOxygenDetailPreview() {
RingHealthTheme {
// 血氧:94.0→97.2 —— 起点在线下、终点在线上,阈值带两侧都有点,才能验出警示色有没有生效。
MetricDetailContent(
state = previewState(MetricType.BLOOD_OXYGEN, base = 94.0, step = 0.53),
onBack = {},
onSelectRange = {},
)
}
}
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun StressDetailPreview() {
RingHealthTheme {
// 压力:18→66 —— 放松(<30)、正常(30–60)、中等(60–80) 三档都会出现,四色图例能对上号。
MetricDetailContent(
state = previewState(MetricType.STRESS, base = 18.0, step = 8.0),
onBack = {},
onSelectRange = {},
)
}
}
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun TemperatureDetailPreview() {
RingHealthTheme {
// 体温:36.1→36.7 —— 全部低于 36.5 基线之上一点点,两侧偏离都出现。
MetricDetailContent(
state = previewState(MetricType.TEMPERATURE, base = 36.1, step = 0.1),
onBack = {},
onSelectRange = {},
)
}
}
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun BloodPressureDetailPreview() {
RingHealthTheme {
MetricDetailContent(
state = MetricDetailUiState(
loading = false,
metric = MetricType.STEP,
range = DateRange.WEEK,
// 步数:3200→11000,落在 Mock 的 3000~12000 区间内,跨度 7800 步,
// 柱高从约 27% 递增到 100%。原来的 7500 起点会让 7 根柱子全挤在 99%~100% 高度上,
// 看起来是一条平带 —— 恰是预览本该暴露的问题。
points = previewPoints(base = 3200.0, step = 1300.0),
// 血压在演示数据里默认关着,所以这一页预览的是「未开启」说明卡。
state = previewState(
metric = MetricType.BLOOD_PRESSURE,
base = 110.0,
step = 3.3,
enabled = false,
),
onBack = {},
onSelectRange = {},
)
}
}
/** 血压开着时的样子:一对读数 + 「还没开始生成记录」的说明。 */
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun BloodPressureEnabledPreview() {
RingHealthTheme {
MetricDetailContent(
state = previewState(
metric = MetricType.BLOOD_PRESSURE,
base = 110.0,
step = 3.3,
).copy(summary = PreviewSummary.copy(bloodPressure = "118/76")),
onBack = {},
onSelectRange = {},
)
}
}
@@ -0,0 +1,254 @@
package com.placeholder.ringhealth.feature.detail
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.common.formatMonthDayNumeric
import com.placeholder.ringhealth.core.common.formatSleepDuration
import com.placeholder.ringhealth.core.common.formatSteps
import com.placeholder.ringhealth.core.common.weekdayResFor
import com.placeholder.ringhealth.core.designsystem.TextPrimary
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.metricIcon
import com.placeholder.ringhealth.core.designsystem.metricTint
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import kotlin.math.roundToInt
/**
* 七个指标详情页共用的小件与口径。
*
* 放在这里的东西有两个共同点:**每个页面都要用**,且**不因指标而变**。
* 只要一个东西是「心率页独有的」,它就该待在自己的页面文件里 ——
* 那正是这次改造要恢复的东西:此前七页共用一整套模板,于是七页长得一模一样。
*/
/** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */
@Composable
internal fun metricTitleRes(type: MetricType): Int = when (type) {
MetricType.STEP -> R.string.metric_step
MetricType.HEART_RATE -> R.string.metric_heart_rate
MetricType.SLEEP -> R.string.metric_sleep
MetricType.BLOOD_OXYGEN -> R.string.metric_blood_oxygen
MetricType.STRESS -> R.string.metric_stress
MetricType.TEMPERATURE -> R.string.metric_temperature
MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure
}
/**
* hero 区的大字读数:数值 + 可选单位,纵向排列在主标签之下。
*
* 单位用更小更浅的字 —— 「72」与「次/分」一样重的话,一眼看不出哪个是要读的数。
*/
@Composable
internal fun HeroReading(
value: String,
unit: String,
modifier: Modifier = Modifier,
unitRes: Int? = null,
) {
Row(modifier = modifier, verticalAlignment = Alignment.Bottom) {
Text(text = value, style = MaterialTheme.typography.displaySmall, color = TextPrimary)
val unitText = unitRes?.let { stringResource(it) } ?: unit
if (unitText.isNotEmpty()) {
Text(
text = unitText,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp),
)
}
}
}
/**
* 一列统计摘要:标签在上、数值在下。
*
* 三项横排而不是三行竖排:三行会把这张卡撑到 150dp 高去放三个数字,
* 横排后高度减半,且三者并列时更容易互相比较。
*
* @param valueColor 数值的强调色;null 表示用正文色。只有「这个数字本身是坏消息」时才染
* (如血氧的偏低天数),全列染色会让三列失去主次。
*/
@Composable
internal fun StatColumn(
label: String,
value: String,
unit: String,
modifier: Modifier = Modifier,
valueColor: Color? = null,
) {
Column(modifier = modifier) {
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
color = valueColor ?: TextPrimary,
)
if (unit.isNotEmpty()) {
Text(
text = unit,
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(start = 3.dp, bottom = 3.dp),
)
}
}
}
}
/** 统计摘要的三个槽位,横向铺满一行。 */
@Composable
internal fun StatRow(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
content()
}
}
/**
* 未开启指标的说明卡:图标 + 「XX监测未开启」+ 一句怎么办。
*
* 七个指标共用一张。哪一项关着就显示哪一项的图标和名字 —— 用户是从首页那张写着
* 「未开启」的卡点进来的,这里的图标和颜色必须和那张卡一模一样,他才知道自己没点错。
*
* 文案拆成「标题」和「怎么办」两句:[titleRes] 只报状态,[R.string.detail_disabled_hint]
* 统一说明去设备页开启。此前只有血压一页有这段说明,而用户一旦关掉别的指标,
* 详情页仍会画出一整张图 —— 那段并不存在的监测数据比空白更误导。
*/
@Composable
internal fun MetricDisabledCard(metric: MetricType, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = metricIcon(metric),
contentDescription = null,
tint = metricTint(metric),
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.height(14.dp))
Text(
text = stringResource(
R.string.detail_disabled_title,
stringResource(metricTitleRes(metric)),
),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(8.dp))
// 说明文案居中对齐、左右留白:它是一段话而不是一行标签,铺满整行会很难读。
Text(
text = stringResource(R.string.detail_disabled_hint),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
/** 详情页统计摘要的显示口径:数值保留几位小数、单位是什么。 */
internal data class StatFormat(val decimals: Int, val unit: String)
/**
* 指标 → 统计摘要的显示口径。
*
* 小数位按指标分别定:此前一律走 `"%.1f"`,于是心率显示成「78.0」、步数显示成「8426.0」——
* 尾随的 `.0` 是假精度,看着像仪器读数精度比实际高一个数量级。
*/
@Composable
internal fun statFormatFor(type: MetricType): StatFormat = when (type) {
// 体温保留一位:36.5 与 36 之间差的 0.5℃ 是有意义的差别,取整会把它抹掉。
MetricType.TEMPERATURE -> StatFormat(decimals = 1, unit = stringResource(R.string.unit_celsius))
MetricType.HEART_RATE -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_bpm))
MetricType.BLOOD_OXYGEN -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_percent))
MetricType.BLOOD_PRESSURE -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_mmhg))
MetricType.STEP -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_steps))
// 压力是 0–100 的评分,本身没有单位;睡眠的数值在 formatStatValue 里换成时长文本,也不需要单位。
MetricType.STRESS, MetricType.SLEEP -> StatFormat(decimals = 0, unit = "")
}
/**
* 统计数值 → 展示文本。
*
* 数值格式化一律走 `core/common/Formatters.kt`,页面里不写 String.format:
* 那是格式串副本,改口径时要满仓库找。
*
* 睡眠是个例外:它的数据点单位是「分钟」,但「平均 451 分钟」远不如「平均 7小时31分」好读,
* 所以这里换成与首页同一套时长格式,而不是走 [formatDecimal]。
*/
internal fun formatStatValue(value: Double, type: MetricType, format: StatFormat): String =
when (type) {
// 睡眠的数值单位是「分钟」,但「平均 451 分钟」远不如「平均 7小时31分」好读。
MetricType.SLEEP -> formatSleepDuration(value.roundToInt())
// 步数要走千分位:首页的步数是「8,426」,详情页写成「8426」会像是两套口径。
MetricType.STEP -> formatSteps(value.roundToInt())
else -> formatDecimal(value, format.decimals)
}
/** 柱状图 X 轴的刻度数量上限。 */
private const val MAX_AXIS_TICKS = 7
/**
* 生成图表的 X 轴刻度。
*
* 点数不多(周视图 7 个点)时每根柱子都标,刻度与柱子一一对齐;
* 点数多(月视图 30 个点)时等距取 [MAX_AXIS_TICKS] 个 —— 30 个「9/10」在约 350dp 宽里
* 每个只分得到 11dp,必然被截成一两个字,不如少标几个能看清的。
*
* 周视图用星期简写(走 `strings.xml`,Ruling R3);月视图用 `M/d` 纯数字格式 ——
* 它不含任何需要翻译的文字,与 R25 对「日期 + 空格 + 时间」的判断一致。
*/
@Composable
internal fun chartAxisLabels(points: List<MetricPoint>): List<String> {
if (points.isEmpty()) return emptyList()
if (points.size <= MAX_AXIS_TICKS) {
return points.map { stringResource(weekdayResFor(it.timestamp)) }
}
val step = (points.size - 1).toFloat() / (MAX_AXIS_TICKS - 1)
return (0 until MAX_AXIS_TICKS).map { tick ->
val index = (tick * step).roundToInt().coerceIn(0, points.lastIndex)
formatMonthDayNumeric(points[index].timestamp)
}
}
/**
* Preview 用的样例数据:7 个点、每天一个,取值从 `base` 起每点递增 `step`。
*
* `base` 与 `step` 由调用方按**所在指标的取值区间**给出:`base` 取区间低位,
* `step` 取区间跨度的约 1/7,使 7 个点铺满区间的大半。这样图才有可见的高低差,
* 统计摘要的三列也不会几乎相等 —— 若点值都挤在一起,七根柱子会齐平,预览就看不出渲染问题。
*/
internal fun previewPoints(base: Double, step: Double): List<MetricPoint> {
val dayMillis = 24L * 60 * 60 * 1000
val start = 1_700_000_000_000L
return List(7) { index -> MetricPoint(timestamp = start + index * dayMillis, value = base + index * step) }
}
@@ -6,25 +6,45 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.domain.model.isEnabled
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import com.placeholder.ringhealth.ringHealthApplication
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
/** 指标详情 UI 状态。 */
/**
* 指标详情 UI 状态。
*
* [summary] 是今日概览,各指标的 hero 卡从它取「当前值」(今日步数、当前心率……)。
* 历史点 [points] 只回答「这几天怎么变的」,取不出「现在是多少」——
* 之前 hero 区缺的就是这个值,页面第一屏只有一个标题和一张图。
*
* [enabled] 是该指标的功能开关是否打开。关掉的指标整页走「未开启」分支、不画图:
* 它的历史点即便有(Mock 会照常生成随机序列),画的也是不存在的监测数据。
*
* [sleepSessions] 只有睡眠页会拿到内容,其余指标恒为空列表(见 [MetricDetailViewModel.sleepSessionsFor])。
* 睡眠时段图要的是「几点到几点」,[points] 里装不下,只能另走一路。
*/
data class MetricDetailUiState(
val loading: Boolean,
val metric: MetricType,
val range: DateRange,
val points: List<MetricPoint>,
val summary: HealthSummary?,
val enabled: Boolean,
val sleepSessions: List<SleepSession>,
)
/**
@@ -33,22 +53,32 @@ data class MetricDetailUiState(
@OptIn(ExperimentalCoroutinesApi::class)
class MetricDetailViewModel(
private val metric: MetricType,
healthDataRepository: HealthDataRepository,
private val healthDataRepository: HealthDataRepository,
) : ViewModel() {
private val range = MutableStateFlow(DateRange.WEEK)
/** 今日概览只需要订阅一次,与时间范围无关,所以放在 flatMapLatest 外面。 */
private val summary = healthDataRepository.observeTodaySummary()
val uiState: StateFlow<MetricDetailUiState> = range
.flatMapLatest { currentRange ->
healthDataRepository.observeMetricHistory(metric, currentRange)
.map { points ->
MetricDetailUiState(
loading = false,
metric = metric,
range = currentRange,
points = points,
)
}
combine(
healthDataRepository.observeMetricHistory(metric, currentRange),
summary,
sleepSessionsFor(currentRange),
) { points, today, sessions ->
MetricDetailUiState(
loading = false,
metric = metric,
range = currentRange,
points = points,
summary = today,
// 概览里有没有这个指标的值,就是它开没开 —— 概览本身由开关生成。
enabled = today.isEnabled(metric),
sleepSessions = sessions,
)
}
}
.stateIn(
scope = viewModelScope,
@@ -58,9 +88,26 @@ class MetricDetailViewModel(
metric = metric,
range = DateRange.WEEK,
points = emptyList(),
summary = null,
enabled = true,
sleepSessions = emptyList(),
),
)
/**
* 睡眠时段只在睡眠页取;其余六个指标拿一个恒空的流。
*
* 不写成「顺手也查一遍、反正用不上」:那会让每个指标页都多订阅一路睡眠数据,
* M2 换成真实实现后就是六次无谓的设备查询,而且多出来的那路数据将来一定会有人用,
* 用着用着就分不清哪个页面依赖什么了。
*/
private fun sleepSessionsFor(currentRange: DateRange): Flow<List<SleepSession>> =
if (metric == MetricType.SLEEP) {
healthDataRepository.observeSleepSessions(currentRange)
} else {
flowOf(emptyList())
}
/** 切换时间范围(周/月)。 */
fun switchRange(newRange: DateRange) {
range.update { newRange }
@@ -0,0 +1,79 @@
package com.placeholder.ringhealth.feature.detail
import com.placeholder.ringhealth.core.common.minutesSinceSleepAnchor
import com.placeholder.ringhealth.domain.model.SleepSession
import kotlin.math.roundToInt
/**
* 一组数据点的统计摘要。
*
* 抽成独立类型而不是散着传四个 Double,是为了让「空数据」有一个明确的表示 ——
* 见 [metricStatsOf]。
*/
data class MetricStats(
val average: Double,
val max: Double,
val min: Double,
val count: Int,
)
/**
* 求统计摘要;**列表为空时返回 null**。
*
* 此前这里返回 0.0,页面上于是出现「平均 0.0 / 最高 0.0 / 最低 0.0」三行 ——
* 看着像三条真实读数,其实是「没有数据」。空数据要么不渲染,要么渲染成「暂无数据」,
* 不能渲染成 0:0 次/分的心率是个能吓死人的假数。
*/
fun metricStatsOf(values: List<Double>): MetricStats? {
if (values.isEmpty()) return null
return MetricStats(
average = values.average(),
max = values.max(),
min = values.min(),
count = values.size,
)
}
/**
* 睡眠统计。入睡/醒来给的是**距锚点 20:00 的分钟数**(见 `minutesSinceSleepAnchor`),
* 不是钟点本身 —— 直接对「23:43」「00:31」这样的时刻求平均会得到 12:07,正好是睡得最沉的时候。
* 显示时用 `formatSleepAnchorOffset` 换回钟点。
*
* @param averageBedtimeMinute 平均入睡时刻(锚点偏移量)。
* @param averageWakeMinute 平均醒来时刻(锚点偏移量)。
* @param averageSleepMinutes 平均实际睡眠时长,不含夜里清醒。
* @param count 统计了几晚。
*/
data class SleepStats(
val averageBedtimeMinute: Int,
val averageWakeMinute: Int,
val averageSleepMinutes: Int,
val count: Int,
)
/**
* 求睡眠统计;**列表为空时返回 null**,理由同 [metricStatsOf]。
*
* 前提:这几晚的作息相差不到半圈(12 小时)。演示数据里入睡都在 21:00–04:00 之间,成立;
* 若将来出现「白天补觉」这类记录,锚点归一化会把 14:00 当成前一天的深夜,
* 均值就会被拽偏,那时该改用中位数。
*/
fun sleepStatsOf(sessions: List<SleepSession>): SleepStats? {
if (sessions.isEmpty()) return null
fun averageOf(values: List<Int>): Int = values.average().roundToInt()
return SleepStats(
averageBedtimeMinute = averageOf(sessions.map { minutesSinceSleepAnchor(it.sleepStart) }),
averageWakeMinute = averageOf(sessions.map { minutesSinceSleepAnchor(it.sleepEnd) }),
averageSleepMinutes = averageOf(sessions.map { it.sleepMinutes }),
count = sessions.size,
)
}
/** 落在 [range] 内的天数。心率页的「正常范围天数」用它。 */
fun List<Double>.countWithin(range: ClosedFloatingPointRange<Double>): Int = count { it in range }
/** 低于 [threshold] 的天数。血氧页的「偏低天数」用它。 */
fun List<Double>.countBelow(threshold: Double): Int = count { it < threshold }
/** 不低于 [threshold] 的天数。步数页的「达标天数」用它。 */
fun List<Double>.countAtLeast(threshold: Double): Int = count { it >= threshold }
@@ -0,0 +1,145 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.designsystem.MetricCyan
import com.placeholder.ringhealth.core.designsystem.MetricRed
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricThresholdStrip
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.MetricReference
import com.placeholder.ringhealth.feature.detail.HeroReading
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
import com.placeholder.ringhealth.feature.detail.countBelow
import com.placeholder.ringhealth.feature.detail.formatStatValue
import com.placeholder.ringhealth.feature.detail.metricStatsOf
import com.placeholder.ringhealth.feature.detail.statFormatFor
/**
* 血氧详情页。
*
* 血氧的特点是**只有一个下限**:高了没有意义(人体血氧到 100% 就到顶了),
* 低了才是问题。所以这一页全篇围绕「95% 这条线」组织 ——
* 头图给出「达标 / 偏低」的判定,趋势图画成阈值带(点在线的哪一侧就是结论),
* 统计里第三列是「偏低几天」而不是「最高多少」。
*/
internal fun LazyListScope.bloodOxygenDetail(state: MetricDetailUiState) {
item { BloodOxygenHeroCard(state) }
item { BloodOxygenChartCard(state) }
item { BloodOxygenStatsCard(state) }
}
/** 头图:当前血氧 + 达标判定 + 达标线说明。 */
@Composable
private fun BloodOxygenHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val current = state.summary?.bloodOxygen ?: return
val below = current < MetricReference.BLOOD_OXYGEN_MIN
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.metric_blood_oxygen),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
HeroReading(value = current.toString(), unit = "", unitRes = R.string.unit_percent)
Spacer(modifier = Modifier.height(10.dp))
// 判定用文字 + 颜色双重表达:单靠颜色的话,色觉障碍用户读不出「达标」还是「偏低」。
Text(
text = stringResource(
if (below) R.string.detail_status_low else R.string.detail_status_normal,
),
style = MaterialTheme.typography.titleMedium,
color = if (below) MetricRed else MetricCyan,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.detail_spo2_threshold, formatDecimal(MetricReference.BLOOD_OXYGEN_MIN, 0)),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
/** 趋势图:阈值带。 */
@Composable
private fun BloodOxygenChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_blood_oxygen)),
trailing = {
Text(
text = stringResource(
R.string.detail_spo2_threshold,
formatDecimal(MetricReference.BLOOD_OXYGEN_MIN, 0),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
},
)
MetricThresholdStrip(
values = state.points.map { it.value },
// 纵轴下沿取 FLOOR(90)而不是阈值(95):低于阈值的点若被夹在轴上,
// 会集体贴着底边,看不出「低了 1%」还是「低了 5%」。
axis = MetricReference.BLOOD_OXYGEN_FLOOR..MetricReference.BLOOD_OXYGEN_MIN.plus(
SPO2_AXIS_HEADROOM,
),
threshold = MetricReference.BLOOD_OXYGEN_MIN,
color = MetricCyan,
warningColor = MetricRed,
axisLabels = chartAxisLabels(state.points),
)
}
}
/**
* 纵轴上沿比阈值高出的部分。
*
* 血氧正常值 95–100,最高就是 100(血氧仪的上限)。上沿取 100 而不是「数据最大值」——
* 固定上沿才能让「点在线的上方多远处」在不同周之间可比。
*/
private const val SPO2_AXIS_HEADROOM = 5.0
/** 统计卡:平均 / 最低 / 偏低天数。 */
@Composable
private fun BloodOxygenStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val values = state.points.map { it.value }
val stats = metricStatsOf(values) ?: return
val format = statFormatFor(state.metric)
val lowDays = values.countBelow(MetricReference.BLOOD_OXYGEN_MIN)
HealthCard(modifier = modifier) {
StatRow {
StatColumn(
label = stringResource(R.string.detail_average),
value = formatStatValue(stats.average, state.metric, format),
unit = format.unit,
)
StatColumn(
label = stringResource(R.string.detail_min),
value = formatStatValue(stats.min, state.metric, format),
unit = format.unit,
)
// 第三列给「偏低天数」,并把数字染成警示色 —— 只在真的出现偏低时才染色,
// 「偏低 0 天」染红会让人以为出了事。
StatColumn(
label = stringResource(R.string.detail_status_low),
value = stringResource(R.string.detail_days_count, lowDays),
unit = "",
valueColor = if (lowDays > 0) MetricRed else null,
)
}
}
}
@@ -0,0 +1,98 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
/**
* 血压详情页:**已开启态**。
*
* 未开启的那一态不在这里 —— 它由 `MetricDetailScreen` 统一的「未开启」分支负责
* (`MetricDisabledCard`),因为「关掉的指标不画图」是七个页面共同的事,不该由某一页各自实现。
* 本页因此可以专注于一件事:功能开着的时候显示什么。
*
* **它不画趋势图,这是有意的。** 血压与其他六个指标不同:一次测量是「收缩压/舒张压」两个数,
* 而当前的历史数据模型 `MetricPoint(timestamp, value: Double)` 只装得下一个数,
* `metricHistory(BLOOD_PRESSURE)` 返回的那串 110–130 是 Mock 随手生成的单值序列,
* 既不是收缩压也不是舒张压,画出来是一张无意义的图。M3 接入真实血压数据后,
* 这一页改成双值趋势图,`HealthSummary.bloodPressure` 也从字符串改为结构化的两个数值。
*/
internal fun LazyListScope.bloodPressureDetail(state: MetricDetailUiState) {
item { BloodPressureHeroCard(state) }
item { BloodPressurePendingCard() }
}
/**
* 头图:一对读数。
*
* 收缩压与舒张压并排,而不是像其他指标那样给一个大字 —— 「118/76」本身就是一对数,
* 拆成两栏才看得出哪个是哪个;挤成一行大字,用户只会看到一个比别的指标长很多的数字。
*/
@Composable
private fun BloodPressureHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
// 概览里是「收缩压/舒张压」字符串(如 "118/76")。M1 的 Mock 保证格式固定,
// 取不到两段时整卡不显示,而不是硬凑一个「--/--」。
val reading = state.summary?.bloodPressure ?: return
val parts = reading.split("/")
if (parts.size != 2) return
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.detail_bp_latest),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
StatColumn(
label = stringResource(R.string.detail_bp_systolic),
value = parts[0].trim(),
unit = stringResource(R.string.unit_mmhg),
)
StatColumn(
label = stringResource(R.string.detail_bp_diastolic),
value = parts[1].trim(),
unit = stringResource(R.string.unit_mmhg),
)
}
}
}
/**
* 说明卡:功能开着,但还没有可看的历史。
*
* 这一句必须留着。功能刚打开时用户最想知道的是「然后呢」——
* 血压与其余指标不同,它需要先在设备页完成手动校准才会开始生成记录
* (同一句话在设备页那个开关的副标题里也写着)。省掉它,这一页就只剩一对孤零零的读数,
* 用户会以为历史数据加载失败了。
*/
@Composable
private fun BloodPressurePendingCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
SectionHeader(title = stringResource(R.string.detail_bp_history))
Text(
text = stringResource(R.string.detail_bp_pending),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
@@ -0,0 +1,147 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.designsystem.MetricRed
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
import com.placeholder.ringhealth.core.designsystem.component.MetricRangeBar
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.MetricReference
import com.placeholder.ringhealth.feature.detail.HeroReading
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
import com.placeholder.ringhealth.feature.detail.countWithin
import com.placeholder.ringhealth.feature.detail.formatStatValue
import com.placeholder.ringhealth.feature.detail.metricStatsOf
import com.placeholder.ringhealth.feature.detail.statFormatFor
/**
* 心率详情页。
*
* 心率的特点是**有正常区间**:「72」这个数字本身说明不了什么,落在 60–100 之内才算正常静息。
* 所以头图的核心不是数字而是那根**区间条** —— 数字只回答「是多少」,区间条回答「算不算正常」。
* 趋势图同样在柱子后面铺一层正常带,柱子冒出去就是那天出了问题。
*/
internal fun LazyListScope.heartRateDetail(state: MetricDetailUiState) {
item { HeartRateHeroCard(state) }
item { HeartRateChartCard(state) }
item { HeartRateStatsCard(state) }
}
/** 头图:当前心率 + 区间条 + 状态文案。 */
@Composable
private fun HeartRateHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val current = state.summary?.heartRate ?: return
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.metric_heart_rate),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
HeroReading(
value = current.toString(),
unit = "",
unitRes = R.string.unit_bpm,
)
Spacer(modifier = Modifier.height(14.dp))
MetricRangeBar(
value = current.toDouble(),
axis = MetricReference.HEART_RATE_AXIS,
normal = MetricReference.HEART_RATE_NORMAL,
color = MetricRed,
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = stringResource(
R.string.detail_hr_normal_band,
formatDecimal(MetricReference.HEART_RATE_NORMAL.start, 0),
formatDecimal(MetricReference.HEART_RATE_NORMAL.endInclusive, 0),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
/**
* 趋势图:柱子 + 正常区间带。
*
* 纵向窗口固定取 [MetricReference.HEART_RATE_AXIS](40–120)而不是按数据自适应:
* 正常带的位置要**每次都一样**,否则同一份 60–100 的带子在不同周里画在不同高度,
* 「柱子冒出去了没有」就失去了跨页面的可比性。
*/
@Composable
private fun HeartRateChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_heart_rate)),
trailing = {
// 参考带的含义必须在图边上写清:一条淡色带子不标注,读者只会当成背景色。
Text(
text = stringResource(
R.string.detail_hr_normal_band,
formatDecimal(MetricReference.HEART_RATE_NORMAL.start, 0),
formatDecimal(MetricReference.HEART_RATE_NORMAL.endInclusive, 0),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
},
)
MetricBarChart(
values = state.points.map { it.value.toFloat() },
color = MetricRed,
valueRange = MetricReference.HEART_RATE_AXIS.start.toFloat()..
MetricReference.HEART_RATE_AXIS.endInclusive.toFloat(),
bandRange = MetricReference.HEART_RATE_NORMAL.start.toFloat()..
MetricReference.HEART_RATE_NORMAL.endInclusive.toFloat(),
axisLabels = chartAxisLabels(state.points),
)
}
}
/** 统计卡:平均 / 最高 / 正常范围天数。 */
@Composable
private fun HeartRateStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val values = state.points.map { it.value }
val stats = metricStatsOf(values) ?: return
val format = statFormatFor(state.metric)
HealthCard(modifier = modifier) {
StatRow {
StatColumn(
label = stringResource(R.string.detail_average),
value = formatStatValue(stats.average, state.metric, format),
unit = format.unit,
)
StatColumn(
label = stringResource(R.string.detail_max),
value = formatStatValue(stats.max, state.metric, format),
unit = format.unit,
)
// 第三列不给「最低」而给「正常范围天数」:心率低到 55 对运动员是好事,
// 单看一个最低值会被误读成异常,而「7 天里正常 6 天」没有这个歧义。
StatColumn(
label = stringResource(R.string.detail_hr_normal_days),
value = stringResource(
R.string.detail_days_count,
values.countWithin(MetricReference.HEART_RATE_NORMAL),
),
unit = "",
)
}
}
}
@@ -0,0 +1,256 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatClock
import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.common.formatMonthDayNumeric
import com.placeholder.ringhealth.core.common.formatSleepAnchorOffset
import com.placeholder.ringhealth.core.common.formatSleepDuration
import com.placeholder.ringhealth.core.common.weekdayResFor
import com.placeholder.ringhealth.core.designsystem.MetricBlue
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.core.designsystem.component.SleepWindowChart
import com.placeholder.ringhealth.core.designsystem.component.StageTints
import com.placeholder.ringhealth.core.designsystem.component.StatBar
import com.placeholder.ringhealth.core.designsystem.component.StatSegment
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.feature.detail.HeroReading
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.sleepStatsOf
/**
* 睡眠详情页。
*
* 睡眠在一天里占的是**一段区间**,所以这一页的头图不是光秃秃一个数字,而是
* 「睡了多久 + 从几点到几点 + 睡得沉不沉」;下面那张时段图把最近几晚的区间并排摆开,
* 横轴是钟点 —— 用户真正会问的「我每天都几点睡」只有这张图回答得了。
*
* 与之相对,通用的柱状图在这一页**不出现**:柱子高度就是时长,而时段图的条长同样是时长,
* 两张图讲同一件事,只会让人多看一遍。
*/
internal fun LazyListScope.sleepDetail(state: MetricDetailUiState) {
item { SleepHeroCard(state) }
item { SleepWindowCard(state) }
item { SleepStatsCard(state) }
}
/** 演示用的目标睡眠时长:8 小时,与设计文档 §7 的口径一致。 */
private const val SLEEP_TARGET_MINUTES = 480
/**
* 分期配色:DEEP/LIGHT 走 MetricBlue 的两档深浅(深浅即「睡得沉不沉」),
* **AWAKE 故意跳出蓝色族**,用一枚暖灰偏粉 —— 睡眠是蓝色调,「醒了」要明显是另一件事。
* 「中途醒」是用户最该被告知的信息之一,不能让它和「睡着了但浅」混淆:
* 在时段图里它会落成一段独立暖色子矩形,在头图分期条里它会落成独立暖色段。
*/
private val DEEP_TINT = MetricBlue
private val LIGHT_TINT = MetricBlue.copy(alpha = 0.45f)
private val AWAKE_TINT = Color(0xFFE8B4A8)
/** 头图:昨晚时长 + 入睡/起床时刻 + 分期条与分期时长。 */
@Composable
private fun SleepHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
// 昨晚 = 最后一个会话。取不到就整卡不画,而不是退回 summary 里那个数字 ——
// 那样会出现「头图写着 7 小时 32 分、下面时段图里没有这一根」的两截对不上。
val session = state.sleepSessions.lastOrNull() ?: return
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.detail_sleep_last_night),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
HeroReading(value = formatSleepDuration(session.sleepMinutes), unit = "")
// 目标值留在头图这一行。原来它是柱状图上那条 8 小时参考线的附注,
// 而柱状图已被时段图取代 —— 目标本身仍然有用,不该跟着那张图一起消失。
Text(
text = stringResource(
R.string.detail_sleep_target,
formatSleepDuration(SLEEP_TARGET_MINUTES),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(bottom = 10.dp),
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "${stringResource(R.string.detail_sleep_fall_asleep)} ${formatClock(session.sleepStart)}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Text(
text = "${stringResource(R.string.detail_sleep_wake_up)} ${formatClock(session.sleepEnd)}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
Spacer(modifier = Modifier.height(16.dp))
// 分期条的权重取**卧床时长**而不是睡着时长:清醒的那一段也要占位置,
// 否则「睡了 7 小时 32 分」的条里藏着一截其实醒着的时间,比例看着对不上。
StatBar(segments = session.stageSegments())
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
StageLegend(
label = stringResource(R.string.detail_sleep_deep),
minutes = session.deepMinutes,
tint = DEEP_TINT,
)
StageLegend(
label = stringResource(R.string.detail_sleep_light),
minutes = session.lightMinutes,
tint = LIGHT_TINT,
)
StageLegend(
label = stringResource(R.string.detail_sleep_awake),
minutes = session.awakeMinutes,
tint = AWAKE_TINT,
)
}
}
}
/** 三个分段的权重 = 各期时长 / 卧床时长。卧床时长为 0 只可能是坏数据,兜一个极小正数。 */
private fun SleepSession.stageSegments(): List<StatSegment> {
val total = inBedMinutes.coerceAtLeast(1).toFloat()
return listOf(
StatSegment(weight = deepMinutes / total, color = DEEP_TINT),
StatSegment(weight = lightMinutes / total, color = LIGHT_TINT),
StatSegment(weight = awakeMinutes / total, color = AWAKE_TINT),
)
}
/** 分期图例:小色块 + 名称 + 时长。「深睡 1小时36分」比光一个「深睡」多告诉用户一件事。 */
@Composable
private fun StageLegend(label: String, minutes: Int, tint: Color, modifier: Modifier = Modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.clip(RoundedCornerShape(5.dp))
.background(tint),
)
Text(
text = "$label ${formatSleepDuration(minutes)}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(start = 6.dp),
)
}
}
/**
* 时段图:每晚一行,一根条从入睡横到起床,横轴是钟点。
*
* 行标签按范围换口径:近 7 天用星期(「周三」),近 30 天用日期(「9/10」)——
* 三十行里「周三」会出现四次,反而认不出是哪一天。
*/
@Composable
private fun SleepWindowCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
if (state.sleepSessions.isEmpty()) return
val stats = sleepStatsOf(state.sleepSessions)
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_sleep_window),
trailing = stats?.let {
{
Text(
text = stringResource(
R.string.detail_sleep_window_average,
formatSleepAnchorOffset(it.averageBedtimeMinute),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
},
)
SleepWindowChart(
sessions = state.sleepSessions,
color = MetricBlue,
// 启用分期染色:少数带 [SleepSession.stages] 的夜晚会把条拆成多色子矩形,
// 与头图分期条共用同一套色系(深浅 = 睡得沉不沉)。其他夜晚无 `stages` 字段,
// 组件会自己退回单色模式 —— 调用方不需要关心「哪些晚有分期」。
stageTints = StageTints(
deep = DEEP_TINT,
light = LIGHT_TINT,
awake = AWAKE_TINT,
),
rowLabel = { session ->
if (state.range == DateRange.WEEK) {
stringResource(weekdayResFor(session.wakeDate))
} else {
formatMonthDayNumeric(session.wakeDate)
}
},
)
}
}
/**
* 统计卡:平均入睡 / 平均醒来 / 平均时长。
*
* 前两项给的是**钟点**而不是时长 —— 「这几天平均几点睡」和「平均睡多久」是两个不同的问题,
* 而原来的最长/最短只回答后者,且回答得没什么用(最长那晚是几号?页面上没有)。
*/
@Composable
private fun SleepStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val stats = sleepStatsOf(state.sleepSessions) ?: return
HealthCard(modifier = modifier) {
StatRow {
StatColumn(
label = stringResource(R.string.detail_sleep_avg_fall_asleep),
value = formatSleepAnchorOffset(stats.averageBedtimeMinute),
unit = "",
)
StatColumn(
label = stringResource(R.string.detail_sleep_avg_wake_up),
value = formatSleepAnchorOffset(stats.averageWakeMinute),
unit = "",
)
StatColumn(
label = stringResource(R.string.detail_sleep_avg_duration),
// 用「7.5 小时」而不是「7小时32分」:三列并排,后者的宽度会把前两列的钟点挤扁。
value = formatDecimal(stats.averageSleepMinutes / 60.0, decimals = 1),
unit = stringResource(R.string.unit_hours),
)
}
}
}
@@ -0,0 +1,197 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatCalories
import com.placeholder.ringhealth.core.common.formatDistance
import com.placeholder.ringhealth.core.common.formatSteps
import com.placeholder.ringhealth.core.designsystem.DividerColor
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.TextPrimary
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
import com.placeholder.ringhealth.core.designsystem.component.RingProgress
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
import com.placeholder.ringhealth.feature.detail.countAtLeast
import com.placeholder.ringhealth.feature.detail.formatStatValue
import com.placeholder.ringhealth.feature.detail.metricStatsOf
import com.placeholder.ringhealth.feature.detail.statFormatFor
import kotlin.math.roundToInt
/**
* 步数详情页。
*
* 步数的特点是**有目标**:它不是一个「多少算正常」的体征,而是一个「今天走够了没有」的任务。
* 所以头图把「完成度」摆在最显眼处(环 + 百分比),趋势图的参考线画的是目标线而不是均值线 ——
* 均值只是「我平时走多少」,目标线才回答「今天够不够」。
*/
internal fun LazyListScope.stepDetail(state: MetricDetailUiState) {
item { StepsHeroCard(state) }
item { StepsChartCard(state) }
item { StepsStatsCard(state) }
}
/** 头图:进度环 + 完成度百分比 + 目标,右侧附距离与千卡。 */
@Composable
private fun StepsHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val summary = state.summary ?: return
val goal = summary.stepGoal.coerceAtLeast(1)
// 完成度可以超过 100%(走爆了是好事),但环只画到满;百分比照实显示。
val progress = summary.steps.toFloat() / goal
HealthCard(modifier = modifier, contentPadding = PaddingValues(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
RingProgress(
progress = progress,
color = MetricGreen,
trackColor = MetricGreen.copy(alpha = 0.15f),
ringSize = HERO_RING_SIZE,
strokeWidth = 10.dp,
) {
Text(text = formatSteps(summary.steps), style = HeroStepsStyle)
}
Column(modifier = Modifier.padding(start = 18.dp)) {
Text(
text = stringResource(R.string.detail_steps_progress),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = (progress * 100).roundToInt().toString(),
style = MaterialTheme.typography.displaySmall,
color = MetricGreen,
)
Text(
text = stringResource(R.string.unit_percent),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
modifier = Modifier.padding(start = 2.dp, bottom = 6.dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(color = DividerColor)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
HeroFact(value = formatDistance(summary.distanceKm), label = stringResource(R.string.home_distance_label))
HeroFact(value = formatCalories(summary.calories), label = stringResource(R.string.home_calories_label))
}
}
}
}
}
/** hero 里的一个小事实:数值在上、标签在下。 */
@Composable
private fun HeroFact(value: String, label: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(text = value, style = MaterialTheme.typography.titleMedium, color = TextPrimary)
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
}
/**
* 趋势图:柱子 + 目标线。
*
* 纵向窗口取「0 到 目标与历史最高中的较大者再上浮 15%」——
* 不能让参考线把柱子压扁:若窗口只按历史最高取,而目标恰好高于历史所有点,
* 目标线会贴到画布顶端,看着像页面的边框而不是一条线。
*/
@Composable
private fun StepsChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val goal = (state.summary?.stepGoal ?: 0).toFloat()
val values = state.points.map { it.value.toFloat() }
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_step)),
trailing = {
if (goal > 0f) {
Text(
text = stringResource(R.string.detail_goal, formatSteps(goal.toInt())),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
},
)
MetricBarChart(
values = values,
color = MetricGreen,
valueRange = 0f..(maxOf(values.maxOrNull() ?: 0f, goal) * 1.15f).coerceAtLeast(1f),
referenceLine = goal.takeIf { it > 0f },
axisLabels = chartAxisLabels(state.points),
)
}
}
/** 统计卡:日均 / 最高 / 达标天数。 */
@Composable
private fun StepsStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val values = state.points.map { it.value }
val stats = metricStatsOf(values) ?: return
val goal = (state.summary?.stepGoal ?: 0).toDouble()
val format = statFormatFor(state.metric)
HealthCard(modifier = modifier) {
StatRow {
StatColumn(
label = stringResource(R.string.detail_steps_average),
value = formatStatValue(stats.average, state.metric, format),
unit = format.unit,
)
StatColumn(
label = stringResource(R.string.detail_max),
value = formatStatValue(stats.max, state.metric, format),
unit = format.unit,
)
// 步数页的第三列不给「最低」,给「达标天数」—— 最低步数(比如卧床那天 300 步)
// 既不好看也没指导意义,而「7 天里达标 4 天」正是这张页面要回答的问题。
if (goal > 0.0) {
StatColumn(
label = stringResource(R.string.detail_steps_reached_label),
value = stringResource(R.string.detail_days_count, values.countAtLeast(goal)),
unit = "",
)
} else {
StatColumn(
label = stringResource(R.string.detail_min),
value = formatStatValue(stats.min, state.metric, format),
unit = format.unit,
)
}
}
}
}
/** 环内步数字号。比首页那个(28sp)小一号 —— 这里环也更小。 */
private val HeroStepsStyle = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-1).sp,
color = TextPrimary,
)
/** 头图圆环外径。比首页的小:详情页第一屏还要留位置给下面的图。 */
private val HERO_RING_SIZE = 108.dp
@@ -0,0 +1,178 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.stressLevelLabelRes
import com.placeholder.ringhealth.core.designsystem.MetricCyan
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.MetricOrange
import com.placeholder.ringhealth.core.designsystem.MetricRed
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.StressLevel
import com.placeholder.ringhealth.domain.model.stressLevelOf
import com.placeholder.ringhealth.feature.detail.HeroReading
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
import com.placeholder.ringhealth.feature.detail.formatStatValue
import com.placeholder.ringhealth.feature.detail.metricStatsOf
import com.placeholder.ringhealth.feature.detail.statFormatFor
/**
* 压力详情页。
*
* 压力的特点是**数值本身没有量纲意义**:31 和 62 的差别不是「差了 31 个单位」,
* 而是「从放松变成了中等」。所以这一页处处按**等级**而不是按数值组织 ——
* 头图给等级名而不是光给数字,趋势图每根柱子按它那天的等级着色,
* 统计里第三列是「放松几天」。同一张图上出现四种颜色,这在别的页面是要避免的,
* 在压力页恰恰是重点。
*/
internal fun LazyListScope.stressDetail(state: MetricDetailUiState) {
item { StressHeroCard(state) }
item { StressChartCard(state) }
item { StressStatsCard(state) }
}
/** 压力等级 → 颜色。等级与颜色的对应只此一份,图例与柱子共用。 */
private fun stressLevelColor(level: StressLevel): Color = when (level) {
StressLevel.RELAXED -> MetricGreen
StressLevel.NORMAL -> MetricCyan
StressLevel.MEDIUM -> MetricOrange
StressLevel.HIGH -> MetricRed
}
/** 头图:当前压力值 + 等级名(染色)。 */
@Composable
private fun StressHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val current = state.summary?.stress ?: return
val level = stressLevelOf(current.toDouble())
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.detail_stress_current),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
HeroReading(value = current.toString(), unit = "")
Spacer(modifier = Modifier.height(10.dp))
Text(
text = stringResource(stressLevelLabelRes(level)),
style = MaterialTheme.typography.titleMedium,
color = stressLevelColor(level),
)
}
}
/** 趋势图:按等级着色的柱子 + 四色图例。 */
@Composable
private fun StressChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_stress)),
)
MetricBarChart(
values = state.points.map { it.value.toFloat() },
// 基础色取紫色(压力指标的语义色),但 barColorOf 会覆盖其中绝大多数柱子 ——
// 它只在返回值列表为空时兜底,那时才轮到这条分支。
color = stressLevelColor(StressLevel.HIGH),
valueRange = 0f..STRESS_AXIS_MAX,
barColorOf = { value -> stressLevelColor(stressLevelOf(value.toDouble())) },
axisLabels = chartAxisLabels(state.points),
)
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
// 四个等级全列出来,而不是只列图上出现过的 —— 图例的作用是给出「颜色 → 等级」的
// 对照表,缺一档就会让人以为少的那档不存在。
StressLevel.entries.forEach { level ->
LevelLegend(
label = stringResource(stressLevelLabelRes(level)),
tint = stressLevelColor(level),
)
}
}
}
}
/**
* 压力纵轴上限。
*
* 固定 100 而不是按数据自适应:压力的分档阈值(30/60/80)是绝对的,
* 若轴随数据缩放,同样高度的柱子在不同周里代表不同等级,图例就成了摆设。
*/
private const val STRESS_AXIS_MAX = 100f
/** 等级图例:小色块 + 名称。 */
@Composable
private fun LevelLegend(label: String, tint: Color, modifier: Modifier = Modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.clip(RoundedCornerShape(5.dp))
.background(tint),
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(start = 6.dp),
)
}
}
/** 统计卡:平均 / 最高 / 放松天数。 */
@Composable
private fun StressStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val values = state.points.map { it.value }
val stats = metricStatsOf(values) ?: return
val format = statFormatFor(state.metric)
val relaxedDays = values.count { stressLevelOf(it) == StressLevel.RELAXED }
HealthCard(modifier = modifier) {
StatRow {
StatColumn(
label = stringResource(R.string.detail_average),
value = formatStatValue(stats.average, state.metric, format),
unit = format.unit,
)
StatColumn(
label = stringResource(R.string.detail_max),
value = formatStatValue(stats.max, state.metric, format),
unit = format.unit,
)
// 第三列给「放松天数」:压力的「最低值」没有解读价值(睡着时人人都低),
// 「这几天有几天是放松的」才是可用的结论。
StatColumn(
label = stringResource(R.string.detail_stress_relaxed_days),
value = stringResource(R.string.detail_days_count, relaxedDays),
unit = "",
valueColor = if (relaxedDays > 0) MetricGreen else null,
)
}
}
}
@@ -0,0 +1,133 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.common.formatSignedDecimal
import com.placeholder.ringhealth.core.designsystem.MetricBlue
import com.placeholder.ringhealth.core.designsystem.MetricOrange
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricDeviationBar
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.MetricReference
import com.placeholder.ringhealth.feature.detail.HeroReading
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
import com.placeholder.ringhealth.feature.detail.formatStatValue
import com.placeholder.ringhealth.feature.detail.metricStatsOf
import com.placeholder.ringhealth.feature.detail.statFormatFor
/**
* 体温详情页。
*
* 体温的特点是**绝对值没有意义**:36.5 与 36.8 都属于正常,要读的是「比自己的基线高还是低」。
* 所以这一页的中心是一条基线而不是零点 —— 头图给「较基线 +0.0°C」,
* 趋势图画成从基线向两侧偏离的棒棒糖(偏高一片暖色、偏低一片冷色),
* 统计里后两列给的是「最高偏离 / 最低偏离」而不是「最高 / 最低体温」。
*/
internal fun LazyListScope.temperatureDetail(state: MetricDetailUiState) {
item { TemperatureHeroCard(state) }
item { TemperatureChartCard(state) }
item { TemperatureStatsCard(state) }
}
/** 头图:当前体温 + 较基线的偏离 + 基线说明。 */
@Composable
private fun TemperatureHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val current = state.summary?.temperature ?: return
val deviation = current - MetricReference.TEMPERATURE_BASELINE
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.metric_temperature),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
HeroReading(value = formatDecimal(current, 1), unit = "", unitRes = R.string.unit_celsius)
Spacer(modifier = Modifier.height(10.dp))
// 偏离量染成与趋势图一致的方向色(偏高橙、偏低蓝),两处说的是同一件事。
Text(
text = stringResource(R.string.detail_temp_above, formatSignedDecimal(deviation, 1)),
style = MaterialTheme.typography.titleMedium,
color = if (deviation >= 0.0) MetricOrange else MetricBlue,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(
R.string.detail_temp_baseline,
formatDecimal(MetricReference.TEMPERATURE_BASELINE, 1),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
/** 趋势图:以基线为中线的偏离条。 */
@Composable
private fun TemperatureChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_temperature)),
trailing = {
Text(
text = stringResource(
R.string.detail_temp_baseline,
formatDecimal(MetricReference.TEMPERATURE_BASELINE, 1),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
},
)
MetricDeviationBar(
values = state.points.map { it.value },
baseline = MetricReference.TEMPERATURE_BASELINE,
span = MetricReference.TEMPERATURE_SPAN,
aboveColor = MetricOrange,
belowColor = MetricBlue,
axisLabels = chartAxisLabels(state.points),
)
}
}
/** 统计卡:平均体温 / 最高偏离 / 最低偏离。 */
@Composable
private fun TemperatureStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val values = state.points.map { it.value }
val stats = metricStatsOf(values) ?: return
val format = statFormatFor(state.metric)
val unit = format.unit
HealthCard(modifier = modifier) {
StatRow {
// 第一列是绝对值(36.5°C),后两列是相对基线的偏离量(+0.4°C / -0.3°C)。
// 混着放看着不齐,但正是这一页要讲的事:绝对值 + 它相对基线偏了多少。
StatColumn(
label = stringResource(R.string.detail_average),
value = formatStatValue(stats.average, state.metric, format),
unit = unit,
)
StatColumn(
label = stringResource(R.string.detail_temp_highest),
value = formatSignedDecimal(stats.max - MetricReference.TEMPERATURE_BASELINE, 1),
unit = unit,
)
StatColumn(
label = stringResource(R.string.detail_temp_lowest),
value = formatSignedDecimal(stats.min - MetricReference.TEMPERATURE_BASELINE, 1),
unit = unit,
)
}
}
}
@@ -1,35 +1,51 @@
package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Watch
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.AppViewModelProvider
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.AppBackground
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextPrimary
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
@@ -47,13 +63,23 @@ fun DeviceScreen(
DeviceContent(state = state, onOpenSettings = onOpenSettings, modifier = modifier)
}
/** 无状态设备页内容。 */
/**
* 无状态设备页内容。
*
* **空态 vs 已连态分两条分支。** 阶段一 Mock 始终 `connected = true`,空态走 Preview 验证;
* M2 接 SDK 后真实未连时会自动落到这里 —— 戒指扫描/绑定是用户买戒指后的第一件事,
* 不能让人看到一张「已连但没数据」的卡(那是 bug)。
*/
@Composable
private fun DeviceContent(
state: DeviceUiState,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
if (!state.device.connected) {
DeviceEmptyState(modifier = modifier.fillMaxSize())
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
@@ -76,6 +102,78 @@ private fun DeviceContent(
}
}
/**
* 未连接戒指空态。
*
* **不做成整页空状态**(参考小米截图),仍用一张卡片托住「扫一扫 / 按型号添加」
* 两个操作入口 —— 用户进来这一页是想做事的,不是看「空空如也」。戒指大图放在卡片上方
* 的居中位置,没有动作,所以不挂在卡片里。
*/
@Composable
private fun DeviceEmptyState(modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(80.dp))
Box(
modifier = Modifier
.size(140.dp)
.clip(CircleShape)
.background(MetricGreen.copy(alpha = 0.10f)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Nfc,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier.size(64.dp),
)
}
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(R.string.device_empty_title),
style = MaterialTheme.typography.titleLarge,
color = TextPrimary,
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.device_empty_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp),
)
Spacer(modifier = Modifier.height(28.dp))
HealthCard(contentPadding = PaddingValues(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = { /* 阶段一未开放:扫一扫入口在 M2 接 BLE 后实接 */ },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = MetricGreen),
) {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.device_empty_scan))
}
OutlinedButton(
onClick = { /* 阶段一未开放 */ },
modifier = Modifier.weight(1f),
) {
Text(text = stringResource(R.string.device_empty_pair_by_model))
}
}
}
}
}
/** 戒指信息卡片。 */
@Composable
private fun DeviceInfoCard(state: DeviceUiState, modifier: Modifier = Modifier) {
@@ -148,8 +246,8 @@ private fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modi
}
}
/** 预览入口:以固定示例数据渲染 [DeviceContent],便于在 IDE 中免真机查看设备页效果。 */
@Preview(showBackground = true)
/** 预览入口:已连态。 */
@Preview(showBackground = true, name = "已连")
@Composable
private fun DeviceContentPreview() {
RingHealthTheme {
@@ -163,6 +261,21 @@ private fun DeviceContentPreview() {
}
}
/** 预览入口:未连空态。 */
@Preview(showBackground = true, name = "未连")
@Composable
private fun DeviceEmptyStatePreview() {
RingHealthTheme {
DeviceContent(
state = DeviceUiState(
device = RingDeviceState(false, "", 0, "", false),
settings = emptyList(),
),
onOpenSettings = {},
)
}
}
/**
* Preview 用的假开关列表。
*
@@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
@@ -28,7 +30,7 @@ import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
/**
@@ -37,6 +39,7 @@ import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
*/
@Composable
fun DeviceSettingsScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
@@ -44,6 +47,7 @@ fun DeviceSettingsScreen(
DeviceSettingsContent(
settings = state.settings,
onToggle = viewModel::setFunctionEnabled,
onBack = onBack,
modifier = modifier,
)
}
@@ -53,46 +57,60 @@ fun DeviceSettingsScreen(
* 与 `DeviceContent` 同样拆出独立函数,Preview 才能喂假数据渲染(ledger Ruling R26)。
* 注意回调签名是 `(id, enabled)` 而不是 `(DeviceFunctionSetting, Boolean)`:
* 无状态层不该知道「怎么改」,只负责把「改哪一项、改成什么」报上去。
*
* 顶栏的标题取代了原先列表里的「功能设置」区块标题 —— 同一个标题在一屏里出现两次是多余的。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DeviceSettingsContent(
settings: List<DeviceFunctionSetting>,
onToggle: (String, Boolean) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { SectionHeader(title = stringResource(R.string.device_settings_title)) }
items(settings) { setting ->
HealthCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = setting.title, style = MaterialTheme.typography.titleSmall)
Text(text = setting.subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.device_settings_title),
onBack = onBack,
)
},
) { innerPadding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(settings) { setting ->
HealthCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = setting.title, style = MaterialTheme.typography.titleSmall)
Text(text = setting.subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Switch(
checked = setting.enabled,
onCheckedChange = { checked -> onToggle(setting.id, checked) },
// SwitchDefaults 走正常 import,不写字面量全限定名(与其余文件的 import 风格一致)
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
)
}
Switch(
checked = setting.enabled,
onCheckedChange = { checked -> onToggle(setting.id, checked) },
// SwitchDefaults 走正常 import,不写字面量全限定名(与其余文件的 import 风格一致)
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
)
}
}
}
item {
Text(
text = stringResource(R.string.device_settings_hint),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(top = 4.dp),
)
item {
Text(
text = stringResource(R.string.device_settings_hint),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
@@ -102,6 +120,6 @@ private fun DeviceSettingsContent(
@Composable
private fun DeviceSettingsContentPreview() {
RingHealthTheme {
DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> })
DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> }, onBack = {})
}
}
@@ -1,76 +1,84 @@
package com.placeholder.ringhealth.feature.home
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bloodtype
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.MonitorHeart
import androidx.compose.material.icons.filled.Spa
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.AppViewModelProvider
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatActiveMinutes
import com.placeholder.ringhealth.core.common.formatCalories
import com.placeholder.ringhealth.core.common.formatDistance
import com.placeholder.ringhealth.core.common.formatMonthDay
import com.placeholder.ringhealth.core.common.formatSleepDuration
import com.placeholder.ringhealth.core.common.formatSteps
import com.placeholder.ringhealth.core.common.greetingResFor
import com.placeholder.ringhealth.core.common.stressLevelLabelRes
import com.placeholder.ringhealth.core.designsystem.MetricBlue
import com.placeholder.ringhealth.core.designsystem.MetricCyan
import com.placeholder.ringhealth.core.common.weekdayResFor
import com.placeholder.ringhealth.core.designsystem.GreetingStyle
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.MetricValueStyle
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextPrimary
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricTile
import com.placeholder.ringhealth.core.designsystem.component.RingProgress
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.core.designsystem.metricIcon
import com.placeholder.ringhealth.core.designsystem.metricTint
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState
/** 一个指标卡的数据。 */
/**
* 一个指标卡的数据。
*
* 只有文案和数值在这里 —— 图标与配色是指标的固有属性,取自 [metricIcon] / [metricTint],
* 不随卡片走。否则同一份映射会被复制到每个用到它的地方。
*/
private data class MetricCardSpec(
val type: MetricType,
val icon: ImageVector,
val tint: Color,
val title: String,
val value: String?,
val unit: String,
)
/** 首页网格列数。 */
private const val HOME_GRID_COLUMNS = 2
/** 健康首页。 */
@Composable
fun HomeScreen(
@@ -79,14 +87,30 @@ fun HomeScreen(
viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
HomeContent(state = state, onOpenDetail = onOpenDetail, modifier = modifier)
HomeContent(
state = state,
onOpenDetail = onOpenDetail,
// 进页面时取一次当前时间,供问候语与日期行使用。
// 取一次而不是起一个每秒跳动的时钟:问候语在「早上好→下午好」之间切换没有实时性要求,
// 而一个常驻协程会在页面不可见时也持续重组。remember 保证配置变更外的重组不重取。
nowMillis = remember { System.currentTimeMillis() },
modifier = modifier,
)
}
/** 无状态首页内容,便于 Preview。 */
/**
* 无状态首页内容,便于 Preview。
*
* 布局用 `LazyVerticalGrid` 两列:整宽内容(问候、设备条、步数大环、区块标题)用
* `GridItemSpan(maxLineSpan)` 跨满整行,六个指标卡各占一格,排成 2×3。
* 之前是 `LazyColumn` 单列 —— 每张指标卡被拉到整屏宽(约 1100px)而高只有 180px,
* 宽高比 6:1,横向大片留白、纵向却被撑开,六个数字要滚三屏才看得完。
*/
@Composable
private fun HomeContent(
state: HomeUiState,
onOpenDetail: (MetricType) -> Unit,
nowMillis: Long,
modifier: Modifier = Modifier,
) {
if (state.loading || state.summary == null) {
@@ -97,38 +121,80 @@ private fun HomeContent(
}
val summary = state.summary
// 注意:LazyColumn 的 content lambda 不是 Composable 上下文,
// 所有需要 stringResource / Composable 调用的内容都必须在 LazyColumn 之前算好。
// 注意:LazyVerticalGrid 的 content lambda 不是 Composable 上下文,
// 所有需要 stringResource / Composable 调用的内容都必须在网格之前算好。
val cards = buildMetricCards(summary)
val notEnabledText = stringResource(R.string.home_not_enabled)
LazyColumn(
LazyVerticalGrid(
columns = GridCells.Fixed(HOME_GRID_COLUMNS),
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { DeviceStrip(device = state.device) }
item { StepsCard(summary = summary, onClick = { onOpenDetail(MetricType.STEP) }) }
item { SectionHeader(title = stringResource(R.string.home_metrics)) }
items(cards) { card ->
// 没有数值(未开启)的指标卡不可点击:传 null 后 MetricTile/HealthCard 不挂 clickable,
// 无跳转也无水波纹。否则首页显示「未开启」、点进去却是一张完整图表,两页自相矛盾。
val openDetail = card.value?.let { { onOpenDetail(card.type) } }
// 跨满整行的元素一律显式传 GridItemSpan(maxLineSpan),而不是写死 2 ——
// 将来改列数时这些元素自动跟随,不会漏改。
item(span = { GridItemSpan(maxLineSpan) }) { GreetingHeader(nowMillis = nowMillis) }
item(span = { GridItemSpan(maxLineSpan) }) { DeviceStrip(device = state.device) }
item(span = { GridItemSpan(maxLineSpan) }) {
TodaySummaryCard(summary = summary, onOpenSteps = { onOpenDetail(MetricType.STEP) })
}
item(span = { GridItemSpan(maxLineSpan) }) {
SectionHeader(title = stringResource(R.string.home_metrics))
}
items(cards, key = { it.type.name }) { card ->
// 未开启的指标卡(血压)**同样可点开** —— 点进去是「未开启 + 怎么开启」的说明页。
//
// 这里曾经反过来:没有数值就不挂 clickable(`card.value?.let { ... }`),用来堵
// 「首页写未开启、点进去却是一张完整趋势图」这个自相矛盾。堵法过头了 ——
// 那张说明页因此一个入口都没有,成了死代码,用户也无从知道该怎么把功能打开。
// 矛盾的根源是详情页画了图,不是卡片可点击,所以改由血压页不画图来解决
// (见 BloodPressureDetail.kt)。
//
// 但 enabled 仍按有无数值来定:未开启时数值位显示的是「未开启」三个字,
// 用大号粗体排出来会比真实数值还重。
val enabled = card.value != null
MetricTile(
icon = card.icon,
iconTint = card.tint,
icon = metricIcon(card.type),
iconTint = metricTint(card.type),
title = card.title,
value = card.value ?: notEnabledText,
unit = if (card.value == null) "" else card.unit,
onClick = openDetail,
// 显示「未开启」时它不是一个数值,也就没有单位可跟。
unit = if (enabled) card.unit else "",
enabled = enabled,
onClick = { onOpenDetail(card.type) },
)
}
}
}
/**
* 顶部问候 + 日期。
*
* 这是设计文档 §6 要求、但 M1 漏做的一块(当时首页直接从设备状态条开始)——
* 补上它既对齐了文档,也给页面一个「身份」:此前进入首页第一眼是一张卡片怼上来,
* 看不出这是哪一页。
*/
@Composable
private fun GreetingHeader(nowMillis: Long, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(top = 8.dp, bottom = 2.dp)) {
Text(
text = stringResource(greetingResFor(nowMillis)),
style = GreetingStyle,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${formatMonthDay(nowMillis)} ${stringResource(weekdayResFor(nowMillis))}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
/** 顶部设备连接状态条。 */
@Composable
private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
HealthCard(modifier = modifier, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.MonitorHeart,
@@ -157,90 +223,140 @@ private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier)
}
}
/** 今日步数大环卡片。 */
/**
* 今日数据卡片:三件套横排(步数 / 卡路里 / 活动时长)。
*
* 早先是「步数大环 + 距离/千卡」两件套 —— 大环占近 1/3 屏,环外信息只有两个换算结果,
* 留白多、信息密度低。换成三件套后:
* - 三列等宽,无主导项 —— 步数不再被环放大成「今天的全部重点」
* - 步数下挂目标("目标 10,000"),目标本身仍是用户对照自己的尺子
* - 步数一格整格可点进详情;卡路里/活动时长一格整格也点了能进(暂未挂路由,行为同环外区域)
*
* **第一版的「value + 单位同行」写法装不下**:30sp 字号下「8,426 步」「327 千卡」「47 分钟」
* 三件套单元格里都会换行 —— 视觉上「8,426」「步」分两行变成「数值」「单位」两截,反而像丢了内容。
* 改为「label 表达单位 + value 纯数字」:label 写成「步数 / 卡路里(千卡)/ 活动(分钟)」,
* 把单位塞进 label,value 行只摆数字。
*/
@Composable
private fun StepsCard(summary: HealthSummary, onClick: () -> Unit, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier, onClick = onClick) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = stringResource(R.string.home_steps_title), style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))
RingProgress(
progress = summary.steps.toFloat() / summary.stepGoal.coerceAtLeast(1),
color = MetricGreen,
trackColor = MetricGreen.copy(alpha = 0.15f),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = formatSteps(summary.steps), style = MetricValueStyle)
Text(
text = stringResource(R.string.home_steps_goal, formatSteps(summary.stepGoal)),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
SummaryMetric(
value = formatDistance(summary.distanceKm),
label = stringResource(R.string.home_distance_label),
tint = MetricBlue,
)
SummaryMetric(
value = formatCalories(summary.calories),
label = stringResource(R.string.home_calories_label),
tint = MetricOrange,
)
}
private fun TodaySummaryCard(summary: HealthSummary, onOpenSteps: () -> Unit, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp)) {
Row(modifier = Modifier.fillMaxWidth()) {
SummaryCell(
modifier = Modifier.weight(1f),
label = stringResource(R.string.home_summary_steps_label),
value = formatSteps(summary.steps),
subline = stringResource(R.string.home_summary_steps_goal, formatSteps(summary.stepGoal)),
onClick = onOpenSteps,
)
SummaryDivider()
SummaryCell(
modifier = Modifier.weight(1f),
label = stringResource(R.string.home_summary_calories_label),
value = formatCalories(summary.calories),
subline = null,
onClick = null,
)
SummaryDivider()
SummaryCell(
modifier = Modifier.weight(1f),
label = stringResource(R.string.home_summary_active_label),
value = formatActiveMinutes(summary.activeMinutes),
subline = null,
onClick = null,
)
}
}
}
/** 环下方的小结指标。 */
/**
* 三件套行中两列之间的细分隔线。
*
* 一根 1dp 宽的浅色竖线,把三件套视觉上连成一张表而不是三张独立卡。
* 颜色用本页自定义的浅灰([HomeSummaryDivider]),不放 `core/designsystem/DividerColor`
* —— 那个值是卡片之间的整宽分割线(`#EFEFF4`),放在卡片内部会太显眼。
*/
@Composable
private fun SummaryMetric(value: String, label: String, tint: Color) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = value, style = MaterialTheme.typography.titleMedium, color = tint)
private fun RowScope.SummaryDivider() {
androidx.compose.foundation.layout.Box(
modifier = Modifier
.width(1.dp)
.padding(vertical = 6.dp)
.background(HomeSummaryDivider),
)
}
/** 三件套行内分隔线专用色,比卡片间分割线略深一档但仍属「几乎看不见」。 */
private val HomeSummaryDivider = androidx.compose.ui.graphics.Color(0xFFE2E5EB)
/**
* 三件套的单格:上标签(含单位)、下大数字、再下一行小字(步数那一格用,挂目标)。
*
* `onClick` 为 null 时整格不响应点击(卡路里/活动时长暂未挂详情路由);
* 不为 null 时整格可点(步数一格进步数详情)。
*/
@Composable
private fun SummaryCell(
label: String,
value: String,
subline: String?,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val clickableModifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier
Column(
modifier = clickableModifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
Spacer(modifier = Modifier.height(6.dp))
Text(text = value, style = TodaySummaryValueStyle)
if (subline != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(text = subline, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
}
}
/** 组装指标卡列表;value 为 null 表示该指标未开启。 */
/** 三件套单格内的数值字号:26sp + tnum;30sp 在三等分格里换行(首版踩过这个坑)。 */
private val TodaySummaryValueStyle = TextStyle(
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-1).sp,
color = TextPrimary,
fontFeatureSettings = "tnum",
)
/**
* 组装指标卡列表;value 为 null 表示该指标未开启。
*
* 图标与配色一律取自 [metricIcon] / [metricTint],不在这里写字面量 ——
* 卡片和详情页必须显示成同一个指标的样子,抄两份就会有一份先过期。
*
* 值为 null 的原因是**功能开关关着**(概览的字段由开关生成,见 `MockHealthDataRepository`),
* 所以这里读到的 null 与功能设置页那个开关是同一个事实的两个说法。
*/
@Composable
private fun buildMetricCards(summary: HealthSummary): List<MetricCardSpec> = listOf(
MetricCardSpec(
type = MetricType.HEART_RATE,
icon = Icons.Filled.Favorite,
tint = MetricRed,
title = stringResource(R.string.metric_heart_rate),
value = summary.heartRate?.toString(),
unit = stringResource(R.string.unit_bpm),
),
MetricCardSpec(
type = MetricType.SLEEP,
icon = Icons.Filled.Bedtime,
tint = MetricBlue,
title = stringResource(R.string.metric_sleep),
value = summary.sleepMinutes?.let { formatSleepDuration(it) },
unit = "",
),
MetricCardSpec(
type = MetricType.BLOOD_OXYGEN,
icon = Icons.Filled.Bloodtype,
tint = MetricCyan,
title = stringResource(R.string.metric_blood_oxygen),
value = summary.bloodOxygen?.toString(),
unit = stringResource(R.string.unit_percent),
),
MetricCardSpec(
type = MetricType.STRESS,
icon = Icons.Filled.Spa,
tint = MetricPurple,
title = stringResource(R.string.metric_stress),
value = summary.stress?.toString(),
// 单位位改放压力等级文案(数值落在哪个区间就是放松/正常/中等/偏高),
@@ -250,24 +366,24 @@ private fun buildMetricCards(summary: HealthSummary): List<MetricCardSpec> = lis
),
MetricCardSpec(
type = MetricType.TEMPERATURE,
icon = Icons.Filled.Thermostat,
tint = MetricOrange,
title = stringResource(R.string.metric_temperature),
value = summary.temperature?.toString(),
unit = stringResource(R.string.unit_celsius),
),
MetricCardSpec(
type = MetricType.BLOOD_PRESSURE,
icon = Icons.Filled.WaterDrop,
tint = MetricYellow,
title = stringResource(R.string.metric_blood_pressure),
value = summary.bloodPressure,
unit = stringResource(R.string.unit_mmhg),
),
)
/** 预览入口:以固定示例数据渲染 [HomeContent],便于在 IDE 中免真机查看首页效果。 */
@Preview(showBackground = true)
/**
* 预览入口:以固定示例数据渲染 [HomeContent],便于在 IDE 中免真机查看首页效果。
*
* `nowMillis` 写死成固定时刻,预览里的问候语与日期才不会随运行时间变化。
*/
@Preview(showBackground = true, heightDp = 900)
@Composable
private fun HomeContentPreview() {
RingHealthTheme {
@@ -278,6 +394,9 @@ private fun HomeContentPreview() {
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
),
onOpenDetail = {},
// 固定为 2026-09-10 08:26(周四)—— 取固定值是为了让预览里的问候语「早上好」
// 与日期行不随运行时间变化,否则同一份 Preview 在不同时段渲染出不同文案。
nowMillis = 1_789_000_000_000L,
)
}
}
@@ -0,0 +1,161 @@
package com.placeholder.ringhealth.feature.profile
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
/**
* 「关于」子页:版本信息 + 检查更新。
*
* 把 spec §6「④ 我的」里的 `关于/检查更新` 合并到一页 ——「关于」页就该告诉用户
* 当前是什么版本、检查更新是关于页的标准动作之一,没必要再单开。
*
* 阶段一版本号是写死的 `0.1.0`(与 spec §12 占位命名阶段一致),
* 设备型号与固件版本从 [com.placeholder.ringhealth.data.mock.MockDataProvider.deviceState] 取演示值。
*
* 「检查更新」按钮按下后弹 Snackbar(这里用 `remember` 改一段说明文字代替真弹窗)
* —— 阶段一没有后端,「已是最新版本」是唯一合理回答。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
onBack: () -> Unit,
deviceModel: String = DEMO_DEVICE_MODEL,
firmwareVersion: String = DEMO_FIRMWARE_VERSION,
appVersion: String = DEMO_APP_VERSION,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.profile_about_title),
onBack = onBack,
)
},
) { innerPadding ->
AboutContent(
modifier = Modifier.fillMaxSize().padding(innerPadding),
deviceModel = deviceModel,
firmwareVersion = firmwareVersion,
appVersion = appVersion,
)
}
}
/** 演示用的版本号常量。真实版本号应在 build.gradle.kts 里取 BuildConfig.VERSION_NAME。 */
private const val DEMO_APP_VERSION = "0.1.0"
private const val DEMO_DEVICE_MODEL = "RingHealth 智能戒指"
private const val DEMO_FIRMWARE_VERSION = "1.0.5"
@Composable
private fun AboutContent(
deviceModel: String,
firmwareVersion: String,
appVersion: String,
modifier: Modifier = Modifier,
) {
// 「检查更新」按过的提示:阶段一没有真后端,按下后给一段静态文案。
// 走完全限定名 + 取 `.value` 而不是 `by remember`,避免在不同包引用 setValue 操作符。
val checkedState = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val checked = checkedState.value
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { SectionHeader(title = stringResource(R.string.profile_about_section_app)) }
item {
HealthCard {
Column {
InfoRow(
label = stringResource(R.string.profile_about_app_version_label),
value = appVersion,
)
InfoRow(
label = stringResource(R.string.profile_about_firmware_version_label),
value = firmwareVersion,
)
InfoRow(
label = stringResource(R.string.profile_about_device_model_label),
value = deviceModel,
)
}
}
}
item { SectionHeader(title = stringResource(R.string.profile_about_section_actions)) }
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = { checkedState.value = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MetricGreen),
) {
Text(text = stringResource(R.string.profile_about_check_update))
}
if (checked) {
Text(
text = stringResource(R.string.profile_about_check_update_done),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Text(
text = stringResource(R.string.profile_about_check_update_current, appVersion),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
}
@Composable
private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium)
Text(text = value, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
}
}
@Preview(showBackground = true)
@Composable
private fun AboutContentPreview() {
RingHealthTheme {
AboutContent(
deviceModel = DEMO_DEVICE_MODEL,
firmwareVersion = DEMO_FIRMWARE_VERSION,
appVersion = DEMO_APP_VERSION,
)
}
}
@@ -0,0 +1,175 @@
package com.placeholder.ringhealth.feature.profile
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
/**
* 「帮助与反馈」子页。
*
* 内容分两块:FAQ 折叠列表 + 「联系我们」入口卡。
* 阶段一 FAQ 是写死的 4 条,覆盖「连接 / 同步 / 单位 / 反馈」四个常见问题;
* M2 接 SDK 后接入真实 FAQ 后端。
*
* 折叠交互:每条 FAQ 是一个 `Card`,点击整张卡切换展开/收起 —— 整卡可点击比只在标题
* 加箭头更符合拇指操作区域。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HelpScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.profile_help_title),
onBack = onBack,
)
},
) { innerPadding ->
HelpContent(modifier = Modifier.fillMaxSize().padding(innerPadding))
}
}
/** FAQ 一条。题目与答案作为参数传入,便于复用与测试。 */
private data class FaqItem(val questionRes: Int, val answerRes: Int)
/** 阶段一 FAQ 列表 —— 按用户最可能先问的顺序排:连接 → 同步 → 设置 → 反馈。 */
private val FaqCatalog: List<FaqItem> = listOf(
FaqItem(R.string.profile_help_faq_1_q, R.string.profile_help_faq_1_a),
FaqItem(R.string.profile_help_faq_2_q, R.string.profile_help_faq_2_a),
FaqItem(R.string.profile_help_faq_3_q, R.string.profile_help_faq_3_a),
FaqItem(R.string.profile_help_faq_4_q, R.string.profile_help_faq_4_a),
)
@Composable
private fun HelpContent(modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { SectionHeader(title = stringResource(R.string.profile_help_section_faq)) }
items(FaqCatalog.size) { index ->
FaqCard(item = FaqCatalog[index])
}
item { Spacer(modifier = Modifier.height(4.dp)) }
item { SectionHeader(title = stringResource(R.string.profile_help_section_contact)) }
item { ContactCard() }
}
}
/**
* 单条 FAQ 卡。
*
* 展开状态用 `remember { mutableStateOf(false) }` 保留:默认收起,进入页面只看问题列表,
* 点开才看答案 —— 减少一屏的信息密度。整张卡可点,比把点击区限制在标题上更顺手。
*/
@Composable
private fun FaqCard(item: FaqItem, modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
HealthCard(modifier = modifier.fillMaxWidth(), onClick = { expanded = !expanded }) {
Column {
Text(
text = stringResource(item.questionRes),
style = MaterialTheme.typography.titleSmall,
)
if (expanded) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(item.answerRes),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
}
}
}
/**
* 「联系我们」卡:邮箱 + 图标。
*
* 阶段一没有真点开的动作 —— 邮件地址是写死的演示值。M2 接后端时换成 `mailto:` Intent。
* 卡片上不放按钮,把整张卡做成「邮箱 + Email 图标 + 右箭头」的结构,按下后跳邮件客户端。
*/
@Composable
private fun ContactCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.padding(6.dp),
)
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.profile_help_contact),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.profile_help_contact_email),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
/** LazyListScope.items(count) — 阶段一直接用 lambda,不引入 item DSL。 */
private fun androidx.compose.foundation.lazy.LazyListScope.items(
count: Int,
itemContent: @Composable (index: Int) -> Unit,
) {
items(count = count, itemContent = { index -> itemContent(index) })
}
@Preview(showBackground = true)
@Composable
private fun HelpContentPreview() {
RingHealthTheme {
HelpContent()
}
}
@@ -0,0 +1,152 @@
package com.placeholder.ringhealth.feature.profile
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextPrimary
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
/**
* 「通知权限」引导子页。
*
* 阶段一只做 **静态引导**:告诉用户当前权限状态、关闭权限会丢什么,
* 并提供「请求权限」与「前往系统设置」两个按钮 —— 后者会真跳到系统设置页
* (`Settings.ACTION_APP_NOTIFICATION_SETTINGS` 是个标准 Intent),
* 前者阶段一不真发起系统权限弹窗(M2 接 SDK 时再做)。
*
* 权限状态用 `remember { mutableStateOf(false) }` 当演示值,「未授权」是默认演示态 —
* — 引导用户从这里开始走流程。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationsScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.profile_notifications_title),
onBack = onBack,
)
},
) { innerPadding ->
NotificationsContent(modifier = Modifier.fillMaxSize().padding(innerPadding))
}
}
@Composable
private fun NotificationsContent(modifier: Modifier = Modifier) {
// 演示用状态:阶段一始终未授权,引导用户走完整流程。
// 走完全限定名 + 取 `.value` 而不是 `var by remember`,避免 setValue 操作符跨包引用。
val grantedState = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val granted = grantedState.value
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { PermissionStatusCard(granted = granted) }
item { PermissionHintCard() }
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = { grantedState.value = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MetricGreen),
) {
Text(text = stringResource(R.string.profile_notifications_request))
}
OutlinedButton(
onClick = { /* 阶段一未实接:M2 时用 Settings.ACTION_APP_NOTIFICATION_SETTINGS 跳系统设置 */ },
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(R.string.profile_notifications_open_settings))
}
}
}
}
}
/** 顶部权限状态展示:圆形徽章 + 当前状态文字。 */
@Composable
private fun PermissionStatusCard(granted: Boolean, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = if (granted) Icons.Filled.NotificationsActive else Icons.Filled.NotificationsOff,
contentDescription = null,
tint = if (granted) MetricGreen else TextSecondary,
modifier = Modifier.size(56.dp),
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = stringResource(
R.string.profile_notifications_status,
stringResource(
if (granted) R.string.profile_notifications_status_granted
else R.string.profile_notifications_status_denied,
),
),
style = MaterialTheme.typography.titleMedium,
color = TextPrimary,
)
}
}
}
/** 解释关掉通知会丢什么的说明卡。 */
@Composable
private fun PermissionHintCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.profile_notifications_hint),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
textAlign = TextAlign.Start,
)
}
}
/** 56dp 图标是「权限状态」这个语义节点该有的视觉重量 —— 比下面的开关按钮重,比顶栏轻。 */
@Preview(showBackground = true)
@Composable
private fun NotificationsContentPreview() {
RingHealthTheme {
NotificationsContent()
}
}
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
@@ -21,25 +22,41 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
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.MetricOrange
import com.placeholder.ringhealth.core.designsystem.MetricPurple
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.IconBadge
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
/**
* “我的”页。全部为静态入口,无状态、无 ViewModel。
* “我的”页。
*
* 2026-09-10 晚收口:四个入口(单位设置 / 通知权限 / 检查更新 / 帮助与反馈)
* 全部挂上 `onClick`,导航到 [SettingsScreen] / [NotificationsScreen] / [AboutScreen] / [HelpScreen],
* 告别阶段一首批的死链接(PROGRESS.md §3.2)。
*
* 本页**不需要**再拆「有状态外壳 + 无状态内容」(ledger Ruling R26 的常规形状):
* 它本身就是无状态的,直接对它挂 `@Preview` 即可。
*/
@Composable
fun ProfileScreen(modifier: Modifier = Modifier) {
fun ProfileScreen(
onOpenSettings: () -> Unit = {},
onOpenNotifications: () -> Unit = {},
onOpenAbout: () -> Unit = {},
onOpenHelp: () -> Unit = {},
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
@@ -72,58 +89,77 @@ fun ProfileScreen(modifier: Modifier = Modifier) {
item {
ProfileEntry(
icon = Icons.Filled.Settings,
tint = MetricBlue,
title = stringResource(R.string.profile_units),
subtitle = stringResource(R.string.profile_units_desc),
onClick = onOpenSettings,
)
}
item {
ProfileEntry(
icon = Icons.Filled.Notifications,
tint = MetricOrange,
title = stringResource(R.string.profile_notification),
subtitle = stringResource(R.string.profile_notification_desc),
onClick = onOpenNotifications,
)
}
item { SectionHeader(title = stringResource(R.string.profile_group_about)) }
item {
ProfileEntry(
icon = Icons.Filled.Refresh,
tint = MetricPurple,
title = stringResource(R.string.profile_check_update),
subtitle = stringResource(R.string.profile_check_update_desc),
onClick = onOpenAbout,
)
}
item {
ProfileEntry(
icon = Icons.Filled.Info,
tint = MetricCyan,
title = stringResource(R.string.profile_help),
subtitle = stringResource(R.string.profile_help_desc),
onClick = onOpenHelp,
)
}
}
}
/** 单条设置入口。 */
/**
* 单条设置入口。
*
* 图标改用 [IconBadge] 的色底圆徽章,与首页指标卡同一套处理 —— 此前是裸的灰色小图标,
* 在全屏白卡上几乎是隐形的;四张卡的图标各配一色之后,一行行扫下来能靠颜色定位。
*
* 末尾的右箭头表示「本条可进」;`onClick` 为空 lambda 时卡片整张不响应点击(**保留
* 默认值兼容旧调用方**,将来如有需要可以让单条退化为纯展示)。
*/
@Composable
private fun ProfileEntry(
icon: ImageVector,
tint: Color,
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
HealthCard(modifier = modifier) {
HealthCard(modifier = modifier, onClick = onClick) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
IconBadge(icon = icon, tint = tint)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(text = title, style = MaterialTheme.typography.titleSmall)
Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Icon(
imageVector = icon,
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = TextSecondary,
modifier = Modifier.size(20.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(text = title, style = MaterialTheme.typography.titleSmall)
Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
}
}
}
@@ -0,0 +1,180 @@
package com.placeholder.ringhealth.feature.profile
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
/**
* 「应用设置」子页。
*
* 把阶段一首批两个死链接(单位设置 + 通知权限)合并到一个页面,
* 按 `单位` / `通知` 两个 [SectionHeader] 分组。开关是 **演示用** —— 切了不持久化、
* 也不影响任何真实行为;M2 接 SDK 后通知开关才会真正写到设置接口。
*
* 为什么不拆成两个子页:单位只有两项只读,不值得单独一页;通知三个开关合在一张卡更直观。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.profile_settings_title),
onBack = onBack,
)
},
) { innerPadding ->
SettingsContent(modifier = Modifier.fillMaxSize().padding(innerPadding))
}
}
/** 内容拆出独立函数便于 Preview。 */
@Composable
private fun SettingsContent(modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { SectionHeader(title = stringResource(R.string.profile_settings_section_units)) }
item { UnitsCard() }
item { Spacer(modifier = Modifier.height(4.dp)) }
item { SectionHeader(title = stringResource(R.string.profile_settings_section_notifications)) }
item { NotificationsCard() }
}
}
/**
* 单位卡:只读展示当前选中的距离/体重单位。
*
* 阶段一是「静态展示」,没有切换交互 —— 让用户看到单位配置在这里,将来要做切换
* 时再补 `onClick` 与路由。M2 接 SDK 后会改成可点的下拉。
*/
@Composable
private fun UnitsCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Column {
SettingRow(
title = stringResource(R.string.profile_settings_units_distance_label),
value = stringResource(R.string.profile_settings_units_distance_value),
)
Spacer(modifier = Modifier.height(12.dp))
SettingRow(
title = stringResource(R.string.profile_settings_units_weight_label),
value = stringResource(R.string.profile_settings_units_weight_value),
)
}
}
}
/**
* 通知卡:三个开关,按 `MockDataProvider.defaultFunctionSettings()` 的思路给演示值。
*
* 三个开关里**默认营销通知关着**、其余默认开着 —— 与「健康/设备提醒应当默认开,
* 营销通知应当默认关」的产品惯例一致;M2 接 SDK 后会真正写到系统通知渠道设置。
*/
@Composable
private fun NotificationsCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Column {
ToggleRow(
title = stringResource(R.string.profile_settings_notifications_health),
subtitle = stringResource(R.string.profile_settings_notifications_health_desc),
initial = true,
)
Spacer(modifier = Modifier.height(12.dp))
ToggleRow(
title = stringResource(R.string.profile_settings_notifications_device),
subtitle = stringResource(R.string.profile_settings_notifications_device_desc),
initial = true,
)
Spacer(modifier = Modifier.height(12.dp))
ToggleRow(
title = stringResource(R.string.profile_settings_notifications_marketing),
subtitle = stringResource(R.string.profile_settings_notifications_marketing_desc),
initial = false,
)
}
}
}
@Composable
private fun SettingRow(title: String, value: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = title, style = MaterialTheme.typography.bodyMedium)
Text(text = value, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
}
}
/**
* 单行开关。阶段一切换不持久化 —— 这里用 `remember` 保留每行自己的状态,
* 离页即丢。视觉一致即可。
*/
@Composable
private fun ToggleRow(
title: String,
subtitle: String,
initial: Boolean,
modifier: Modifier = Modifier,
) {
// `var checked by remember { ... }` 需要 `getValue` / `setValue` 两个 delegate 操作符,
// 跨包引用时 Compose 不会自动导,所以这里走完全限定名 —— `var` 的左值其实只需要 setValue。
val mutableState = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(initial) }
val checked = mutableState.value
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) {
Text(text = title, style = MaterialTheme.typography.bodyMedium)
Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Switch(
checked = checked,
onCheckedChange = { mutableState.value = it },
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
)
}
}
/** 预览入口:直接渲染 [SettingsContent],便于在 IDE 中免真机查看应用设置页效果。 */
@Preview(showBackground = true)
@Composable
private fun SettingsContentPreview() {
RingHealthTheme {
SettingsContent()
}
}
@@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -24,53 +26,78 @@ import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
import com.placeholder.ringhealth.domain.model.WorkoutRecord
/** 运动历史子页:展示全部运动记录。 */
@Composable
fun WorkoutHistoryScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
WorkoutHistoryContent(state = state, modifier = modifier)
WorkoutHistoryContent(state = state, onBack = onBack, modifier = modifier)
}
/** 无状态运动历史内容,便于 Preview。 */
/**
* 无状态运动历史内容,便于 Preview。
*
* 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 的 `innerPadding` 已在 NavHost 上
* 消费过,这里再补一次 `padding(innerPadding)` 是正常的层级内边距,不会叠加状态栏 ——
* 与 `MetricDetailScreen` 同一套写法)。补顶栏是为了和详情页一致:此前本页没有返回箭头,
* 只能靠系统返回手势或点底部 Tab 离开。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WorkoutHistoryContent(
state: WorkoutUiState,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
// 必须先判 loading 再判空(ledger Ruling R25):Mock 仓库有 200ms 延迟,
// records 在加载完成前就是 emptyList,若先判空会先闪一下「暂无运动记录」,
// 数据到了再跳成列表 —— 用户看到的是「明明有记录却说没有」。
if (state.loading) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MetricGreen)
}
return
}
if (state.records.isEmpty()) {
Box(
modifier = modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.workout_empty),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.workout_history_title),
onBack = onBack,
)
},
) { innerPadding ->
// 必须先判 loading 再判空(ledger Ruling R25):Mock 仓库有 200ms 延迟,
// records 在加载完成前就是 emptyList,若先判空会先闪一下「暂无运动记录」,
// 数据到了再跳成列表 —— 用户看到的是「明明有记录却说没有」。
if (state.loading) {
Box(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = MetricGreen)
}
return@Scaffold
}
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.records) { record ->
WorkoutRecordCard(record = record)
if (state.records.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(innerPadding).padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.workout_empty),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
return@Scaffold
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.records) { record ->
WorkoutRecordCard(record = record)
}
}
}
}
@@ -102,6 +129,7 @@ private fun WorkoutHistoryContentPreview() {
),
),
),
onBack = {},
)
}
}
@@ -1,30 +1,52 @@
package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.DirectionsBike
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.SportsBaseball
import androidx.compose.material.icons.filled.SportsHandball
import androidx.compose.material.icons.filled.SportsMartialArts
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.AppViewModelProvider
@@ -41,6 +63,28 @@ import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.WorkoutRecord
/**
* 运动类型。
*
* 戒指协议覆盖的运动模式列表([SDK 协议文档](SmartRingF01_SDK))。
* 阶段一尚未接入真实运动检测,**全部仅作占位** —— 点击任何一项都弹 Snackbar
* 「将在接入戒指后开放」,不会真的启动运动。
*
* 排序按使用频率从高到低:跑步(最常)→ 自由训练(兜底)。
* 改这里记得同步 [SportCatalog] 的顺序 —— 列表按它枚举。
*/
internal enum class SportType(val labelRes: Int, val icon: ImageVector) {
OUTDOOR_RUN(R.string.workout_sport_running_outdoor, Icons.AutoMirrored.Filled.DirectionsRun),
WALK(R.string.workout_sport_walking, Icons.AutoMirrored.Filled.DirectionsWalk),
OUTDOOR_CYCLE(R.string.workout_sport_cycling_outdoor, Icons.AutoMirrored.Filled.DirectionsBike),
INDOOR_RUN(R.string.workout_sport_running_indoor, Icons.Filled.SportsBaseball),
JUMP_ROPE(R.string.workout_sport_jump_rope, Icons.Filled.SportsHandball),
FREE_TRAINING(R.string.workout_sport_free_training, Icons.Filled.SportsMartialArts),
}
/** 运动类型网格的渲染顺序。 */
private val SportCatalog: List<SportType> = SportType.entries
/** 运动页。 */
@Composable
fun WorkoutScreen(
@@ -52,7 +96,14 @@ fun WorkoutScreen(
WorkoutContent(state = state, onOpenHistory = onOpenHistory, modifier = modifier)
}
/** 无状态运动页内容。 */
/**
* 无状态运动页内容。
*
* 顶部是 [SportTypeGrid](横排可滑的 6 个运动类型入口),
* 下方是「最近运动」记录列表。每个运动类型都跟原 [StartWorkoutCard] 一样
* 弹一条 Snackbar 说明该能力尚未开放 —— 弹提示比 `enabled = false` 的半透明按钮好,
* 前者明确告诉用户「为什么现在没有」。
*/
@Composable
private fun WorkoutContent(
state: WorkoutUiState,
@@ -66,44 +117,109 @@ private fun WorkoutContent(
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
HealthCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.FitnessCenter,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(text = stringResource(R.string.workout_start), style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(R.string.workout_start_desc),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
// 在 Composable 上下文里先取好文案:onClick 是普通 lambda,里面不能调 stringResource。
val startUnavailableMessage = stringResource(R.string.workout_start_unavailable)
Box(modifier = modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
SportTypeGrid(
onSportClick = {
scope.launch { snackbarHostState.showSnackbar(startUnavailableMessage) }
},
)
}
item {
SectionHeader(
title = stringResource(R.string.workout_recent),
trailing = {
TextButton(onClick = onOpenHistory) {
Text(text = stringResource(R.string.action_view_all))
}
},
)
}
items(state.records) { record ->
WorkoutRecordCard(record = record)
}
}
item {
SectionHeader(
title = stringResource(R.string.workout_recent),
trailing = {
TextButton(onClick = onOpenHistory) {
Text(text = stringResource(R.string.action_view_all))
}
},
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
/**
* 运动类型入口网格。
*
* 横排 6 个类型入口,可滑。
*
* 视觉选择:每格都是「72dp 圆角方块 + MetricGreen 浅色底 + MetricGreen 深色图标 + 类型名下方」
* —— 与 `HealthCard` 的白底区分开(这些是「操作入口」而不是「数据卡片」)。
* 图标背景用 `MetricGreen.copy(alpha = 0.12f)` 而不是小米那种饱和度极高的大色块:
* 设计文档 §7.1 说「颜色是有含义的,不能当装饰用」,所以运动类型不带色彩信号,
* 一律走品牌主色的浅色底,与品牌色图标配对传达「这里是行动入口」。
*/
@Composable
private fun SportTypeGrid(onSportClick: () -> Unit, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier, contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp)) {
LazyRow(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(SportCatalog, key = { it.name }) { sport ->
SportTile(
label = stringResource(sport.labelRes),
icon = sport.icon,
onClick = onSportClick,
)
}
}
}
}
/** 单个运动类型入口。 */
@Composable
private fun SportTile(label: String, icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(12.dp))
.background(MetricGreen.copy(alpha = 0.10f))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 14.dp)
.width(80.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MetricGreen),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(26.dp),
)
}
items(state.records) { record ->
WorkoutRecordCard(record = record)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
}
+153
View File
@@ -12,6 +12,24 @@
<string name="tab_device">设备</string>
<string name="tab_profile">我的</string>
<!--
首页顶部问候。按当前时段四选一,由 HomeScreen 依时间片决定用哪条。
分四档而不是三档:凌晨(0:00–5:00)说「早上好」不符合直觉,单列一条「夜深了」。
-->
<string name="home_greeting_morning">早上好</string>
<string name="home_greeting_afternoon">下午好</string>
<string name="home_greeting_evening">晚上好</string>
<string name="home_greeting_night">夜深了</string>
<!-- 星期简写。日期行形如「9月10日 周四」,日期本身走 Formatters 的格式化,星期走这里。 -->
<string name="weekday_monday">周一</string>
<string name="weekday_tuesday">周二</string>
<string name="weekday_wednesday">周三</string>
<string name="weekday_thursday">周四</string>
<string name="weekday_friday">周五</string>
<string name="weekday_saturday">周六</string>
<string name="weekday_sunday">周日</string>
<string name="home_device_connected">%1$s · 已连接</string>
<string name="home_device_disconnected">未连接设备</string>
<string name="home_battery">电量 %1$d%%</string>
@@ -25,6 +43,8 @@
<string name="unit_percent">%</string>
<string name="unit_celsius">°C</string>
<string name="unit_mmhg">mmHg</string>
<!-- 详情页统计摘要的单位位用。步数此前没有单位资源,摘要里只能显示一个光秃秃的 8,426。 -->
<string name="unit_steps">步</string>
<string name="metric_heart_rate">心率</string>
<string name="metric_sleep">睡眠</string>
@@ -37,9 +57,12 @@
<string name="workout_start">开始运动</string>
<string name="workout_start_desc">选择运动类型,记录你的训练</string>
<string name="workout_recent">最近运动</string>
<string name="workout_history_title">运动历史</string>
<string name="workout_empty">暂无运动记录</string>
<string name="workout_item_summary">%1$s · %2$s 公里</string>
<string name="workout_detail_calories">消耗 %1$s 千卡</string>
<!-- 「开始运动」目前没有真实记录能力,点击后以此条提示反馈,避免出现「点了没反应」的死按钮。 -->
<string name="workout_start_unavailable">运动记录将在接入戒指后开放</string>
<string name="action_view_all">查看全部</string>
<string name="device_connected">已连接</string>
@@ -78,5 +101,135 @@
<string name="detail_sleep_awake">清醒</string>
<string name="detail_sleep_fall_asleep">入睡时间</string>
<string name="detail_sleep_wake_up">起床时间</string>
<!-- 详情页:七页专属化后按指标拆分。通用词在前,指标专属在后。 -->
<string name="detail_chart_daily">每日%1$s</string>
<string name="detail_goal">目标 %1$s</string>
<string name="detail_days_count">%1$d 天</string>
<string name="detail_status_normal">正常</string>
<string name="detail_status_high">偏高</string>
<string name="detail_status_low">偏低</string>
<!-- 步数 -->
<string name="detail_steps_today">今日步数</string>
<string name="detail_steps_progress">已完成</string>
<string name="detail_steps_reached_label">达标天数</string>
<string name="detail_steps_average">日均</string>
<!-- 心率 -->
<string name="detail_hr_normal_band">正常 %1$s–%2$s 次/分</string>
<string name="detail_hr_normal_days">正常范围</string>
<!-- 睡眠 -->
<string name="detail_sleep_last_night">昨晚睡眠</string>
<string name="detail_sleep_target">目标 %1$s</string>
<!-- 睡眠时段图:横轴是钟点,一根条从入睡横到起床。柱状图只画「睡了多久」,
看不出作息是不是一天天往后拖,这张图才回答得了那个问题。 -->
<string name="detail_sleep_window">睡眠时段</string>
<string name="detail_sleep_window_average">平均 %1$s 入睡</string>
<string name="detail_sleep_avg_fall_asleep">平均入睡</string>
<string name="detail_sleep_avg_wake_up">平均醒来</string>
<string name="detail_sleep_avg_duration">平均时长</string>
<string name="unit_hours">小时</string>
<!-- 血氧 -->
<string name="detail_spo2_threshold">达标线 %1$s%%</string>
<string name="detail_spo2_low_days">偏低 %1$d 天</string>
<!-- 压力 -->
<string name="detail_stress_current">当前压力</string>
<string name="detail_stress_relaxed_days">放松天数</string>
<string name="detail_stress_legend">等级分布</string>
<!-- 体温 -->
<string name="detail_temp_baseline">基线 %1$s°C</string>
<string name="detail_temp_above">较基线 %1$s°C</string>
<string name="detail_temp_highest">最高偏离</string>
<string name="detail_temp_lowest">最低偏离</string>
<!-- 血压:演示数据里该功能未开启,页面给空态而不是画图 -->
<!-- 未开启说明卡:七个指标共用,%1$s 是指标名(如「血压」) -->
<string name="detail_disabled_title">%1$s监测未开启</string>
<string name="detail_disabled_hint">可在「设备 - 功能设置」中开启该监测项,开启后才会生成记录。</string>
<!-- 血压页(已开启态) -->
<string name="detail_bp_latest">最近一次测量</string>
<string name="detail_bp_systolic">收缩压</string>
<string name="detail_bp_diastolic">舒张压</string>
<string name="detail_bp_history">测量记录</string>
<string name="detail_bp_pending">血压需先在设备页完成手动校准,之后才会生成测量记录。</string>
<string name="action_back">返回</string>
<!-- ====== 2026-09-10 晚追加:四个 Tab 收口(首页三件套 / 运动类型网格 / 设备空态 / 我的子页) ====== -->
<!-- 首页:三件套横排(替代原「步数大环 + 距离/千卡」结构)。 -->
<string name="home_today_summary">今日数据</string>
<string name="home_summary_steps_label">步数</string>
<string name="home_summary_steps_goal">目标 %1$s</string>
<string name="home_summary_calories_label">卡路里(千卡)</string>
<string name="home_summary_active_label">活动(分钟)</string>
<!-- 运动:六类运动类型网格。 -->
<string name="workout_sport_running_outdoor">户外跑步</string>
<string name="workout_sport_walking">健走</string>
<string name="workout_sport_cycling_outdoor">户外骑行</string>
<string name="workout_sport_running_indoor">室内跑步</string>
<string name="workout_sport_jump_rope">跳绳</string>
<string name="workout_sport_free_training">自由训练</string>
<!-- 设备:未连接空态。 -->
<string name="device_empty_title">未连接戒指</string>
<string name="device_empty_subtitle">连接戒指后即可查看电量、固件与监测设置</string>
<string name="device_empty_scan">扫一扫</string>
<string name="device_empty_pair_by_model">按型号添加</string>
<!-- 我的 - 应用设置。 -->
<string name="profile_settings_title">应用设置</string>
<string name="profile_settings_section_units">单位</string>
<string name="profile_settings_units_distance_label">距离</string>
<string name="profile_settings_units_distance_value">公里</string>
<string name="profile_settings_units_weight_label">体重</string>
<string name="profile_settings_units_weight_value">公斤</string>
<string name="profile_settings_section_notifications">通知</string>
<string name="profile_settings_notifications_health">健康提醒</string>
<string name="profile_settings_notifications_health_desc">达标 / 异常时推送</string>
<string name="profile_settings_notifications_device">设备提醒</string>
<string name="profile_settings_notifications_device_desc">低电量 / 断开连接</string>
<string name="profile_settings_notifications_marketing">营销通知</string>
<string name="profile_settings_notifications_marketing_desc">活动与产品信息</string>
<!-- 我的 - 通知权限引导。 -->
<string name="profile_notifications_title">通知权限</string>
<string name="profile_notifications_status">当前状态:%1$s</string>
<string name="profile_notifications_status_granted">已授权</string>
<string name="profile_notifications_status_denied">未授权</string>
<string name="profile_notifications_hint">通知权限关闭时,健康异常、设备状态等推送将无法送达。开启后本 App 会在系统状态栏推送提醒。</string>
<string name="profile_notifications_request">请求权限</string>
<string name="profile_notifications_open_settings">前往系统设置</string>
<!-- 我的 - 关于 / 检查更新。 -->
<string name="profile_about_title">关于</string>
<string name="profile_about_section_app">App</string>
<string name="profile_about_app_version_label">App 版本</string>
<string name="profile_about_firmware_version_label">固件版本</string>
<string name="profile_about_device_model_label">设备型号</string>
<string name="profile_about_section_actions">操作</string>
<string name="profile_about_check_update">检查更新</string>
<string name="profile_about_check_update_done">已是最新版本</string>
<string name="profile_about_check_update_current">当前版本 %1$s</string>
<!-- 我的 - 帮助与反馈。 -->
<string name="profile_help_section_faq">常见问题</string>
<string name="profile_help_faq_1_q">戒指怎么连?</string>
<string name="profile_help_faq_1_a">在「设备」页点击「添加设备」,扫一扫或按型号添加。</string>
<string name="profile_help_faq_2_q">数据多久同步一次?</string>
<string name="profile_help_faq_2_a">戒指开启自动监测后,每 10 分钟同步一次到 App。</string>
<string name="profile_help_faq_3_q">如何切换单位?</string>
<string name="profile_help_faq_3_a">「我的 - 应用设置 - 单位」中切换距离与体重。</string>
<string name="profile_help_faq_4_q">如何反馈问题?</string>
<string name="profile_help_faq_4_a">可通过邮箱 support@ringhealth.example 联系我们。</string>
<string name="profile_help_section_contact">联系我们</string>
<string name="profile_help_contact_email">support@ringhealth.example</string>
<string name="profile_help_title">帮助与反馈</string>
<string name="profile_help_contact">联系我们</string>
</resources>
@@ -0,0 +1,56 @@
package com.placeholder.ringhealth.core.common
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Calendar
/**
* 睡眠时段图的横轴换算。
*
* 这里每一处都对着一个**看起来仍然合理**的错误:时区错八小时、跨零点算反、
* 负数偏移没归一化 —— 出了任何一种,图还是那张图,只是每根条的位置都悄悄偏了,
* 而偏过的位置照样像一份正常的睡眠记录。所以这几条断言必须钉在具体数值上。
*/
class SleepTimeTest {
@Test
fun `入睡时刻按锚点归一化后仍排在醒来之前`() {
// 时段图成立的前提:23:43 那根条的左端必须在 07:37 的左端**左边**。
// 用「当天第几分钟」直接比会得到 1423 > 457,整根条反过来画。
val bedtime = localTime(daysAgo = 1, hour = 23, minute = 43)
val wakeUp = localTime(daysAgo = 0, hour = 7, minute = 37)
assertTrue(minutesSinceSleepAnchor(bedtime) < minutesSinceSleepAnchor(wakeUp))
// 23:43 距 20:00 是 3 小时 43 分;07:37 跨过零点后是 11 小时 37 分。
assertEquals(223, minutesSinceSleepAnchor(bedtime))
assertEquals(697, minutesSinceSleepAnchor(wakeUp))
}
@Test
fun `锚点偏移量换回钟点 是上一条的逆运算`() {
assertEquals("23:43", formatSleepAnchorOffset(223))
assertEquals("07:37", formatSleepAnchorOffset(697))
// 早于 20:00 的时刻会得到负偏移,仍要换回正确的钟点而不是绕到另一天。
assertEquals("17:00", formatSleepAnchorOffset(-180))
}
@Test
fun `minutesOfDay 按本地时区取 而不是拿时间戳取余`() {
// `timestamp % 86_400_000` 是 UTC 的余数,在东八区会把 23:43 算成 15:43 ——
// 整张时段图平移八小时,而平移后每根条看着都还挺合理。
assertEquals(23 * 60 + 43, minutesOfDay(localTime(daysAgo = 0, hour = 23, minute = 43)))
assertEquals("00:00", formatMinuteOfDay(0))
assertEquals("23:59", formatMinuteOfDay(23 * 60 + 59))
}
/** 本地时间戳:今天往前 [daysAgo] 天的 [hour]:[minute]。 */
private fun localTime(daysAgo: Int, hour: Int, minute: Int): Long =
Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
add(Calendar.DAY_OF_YEAR, -daysAgo)
}.timeInMillis
}
@@ -1,8 +1,14 @@
package com.placeholder.ringhealth.data.mock
import com.placeholder.ringhealth.core.common.formatClock
import com.placeholder.ringhealth.core.common.minutesOfDay
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.enabledMetrics
import com.placeholder.ringhealth.domain.model.valueOf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -19,28 +25,79 @@ class MockDataProviderTest {
const val FIXED_NOW = 1_760_000_000_000L
}
/** 默认演示状态下的开关:血压关着,其余开着(见 [MockDataProvider.defaultFunctionSettings])。 */
private val defaultEnabled: Set<MetricType> =
provider.defaultFunctionSettings().enabledMetrics()
@Test
fun `今日概览返回固定演示数据`() {
val summary = provider.todaySummary()
val summary = provider.todaySummary(defaultEnabled)
assertEquals(8426, summary.steps)
assertEquals(10000, summary.stepGoal)
assertEquals(327, summary.calories)
assertEquals(72, summary.heartRate)
assertEquals(452, summary.sleepMinutes)
// 血压未开启,应为 null
// 血压默认未开启,应为 null
assertEquals(null, summary.bloodPressure)
}
@Test
fun `关掉的指标在概览里没有值`() {
// 这是「功能开关能影响首页」的根:概览的字段必须由开关算出来。
// 若这里退回写死的常量,首页就会在开关关着时照样显示数字,用户翻开关毫无反应。
val summary = provider.todaySummary(enabledMetrics = emptySet())
assertEquals(null, summary.heartRate)
assertEquals(null, summary.sleepMinutes)
assertEquals(null, summary.bloodOxygen)
assertEquals(null, summary.stress)
assertEquals(null, summary.bloodPressure)
}
@Test
fun `常开指标不在开关列表里也算开启`() {
// 计步与体温没有开关(MetricType.switchId 为 null),所以开关列表里找不到它们。
// 兜底必须发生在「开关列表 → 已开启指标集」这一步:若按「找不到对应开关就算关」处理,
// 一个空列表会让步数和体温凭空变成「未开启」—— 而戒指一直在数着步。
val enabled = emptyList<DeviceFunctionSetting>().enabledMetrics()
assertTrue(MetricType.STEP in enabled)
assertTrue(MetricType.TEMPERATURE in enabled)
// 反向:真的有关卡的指标,列表为空时就不该算开启。
assertFalse(MetricType.HEART_RATE in enabled)
val summary = provider.todaySummary(enabledMetrics = enabled)
assertEquals(8426, summary.steps)
assertEquals(36.5, summary.temperature)
}
@Test
fun `开关 id 与指标枚举一一对应`() {
// 双向核对:开关列表里的每个 id 都能找到归属,每个有开关的指标也都真的在列表里。
// 少了任何一个方向,对应关系就只是「字符串长得像」——那正是这次修掉的病根。
val settingIds = provider.defaultFunctionSettings().map { it.id }.toSet()
val declaredIds = MetricType.entries.mapNotNull { it.switchId }.toSet()
assertEquals(declaredIds, settingIds)
}
@Test
fun `血压开关打开后概览给出读数`() {
val summary = provider.todaySummary(defaultEnabled + MetricType.BLOOD_PRESSURE)
assertEquals(MockDataProvider.DEMO_BLOOD_PRESSURE, summary.bloodPressure)
}
@Test
fun `历史数据长度与范围一致`() {
assertEquals(7, provider.metricHistory(MetricType.STEP, DateRange.WEEK).size)
assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH).size)
assertEquals(7, provider.metricHistory(MetricType.STEP, DateRange.WEEK, defaultEnabled).size)
assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH, defaultEnabled).size)
}
@Test
fun `历史数据可重复生成且值合理`() {
val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK)
val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK)
val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK, defaultEnabled)
val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK, defaultEnabled)
assertEquals(first, second)
assertTrue(first.all { it.value > 40.0 && it.value < 180.0 })
// 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前)
@@ -52,6 +109,80 @@ class MockDataProviderTest {
assertEquals(FIXED_NOW - 6L * 24 * 60 * 60 * 1000, first.first().timestamp)
}
@Test
fun `历史数据的最后一个点与今日概览一致`() {
// 详情页的头图显示概览的当前值、趋势图的最后一根柱子显示今天的值。两者若各生成各的,
// 页面会出现「头图写心率 72、图上今天却是 96」这种自相矛盾(ledger R35)。
val summary = provider.todaySummary(defaultEnabled)
MetricType.entries.forEach { metric ->
val todayValue = summary.valueOf(metric) ?: return@forEach
val last = provider.metricHistory(metric, DateRange.WEEK, defaultEnabled).last()
assertEquals("$metric 的今日值与历史末点对不上", todayValue, last.value, 0.0001)
if (metric == MetricType.SLEEP) {
// 睡眠的末点是**今晨醒来的那一刻**,不是「此刻」——
// 这段睡眠属于今天,但它发生在今天早上。硬套 FIXED_NOW 会逼着
// 时段图把每一晚都画成「现在结束」,整张图会糊成一列。
assertEquals(provider.sleepSessions(DateRange.WEEK).last().wakeDate, last.timestamp)
} else {
assertEquals(FIXED_NOW, last.timestamp)
}
}
}
@Test
fun `睡眠时段的时长与两端时刻自洽`() {
// 时段图上那根条的左右端由 sleepStart/sleepEnd 决定,头图上的大数字由分期时长相加得到。
// 两者若各算各的,同一张卡上就会出现「睡了 7 小时 32 分」和一根长度不符的条。
provider.sleepSessions(DateRange.WEEK).forEach { session ->
assertEquals(
"卧床时长与起止时刻对不上:$session",
session.inBedMinutes * 60_000L,
session.sleepEnd - session.sleepStart,
)
assertEquals(
session.inBedMinutes,
session.deepMinutes + session.lightMinutes + session.awakeMinutes,
)
}
}
@Test
fun `昨晚是固定演示值 且与今日概览一致`() {
// 昨晚这一条和步数 8426、心率 72 同一性质:写死的演示值,负责让第一屏的数字好看且固定。
val last = provider.sleepSessions(DateRange.WEEK).last()
assertEquals(452, last.sleepMinutes)
assertEquals(96, last.deepMinutes)
assertEquals(452, provider.todaySummary(defaultEnabled).sleepMinutes)
}
@Test
fun `睡眠时段按时间正序 且醒来时刻落在清晨`() {
val sessions = provider.sleepSessions(DateRange.WEEK)
assertEquals(7, sessions.size)
assertTrue(sessions.zipWithNext().all { (a, b) -> b.wakeDate > a.wakeDate })
// 除昨晚外都是随机生成的。醒来时刻必须落在 6–9 点:
// 前几晚若也贴着「此刻」生成,七根条会排成整齐的一列,看不出作息差异 ——
// 而「作息差在哪」正是这张图存在的理由。
sessions.dropLast(1).forEach {
assertTrue(
"醒来时刻落在清晨之外:${formatClock(it.sleepEnd)}",
minutesOfDay(it.sleepEnd) in 6 * 60..9 * 60 + 59,
)
}
}
@Test
fun `睡眠历史点由时段折算 不另生成一串随机数`() {
// 同一个指标有两套数据源,就会出现「头图 7 小时 32 分、时段图上却是另一根长度」——
// 而这恰好是睡眠页最显眼的两块内容。
val sessions = provider.sleepSessions(DateRange.WEEK)
val points = provider.metricHistory(MetricType.SLEEP, DateRange.WEEK, defaultEnabled)
assertEquals(sessions.map { it.wakeDate }, points.map { it.timestamp })
assertEquals(sessions.map { it.sleepMinutes.toDouble() }, points.map { it.value })
assertEquals(30, provider.sleepSessions(DateRange.MONTH).size)
}
@Test
fun `设备状态与开关数据合法`() {
val device = provider.deviceState()
@@ -0,0 +1,22 @@
package com.placeholder.ringhealth.data.mock
/**
* 造一对**已接线**的 Mock 仓库:健康数据的开关状态来自同一个设备仓库。
*
* 测试里必须这样接,不能各 `new` 一个。各 new 一个正是生产代码修掉的那个 bug ——
* 开关状态与健康数据分属两个互不引用的实例,用户翻了开关首页纹丝不动。
* 测试若照抄旧写法,就会在一套与线上不同的接线方式上做断言:
* 断言全绿,线上照样是坏的。
*/
/** 设备仓库,功能开关为 [MockDataProvider.defaultFunctionSettings];[enable] 用于覆盖个别开关。 */
fun mockDeviceRepository(enable: Map<String, Boolean> = emptyMap()): MockRingDeviceRepository {
val repository = MockRingDeviceRepository()
enable.forEach { (id, on) -> repository.setFunctionEnabled(id, on) }
return repository
}
/** 健康数据仓库,开关状态取自 [device] —— 必须传同一个实例,理由见文件头。 */
fun mockHealthRepository(
device: MockRingDeviceRepository = mockDeviceRepository(),
): MockHealthDataRepository = MockHealthDataRepository(functionSettings = device.functionSettings)
@@ -1,6 +1,7 @@
package com.placeholder.ringhealth.feature.detail
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
import com.placeholder.ringhealth.data.mock.mockDeviceRepository
import com.placeholder.ringhealth.data.mock.mockHealthRepository
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.MetricType
import kotlinx.coroutines.Dispatchers
@@ -36,7 +37,7 @@ class MetricDetailViewModelTest {
@Test
fun `默认输出近 7 天的数据`() = runTest(dispatcher) {
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
@@ -46,11 +47,48 @@ class MetricDetailViewModelTest {
assertEquals(MetricType.HEART_RATE, state.metric)
assertEquals(DateRange.WEEK, state.range)
assertEquals(7, state.points.size)
// 今日概览必须一并到达:各页 hero 卡的「当前值」取自 summary,
// combine 若漏了这一路,页面第一屏会只剩图、没有读数(而 points 仍然正常,不易察觉)。
assertEquals(72, state.summary?.heartRate)
}
@Test
fun `关掉的指标 enabled 为 false`() = runTest(dispatcher) {
// 详情页靠这个标志决定画图还是显示「未开启」说明卡。它取错了,页面就会一边
// 说功能没开、一边画出七天的数据 —— 一段根本不存在的监测记录。
val device = mockDeviceRepository(enable = mapOf("heart_rate" to false))
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository(device))
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.enabled)
assertEquals(null, state.summary?.heartRate)
}
@Test
fun `开关变化会传到已订阅的详情页`() = runTest(dispatcher) {
// 承重的是「切换之后状态跟着变」,所以必须在订阅建立之后再翻开关。
// 只断言初始值等于某个常量是没有鉴别力的:那样即使数据流根本不订阅开关,
// 只要初始值碰巧对,测试照样绿。
val device = mockDeviceRepository(enable = mapOf("heart_rate" to false))
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository(device))
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
assertFalse(viewModel.uiState.value.enabled)
device.setFunctionEnabled("heart_rate", true)
advanceUntilIdle()
val state = viewModel.uiState.value
assertTrue(state.enabled)
assertEquals(72, state.summary?.heartRate)
}
@Test
fun `switchRange 切到月度后变成 30 个点`() = runTest(dispatcher) {
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
@@ -69,13 +107,29 @@ class MetricDetailViewModelTest {
assertEquals(7, viewModel.uiState.value.points.size)
}
@Test
fun `只有睡眠页拿得到时段数据`() = runTest(dispatcher) {
// 时段图要的是「几点到几点」,points 里装不下,只能另走一路。
// 两个断言必须成对:单看「心率页的时段为空」,initialValue 本来就是空列表,
// 数据流从不发射时它照样成立 —— 加上「同一时刻睡眠页拿得到」才说明这是分流而不是没接上。
val sleep = MetricDetailViewModel(MetricType.SLEEP, mockHealthRepository())
backgroundScope.launch { sleep.uiState.collect {} }
advanceUntilIdle()
assertEquals(7, sleep.uiState.value.sleepSessions.size)
val heartRate = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository())
backgroundScope.launch { heartRate.uiState.collect {} }
advanceUntilIdle()
assertTrue(heartRate.uiState.value.sleepSessions.isEmpty())
}
@Test
fun `SLEEP 指标查到的是睡眠区间的数据`() = runTest(dispatcher) {
// 名字不叫「构造时传入的 metric 被原样带进状态」——本用例真正锁的是
// 「构造函数的 metric 被传给了仓库查询」,而不是状态字段回显(ledger R34)。
// 也不要写成「标题映射不同」:`metricTitleRes` 是 @Composable,JVM 测试里根本调不了,
// 那样命名会让人以为这里验了标题映射(其实没有,映射由 `when` 的穷尽性在编译期保证)。
val viewModel = MetricDetailViewModel(MetricType.SLEEP, MockHealthDataRepository())
val viewModel = MetricDetailViewModel(MetricType.SLEEP, mockHealthRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
@@ -0,0 +1,111 @@
package com.placeholder.ringhealth.feature.detail
import com.placeholder.ringhealth.core.common.formatSleepAnchorOffset
import com.placeholder.ringhealth.domain.model.SleepSession
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Calendar
class MetricStatsTest {
@Test
fun `平均入睡不会把 23 点与 1 点平均成中午`() {
// 直接对「23:00」和「01:00」这两个钟点求平均会得到 12:00 —— 正好是睡得最沉的时候。
// 求平均必须在「距锚点 20:00 的分钟数」这个连续量上做:180 和 300,均值 240,即 00:00。
val stats = sleepStatsOf(
listOf(
session(bedHour = 23, bedMinute = 0, wakeHour = 7, wakeMinute = 0),
session(bedHour = 1, bedMinute = 0, wakeHour = 6, wakeMinute = 0),
),
)!!
assertEquals(240, stats.averageBedtimeMinute)
assertEquals("00:00", formatSleepAnchorOffset(stats.averageBedtimeMinute))
assertEquals(2, stats.count)
}
@Test
fun `sleepStatsOf 空列表返回 null 而不是全 0`() {
// 同 metricStatsOf:0 点入睡、0 分钟时长是能吓死人的假读数,不能用它表示「没有数据」。
assertNull(sleepStatsOf(emptyList()))
}
@Test
fun `平均时长不含夜里清醒`() {
// 「睡了多久」与「躺了多久」是两个数。头图上那个大数字是睡着的时长,
// 统计卡里的平均必须同一口径,否则同一页两处各说各的。
val stats = sleepStatsOf(listOf(session(bedHour = 23, bedMinute = 0, wakeHour = 7, wakeMinute = 0)))!!
// 卧床 480 分钟(深睡 96 + 浅睡 360 + 清醒 24),睡着的是 456。
assertEquals(456, stats.averageSleepMinutes)
assertTrue("平均时长把夜里清醒也算进去了", stats.averageSleepMinutes < 480)
}
/**
* 造一晚。时长由两端时刻算出(卧床 = 醒来 - 入睡),再按 20% 深睡、5% 清醒、其余浅睡拆分 ——
* 三者必须加起来等于卧床时长,否则这里就是在拿一份自相矛盾的数据做断言。
*/
private fun session(bedHour: Int, bedMinute: Int, wakeHour: Int, wakeMinute: Int): SleepSession {
val start = localTime(daysAgo = 1, hour = bedHour, minute = bedMinute)
val end = localTime(daysAgo = 0, hour = wakeHour, minute = wakeMinute)
val inBed = ((end - start) / MILLIS_PER_MINUTE).toInt()
val deep = inBed / 5
val awake = inBed / 20
return SleepSession(
wakeDate = end,
sleepStart = start,
sleepEnd = end,
deepMinutes = deep,
lightMinutes = inBed - deep - awake,
awakeMinutes = awake,
)
}
private companion object {
const val MILLIS_PER_MINUTE = 60_000L
}
private fun localTime(daysAgo: Int, hour: Int, minute: Int): Long =
Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
add(Calendar.DAY_OF_YEAR, -daysAgo)
}.timeInMillis
@Test
fun `metricStatsOf 求平均最高最低与天数`() {
val stats = metricStatsOf(listOf(60.0, 80.0, 100.0))
assertEquals(80.0, stats!!.average, 0.0001)
assertEquals(100.0, stats.max, 0.0001)
assertEquals(60.0, stats.min, 0.0001)
assertEquals(3, stats.count)
}
@Test
fun `metricStatsOf 空列表返回 null 而不是全 0`() {
// 空数据返回 0.0 会在页面上渲染成「平均 0.0 次/分」这种假读数。
assertNull(metricStatsOf(emptyList()))
}
@Test
fun `countWithin 只数落在区间内的点 边界算在内`() {
val values = listOf(40.0, 60.0, 75.0, 100.0, 110.0)
assertEquals(3, values.countWithin(60.0..100.0))
}
@Test
fun `countBelow 严格小于阈值`() {
val values = listOf(94.0, 95.0, 96.0)
assertEquals(1, values.countBelow(95.0))
}
@Test
fun `countAtLeast 达到阈值即算达标`() {
val values = listOf(9999.0, 10000.0, 10001.0)
assertEquals(2, values.countAtLeast(10000.0))
}
}
@@ -49,9 +49,12 @@ class DeviceViewModelTest {
// 直接取仓库当前值),所以**单靠它们无法区分「combine 发射过」与「一直停在 initialValue」**
// (ledger Ruling R30,审查者指出)。这里绕开 ViewModel 直接改仓库,再断言状态跟着变 ——
// 只有 combine 真的在发射,这一条才可能成立。
repository.setFunctionEnabled("stress", true)
// 关掉一个**初始为 true** 的开关,断言它跟着变 false。方向很重要:
// 若这里改成「把初始为 false 的打开」,即使 combine 一次都没发射过,
// 只要 initialValue 里那项碰巧是 true,断言照样通过(ledger Ruling R30)。
repository.setFunctionEnabled("stress", false)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.settings.first { it.id == "stress" }.enabled)
assertFalse(viewModel.uiState.value.settings.first { it.id == "stress" }.enabled)
}
@Test
@@ -60,19 +63,20 @@ class DeviceViewModelTest {
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
// Mock 的 5 项里只有 stress 与 blood_pressure 初始为 false,其余 3 项为 true
// (见 MockDataProvider.defaultFunctionSettings)。切换 stress 后应为 4 项开启。
val target = "stress"
// Mock 的 5 项里只有 blood_pressure 初始为 false,其余 4 项为 true
// (见 MockDataProvider.defaultFunctionSettings)。打开它后应变成 5 项全开。
val target = "blood_pressure"
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
viewModel.setFunctionEnabled(target, true)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.settings.first { it.id == target }.enabled)
// 关键:其它开关不受影响。只断言目标项变了,无法发现「整表被覆盖」这类回归。
assertEquals(4, viewModel.uiState.value.settings.count { it.enabled })
assertEquals(5, viewModel.uiState.value.settings.count { it.enabled })
viewModel.setFunctionEnabled(target, false)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
assertEquals(4, viewModel.uiState.value.settings.count { it.enabled })
}
}
@@ -1,7 +1,7 @@
package com.placeholder.ringhealth.feature.home
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository
import com.placeholder.ringhealth.data.mock.mockDeviceRepository
import com.placeholder.ringhealth.data.mock.mockHealthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@@ -36,7 +36,9 @@ class HomeViewModelTest {
@Test
fun `加载完成后输出今日概览与设备状态`() = runTest(dispatcher) {
val viewModel = HomeViewModel(MockHealthDataRepository(), MockRingDeviceRepository())
// 两个仓库必须共用同一个开关实例,理由见 TestRepositories.kt 文件头。
val device = mockDeviceRepository()
val viewModel = HomeViewModel(mockHealthRepository(device), device)
// uiState 用 SharingStarted.WhileSubscribed 构建,必须有订阅者才会启动上游数据流;
// 否则 .value 永远是初始值(loading = true)。用 backgroundScope 起一个收集者。
@@ -1,6 +1,6 @@
package com.placeholder.ringhealth.feature.workout
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
import com.placeholder.ringhealth.data.mock.mockHealthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@@ -36,7 +36,7 @@ class WorkoutViewModelTest {
@Test
fun `加载完成后输出运动记录`() = runTest(dispatcher) {
val viewModel = WorkoutViewModel(MockHealthDataRepository())
val viewModel = WorkoutViewModel(mockHealthRepository())
// 与 HomeViewModelTest 同理:uiState 用 WhileSubscribed 构建,
// 没有订阅者时 .value 会停在 initialValue(loading = true)。
+107 -107
View File
@@ -4,124 +4,105 @@
- 记录日期:2026-09-10
- 构建版本:0.1.0 (versionCode 1)
- 构建机:Windows 11,JDK 17.0.12,Android SDK `D:\Android\Sdk`(仅 `platform-tools` /
`platforms;android-36` / `build-tools;36.1.0`)
- **测试设备:无** —— 本机没有安装任何模拟器镜像,也没有连接真机
- 构建机:Windows 11,JDK 17.0.12,Android SDK `D:\Android\Sdk`
- **验收设备:Android 模拟器**(非真机)
- AVD `ringtest`,外形 `pixel_7`,镜像 `system-images;android-36;google_apis;x86_64`
- 1080×2400 @ 420dpi;系统栏 inset:状态栏 136 px、导航栏 63 px
补充证据(说明「无设备」不是随口一说,而是逐项查过的):
```
$ ls D:\Android\Sdk
build-tools cmdline-tools licenses platform-tools platforms
# 没有 emulator/,也没有 system-images/
$ adb devices
List of devices attached
# 空列表,没有连接真机
```
`C:\Users\10314\.android\avd\` 下**残留着两个 AVD 定义**(`Medium_Phone_API_36.1`、
`Pixel_9_Pro`),但它们是**不可用的空壳**:二者都指向已删除的旧 SDK
`C:\Users\10314\AppData\Local\Android\Sdk`(其 `emulator\emulator.exe` 与
`system-images\android-36.1\google_apis_playstore\x86_64\` 均已不存在),且属于另一个项目
(`D:\work\xiangmu\mediq-app`)。**不要因为看到 AVD 目录就以为本机可以起模拟器。**
另注:当前 shell 的 `ANDROID_HOME` 仍指向那个已删除的旧路径,构建实际靠
`local.properties` 的 `sdk.dir=D:\Android\Sdk` 生效。
> **本次与上一次记录的差别:** 上一版这份文件里,人工验收**整张表都是「未执行 —— 无设备」**。
> 2026-09-10 当天下载并装好了模拟器(见文末「模拟器是怎么装上的」),因此**下面这张表已经真的跑过了**。
> 但仍有一处必须说清楚:**跑的是模拟器,不是真机**。模拟器能证明界面渲染正确、导航不崩,
> 但证明不了真机上的性能、厂商 ROM 差异、以及 M2 之后才涉及的蓝牙权限行为。
## 自动化验证(已执行)
- 单元测试:`./gradlew :app:testDebugUnitTest` —— **21 项全绿**
(`FormattersTest` 7 + `MockDataProviderTest` 5 + `HomeViewModelTest` 1 + `WorkoutViewModelTest` 1
+ `DeviceViewModelTest` 2 + `MetricDetailViewModelTest` 3 + `RoutesTest` 2)
- 构建:`./gradlew clean assembleDebug` —— `BUILD SUCCESSFUL`,
产物 `app/build/outputs/apk/debug/app-debug.apk`(18,907,329 字节)
- **未执行**:各页面的 `@Preview` 在 Android Studio 中渲染。本机没有 IDE GUI,agent 无法打开
Preview 面板,**这一项与人工验收一样是未执行的** —— 不要把它写进「已执行」段落
(旧稿把它列在上面这一节里,那等于声称验证过一件没做过的事)。
- 构建:`./gradlew :app:assembleDebug` —— `BUILD SUCCESSFUL`,
产物 `app/build/outputs/apk/debug/app-debug.apk`(18,907,725 字节)
- 安装并启动:`adb install -r` + `am start` —— 成功,进程存活,`logcat -b crash` 全程为空
- **未执行**:各页面的 `@Preview` 在 Android Studio 中渲染。本机没有 IDE GUI 自动化手段,
这一项仍是未执行的 —— 不过它原本的作用(没有设备时唯一的界面检查手段)已被下面的模拟器实测取代
### 终审修复轮(`5fe0d4a`)各项改动的可验证性
## 人工验收(模拟器实测,逐项已执行)
终审后又有一轮修复,共 6 处改动。**不要把这 6 处一律当成「已验证」** —— 它们能验到什么程度
差别很大,逐项列明:
| 改动 | 实际验到了什么 |
|---|---|
| 详情页按指标取色(原先一律绿) | `when` 穷尽七个 `MetricType`,代码可确认;**配色是否好看/是否与首页一致,未经渲染验证** |
| 详情页空数据态(原先渲染全 0 柱状图 + 三行 `0.0`) | **无测试覆盖**,仅代码推理。且血压卡改为不可点后,该路径在当前 Mock 下实际不可达(`metricHistory` 恒返回 7 或 30 个点)—— 属防御性代码 |
| 血压卡不可点击(原先显示「未开启」却能点进一张有数据的图表) | `onClick` 改为可空并传 `null`,代码可确认;**点击涟漪是否真的消失,未经渲染验证** |
| 压力卡 `unit` 接线(4 条字符串此前无调用点) | 调用点已存在,代码可确认;**文字是否在卡片里溢出,未经渲染验证** |
| `RingHealthApp` NavHost 补 `consumeWindowInsets(innerPadding)` | **推理得出,未经设备验证** —— 见下方人工验收第 1 项 |
| README 注明 `installDebug` 需设备 | 文档,无需验证 |
结论不变:**这几处里凡带「视觉」二字的,本机都没验过。**
## 人工验收(未执行 —— 无设备)
下表**全部未打勾**。这份清单已原样保留,交给有设备的人执行;不要在没有实测的情况下填入结果。
| 项 | 结果 | 备注 |
| 项 | 结果 | 证据 |
|---|---|---|
| 应用启动无崩溃 | 未执行 | 无设备 |
| 四 Tab 切换 | 未执行 | 无设备 |
| 重按已高亮的父 Tab 的行为 | 未执行 | 需实测:推测会停在子页而非回到 Tab 根 |
| 每个页面的 `@Preview` 可渲染 | 未执行 | 需在 Android Studio 中逐个打开确认 |
| 次要文字灰度一致 | 未执行 | 不得出现 `TextSecondary` 与 `onSurfaceVariant` 混用 |
| 首页:设备状态条 / 步数大环 8,426 / 六个指标卡(血压“未开启”) | 未执行 | 无设备 |
| 指标详情页:周/月切换(7 根 / 30 根柱子) | 未执行 | 无设备 |
| 睡眠详情:分期卡片只在睡眠指标出现,入睡/起床读起来像「一晚」 | 未执行 | 无设备 |
| 运动页 / 运动历史页 | 未执行 | 无设备 |
| 设备页 / 设备设置开关(返回后状态保持) | 未执行 | 无设备 |
| 我的页 | 未执行 | 无设备 |
| 旋转屏幕、深色系统设置下不崩溃 | 未执行 | 无设备 |
| 应用启动无崩溃 | ✅ | 进程 pid 存活,`mCurrentFocus` 为 `MainActivity`,crash buffer 空 |
| 四个 Tab 显示为 健康 / 运动 / 设备 / 我的 | ✅ | 四个 Tab 文案与图标正确,选中态为淡紫底 + 绿色文字 |
| 四个 Tab 可自由切换 | ✅ | 逐个点过,无闪烁、无崩溃 |
| 首页:设备状态条 | ✅ | 「RingHealth 智能戒指 · 已连接 / 电量 78%」 |
| 首页:步数大环 8,426 / 目标 10,000 | ✅ | 环按 84% 填充,另有「6.21 公里 / 327 千卡」 |
| 首页:六个指标卡,血压显示「未开启」 | ✅ | 心率 72 次/分、睡眠 7小时32分、血氧 98%、压力 34 正常、体温 36.5 °C、血压 未开启 |
| 首页:点指标卡进详情,标题为该指标中文名 | ✅ | 点「睡眠」→ 标题「睡眠」;点「心率」→ 标题「心率」 |
| 详情页:近 7 天 7 根柱 / 近 30 天 30 根柱 | ✅ | 切换后柱子数量与统计值都变了(心率:78.0/95.1/58.0 → 77.0/96.0/59.8) |
| 睡眠详情额外显示分期卡片,其他指标没有 | ✅ | 睡眠有「睡眠分期」卡(深睡/浅睡/清醒 + 入睡/起床),心率页没有该卡 |
| 睡眠入睡/起床时间读起来像「一晚」 | ✅ | 入睡 22:08 / 起床 06:08,正好相隔 8 小时(`DEMO_SLEEP_WINDOW_MILLIS` 生效) |
| 详情页:返回后回到首页 | ✅ | `keyevent 4` 后回到健康首页 |
| 运动页:开始运动 + 5 条记录 | ✅ | 5 条:户外跑步/健走/骑行/自由训练/户外跑步 |
| 运动页「查看全部」进历史页,底部导航栏仍可见 | ✅ | 历史页 5 条记录,底部四 Tab 正常显示 |
| 设备页:戒指卡片 电量 78% / 固件 1.0.5 | ✅ | 另显示「已连接」与蓝牙图标 |
| 设备页「更多设置」开关可切换,返回后状态保持 | ✅ | 打开「压力监测」→ 返回设备页 → 再进入,开关仍为开 |
| 我的页 | ✅ | 头像卡与分组入口正常 |
| 全程无崩溃 | ✅ | `logcat -b crash` 自始至终为空 |
### 需在有设备时重点确认的项(原清单细目,逐条保留)
### 补充实测:终审修复轮的六项改动
- **【第 1 优先】内边距是否正确(终审 fix 5,本表最可能出问题的一项)**:
`RingHealthApp` 的 NavHost 现为 `Modifier.padding(innerPadding).consumeWindowInsets(innerPadding)`。
原因是详情页/设备设置页**自带** `Scaffold` + `TopAppBar`,而外层的 `Modifier.padding`
**不消费** window insets,于是状态栏高度被上下叠加两次。补 `consumeWindowInsets` 是标准修法,
但**这一改动从未在设备上看过一眼**。请优先确认两件事:
① 详情页、设备设置页的返回箭头与标题**没有被状态栏遮住**;
② 顶部也**没有多出一条无意义的空白**。
若仍有多余留白,问题在 insets 的消费方式而不在这行代码本身
- 应用启动无崩溃,底部四个 Tab 显示为 健康 / 运动 / 设备 / 我的
- 四个 Tab 可自由切换,切换后状态保留、无闪烁
- **重按已高亮的父 Tab 的行为**:实现为 `popUpTo(start){saveState}` + `restoreState`,
推测会**恢复到该 Tab 保存的子页栈**(停在子页而非回到 Tab 根)。请实测并记录实际行为;
若实测是「停在子页」而产品希望「回到根」,那是一个独立的小改动,记下来即可
- 首页:步数大环显示 8,426,目标 10,000,环按比例填充
- 首页:点击任一指标卡进入详情页,标题为该指标中文名
- 详情页:返回后回到首页
- 运动页:显示“开始运动”卡片 + 5 条运动记录;「查看全部」进入历史页后底部导航栏仍可见
- 设备页:戒指卡片显示电量 78%、固件版本 1.0.5
- 睡眠卡片的入睡/起床时间**读起来像「一晚」**(如 入睡 22:41 / 起床 06:41),而不是跨好几天。
阶段一按「最后一个数据点往前 8 小时」演示(R29);若显示成跨天,说明
`DEMO_SLEEP_WINDOW_MILLIS` 那段被改坏了
这六项原先只有代码阅读作为依据,现在逐项在模拟器上确认:
| 改动 | 实测结果 |
|---|---|
| 详情页按指标取色 | ✅ 睡眠=蓝、心率=红,与首页各卡的 tint 一致 |
| 血压卡不可点击 | ✅ 点血压卡后与点击前**逐像素一致**,未发生跳转 |
| 压力卡 `unit` 接线 | ✅ 卡片显示「34 **正常**」,即 `stressLevelLabelRes` 真的接上了 |
| 周/月 chips 常驻 | ✅ 两个范围的详情页都始终显示 chips |
| `consumeWindowInsets` 顶栏内边距 | ✅ **量了像素**:状态栏 136 px,顶栏白底 136→303 px,即**正好 168 px = 64 dp**(Material 3 `TopAppBar` 标准高度),标题文字中心在顶栏内 86 px ≈ 32 dp,**正中**。若 insets 被叠加,顶栏会变成 88 dp 且标题偏下 —— 实测不是 |
| README 注明 `installDebug` 需设备 | ✅ 文档项 |
> `consumeWindowInsets` 这一项此前被反复标注「推理得出、未经设备验证」,**现在验证过了**。
> 顺带确认:状态栏上方那条灰带就是状态栏本身(136 px),不是多余留白。
## 本轮新发现的观察(非缺陷,记录备查)
1. **设备设置页与运动历史页没有 `TopAppBar`,也就没有返回箭头** —— 标题(「功能设置」)是普通
标题文字。返回只能靠系统返回手势,或点底部「设备」Tab。功能上可用,但与详情页(有 `←`)
不一致。**是否补一个顶栏,留给 M2 一起定。**
2. **运动记录的日期随运行时间变化** —— 两次截图里同一批记录显示 06:08 与 06:10。
因为 Mock 的时间戳是相对「此刻」生成的。这**削弱了设计文档 §8「每次运行截图一致」的说法**:
数据形状是确定性的,但日期不是。若将来要做截图对比测试,需要连时间源一起固定。
3. **趋势图把最后一根柱子画成深色**(其余为其 30% 透明度)—— 这是**有意的**,
`MetricBarChart(highlightLast = true)` 即「高亮今天」,KDoc 里写明了。
记录在此只是为了说明:实测看到的「只有最后一根是深色」不是渲染错误。
## 已知限制(阶段一有意为之)
- 数据全部为 Mock,不连蓝牙、不连后端
- 血压恒为“未开启”占位(`HealthSummary.bloodPressure = null`)
- 设备设置页的开关只改内存状态,重启即重置(对应 M2 的协议 0502/0504)
- 血压恒为「未开启」占位(`HealthSummary.bloodPressure = null`)
- 设备设置页的开关只改内存状态,**重启即重置**(对应 M2 的协议 0502/0504);
本次只验证了跨页面保持,没有验证跨进程重启
- 睡眠分期条与入睡/起床时间是演示值(固定比例 + 最后一个数据点往前 8 小时),无真实分期数据
- 详情页统计摘要一律保留一位小数(步数因此显示为「平均 7512.3」这类精度过剩的形式)
- 仅浅色模式
- 血压卡**不可点击**(`value == null` 时 `onClick` 传 `null`)—— 阶段一没有血压数据,
点进去只会看到一张编出来的图表,不如不点
- 详情页的**周/月切换常驻**,空数据时只把切换条下方的图表与统计换成一行空态文案。
切换条属于页头而非数据区:数据显示为空时,切换区间正是用户的第一反应,它也是判断
「哪个区间是空的」的唯一线索
- 血压卡**不可点击**(`value == null` 时 `onClick` 传 `null`)
- 详情页的**周/月切换常驻**,空数据时只把切换条下方的图表与统计换成一行空态文案
### 仍然没有验证到的
- **详情页的空数据态**:当前 Mock 恒返回 7 或 30 个点,该分支不可达,**本次没有、也无法触达**。
它仍然是只有代码推理保证的防御性代码。
- **旋转屏幕 / 深色系统设置下不崩溃**:本次未测。
- **`@Preview` 渲染**:未测(见上)。
- **真机**:未测。模拟器不能替代真机验收。
### 与设计文档 §6 的偏差(有意收窄,非遗漏)
- **设备页少了四项**:查找戒指(050B)、智能提醒/闹钟占位、固件升级占位(OTA 17xx)、佩戴检测。
阶段一没有真实协议可对接,摆一排点不动的入口不如不摆(ledger R27)。**这些能力仍属产品范围,
将于后续里程碑回归**;设计文档 §6 的设备页一行已用删除线标注。
- **详情页由「四个专属设计」收敛为一个模板**:`detail/step`、`detail/heart_rate`、`detail/sleep`、
`detail/blood_oxygen` 四条路由合并为 `detail/{metric}`,七个指标共用同一模板(柱状趋势图 +
周/月切换 + 平均/最高/最低)。**仅睡眠保留了专属区块**(`SleepStagesCard`)。
未实现的是:步数的大环、心率的折线/静息-运动区间、血氧的正常范围说明文案。
收敛本身是有意的(避免七套几乎相同的页面代码),详见设计文档 §6 下的注记。
将于后续里程碑回归**;设计文档 §6 的设备页一行已用删除线标注。**本次实测确认设备页确实没有这四项。**
- **详情页由「四个专属设计」收敛为一个模板**:四条路由合并为 `detail/{metric}`,七个指标共用
同一模板。**仅睡眠保留了专属区块**。未实现的是:步数的大环、心率的折线/静息-运动区间、
血氧的正常范围说明文案。详见设计文档 §6 下的注记。
### M2 接入时的已知接缝(交接用,本阶段不修)
@@ -136,19 +117,38 @@ List of devices attached
(厂商 Java 依赖,**很可能需要 Jetifier**)、`feature/device/DeviceScreen.kt`。
完整清单见实施计划的「M2 交接提示」一节。
## 覆盖缺口声明
## 覆盖缺口声明(已大幅收窄,但仍非零)
**M1 的全部运行时行为目前只有编译期与单元测试保证,没有任何一次真机/模拟器运行。**
`assembleDebug` 成功只说明代码能编译、能打包,**不说明界面正确、不说明不会崩溃**。
这是本机没有设备导致的已知缺口(ledger Ruling R21),不是「已验证通过」。
首次拿到设备时,请从上面那张人工验收表开始逐项补齐。
M1 的运行时行为**已在 Android 模拟器上完整走查过一遍**(上表逐项打勾),不再是「只有编译期保证」。
但仍有三个缺口,**不要把它们读成「已验证」**:
具体而言,**没有任何一行 M1 的 Compose 界面代码被真正渲染过**:导航图能否起来、
四个 Tab 是否显示正确文案、`MetricBarChart` 的柱子数量、`RingProgress` 的填充比例、
睡眠分期卡片是否只在睡眠指标下出现、次要文字灰度是否统一 —— 这些全部未经运行时检验。
单元测试覆盖的是 `Formatters` 的纯函数、`MockDataProvider` 的数据形状,以及七个 ViewModel
的状态流;**它们不经过 Compose 渲染管线,也不能发现布局、主题或导航层面的错误。**
1. **跑的是模拟器不是真机** —— 真机上的厂商 ROM、性能、字体缩放、刘海/挖孔适配均未检验。
2. **空数据态与旋转/深色模式未触达**(见上)。
3. **`@Preview` 未渲染** —— 不过它原本的替代作用已被模拟器实测覆盖。
终审修复轮新改的几处**尤其如此**:按指标取色、血压卡不可点、空数据态、压力卡的单位接线、
以及 `consumeWindowInsets` —— 这五处**全部是界面行为**,而 21 项单测**一项都没有、也不可能**
碰到它们。它们的依据是代码阅读与 Compose 的既有语义,不是运行结果。
首次拿到真机时,请至少重跑上表中的「启动无崩溃」「四 Tab 切换」「详情页顶栏内边距」三项。
## 附:模拟器是怎么装上的(含一个踩过的坑)
本机原先 `D:\Android\Sdk` 下没有 `emulator/` 也没有 `system-images/`,本次新装:
```bash
# 1) 装包(注意坑:见下)
/d/Android/Sdk/cmdline-tools/latest/bin/sdkmanager.bat \
--sdk_root=D:/Android/Sdk "emulator" "system-images;android-36;google_apis;x86_64"
# 2) 建 AVD(avdmanager 不认 --sdk_root,只认环境变量)
export ANDROID_SDK_ROOT='D:\Android\Sdk' ANDROID_AVD_HOME='D:\Android\avd'
echo no | /d/Android/Sdk/cmdline-tools/latest/bin/avdmanager.bat create avd \
-n ringtest -k "system-images;android-36;google_apis;x86_64" -d pixel_7
# 3) 启动
/d/Android/Sdk/emulator/emulator.exe -avd ringtest
```
**坑:`--sdk_root` 一定要写正斜杠。** 写成 `D:\Android\Sdk` 时,Git Bash 会把反斜杠吃掉,
变成 Windows 的**盘符相对路径** `D:AndroidSdk` —— 这不是绝对路径,而是「D 盘当前目录下的
AndroidSdk」。结果 sdkmanager 把 5.3 GB 的包装进了 `cmdline-tools\latest\bin\AndroidSdk\`。
同盘移动是瞬时的,已搬回 `D:\Android\Sdk\`。**在 Git Bash 里给 Windows 工具传路径,一律用正斜杠。**
另外 `ANDROID_AVD_HOME` 已设为用户级变量 `D:\Android\avd`(AVD 镜像有几个 G,C 盘只剩 29 G)。