feat: 新增通用 UI 组件(卡片/环形进度/指标块/分段条/柱状图)
This commit is contained in:
+42
@@ -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)
|
||||
}
|
||||
}
|
||||
+61
@@ -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<Float>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -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()
|
||||
}
|
||||
}
|
||||
+37
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<StatSegment>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user