18 changed files with 1331 additions and 219 deletions
After Width: | Height: | Size: 2.4 KiB |
@ -0,0 +1,284 @@ |
|||||
|
package com.whitefish.ring.ui.chart |
||||
|
|
||||
|
import androidx.compose.foundation.background |
||||
|
import androidx.compose.foundation.layout.* |
||||
|
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
|
import androidx.compose.material3.Card |
||||
|
import androidx.compose.material3.Text |
||||
|
import androidx.compose.runtime.* |
||||
|
import androidx.compose.ui.Alignment |
||||
|
import androidx.compose.ui.Modifier |
||||
|
import androidx.compose.ui.draw.clip |
||||
|
import androidx.compose.ui.graphics.* |
||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope |
||||
|
import androidx.compose.ui.graphics.drawscope.Stroke |
||||
|
import androidx.compose.ui.text.font.FontWeight |
||||
|
import androidx.compose.ui.unit.dp |
||||
|
import androidx.compose.ui.unit.sp |
||||
|
import androidx.compose.foundation.Canvas |
||||
|
import androidx.compose.ui.geometry.Offset |
||||
|
import kotlin.math.* |
||||
|
|
||||
|
@Composable |
||||
|
fun RecoveryChart( |
||||
|
modifier: Modifier = Modifier, |
||||
|
currentHour: Float = 18f, |
||||
|
recoveryData: List<Float> = generateSampleRecoveryData() |
||||
|
) { |
||||
|
Card( |
||||
|
modifier = modifier |
||||
|
.fillMaxWidth() |
||||
|
.clip(RoundedCornerShape(24.dp)), |
||||
|
shape = RoundedCornerShape(24.dp) |
||||
|
) { |
||||
|
Box( |
||||
|
modifier = Modifier |
||||
|
.background( |
||||
|
Brush.radialGradient( |
||||
|
colors = listOf( |
||||
|
Color(0xFF8B7ED8), |
||||
|
Color(0xFF6B5B95) |
||||
|
), |
||||
|
radius = 800f |
||||
|
) |
||||
|
) |
||||
|
.padding(24.dp) |
||||
|
) { |
||||
|
Column { |
||||
|
// 标题和箭头 |
||||
|
Row( |
||||
|
modifier = Modifier.fillMaxWidth(), |
||||
|
horizontalArrangement = Arrangement.SpaceBetween, |
||||
|
verticalAlignment = Alignment.CenterVertically |
||||
|
) { |
||||
|
Text( |
||||
|
text = "恢复曲线", |
||||
|
color = Color.White, |
||||
|
fontSize = 18.sp, |
||||
|
fontWeight = FontWeight.Medium |
||||
|
) |
||||
|
Text( |
||||
|
text = "→", |
||||
|
color = Color.White.copy(alpha = 0.8f), |
||||
|
fontSize = 24.sp |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
Spacer(modifier = Modifier.height(24.dp)) |
||||
|
|
||||
|
// 图表区域 |
||||
|
Canvas( |
||||
|
modifier = Modifier |
||||
|
.fillMaxWidth() |
||||
|
.height(120.dp) |
||||
|
) { |
||||
|
drawRecoveryChart( |
||||
|
size = size, |
||||
|
recoveryData = recoveryData, |
||||
|
currentHour = currentHour |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
Spacer(modifier = Modifier.height(24.dp)) |
||||
|
|
||||
|
// 建议文本 |
||||
|
Box( |
||||
|
modifier = Modifier |
||||
|
.fillMaxWidth() |
||||
|
.background( |
||||
|
Color.White.copy(alpha = 0.15f), |
||||
|
RoundedCornerShape(16.dp) |
||||
|
) |
||||
|
.padding(16.dp) |
||||
|
) { |
||||
|
Text( |
||||
|
text = "你当天的身体状态似乎不太好,请注意保持适量的运动,晚上早点休息,养成良好的锻炼和睡眠规律。", |
||||
|
color = Color.White.copy(alpha = 0.9f), |
||||
|
fontSize = 14.sp, |
||||
|
lineHeight = 20.sp |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun DrawScope.drawRecoveryChart( |
||||
|
size: androidx.compose.ui.geometry.Size, |
||||
|
recoveryData: List<Float>, |
||||
|
currentHour: Float |
||||
|
) { |
||||
|
val padding = 40f |
||||
|
val chartWidth = size.width - padding * 2 |
||||
|
val chartHeight = size.height - padding * 2 |
||||
|
val chartBottom = size.height - padding |
||||
|
|
||||
|
// 绘制水平虚线 |
||||
|
val midLineY = chartBottom - chartHeight / 2 |
||||
|
drawDashedLine( |
||||
|
start = Offset(padding, midLineY), |
||||
|
end = Offset(size.width - padding, midLineY), |
||||
|
color = Color.White.copy(alpha = 0.4f) |
||||
|
) |
||||
|
|
||||
|
// 绘制时间标签 |
||||
|
val timeLabels = listOf("00:00", "6:00", "12:00", "18:00", "24:00") |
||||
|
timeLabels.forEachIndexed { index, label -> |
||||
|
val x = padding + (chartWidth / (timeLabels.size - 1)) * index |
||||
|
drawContext.canvas.nativeCanvas.apply { |
||||
|
// 注意:这里需要平台特定的实现来绘制文本 |
||||
|
// 在实际实现中,你可能需要使用TextPaint或其他方式 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 准备路径数据 |
||||
|
val points = recoveryData.mapIndexed { index, value -> |
||||
|
val x = padding + (chartWidth / (recoveryData.size - 1)) * index |
||||
|
val y = chartBottom - (value * chartHeight) |
||||
|
Offset(x, y) |
||||
|
} |
||||
|
|
||||
|
// 找到当前时间对应的点索引 |
||||
|
val currentIndex = (currentHour * recoveryData.size / 24f).toInt() |
||||
|
.coerceIn(0, recoveryData.size - 1) |
||||
|
|
||||
|
// 绘制主要恢复曲线(渐变色) |
||||
|
if (points.isNotEmpty()) { |
||||
|
val gradientBrush = Brush.linearGradient( |
||||
|
colors = listOf( |
||||
|
Color(0xFF4CAF50), // 绿色 |
||||
|
Color(0xFFFFEB3B), // 黄色 |
||||
|
Color(0xFFFF9800) // 橙色 |
||||
|
), |
||||
|
start = Offset(points.first().x, 0f), |
||||
|
end = Offset(points[currentIndex].x, 0f) |
||||
|
) |
||||
|
|
||||
|
// 绘制实际曲线(到当前时间) |
||||
|
drawSmoothCurve( |
||||
|
points = points.take(currentIndex + 1), |
||||
|
brush = gradientBrush, |
||||
|
strokeWidth = 4.dp.toPx() |
||||
|
) |
||||
|
|
||||
|
// 绘制预测曲线(白色虚线) |
||||
|
if (currentIndex < points.size - 1) { |
||||
|
drawSmoothCurve( |
||||
|
points = points.drop(currentIndex), |
||||
|
color = Color.White.copy(alpha = 0.8f), |
||||
|
strokeWidth = 3.dp.toPx(), |
||||
|
isDashed = true |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// 绘制当前位置的白色亮点 |
||||
|
drawCircle( |
||||
|
color = Color.White, |
||||
|
radius = 8.dp.toPx(), |
||||
|
center = points[currentIndex] |
||||
|
) |
||||
|
drawCircle( |
||||
|
color = Color.White.copy(alpha = 0.3f), |
||||
|
radius = 12.dp.toPx(), |
||||
|
center = points[currentIndex] |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun DrawScope.drawDashedLine( |
||||
|
start: Offset, |
||||
|
end: Offset, |
||||
|
color: Color, |
||||
|
dashWidth: Float = 8f, |
||||
|
gapWidth: Float = 8f |
||||
|
) { |
||||
|
val totalDistance = (end - start).getDistance() |
||||
|
val dashCount = (totalDistance / (dashWidth + gapWidth)).toInt() |
||||
|
|
||||
|
repeat(dashCount) { index -> |
||||
|
val startRatio = index * (dashWidth + gapWidth) / totalDistance |
||||
|
val endRatio = (index * (dashWidth + gapWidth) + dashWidth) / totalDistance |
||||
|
|
||||
|
if (endRatio <= 1f) { |
||||
|
val dashStart = start + (end - start) * startRatio |
||||
|
val dashEnd = start + (end - start) * endRatio.coerceAtMost(1f) |
||||
|
|
||||
|
drawLine( |
||||
|
color = color, |
||||
|
start = dashStart, |
||||
|
end = dashEnd, |
||||
|
strokeWidth = 2.dp.toPx() |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun DrawScope.drawSmoothCurve( |
||||
|
points: List<Offset>, |
||||
|
brush: Brush? = null, |
||||
|
color: Color = Color.Transparent, |
||||
|
strokeWidth: Float = 4f, |
||||
|
isDashed: Boolean = false |
||||
|
) { |
||||
|
if (points.size < 2) return |
||||
|
|
||||
|
val path = Path() |
||||
|
path.moveTo(points.first().x, points.first().y) |
||||
|
|
||||
|
// 使用二次贝塞尔曲线创建平滑路径 |
||||
|
for (i in 1 until points.size) { |
||||
|
val currentPoint = points[i] |
||||
|
val previousPoint = points[i - 1] |
||||
|
|
||||
|
val midPointX = (previousPoint.x + currentPoint.x) / 2 |
||||
|
val midPointY = (previousPoint.y + currentPoint.y) / 2 |
||||
|
|
||||
|
if (i == 1) { |
||||
|
path.lineTo(midPointX, midPointY) |
||||
|
} else { |
||||
|
path.quadraticTo(previousPoint.x, previousPoint.y, midPointX, midPointY) |
||||
|
} |
||||
|
|
||||
|
if (i == points.size - 1) { |
||||
|
path.lineTo(currentPoint.x, currentPoint.y) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
val pathEffect = if (isDashed) { |
||||
|
PathEffect.dashPathEffect(floatArrayOf(12f, 8f)) |
||||
|
} else null |
||||
|
|
||||
|
if (brush != null) { |
||||
|
drawPath( |
||||
|
path = path, |
||||
|
brush = brush, |
||||
|
style = Stroke( |
||||
|
width = strokeWidth, |
||||
|
pathEffect = pathEffect |
||||
|
) |
||||
|
) |
||||
|
} else { |
||||
|
drawPath( |
||||
|
path = path, |
||||
|
color = color, |
||||
|
style = Stroke( |
||||
|
width = strokeWidth, |
||||
|
pathEffect = pathEffect |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 生成示例恢复数据 |
||||
|
private fun generateSampleRecoveryData(): List<Float> { |
||||
|
// 24小时的恢复数据点,模拟一天的身体状态变化 |
||||
|
return (0..47).map { hour -> |
||||
|
val timeInHours = hour * 0.5f |
||||
|
when { |
||||
|
timeInHours < 6f -> 0.2f + timeInHours * 0.05f // 夜间恢复 |
||||
|
timeInHours < 12f -> 0.5f + sin((timeInHours - 6f) * PI / 6f).toFloat() * 0.3f // 上午活跃 |
||||
|
timeInHours < 18f -> 0.8f - (timeInHours - 12f) * 0.05f // 下午下降 |
||||
|
else -> 0.5f - sin((timeInHours - 18f) * PI / 6f).toFloat() * 0.2f // 晚间 |
||||
|
}.coerceIn(0f, 1f) |
||||
|
} |
||||
|
} |
@ -0,0 +1,190 @@ |
|||||
|
package com.whitefish.ring.ui.chart |
||||
|
|
||||
|
import androidx.compose.foundation.layout.* |
||||
|
import androidx.compose.foundation.rememberScrollState |
||||
|
import androidx.compose.foundation.verticalScroll |
||||
|
import androidx.compose.material3.* |
||||
|
import androidx.compose.runtime.* |
||||
|
import androidx.compose.ui.Alignment |
||||
|
import androidx.compose.ui.Modifier |
||||
|
import androidx.compose.ui.graphics.Color |
||||
|
import androidx.compose.ui.text.font.FontWeight |
||||
|
import androidx.compose.ui.unit.dp |
||||
|
import androidx.compose.ui.unit.sp |
||||
|
// import kotlinx.datetime.* |
||||
|
|
||||
|
@OptIn(ExperimentalMaterial3Api::class) |
||||
|
@Composable |
||||
|
fun RecoveryChartDemo( |
||||
|
modifier: Modifier = Modifier |
||||
|
) { |
||||
|
var currentHour by remember { mutableFloatStateOf(18f) } |
||||
|
|
||||
|
Column( |
||||
|
modifier = modifier |
||||
|
.fillMaxSize() |
||||
|
.verticalScroll(rememberScrollState()) |
||||
|
.padding(16.dp), |
||||
|
verticalArrangement = Arrangement.spacedBy(16.dp) |
||||
|
) { |
||||
|
Text( |
||||
|
text = "恢复曲线图表演示", |
||||
|
fontSize = 24.sp, |
||||
|
fontWeight = FontWeight.Bold, |
||||
|
modifier = Modifier.align(Alignment.CenterHorizontally) |
||||
|
) |
||||
|
|
||||
|
// 时间控制器 |
||||
|
Card( |
||||
|
modifier = Modifier.fillMaxWidth() |
||||
|
) { |
||||
|
Column( |
||||
|
modifier = Modifier.padding(16.dp), |
||||
|
verticalArrangement = Arrangement.spacedBy(12.dp) |
||||
|
) { |
||||
|
Text( |
||||
|
text = "当前时间: ${formatTime(currentHour)}", |
||||
|
fontSize = 16.sp, |
||||
|
fontWeight = FontWeight.Medium |
||||
|
) |
||||
|
|
||||
|
Slider( |
||||
|
value = currentHour, |
||||
|
onValueChange = { currentHour = it }, |
||||
|
valueRange = 0f..24f, |
||||
|
steps = 47 // 24小时 * 2 - 1 (每30分钟一步) |
||||
|
) |
||||
|
|
||||
|
Row( |
||||
|
modifier = Modifier.fillMaxWidth(), |
||||
|
horizontalArrangement = Arrangement.SpaceBetween |
||||
|
) { |
||||
|
Text("00:00", fontSize = 12.sp, color = Color.Gray) |
||||
|
Text("06:00", fontSize = 12.sp, color = Color.Gray) |
||||
|
Text("12:00", fontSize = 12.sp, color = Color.Gray) |
||||
|
Text("18:00", fontSize = 12.sp, color = Color.Gray) |
||||
|
Text("24:00", fontSize = 12.sp, color = Color.Gray) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 第一个图表 - 基础版本 |
||||
|
Text( |
||||
|
text = "基础恢复曲线", |
||||
|
fontSize = 18.sp, |
||||
|
fontWeight = FontWeight.Medium |
||||
|
) |
||||
|
|
||||
|
RecoveryChart( |
||||
|
modifier = Modifier.fillMaxWidth(), |
||||
|
currentHour = currentHour |
||||
|
) |
||||
|
|
||||
|
// 第二个图表 - 带时间标签版本 |
||||
|
Text( |
||||
|
text = "带时间标签的恢复曲线", |
||||
|
fontSize = 18.sp, |
||||
|
fontWeight = FontWeight.Medium |
||||
|
) |
||||
|
|
||||
|
RecoveryChartWithTimeLabels( |
||||
|
modifier = Modifier.fillMaxWidth(), |
||||
|
actualData = arrayListOf(1.0f, 0.8f, 0.6f, 0.4f, 0.2f, 0.1f,1.0f, 0.8f, 0.6f, 0.4f, 0.2f, 0.1f,1.0f, 0.8f, 0.6f, 0.4f, 0.2f, 0.1f,1.0f, 0.8f, 0.6f, 0.4f, 0.2f, 0.1f), |
||||
|
predictedData = arrayListOf() |
||||
|
) |
||||
|
|
||||
|
// 功能说明 |
||||
|
Card( |
||||
|
modifier = Modifier.fillMaxWidth(), |
||||
|
colors = CardDefaults.cardColors( |
||||
|
containerColor = Color(0xFFF5F5F5) |
||||
|
) |
||||
|
) { |
||||
|
Column( |
||||
|
modifier = Modifier.padding(16.dp), |
||||
|
verticalArrangement = Arrangement.spacedBy(8.dp) |
||||
|
) { |
||||
|
Text( |
||||
|
text = "图表特性说明", |
||||
|
fontSize = 16.sp, |
||||
|
fontWeight = FontWeight.Medium |
||||
|
) |
||||
|
|
||||
|
val features = listOf( |
||||
|
"🌈 渐变色线条:从绿色到橙色的动态渐变", |
||||
|
"⭐ 白色亮点:标记当前时间位置,带光晕效果", |
||||
|
"📊 预测线段:白色线条显示未来预测数据", |
||||
|
"⏰ 时间刻度:准确的24小时时间标记", |
||||
|
"🎨 紫色背景:符合设计稿的渐变背景", |
||||
|
"📱 响应式设计:适配不同屏幕尺寸" |
||||
|
) |
||||
|
|
||||
|
features.forEach { feature -> |
||||
|
Text( |
||||
|
text = feature, |
||||
|
fontSize = 14.sp, |
||||
|
color = Color.DarkGray |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 数据说明 |
||||
|
Card( |
||||
|
modifier = Modifier.fillMaxWidth(), |
||||
|
colors = CardDefaults.cardColors( |
||||
|
containerColor = Color(0xFFE3F2FD) |
||||
|
) |
||||
|
) { |
||||
|
Column( |
||||
|
modifier = Modifier.padding(16.dp), |
||||
|
verticalArrangement = Arrangement.spacedBy(8.dp) |
||||
|
) { |
||||
|
Text( |
||||
|
text = "数据模型说明", |
||||
|
fontSize = 16.sp, |
||||
|
fontWeight = FontWeight.Medium |
||||
|
) |
||||
|
|
||||
|
Text( |
||||
|
text = "• 夜间 (00:00-06:00): 低水平,逐渐恢复\n" + |
||||
|
"• 上午 (06:00-12:00): 快速上升至峰值\n" + |
||||
|
"• 下午 (12:00-18:00): 从峰值逐渐下降\n" + |
||||
|
"• 晚上 (18:00-24:00): 持续下降至夜间水平", |
||||
|
fontSize = 14.sp, |
||||
|
color = Color.DarkGray |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun formatTime(hour: Float): String { |
||||
|
val hours = hour.toInt() |
||||
|
val minutes = ((hour - hours) * 60).toInt() |
||||
|
return "${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}" |
||||
|
} |
||||
|
|
||||
|
// 根据当前时间生成演示的实际数据 |
||||
|
private fun generateDemoActualData(currentHour: Int): List<Float> { |
||||
|
val actualHours = currentHour.coerceIn(1, 23) // 至少1小时,最多23小时 |
||||
|
return (0 until actualHours).map { hour -> |
||||
|
when { |
||||
|
hour < 6 -> 0.3f + hour * 0.05f // 夜间缓慢上升 |
||||
|
hour < 12 -> 0.6f + (hour - 6) * 0.05f // 上午继续上升 |
||||
|
else -> 0.9f - (hour - 12) * 0.03f // 下午缓慢下降 |
||||
|
}.coerceIn(0f, 1f) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 根据当前时间生成演示的预测数据 |
||||
|
private fun generateDemoPredictedData(currentHour: Int): List<Float> { |
||||
|
val startHour = currentHour.coerceIn(1, 23) |
||||
|
return (startHour until 24).map { hour -> |
||||
|
when { |
||||
|
hour < 18 -> 0.8f - (hour - startHour) * 0.02f // 继续下降 |
||||
|
hour < 22 -> 0.6f - (hour - 18) * 0.05f // 傍晚下降 |
||||
|
else -> 0.4f - (hour - 22) * 0.05f // 夜间低水平 |
||||
|
}.coerceIn(0f, 1f) |
||||
|
} |
||||
|
} |
@ -0,0 +1,432 @@ |
|||||
|
package com.whitefish.ring.ui.chart |
||||
|
|
||||
|
import androidx.compose.foundation.background |
||||
|
import androidx.compose.foundation.layout.* |
||||
|
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
|
import androidx.compose.material3.Card |
||||
|
import androidx.compose.material3.Text |
||||
|
import androidx.compose.runtime.* |
||||
|
import androidx.compose.ui.Alignment |
||||
|
import androidx.compose.ui.Modifier |
||||
|
import androidx.compose.ui.draw.clip |
||||
|
import androidx.compose.ui.graphics.* |
||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope |
||||
|
import androidx.compose.ui.graphics.drawscope.Stroke |
||||
|
import androidx.compose.ui.text.font.FontWeight |
||||
|
import androidx.compose.ui.unit.dp |
||||
|
import androidx.compose.ui.unit.sp |
||||
|
import androidx.compose.foundation.Canvas |
||||
|
import androidx.compose.foundation.Image |
||||
|
import androidx.compose.ui.geometry.Offset |
||||
|
import androidx.compose.ui.layout.ContentScale |
||||
|
import androidx.compose.ui.text.TextMeasurer |
||||
|
import androidx.compose.ui.text.TextStyle |
||||
|
import androidx.compose.ui.text.drawText |
||||
|
import androidx.compose.ui.text.rememberTextMeasurer |
||||
|
import org.jetbrains.compose.resources.painterResource |
||||
|
import ring.shared.generated.resources.Res |
||||
|
import ring.shared.generated.resources.bg_recovery_chart |
||||
|
import ring.shared.generated.resources.bg_recovery_score |
||||
|
import kotlin.math.* |
||||
|
|
||||
|
@Composable |
||||
|
fun RecoveryChartWithTimeLabels( |
||||
|
modifier: Modifier = Modifier, |
||||
|
actualData: List<Float> = generateSampleActualData(), // 实际数据(彩色曲线) |
||||
|
predictedData: List<Float> = generateSamplePredictedData() // 预测数据(白色曲线) |
||||
|
) { |
||||
|
val textMeasurer = rememberTextMeasurer() |
||||
|
|
||||
|
Card( |
||||
|
modifier = modifier |
||||
|
.clip(RoundedCornerShape(24.dp)), |
||||
|
shape = RoundedCornerShape(24.dp) |
||||
|
) { |
||||
|
|
||||
|
Box(modifier = Modifier.wrapContentSize()) { |
||||
|
Image( |
||||
|
painterResource(Res.drawable.bg_recovery_chart), |
||||
|
contentDescription = null, |
||||
|
modifier = Modifier.fillMaxSize(), |
||||
|
contentScale = ContentScale.Crop |
||||
|
) |
||||
|
Box( |
||||
|
modifier = Modifier.padding(horizontal = 24.dp) |
||||
|
) { |
||||
|
Column { |
||||
|
// 标题和箭头 |
||||
|
Row( |
||||
|
modifier = Modifier.fillMaxWidth().padding(top = 15.dp), |
||||
|
horizontalArrangement = Arrangement.SpaceBetween, |
||||
|
verticalAlignment = Alignment.CenterVertically |
||||
|
) { |
||||
|
Text( |
||||
|
text = "恢复曲线", |
||||
|
color = Color.White, |
||||
|
fontSize = 20.sp, |
||||
|
fontWeight = FontWeight.Medium |
||||
|
) |
||||
|
Text( |
||||
|
text = "→", |
||||
|
color = Color.White.copy(alpha = 0.8f), |
||||
|
fontSize = 24.sp |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
Spacer(modifier = Modifier.height(24.dp)) |
||||
|
|
||||
|
// 图表区域 |
||||
|
Canvas( |
||||
|
modifier = Modifier |
||||
|
.fillMaxWidth() |
||||
|
.height(83.dp) |
||||
|
) { |
||||
|
drawRecoveryChartWithLabels( |
||||
|
size = size, |
||||
|
actualData = actualData, |
||||
|
predictedData = predictedData, |
||||
|
textMeasurer = textMeasurer |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
Spacer(modifier = Modifier.height(16.dp)) |
||||
|
|
||||
|
} |
||||
|
|
||||
|
|
||||
|
} |
||||
|
|
||||
|
Box(modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 13.dp).align(Alignment.BottomCenter)) { |
||||
|
// 建议文本 |
||||
|
Row( |
||||
|
modifier = Modifier |
||||
|
.fillMaxWidth() |
||||
|
.height(80.dp) |
||||
|
.background( |
||||
|
Color.White.copy(alpha = 0.15f), |
||||
|
RoundedCornerShape(16.dp) |
||||
|
) |
||||
|
) { |
||||
|
Text( |
||||
|
text = "你当天的身体状态似乎不太好,请注意保持适量的运动,晚上早点休息,养成良好的锻炼和睡眠规律。", |
||||
|
color = Color.White.copy(alpha = 0.9f), |
||||
|
fontSize = 14.sp, |
||||
|
lineHeight = 20.sp, |
||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 16.dp) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun DrawScope.drawRecoveryChartWithLabels( |
||||
|
size: androidx.compose.ui.geometry.Size, |
||||
|
actualData: List<Float>, |
||||
|
predictedData: List<Float>, |
||||
|
textMeasurer: TextMeasurer |
||||
|
) { |
||||
|
val padding = 24f |
||||
|
val bottomPadding = 30f // 为时间标签留出更多空间 |
||||
|
val chartWidth = size.width - padding * 2 |
||||
|
val chartHeight = size.height - padding - bottomPadding |
||||
|
val chartBottom = size.height - bottomPadding |
||||
|
|
||||
|
// 绘制水平虚线 |
||||
|
val midLineY = chartBottom - chartHeight / 2 |
||||
|
drawDashedLine( |
||||
|
start = Offset(padding, midLineY), |
||||
|
end = Offset(size.width - padding, midLineY), |
||||
|
color = Color.White.copy(alpha = 0.4f) |
||||
|
) |
||||
|
|
||||
|
// 合并两组数据(实际数据 + 预测数据) |
||||
|
val allData = actualData + predictedData |
||||
|
val actualDataSize = actualData.size |
||||
|
|
||||
|
// 确保总共24个数据点(每小时一个点) |
||||
|
val totalExpectedPoints = 24 |
||||
|
val combinedData = if (allData.size == totalExpectedPoints) { |
||||
|
allData |
||||
|
} else { |
||||
|
// 如果数据点不是24个,进行插值或截取 |
||||
|
generateInterpolatedData(allData, totalExpectedPoints) |
||||
|
} |
||||
|
|
||||
|
// 准备路径数据 |
||||
|
val points = combinedData.mapIndexed { index, value -> |
||||
|
val x = padding + (chartWidth / (combinedData.size - 1)) * index |
||||
|
val y = chartBottom - (value * chartHeight) |
||||
|
Offset(x, y) |
||||
|
} |
||||
|
|
||||
|
// 绘制垂直虚线和时间标签 |
||||
|
val timeLabels = listOf("00:00", "6:00", "12:00", "18:00", "24:00") |
||||
|
val timeHours = listOf(0f, 6f, 12f, 18f, 24f) |
||||
|
val timeTextStyle = TextStyle( |
||||
|
color = Color.White.copy(alpha = 0.7f), |
||||
|
fontSize = 12.sp |
||||
|
) |
||||
|
|
||||
|
timeLabels.forEachIndexed { index, label -> |
||||
|
// 计算对应时间点的曲线Y坐标 |
||||
|
val timeHour = timeHours[index] |
||||
|
val dataIndex = (timeHour * combinedData.size / 24f).toInt() |
||||
|
.coerceIn(0, combinedData.size - 1) |
||||
|
|
||||
|
// 使用与曲线数据点相同的x坐标计算方式 |
||||
|
val x = if (points.isNotEmpty()) { |
||||
|
points[dataIndex].x |
||||
|
} else { |
||||
|
padding + (chartWidth / (timeLabels.size - 1)) * index |
||||
|
} |
||||
|
|
||||
|
val curveY = if (points.isNotEmpty()) { |
||||
|
points[dataIndex].y |
||||
|
} else { |
||||
|
chartBottom - chartHeight * 0.5f // 默认中间位置 |
||||
|
} |
||||
|
|
||||
|
// 绘制垂直虚线(从曲线位置到时间轴) |
||||
|
drawDashedLine( |
||||
|
start = Offset(x, curveY), |
||||
|
end = Offset(x, chartBottom), |
||||
|
color = Color.White.copy(alpha = 0.2f), |
||||
|
dashWidth = 4f, |
||||
|
gapWidth = 6f |
||||
|
) |
||||
|
|
||||
|
// 绘制时间标签 |
||||
|
val textLayoutResult = textMeasurer.measure(label, timeTextStyle) |
||||
|
drawText( |
||||
|
textLayoutResult = textLayoutResult, |
||||
|
topLeft = Offset( |
||||
|
x - textLayoutResult.size.width / 2, |
||||
|
chartBottom + 12.dp.toPx() |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// 绘制曲线 |
||||
|
if (points.isNotEmpty()) { |
||||
|
// 分离实际数据点和预测数据点 |
||||
|
val actualPoints = points.take(actualDataSize) |
||||
|
val predictedPoints = if (actualDataSize < points.size) { |
||||
|
// 从实际数据的最后一个点开始,包含预测数据 |
||||
|
points.drop(actualDataSize - 1) |
||||
|
} else { |
||||
|
emptyList() |
||||
|
} |
||||
|
|
||||
|
// 绘制实际数据曲线(彩色渐变) |
||||
|
if (actualPoints.isNotEmpty()) { |
||||
|
val gradientColors = listOf( |
||||
|
Color(0xFF00C851), // 亮绿色 |
||||
|
Color(0xFF66BB6A), // 中绿色 |
||||
|
Color(0xFFFFEB3B), // 黄色 |
||||
|
Color(0xFFFF9800), // 橙色 |
||||
|
Color(0xFFFF5722) // 深橙色 |
||||
|
) |
||||
|
|
||||
|
val gradientBrush = Brush.linearGradient( |
||||
|
colors = gradientColors, |
||||
|
start = Offset(actualPoints.first().x, 0f), |
||||
|
end = Offset(actualPoints.last().x, 0f) |
||||
|
) |
||||
|
|
||||
|
drawSmoothCurve( |
||||
|
points = actualPoints, |
||||
|
brush = gradientBrush, |
||||
|
strokeWidth = 4.dp.toPx() |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// 绘制预测数据曲线(白色) |
||||
|
if (predictedPoints.isNotEmpty()) { |
||||
|
drawSmoothCurve( |
||||
|
points = predictedPoints, |
||||
|
color = Color.White.copy(alpha = 0.9f), |
||||
|
strokeWidth = 3.dp.toPx() |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// 绘制分界点的白色亮点(在实际数据的最后一个点) |
||||
|
if (actualPoints.isNotEmpty()) { |
||||
|
val dividerPoint = actualPoints.last() |
||||
|
|
||||
|
// 外层光晕 |
||||
|
drawCircle( |
||||
|
color = Color.White.copy(alpha = 0.3f), |
||||
|
radius = 16.dp.toPx(), |
||||
|
center = dividerPoint |
||||
|
) |
||||
|
// 中层光晕 |
||||
|
drawCircle( |
||||
|
color = Color.White.copy(alpha = 0.6f), |
||||
|
radius = 10.dp.toPx(), |
||||
|
center = dividerPoint |
||||
|
) |
||||
|
// 核心亮点 |
||||
|
drawCircle( |
||||
|
color = Color.White, |
||||
|
radius = 6.dp.toPx(), |
||||
|
center = dividerPoint |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun DrawScope.drawDashedLine( |
||||
|
start: Offset, |
||||
|
end: Offset, |
||||
|
color: Color, |
||||
|
dashWidth: Float = 8f, |
||||
|
gapWidth: Float = 8f |
||||
|
) { |
||||
|
val totalDistance = (end - start).getDistance() |
||||
|
val dashCount = (totalDistance / (dashWidth + gapWidth)).toInt() |
||||
|
|
||||
|
repeat(dashCount) { index -> |
||||
|
val startRatio = index * (dashWidth + gapWidth) / totalDistance |
||||
|
val endRatio = (index * (dashWidth + gapWidth) + dashWidth) / totalDistance |
||||
|
|
||||
|
if (endRatio <= 1f) { |
||||
|
val dashStart = start + (end - start) * startRatio |
||||
|
val dashEnd = start + (end - start) * endRatio.coerceAtMost(1f) |
||||
|
|
||||
|
drawLine( |
||||
|
color = color, |
||||
|
start = dashStart, |
||||
|
end = dashEnd, |
||||
|
strokeWidth = 1.dp.toPx() |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun DrawScope.drawSmoothCurve( |
||||
|
points: List<Offset>, |
||||
|
brush: Brush? = null, |
||||
|
color: Color = Color.Transparent, |
||||
|
strokeWidth: Float = 4f, |
||||
|
isDashed: Boolean = false |
||||
|
) { |
||||
|
if (points.size < 2) return |
||||
|
|
||||
|
val path = Path() |
||||
|
path.moveTo(points.first().x, points.first().y) |
||||
|
|
||||
|
// 使用三次贝塞尔曲线创建更平滑的路径 |
||||
|
for (i in 1 until points.size) { |
||||
|
val currentPoint = points[i] |
||||
|
val previousPoint = points[i - 1] |
||||
|
|
||||
|
// 计算控制点来创建平滑曲线 |
||||
|
val controlPointDistance = (currentPoint.x - previousPoint.x) * 0.3f |
||||
|
|
||||
|
val control1 = Offset( |
||||
|
previousPoint.x + controlPointDistance, |
||||
|
previousPoint.y |
||||
|
) |
||||
|
val control2 = Offset( |
||||
|
currentPoint.x - controlPointDistance, |
||||
|
currentPoint.y |
||||
|
) |
||||
|
|
||||
|
path.cubicTo( |
||||
|
control1.x, control1.y, |
||||
|
control2.x, control2.y, |
||||
|
currentPoint.x, currentPoint.y |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
val pathEffect = if (isDashed) { |
||||
|
PathEffect.dashPathEffect(floatArrayOf(12f, 8f)) |
||||
|
} else null |
||||
|
|
||||
|
if (brush != null) { |
||||
|
drawPath( |
||||
|
path = path, |
||||
|
brush = brush, |
||||
|
style = Stroke( |
||||
|
width = strokeWidth, |
||||
|
pathEffect = pathEffect, |
||||
|
cap = StrokeCap.Round, |
||||
|
join = StrokeJoin.Round |
||||
|
) |
||||
|
) |
||||
|
} else { |
||||
|
drawPath( |
||||
|
path = path, |
||||
|
color = color, |
||||
|
style = Stroke( |
||||
|
width = strokeWidth, |
||||
|
pathEffect = pathEffect, |
||||
|
cap = StrokeCap.Round, |
||||
|
join = StrokeJoin.Round |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 生成插值数据 |
||||
|
private fun generateInterpolatedData(data: List<Float>, targetSize: Int): List<Float> { |
||||
|
if (data.isEmpty()) return List(targetSize) { 0.5f } |
||||
|
if (data.size == targetSize) return data |
||||
|
|
||||
|
return (0 until targetSize).map { index -> |
||||
|
val ratio = index.toFloat() / (targetSize - 1) |
||||
|
val sourceIndex = ratio * (data.size - 1) |
||||
|
val lowerIndex = sourceIndex.toInt().coerceIn(0, data.size - 1) |
||||
|
val upperIndex = (lowerIndex + 1).coerceIn(0, data.size - 1) |
||||
|
val fraction = sourceIndex - lowerIndex |
||||
|
|
||||
|
// 线性插值 |
||||
|
data[lowerIndex] * (1 - fraction) + data[upperIndex] * fraction |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 生成示例实际数据(0-18小时,共18个点) |
||||
|
private fun generateSampleActualData(): List<Float> { |
||||
|
return (0..17).map { hour -> |
||||
|
val timeInHours = hour.toFloat() |
||||
|
|
||||
|
val baseValue = when { |
||||
|
timeInHours < 6f -> { |
||||
|
// 夜间:低水平,逐渐恢复 |
||||
|
0.2f + (timeInHours / 6f) * 0.3f |
||||
|
} |
||||
|
|
||||
|
timeInHours < 12f -> { |
||||
|
// 上午:快速上升 |
||||
|
0.5f + ((timeInHours - 6f) / 6f) * 0.4f |
||||
|
} |
||||
|
|
||||
|
else -> { |
||||
|
// 下午:逐渐下降 |
||||
|
0.9f - ((timeInHours - 12f) / 6f) * 0.3f |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 添加轻微波动 |
||||
|
val noise = sin(timeInHours * 0.5f).toFloat() * 0.05f |
||||
|
(baseValue + noise).coerceIn(0f, 1f) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 生成示例预测数据(18-24小时,共6个点) |
||||
|
private fun generateSamplePredictedData(): List<Float> { |
||||
|
return (18..23).map { hour -> |
||||
|
val timeInHours = hour.toFloat() |
||||
|
|
||||
|
// 傍晚到夜间的预测:继续下降 |
||||
|
val baseValue = 0.6f - ((timeInHours - 18f) / 6f) * 0.4f |
||||
|
|
||||
|
// 添加轻微波动 |
||||
|
val noise = cos(timeInHours * 0.3f).toFloat() * 0.03f |
||||
|
(baseValue + noise).coerceIn(0f, 1f) |
||||
|
} |
||||
|
} |
@ -0,0 +1,166 @@ |
|||||
|
package com.whitefish.ring.ui.components |
||||
|
|
||||
|
import androidx.compose.foundation.Canvas |
||||
|
import androidx.compose.foundation.layout.* |
||||
|
import androidx.compose.foundation.background |
||||
|
import androidx.compose.foundation.border |
||||
|
import androidx.compose.foundation.shape.CircleShape |
||||
|
import androidx.compose.material3.* |
||||
|
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.StrokeCap |
||||
|
import androidx.compose.ui.graphics.drawscope.Stroke |
||||
|
import androidx.compose.ui.text.font.FontWeight |
||||
|
import androidx.compose.ui.text.style.TextAlign |
||||
|
import androidx.compose.ui.unit.dp |
||||
|
import androidx.compose.ui.unit.sp |
||||
|
import kotlin.math.cos |
||||
|
import kotlin.math.sin |
||||
|
|
||||
|
/** |
||||
|
* 圆弧进度卡片组件 |
||||
|
* @param currentMinutes 当前分钟数 |
||||
|
* @param totalMinutes 总分钟数 |
||||
|
* @param completionTime 完成时间描述 |
||||
|
* @param modifier 修饰符 |
||||
|
*/ |
||||
|
@Composable |
||||
|
fun CircularProgressCard( |
||||
|
currentMinutes: Int, |
||||
|
totalMinutes: Int, |
||||
|
completionTime: String, |
||||
|
modifier: Modifier = Modifier |
||||
|
) { |
||||
|
Column( |
||||
|
modifier = modifier |
||||
|
.fillMaxWidth() |
||||
|
.padding(horizontal = 24.dp, vertical = 16.dp), |
||||
|
horizontalAlignment = Alignment.CenterHorizontally |
||||
|
) { |
||||
|
Box( |
||||
|
contentAlignment = Alignment.Center |
||||
|
) { |
||||
|
// 圆弧进度条 |
||||
|
CircularProgressArc( |
||||
|
progress = currentMinutes.toFloat() / totalMinutes.toFloat(), |
||||
|
modifier = Modifier.width(195.dp).height(97.5.dp) |
||||
|
) |
||||
|
|
||||
|
// 中心文字 - 与圆弧底部对齐 |
||||
|
Column( |
||||
|
horizontalAlignment = Alignment.CenterHorizontally, |
||||
|
verticalArrangement = Arrangement.Center, |
||||
|
modifier = Modifier.offset(y = 35.dp) // 向下偏移,与圆弧底部对齐 |
||||
|
) { |
||||
|
Text( |
||||
|
text = currentMinutes.toString(), |
||||
|
style = MaterialTheme.typography.displayLarge.copy( |
||||
|
fontSize = 40.sp, |
||||
|
fontWeight = FontWeight.Bold |
||||
|
), |
||||
|
color = Color(0xFF352764) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 底部完成时间描述 - 紧贴圆弧下方 |
||||
|
Row( |
||||
|
modifier = Modifier.padding(top = 10.dp) |
||||
|
) { |
||||
|
Text( |
||||
|
text = completionTime, |
||||
|
style = MaterialTheme.typography.bodyMedium.copy( |
||||
|
fontSize = 14.sp, |
||||
|
fontWeight = FontWeight.Medium |
||||
|
), |
||||
|
color = Color(0xFF394298), |
||||
|
textAlign = TextAlign.Center |
||||
|
) |
||||
|
|
||||
|
Spacer(modifier = Modifier.width(8.dp)) |
||||
|
|
||||
|
// 问号图标 |
||||
|
Box( |
||||
|
modifier = Modifier |
||||
|
.size(16.dp) |
||||
|
.background( |
||||
|
color = Color.Transparent, |
||||
|
shape = CircleShape |
||||
|
) |
||||
|
.border( |
||||
|
width = 1.dp, |
||||
|
color = Color(0xFF9CA3AF), |
||||
|
shape = CircleShape |
||||
|
), |
||||
|
contentAlignment = Alignment.Center |
||||
|
) { |
||||
|
Text( |
||||
|
text = "?", |
||||
|
style = MaterialTheme.typography.bodySmall.copy( |
||||
|
fontSize = 10.sp, |
||||
|
fontWeight = FontWeight.Bold |
||||
|
), |
||||
|
color = Color(0xFF9CA3AF) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 圆弧进度组件 |
||||
|
* @param progress 进度值 (0.0 到 1.0) |
||||
|
* @param modifier 修饰符 |
||||
|
*/ |
||||
|
@Composable |
||||
|
private fun CircularProgressArc( |
||||
|
progress: Float, |
||||
|
modifier: Modifier = Modifier |
||||
|
) { |
||||
|
Canvas(modifier = modifier) { |
||||
|
|
||||
|
val strokeWidth = 12.dp.toPx() |
||||
|
val radius = (size.width - strokeWidth) / 2 |
||||
|
val center = androidx.compose.ui.geometry.Offset( |
||||
|
x = size.width / 2, |
||||
|
y = size.height - strokeWidth / 2 // 调整中心点,让半圆底部对齐 |
||||
|
) |
||||
|
|
||||
|
// 背景圆弧 (浅灰色) - 180度半圆 |
||||
|
drawArc( |
||||
|
color = Color(0xFFE5E7EB), |
||||
|
startAngle = 180f, // 从左侧开始 |
||||
|
sweepAngle = 180f, // 180度的半圆 |
||||
|
useCenter = false, |
||||
|
topLeft = androidx.compose.ui.geometry.Offset( |
||||
|
x = center.x - radius, |
||||
|
y = center.y - radius |
||||
|
), |
||||
|
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2), |
||||
|
style = Stroke( |
||||
|
width = strokeWidth, |
||||
|
cap = StrokeCap.Round |
||||
|
) |
||||
|
) |
||||
|
|
||||
|
// 进度圆弧 (紫色渐变) |
||||
|
val progressAngle = 180f * progress.coerceIn(0f, 1f) |
||||
|
drawArc( |
||||
|
color = Color(0xFF352764), |
||||
|
startAngle = 180f, |
||||
|
sweepAngle = progressAngle, |
||||
|
useCenter = false, |
||||
|
topLeft = androidx.compose.ui.geometry.Offset( |
||||
|
x = center.x - radius, |
||||
|
y = center.y - radius |
||||
|
), |
||||
|
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2), |
||||
|
style = Stroke( |
||||
|
width = strokeWidth, |
||||
|
cap = StrokeCap.Round |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
} |
@ -0,0 +1,74 @@ |
|||||
|
package com.whitefish.ring.ui.components |
||||
|
|
||||
|
import androidx.compose.foundation.Canvas |
||||
|
import androidx.compose.foundation.layout.Arrangement |
||||
|
import androidx.compose.foundation.layout.Box |
||||
|
import androidx.compose.foundation.layout.Column |
||||
|
import androidx.compose.foundation.layout.padding |
||||
|
import androidx.compose.foundation.layout.size |
||||
|
import androidx.compose.material3.Text |
||||
|
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.text.font.FontWeight |
||||
|
import androidx.compose.ui.unit.TextUnit |
||||
|
import androidx.compose.ui.unit.dp |
||||
|
import androidx.compose.ui.unit.sp |
||||
|
|
||||
|
@Composable |
||||
|
fun CircularScoreChart(score: Int,scoreText: String,title:String,scoreTextSize: TextUnit,bottomText: String = "") { |
||||
|
Box(contentAlignment = Alignment.BottomCenter) { |
||||
|
Canvas(modifier = Modifier.size(250.dp, 125.dp)) { |
||||
|
val strokeWidth = 12.dp.toPx() |
||||
|
val radius = (size.width - strokeWidth) / 2 |
||||
|
val center = Offset(size.width / 2, size.height) |
||||
|
|
||||
|
// 背景半圆弧 |
||||
|
drawArc( |
||||
|
color = Color(0xFFE0E0E0), |
||||
|
startAngle = 180f, |
||||
|
sweepAngle = 180f, |
||||
|
useCenter = false, |
||||
|
style = Stroke(strokeWidth, cap = StrokeCap.Round), |
||||
|
topLeft = Offset(center.x - radius, center.y - radius), |
||||
|
size = Size(radius * 2, radius * 2) |
||||
|
) |
||||
|
|
||||
|
// 进度半圆弧 |
||||
|
val sweepAngle = (score / 100f) * 180f |
||||
|
drawArc( |
||||
|
color = Color(0xFF5E35B1), |
||||
|
startAngle = 180f, |
||||
|
sweepAngle = sweepAngle, |
||||
|
useCenter = false, |
||||
|
style = Stroke(strokeWidth, cap = StrokeCap.Round), |
||||
|
topLeft = Offset(center.x - radius, center.y - radius), |
||||
|
size = Size(radius * 2, radius * 2) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
Column( |
||||
|
horizontalAlignment = Alignment.CenterHorizontally, |
||||
|
modifier = Modifier.padding(bottom = 8.dp) |
||||
|
) { |
||||
|
Text( |
||||
|
text = scoreText, |
||||
|
fontSize = scoreTextSize, |
||||
|
fontWeight = FontWeight.Bold, |
||||
|
color = Color(0xFF352764) |
||||
|
) |
||||
|
Text( |
||||
|
text = title, |
||||
|
fontSize = 14.sp, |
||||
|
color = Color(0xFF352764) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
} |
||||
|
} |
@ -0,0 +1,108 @@ |
|||||
|
package com.whitefish.ring.ui.components |
||||
|
|
||||
|
import androidx.compose.foundation.background |
||||
|
import androidx.compose.foundation.layout.* |
||||
|
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
|
import androidx.compose.material3.* |
||||
|
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.text.font.FontWeight |
||||
|
import androidx.compose.ui.text.style.TextAlign |
||||
|
import androidx.compose.ui.unit.dp |
||||
|
import androidx.compose.ui.unit.sp |
||||
|
|
||||
|
/** |
||||
|
* 顶部导航栏组件 |
||||
|
* @param title 标题文字 |
||||
|
* @param month 月 |
||||
|
* @param day 总天数 |
||||
|
* @param onNavigateClick 右侧箭头点击事件 |
||||
|
* @param modifier 修饰符 |
||||
|
*/ |
||||
|
@OptIn(ExperimentalMaterial3Api::class) |
||||
|
@Composable |
||||
|
fun TopNavigationBar( |
||||
|
title: String, |
||||
|
month: Int, |
||||
|
day: Int, |
||||
|
onNavigateClick: () -> Unit = {}, |
||||
|
modifier: Modifier = Modifier |
||||
|
) { |
||||
|
Row( |
||||
|
modifier = modifier |
||||
|
.fillMaxWidth() |
||||
|
.padding(horizontal = 24.dp, vertical = 20.dp), |
||||
|
verticalAlignment = Alignment.CenterVertically, |
||||
|
horizontalArrangement = Arrangement.SpaceBetween |
||||
|
) { |
||||
|
// 左侧日期椭圆 |
||||
|
DateOval( |
||||
|
month = month, |
||||
|
day = day, |
||||
|
modifier = Modifier |
||||
|
.width(30.dp) |
||||
|
.height(53.dp) |
||||
|
) |
||||
|
|
||||
|
// 中间标题 |
||||
|
Text( |
||||
|
text = title, |
||||
|
style = MaterialTheme.typography.titleLarge.copy( |
||||
|
fontSize = 22.sp, |
||||
|
fontWeight = FontWeight.SemiBold |
||||
|
), |
||||
|
color = Color(0xFF394298), |
||||
|
modifier = Modifier |
||||
|
.weight(1f) |
||||
|
.padding(horizontal = 16.dp), |
||||
|
textAlign = TextAlign.Center |
||||
|
) |
||||
|
|
||||
|
// 右侧箭头 |
||||
|
IconButton( |
||||
|
onClick = onNavigateClick, |
||||
|
modifier = Modifier.size(48.dp) |
||||
|
) { |
||||
|
// Icon( |
||||
|
// imageVector = Icons.AutoMirrored.Filled.ArrowForward, |
||||
|
// contentDescription = "导航", |
||||
|
// tint = MaterialTheme.colorScheme.primary, |
||||
|
// modifier = Modifier.size(24.dp) |
||||
|
// ) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 日期椭圆组件 |
||||
|
* @param currentDay 当前日期 |
||||
|
* @param totalDays 总天数 |
||||
|
* @param modifier 修饰符 |
||||
|
*/ |
||||
|
@Composable |
||||
|
private fun DateOval( |
||||
|
month: Int, |
||||
|
day: Int, |
||||
|
modifier: Modifier = Modifier |
||||
|
) { |
||||
|
Box( |
||||
|
modifier = modifier |
||||
|
.clip(RoundedCornerShape(24.dp)) |
||||
|
.background( |
||||
|
color = Color(0xFF2D2D3A) |
||||
|
), |
||||
|
contentAlignment = Alignment.Center |
||||
|
) { |
||||
|
Text( |
||||
|
text = "$month/\n$day", |
||||
|
style = MaterialTheme.typography.titleMedium.copy( |
||||
|
fontSize = 16.sp, |
||||
|
fontWeight = FontWeight.Medium |
||||
|
), |
||||
|
color = Color.White |
||||
|
) |
||||
|
} |
||||
|
} |
@ -1,57 +1,9 @@ |
|||||
package com.whitefish.app.ui.home.recovery |
package com.whitefish.ring.ui.home.recovery |
||||
|
|
||||
import androidx.lifecycle.ViewModel |
import androidx.lifecycle.ViewModel |
||||
import com.whitefish.ring.ui.home.recovery.RecoveryData |
|
||||
import kotlinx.coroutines.flow.MutableStateFlow |
|
||||
import kotlinx.coroutines.flow.StateFlow |
|
||||
import kotlinx.coroutines.flow.asStateFlow |
|
||||
|
|
||||
data class RecoveryUiState( |
|
||||
val recoveryData: List<RecoveryData> = emptyList(), |
|
||||
val isLoading: Boolean = false |
|
||||
) |
|
||||
|
|
||||
class RecoveryViewModel : ViewModel() { |
class RecoveryViewModel : ViewModel() { |
||||
|
|
||||
private val _uiState = MutableStateFlow(RecoveryUiState()) |
|
||||
val uiState: StateFlow<RecoveryUiState> = _uiState.asStateFlow() |
|
||||
|
|
||||
init { |
|
||||
loadRecoveryData() |
|
||||
} |
|
||||
|
|
||||
private fun loadRecoveryData() { |
|
||||
// 模拟加载恢复数据 |
|
||||
val mockData = listOf( |
|
||||
RecoveryData( |
|
||||
title = "今日恢复状态", |
|
||||
description = "基于昨晚睡眠质量和心率变异性分析", |
|
||||
recoveryIndex = 85, |
|
||||
restTime = "6小时", |
|
||||
status = "良好" |
|
||||
), |
|
||||
RecoveryData( |
|
||||
title = "压力水平", |
|
||||
description = "当前压力指数偏低,适合进行中等强度运动", |
|
||||
recoveryIndex = 72, |
|
||||
restTime = "4小时", |
|
||||
status = "正常" |
|
||||
), |
|
||||
RecoveryData( |
|
||||
title = "肌肉恢复", |
|
||||
description = "上次运动后肌肉恢复情况", |
|
||||
recoveryIndex = 90, |
|
||||
restTime = "2小时", |
|
||||
status = "优秀" |
|
||||
) |
|
||||
) |
|
||||
|
|
||||
_uiState.value = _uiState.value.copy(recoveryData = mockData) |
|
||||
} |
|
||||
|
|
||||
fun refresh() { |
|
||||
_uiState.value = _uiState.value.copy(isLoading = true) |
|
||||
loadRecoveryData() |
|
||||
_uiState.value = _uiState.value.copy(isLoading = false) |
|
||||
} |
|
||||
} |
} |
Loading…
Reference in new issue