From 75a6749619e976b9cc02ccb91e81e3cc5e60203a 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 10:23:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=80=9A=E7=94=A8=20?= =?UTF-8?q?UI=20=E7=BB=84=E4=BB=B6=EF=BC=88=E5=8D=A1=E7=89=87/=E7=8E=AF?= =?UTF-8?q?=E5=BD=A2=E8=BF=9B=E5=BA=A6/=E6=8C=87=E6=A0=87=E5=9D=97/?= =?UTF-8?q?=E5=88=86=E6=AE=B5=E6=9D=A1/=E6=9F=B1=E7=8A=B6=E5=9B=BE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/designsystem/component/HealthCard.kt | 42 +++++++++++ .../designsystem/component/MetricBarChart.kt | 61 ++++++++++++++++ .../core/designsystem/component/MetricTile.kt | 69 ++++++++++++++++++ .../designsystem/component/RingProgress.kt | 70 +++++++++++++++++++ .../designsystem/component/SectionHeader.kt | 37 ++++++++++ .../core/designsystem/component/StatBar.kt | 51 ++++++++++++++ 6 files changed, 330 insertions(+) create mode 100644 app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/HealthCard.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricBarChart.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricTile.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/RingProgress.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SectionHeader.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/StatBar.kt diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/HealthCard.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/HealthCard.kt new file mode 100644 index 0000000..d282b93 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/HealthCard.kt @@ -0,0 +1,42 @@ +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.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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * 统一卡片容器:白底、大圆角、轻阴影。所有页面内容块都应包在它里面。 + * + * 颜色取自主题的 surface(由 [com.placeholder.ringhealth.core.designsystem.RingHealthTheme] + * 映射为设计系统的 CardSurface),因此升级深色模式时无需改动本文件。 + * + * @param modifier 外部修饰符,会在 fillMaxWidth 之前生效。 + * @param onClick 非空时整卡可点击(如水/体重等可跳转的卡片);为空则纯展示。 + * @param content 卡片内容,作用域为 ColumnScope,默认内边距 16dp。 + */ +@Composable +fun HealthCard( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + 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), + ) { + Column(modifier = Modifier.padding(16.dp), content = content) + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricBarChart.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricBarChart.kt new file mode 100644 index 0000000..96e7dc1 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricBarChart.kt @@ -0,0 +1,61 @@ +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 androidx.compose.ui.unit.dp + +/** + * 极简柱状图(Compose Canvas 手绘,无第三方依赖)。 + * values 为空时不绘制;被高亮的一根用纯色,其余半透明。 + * + * 几何说明:画布横向按 values.size 等分得到每个槽位 slot,柱宽取槽位的 65%, + * 余下 35% 作为间隔,柱子因此在槽内水平居中;柱高 = 画布高 × (value / maxValue), + * 底边贴合画布底边(y = 画布高 - 柱高),圆角半径取柱宽一半,形成胶囊柱。 + * maxValue 下限为 1f,避免「全为 0」时出现 0 除。 + * + * @param values 各柱数值(须为非负),按时间顺序从左到右。 + * @param color 柱子颜色;高亮柱用纯色,其余用其 30% 透明度。 + * @param modifier 外部修饰符。 + * @param barHeight 画布高度,即最高柱的高度。 + * @param highlightLast 为 true 时高亮最后一根(如「今天」),否则高亮数值最大的一根。 + */ +@Composable +fun MetricBarChart( + values: List, + color: Color, + modifier: Modifier = Modifier, + barHeight: Dp = 120.dp, + highlightLast: Boolean = true, +) { + 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 + + 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), + ) + } + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricTile.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricTile.kt new file mode 100644 index 0000000..75eb810 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricTile.kt @@ -0,0 +1,69 @@ +package com.placeholder.ringhealth.core.designsystem.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.placeholder.ringhealth.core.designsystem.MetricTileValueStyle +import com.placeholder.ringhealth.core.designsystem.TextSecondary + +/** + * 单个健康指标小卡:图标 + 标题 + 数值 + 单位。 + * + * 文案全部由调用方传入(标题/数值/单位),组件内不出现任何用户可见字面量。 + * 图标为纯装饰(contentDescription = null),语义由 title 承担,避免无障碍重复朗读。 + * + * @param icon 指标图标,如 Icons.Filled.DirectionsWalk。 + * @param iconTint 图标着色,通常取设计系统中该指标的语义色。 + * @param title 指标名(由调用方从字符串资源取得)。 + * @param value 主数值文本。 + * @param unit 单位文本,如「步」「次/分」。 + * @param modifier 外部修饰符。 + * @param onClick 点击回调,默认空实现(配合 HealthCard 的整卡可点击)。 + */ +@Composable +fun MetricTile( + icon: ImageVector, + iconTint: Color, + title: String, + value: String, + unit: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + HealthCard(modifier = modifier, onClick = onClick) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(18.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 6.dp), + ) + } + Column(modifier = Modifier.padding(top = 6.dp), verticalArrangement = Arrangement.Bottom) { + Row(verticalAlignment = Alignment.Bottom) { + Text(text = value, style = MetricTileValueStyle) + Text( + text = unit, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + modifier = Modifier.padding(start = 4.dp, bottom = 3.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/RingProgress.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/RingProgress.kt new file mode 100644 index 0000000..1027e64 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/RingProgress.kt @@ -0,0 +1,70 @@ +package com.placeholder.ringhealth.core.designsystem.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * 环形进度(首页大环与详情页复用)。progress 取 0f..1f。 + * content 渲染在环中心(如步数大数字与单位)。 + * + * 几何说明:描边以路径为中心向两侧各扩 stroke/2,因此圆弧的包围盒必须整体内缩 + * stroke/2 后作为 topLeft,并以「边长 - stroke」作为 Size,否则圆环会被画布裁掉半个线宽。 + * 进度从 12 点方向(-90°)开始顺时针增长;底环从 3 点方向起画满 360°(起点不影响整圆观感)。 + * + * @param progress 进度比例,超出 0f..1f 会被裁剪。 + * @param color 进度弧颜色,通常取设计系统的指标色。 + * @param trackColor 底环颜色,通常取 DividerColor。 + * @param modifier 外部修饰符。 + * @param ringSize 圆环外径(含描边)。 + * @param strokeWidth 描边宽度。 + * @param content 环心内容。 + */ +@Composable +fun RingProgress( + progress: Float, + color: Color, + trackColor: Color, + modifier: Modifier = Modifier, + ringSize: Dp = 180.dp, + strokeWidth: Dp = 14.dp, + content: @Composable () -> Unit = {}, +) { + Box(modifier = modifier.size(ringSize), contentAlignment = Alignment.Center) { + Canvas(modifier = Modifier.size(ringSize)) { + val stroke = strokeWidth.toPx() + val inset = stroke / 2f + // 内缩后的包围盒:宽高均为画布尺寸减去一个线宽。 + val arcSize = Size(this.size.width - stroke, this.size.height - stroke) + drawArc( + color = trackColor, + startAngle = 0f, + sweepAngle = 360f, + useCenter = false, + topLeft = Offset(inset, inset), + size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + drawArc( + color = color, + startAngle = -90f, + sweepAngle = 360f * progress.coerceIn(0f, 1f), + useCenter = false, + topLeft = Offset(inset, inset), + size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + } + content() + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SectionHeader.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SectionHeader.kt new file mode 100644 index 0000000..d17da9a --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SectionHeader.kt @@ -0,0 +1,37 @@ +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.foundation.layout.padding +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.unit.dp + +/** + * 区块标题,可选右侧扩展内容(如「查看全部」按钮)。 + * + * 标题与右侧内容左右两端对齐;trailing 为空时不占位。 + * + * @param title 标题文本,由调用方从字符串资源取得。 + * @param modifier 外部修饰符。 + * @param trailing 右侧内容槽,例如一个 TextButton 或「更多」图标。 + */ +@Composable +fun SectionHeader( + title: String, + modifier: Modifier = Modifier, + trailing: (@Composable () -> Unit)? = null, +) { + Row( + modifier = modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + trailing?.invoke() + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/StatBar.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/StatBar.kt new file mode 100644 index 0000000..e4fde4d --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/StatBar.kt @@ -0,0 +1,51 @@ +package com.placeholder.ringhealth.core.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** 分段条的一段:权重决定宽度。 */ +data class StatSegment(val weight: Float, val color: Color) + +/** + * 横向分段条,用于睡眠分期(深睡/浅睡/清醒)等占比展示。 + * + * 各段宽度按其 weight 在总权重中的占比分配;两端整体裁剪为半圆角。 + * 权重被下限保护为极小正数,避免全零权重导致 Row 的 weight 分配异常。 + * + * @param segments 分段列表,顺序即从左到右的绘制顺序。 + * @param modifier 外部修饰符。 + * @param barHeight 条高,同时决定两端圆角半径(barHeight / 2)。 + */ +@Composable +fun StatBar( + segments: List, + modifier: Modifier = Modifier, + barHeight: Dp = 12.dp, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(barHeight) + .clip(RoundedCornerShape(barHeight / 2)), + ) { + segments.forEach { segment -> + Box( + modifier = Modifier + .weight(segment.weight.coerceAtLeast(0.0001f)) + .fillMaxWidth() + .height(barHeight) + .background(segment.color), + ) + } + } +}