From fba26fc372e7e499090ecfe4d4b1584bf704d5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E9=B2=81=E6=AD=A3?= <13780741225@163.com> Date: Thu, 10 Sep 2026 17:25:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9D=A1=E7=9C=A0=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E6=94=AF=E6=8C=81=E6=8C=89=E5=88=86=E6=9C=9F=E6=9F=93?= =?UTF-8?q?=E8=89=B2=EF=BC=88=E6=BC=94=E7=A4=BA=E4=BB=85=E6=98=A8=E6=99=9A?= =?UTF-8?q?=E5=B8=A6=E5=88=86=E6=9C=9F=E5=BA=8F=E5=88=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SleepSession 增加可选 stages: List?;为 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 接入真机后逐步补全其他夜晚 --- .../component/SleepWindowChart.kt | 79 +++++++++++++++++-- .../ringhealth/data/mock/MockDataProvider.kt | 44 +++++++++++ .../ringhealth/domain/model/HealthModels.kt | 20 +++++ .../feature/detail/pages/SleepDetail.kt | 18 ++++- 4 files changed, 154 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SleepWindowChart.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SleepWindowChart.kt index e39d0df..c2589e3 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SleepWindowChart.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SleepWindowChart.kt @@ -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, 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 = color, + 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 —— 等宽的文本框中心落在 diff --git a/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt index 86ac1a6..ab9148b 100644 --- a/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt +++ b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt @@ -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 = 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), + ) } } diff --git a/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt b/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt index 7ff1125..14acf21 100644 --- a/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt +++ b/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt @@ -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? = null, ) { /** * 实际睡着的时长 = 深睡 + 浅睡,**不含夜里清醒**。 diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/SleepDetail.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/SleepDetail.kt index 48e50a0..4aa7718 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/SleepDetail.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/SleepDetail.kt @@ -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))