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 接入真机后逐步补全其他夜晚
This commit is contained in:
2026-09-10 17:25:48 +08:00
parent 514e5ec9e2
commit fba26fc372
4 changed files with 154 additions and 7 deletions
@@ -26,8 +26,11 @@ 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
@@ -41,6 +44,39 @@ 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,
)
/**
* 睡眠时段图:**每晚一行**,一根圆角条从入睡时刻横到醒来时刻,横轴是钟点。
*
@@ -51,14 +87,16 @@ private val GRID_WIDTH = 1.dp
* 横轴以晚上 20:00 为锚点跨过零点:一晚从 23:43 到 07:37,用「当天第几分钟」表示会得到
* 1423 → 457 这样一对反过来的数,画出来入睡点跑到起床点右边。换算见 `minutesSinceSleepAnchor`。
*
* 只用一个颜色,**不画睡眠分期**:手头只有「深睡共 96 分钟」这样的累计时长,
* 没有「第 47 分钟进入深睡」这种分期序列。按累计时长的比例铺成色块,
* 画的是一段并不存在的分期位置 —— 那比不画更容易让人当真。
* **分期染色**:[SleepSession.stages] 非空的那几晚,条会被拆成多个染色子矩形;
* 传 [stageTints] 启用这一支。不传则所有夜晚走单色原行为 —— 多数 Mock 数据没有分期序列,
* 这是默认状态;有这层数据的夜晚再单独着色。M2 接入真机后会逐步补全序列,
* 把每一晚都变成可分期着色的版本。
*
* @param sessions 睡眠时段,按时间正序。
* @param color 时段条颜色。
* @param color 单色模式下的时段条颜色;分期模式下充当未指定分期的兜底色。
* @param rowLabel 每行左侧的标签(如「周三」)。由调用方从 [SleepSession.wakeDate] 取,
* 组件本身不做资源查找;标成 `@Composable` 是因为调用方多半要 `stringResource`。
* @param stageTints 为非 null 时启用分期染色;为 null 时全部夜晚走单色。
* @param modifier 外部修饰符。
* @param rowHeight 时段条的高度。
* @param rowGap 行距。
@@ -68,6 +106,7 @@ 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,
@@ -131,12 +170,42 @@ fun SleepWindowChart(
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 —— 等宽的文本框中心落在
@@ -6,7 +6,9 @@ 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
@@ -125,6 +127,11 @@ class MockDataProvider(
* 它是一个**写死的演示值**,和步数 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)
@@ -135,6 +142,7 @@ class MockDataProvider(
deepMinutes = DEMO_DEEP_MINUTES,
lightMinutes = DEMO_LIGHT_MINUTES,
awakeMinutes = DEMO_AWAKE_MINUTES,
stages = DEMO_LAST_NIGHT_STAGES,
)
}
@@ -273,5 +281,41 @@ class MockDataProvider(
/** 昨晚卧床时长 = 睡着 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),
)
}
}
@@ -76,6 +76,23 @@ fun HealthSummary.valueOf(metric: MetricType): Double? = when (metric) {
/** 一条历史数据点。 */
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,
)
/**
* 一晚睡眠。
*
@@ -90,6 +107,8 @@ data class MetricPoint(val timestamp: Long, val value: Double)
* @param deepMinutes 深睡时长。
* @param lightMinutes 浅睡时长。
* @param awakeMinutes 夜里清醒的累计时长(中途醒来)。
* @param stages 分期序列(相对 [sleepStart] 的分钟偏移)。**为 null 表示该晚不画分期染色**,
* 时段图按单色画这根条 —— 大多数 Mock 数据没有这层序列,M2 接入真机后会逐步补上。
*/
data class SleepSession(
val wakeDate: Long,
@@ -98,6 +117,7 @@ data class SleepSession(
val deepMinutes: Int,
val lightMinutes: Int,
val awakeMinutes: Int,
val stages: List<SleepSegment>? = null,
) {
/**
* 实际睡着的时长 = 深睡 + 浅睡,**不含夜里清醒**。
@@ -32,6 +32,7 @@ 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
@@ -61,10 +62,15 @@ internal fun LazyListScope.sleepDetail(state: MetricDetailUiState) {
/** 演示用的目标睡眠时长: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 = MetricBlue.copy(alpha = 0.2f)
private val AWAKE_TINT = Color(0xFFE8B4A8)
/** 头图:昨晚时长 + 入睡/起床时刻 + 分期条与分期时长。 */
@Composable
@@ -199,6 +205,14 @@ private fun SleepWindowCard(state: MetricDetailUiState, modifier: Modifier = Mod
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))