@ -0,0 +1,123 @@ |
|||
# Android UI 到 iOS KMP Compose 迁移总结 |
|||
|
|||
## 项目概述 |
|||
成功将Android Activity的UI完全复刻到iOS上,使用Kotlin Multiplatform的Compose实现跨平台UI共享。 |
|||
|
|||
## 完成的工作 |
|||
|
|||
### 1. 主要架构迁移 |
|||
- **HomeActivity** → **HomeScreen** (Compose) |
|||
- 复刻了原Android HomeActivity的完整UI结构 |
|||
- 包含状态栏、Fragment容器、底部导航栏和加载动画 |
|||
- 使用Compose的状态管理替代Android的Fragment管理 |
|||
|
|||
### 2. 页面组件迁移 |
|||
创建了四个主要页面的Compose版本: |
|||
|
|||
#### StateScreen (状态页面) |
|||
- 复刻Android StateFragment的网格布局 |
|||
- 使用LazyVerticalGrid显示健康数据卡片 |
|||
- 包含心率、睡眠、步数、卡路里等数据展示 |
|||
|
|||
#### ExerciseScreen (运动页面) |
|||
- 复刻Android ExerciseFragment的列表布局 |
|||
- 使用LazyColumn显示运动记录 |
|||
- 包含运动类型、时长、卡路里、距离等信息 |
|||
|
|||
#### RecoveryScreen (恢复页面) |
|||
- 复刻Android RecoveryFragment的恢复数据展示 |
|||
- 显示恢复指数、建议休息时间、状态等信息 |
|||
|
|||
#### SettingScreen (设置页面) |
|||
- 复刻Android SettingFragment的设置项列表 |
|||
- 包含开关控件和导航项 |
|||
- 支持设备连接、数据同步、通知设置等功能 |
|||
|
|||
### 3. ViewModel架构 |
|||
为每个页面创建了对应的ViewModel: |
|||
- `HomeViewModel` - 管理主页状态和标签切换 |
|||
- `StateViewModel` - 管理健康状态数据 |
|||
- `ExerciseViewModel` - 管理运动数据 |
|||
- `RecoveryViewModel` - 管理恢复数据 |
|||
- `SettingViewModel` - 管理设置项数据 |
|||
|
|||
### 4. UI设计复刻 |
|||
完全复刻了Android版本的UI设计: |
|||
- **颜色方案**: 深色主题 (#1A1A1A背景, #2A2A2A卡片) |
|||
- **导航栏**: 浅色背景 (#E8E0E6) 配选中状态 (#352764) |
|||
- **布局结构**: 保持与Android版本一致的布局层次 |
|||
- **动画效果**: 复刻了加载动画的旋转效果 |
|||
|
|||
### 5. 跨平台集成 |
|||
- **commonMain**: 所有UI代码放在共享模块中 |
|||
- **iOS集成**: 通过MainViewController正确集成到iOS项目 |
|||
- **Android兼容**: 保持与现有Android代码的兼容性 |
|||
|
|||
## 技术实现细节 |
|||
|
|||
### 依赖配置 |
|||
```kotlin |
|||
commonMain.dependencies { |
|||
implementation(compose.runtime) |
|||
implementation(compose.foundation) |
|||
implementation(compose.material3) |
|||
implementation(compose.ui) |
|||
implementation(compose.components.resources) |
|||
implementation(compose.components.uiToolingPreview) |
|||
implementation(libs.androidx.lifecycle.viewmodel) |
|||
implementation(libs.androidx.lifecycle.runtimeCompose) |
|||
implementation(libs.lifecycle.viewmodel.compose) |
|||
} |
|||
``` |
|||
|
|||
### 文件结构 |
|||
``` |
|||
composeApp/src/commonMain/kotlin/com/whitefish/app/ |
|||
├── App.kt |
|||
├── ui/home/ |
|||
│ ├── HomeScreen.kt |
|||
│ ├── HomeViewModel.kt |
|||
│ ├── state/ |
|||
│ │ ├── StateScreen.kt |
|||
│ │ └── StateViewModel.kt |
|||
│ ├── exercise/ |
|||
│ │ ├── ExerciseScreen.kt |
|||
│ │ └── ExerciseViewModel.kt |
|||
│ ├── recovery/ |
|||
│ │ ├── RecoveryScreen.kt |
|||
│ │ └── RecoveryViewModel.kt |
|||
│ └── setting/ |
|||
│ ├── SettingScreen.kt |
|||
│ └── SettingViewModel.kt |
|||
``` |
|||
|
|||
## 编译状态 |
|||
- ✅ **Android编译**: 成功 |
|||
- ✅ **iOS Kotlin编译**: 成功 |
|||
- ⚠️ **iOS框架构建**: 需要正确配置Xcode环境 |
|||
|
|||
## 使用方法 |
|||
|
|||
### Android |
|||
现有的Android代码可以继续使用,同时也可以选择使用新的Compose版本。 |
|||
|
|||
### iOS |
|||
1. 确保Xcode正确安装和配置 |
|||
2. 运行 `./gradlew composeApp:linkDebugFrameworkIosSimulatorArm64` |
|||
3. 在iOS项目中使用生成的框架 |
|||
|
|||
## 优势 |
|||
1. **代码复用**: UI代码在Android和iOS之间100%共享 |
|||
2. **一致性**: 确保两个平台的UI完全一致 |
|||
3. **维护性**: 只需维护一套UI代码 |
|||
4. **现代化**: 使用Compose的声明式UI范式 |
|||
|
|||
## 后续工作 |
|||
1. 配置正确的Xcode环境以完成iOS框架构建 |
|||
2. 添加实际的图标资源 |
|||
3. 集成真实的数据源和业务逻辑 |
|||
4. 添加更多的交互功能和动画效果 |
|||
5. 进行iOS设备上的实际测试 |
|||
|
|||
## 结论 |
|||
成功完成了Android UI到iOS的KMP Compose迁移,实现了跨平台UI代码共享的目标。所有主要页面和功能都已复刻完成,代码结构清晰,易于维护和扩展。 |
@ -0,0 +1,16 @@ |
|||
package com.whitefish.app |
|||
|
|||
import android.os.Bundle |
|||
import androidx.activity.ComponentActivity |
|||
import androidx.activity.compose.setContent |
|||
import androidx.activity.enableEdgeToEdge |
|||
|
|||
class ComposeActivity : ComponentActivity() { |
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
enableEdgeToEdge() |
|||
setContent { |
|||
App() |
|||
} |
|||
} |
|||
} |
After Width: | Height: | Size: 313 KiB |
After Width: | Height: | Size: 218 KiB |
After Width: | Height: | Size: 151 KiB |
After Width: | Height: | Size: 746 B |
After Width: | Height: | Size: 713 B |
After Width: | Height: | Size: 790 B |
After Width: | Height: | Size: 555 B |
@ -1,44 +1,17 @@ |
|||
package com.whitefish.app |
|||
|
|||
import androidx.compose.animation.AnimatedVisibility |
|||
import androidx.compose.foundation.Image |
|||
import androidx.compose.foundation.layout.Column |
|||
import androidx.compose.foundation.layout.fillMaxSize |
|||
import androidx.compose.foundation.layout.fillMaxWidth |
|||
import androidx.compose.foundation.layout.safeContentPadding |
|||
import androidx.compose.material3.Button |
|||
import androidx.compose.material3.MaterialTheme |
|||
import androidx.compose.material3.Text |
|||
import androidx.compose.runtime.* |
|||
import androidx.compose.ui.Alignment |
|||
import androidx.compose.ui.Modifier |
|||
import org.jetbrains.compose.resources.painterResource |
|||
import org.jetbrains.compose.ui.tooling.preview.Preview |
|||
|
|||
import ringappkmp.composeapp.generated.resources.Res |
|||
import ringappkmp.composeapp.generated.resources.compose_multiplatform |
|||
import com.whitefish.app.ui.home.HomeScreen |
|||
|
|||
@Composable |
|||
@Preview |
|||
fun App() { |
|||
MaterialTheme { |
|||
var showContent by remember { mutableStateOf(false) } |
|||
Column( |
|||
HomeScreen( |
|||
modifier = Modifier |
|||
.safeContentPadding() |
|||
.fillMaxSize(), |
|||
horizontalAlignment = Alignment.CenterHorizontally, |
|||
) { |
|||
Button(onClick = { showContent = !showContent }) { |
|||
Text("Click me!") |
|||
} |
|||
AnimatedVisibility(showContent) { |
|||
val greeting = remember { Greeting().greet() } |
|||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { |
|||
Image(painterResource(Res.drawable.compose_multiplatform), null) |
|||
Text("Compose: $greeting") |
|||
} |
|||
} |
|||
} |
|||
) |
|||
} |
|||
} |
@ -0,0 +1,56 @@ |
|||
package com.whitefish.app.ui.chart |
|||
|
|||
import androidx.compose.runtime.Composable |
|||
import androidx.compose.runtime.LaunchedEffect |
|||
import androidx.compose.runtime.remember |
|||
import androidx.compose.ui.Modifier |
|||
import androidx.compose.ui.graphics.Brush |
|||
import androidx.compose.ui.graphics.Color |
|||
import com.patrykandpatrick.vico.multiplatform.cartesian.CartesianChartHost |
|||
import com.patrykandpatrick.vico.multiplatform.cartesian.axis.HorizontalAxis |
|||
import com.patrykandpatrick.vico.multiplatform.cartesian.axis.VerticalAxis |
|||
import com.patrykandpatrick.vico.multiplatform.cartesian.data.CartesianChartModelProducer |
|||
import com.patrykandpatrick.vico.multiplatform.cartesian.data.lineSeries |
|||
import com.patrykandpatrick.vico.multiplatform.cartesian.layer.LineCartesianLayer |
|||
import com.patrykandpatrick.vico.multiplatform.cartesian.layer.rememberLine |
|||
import com.patrykandpatrick.vico.multiplatform.cartesian.layer.rememberLineCartesianLayer |
|||
import com.patrykandpatrick.vico.multiplatform.cartesian.rememberCartesianChart |
|||
import com.patrykandpatrick.vico.multiplatform.common.Fill |
|||
import com.patrykandpatrick.vico.multiplatform.common.component.rememberLineComponent |
|||
import com.patrykandpatrick.vico.multiplatform.common.fill |
|||
|
|||
@Composable |
|||
fun ComposeMultiplatformBasicLineChart(modifier: Modifier = Modifier) { |
|||
val modelProducer = remember { CartesianChartModelProducer() } |
|||
LaunchedEffect(Unit) { |
|||
modelProducer.runTransaction { |
|||
// Learn more: https://patrykandpatrick.com/z5ah6v. |
|||
lineSeries { series(13, 8, 7, 12, 0, 1, 15, 14, 0, 11, 6, 12, 0, 11, 12, 11) } |
|||
} |
|||
} |
|||
CartesianChartHost( |
|||
chart = |
|||
rememberCartesianChart( |
|||
rememberLineCartesianLayer( |
|||
lineProvider = LineCartesianLayer.LineProvider.series( |
|||
LineCartesianLayer.rememberLine( |
|||
fill = LineCartesianLayer.LineFill.single(Fill(Color.Red)), |
|||
areaFill = LineCartesianLayer.AreaFill.single( |
|||
Fill( |
|||
Brush.verticalGradient( |
|||
listOf( |
|||
Color.Red.copy(alpha = 0.6f), |
|||
Color.Transparent |
|||
) |
|||
) |
|||
) |
|||
) |
|||
) |
|||
) |
|||
), |
|||
), |
|||
modelProducer = modelProducer, |
|||
modifier = modifier, |
|||
|
|||
) |
|||
} |
@ -0,0 +1,85 @@ |
|||
# SleepChart Compose组件 |
|||
|
|||
这是一个完全复刻Android `SleepChartView` 功能和样式的Compose组件,支持KMP多平台。 |
|||
|
|||
## 功能特性 |
|||
|
|||
- ✅ 完全复刻原Android版本的视觉效果 |
|||
- ✅ 支持4种睡眠状态:清醒、浅睡、深睡、REM |
|||
- ✅ 垂直渐变色绘制 |
|||
- ✅ 选择性圆角绘制 |
|||
- ✅ 状态间连接柱平滑过渡 |
|||
- ✅ 支持自定义柱宽度和高度 |
|||
- ✅ KMP多平台支持 |
|||
|
|||
## 基本用法 |
|||
|
|||
```kotlin |
|||
import com.whitefish.app.ui.chart.sleep.SleepChart |
|||
import com.whitefish.app.ui.chart.sleep.SleepSegment |
|||
|
|||
@Composable |
|||
fun MySleepScreen() { |
|||
val sleepData = listOf( |
|||
SleepSegment(state = 0, durationMinutes = 30f), // 清醒 |
|||
SleepSegment(state = 2, durationMinutes = 120f), // 深睡 |
|||
SleepSegment(state = 1, durationMinutes = 90f), // 浅睡 |
|||
SleepSegment(state = 3, durationMinutes = 60f), // REM |
|||
) |
|||
|
|||
SleepChart( |
|||
sleepData = sleepData, |
|||
modifier = Modifier |
|||
.fillMaxWidth() |
|||
.height(120.dp), |
|||
barWidthMultiplier = 1f, |
|||
barHeight = 10f |
|||
) |
|||
} |
|||
``` |
|||
|
|||
## 高级用法 |
|||
|
|||
### 从SleepStage转换 |
|||
|
|||
```kotlin |
|||
// 使用扩展函数转换数据 |
|||
val sleepSegments = sleepStages.map { it.toSegment() } |
|||
|
|||
SleepChart( |
|||
sleepData = sleepSegments, |
|||
modifier = Modifier.fillMaxWidth().height(120.dp) |
|||
) |
|||
``` |
|||
|
|||
### 自定义样式 |
|||
|
|||
```kotlin |
|||
SleepChart( |
|||
sleepData = sleepData, |
|||
modifier = Modifier.fillMaxWidth().height(200.dp), |
|||
barWidthMultiplier = 1.5f, // 增加柱宽度 |
|||
barHeight = 5f // 减少柱间距 |
|||
) |
|||
``` |
|||
|
|||
## 睡眠状态常量 |
|||
|
|||
```kotlin |
|||
SleepStateConstants.SLEEP_STATE_WAKE // 0 - 清醒 |
|||
SleepStateConstants.SLEEP_STATE_REM // 1 - REM睡眠 |
|||
SleepStateConstants.SLEEP_STATE_LIGHT // 2 - 浅睡 |
|||
SleepStateConstants.SLEEP_STATE_DEEP // 3 - 深睡 |
|||
SleepStateConstants.SLEEP_STATE_NAP // 4 - 小憩 |
|||
``` |
|||
|
|||
## 完整示例 |
|||
|
|||
参考 `SleepChartPreview.kt` 文件,其中包含了完整的使用示例,包括图例和统计信息的显示。 |
|||
|
|||
## 与原Android版本的兼容性 |
|||
|
|||
- 数据结构完全兼容:`SleepSegment(state, durationMinutes)` |
|||
- 视觉效果完全一致:相同的颜色、圆角、渐变 |
|||
- 功能完全对等:支持所有原版功能 |
|||
- API接口相似:`setSleepData()` -> `sleepData参数` |
@ -0,0 +1,295 @@ |
|||
package com.whitefish.app.ui.chart.sleep |
|||
|
|||
import androidx.compose.foundation.Canvas |
|||
import androidx.compose.runtime.* |
|||
import androidx.compose.ui.Modifier |
|||
import androidx.compose.ui.geometry.CornerRadius |
|||
import androidx.compose.ui.geometry.Offset |
|||
import androidx.compose.ui.geometry.Rect |
|||
import androidx.compose.ui.geometry.Size |
|||
import androidx.compose.ui.graphics.* |
|||
import androidx.compose.ui.graphics.drawscope.DrawScope |
|||
import androidx.compose.ui.platform.LocalDensity |
|||
import androidx.compose.ui.unit.dp |
|||
import kotlin.math.max |
|||
|
|||
/** |
|||
* 睡眠图表数据段 |
|||
* @param state 睡眠状态 (0=清醒, 1=浅睡, 2=深睡, 3=REM) |
|||
* @param durationMinutes 持续时间(分钟) |
|||
*/ |
|||
data class SleepSegment( |
|||
val state: Int, |
|||
val durationMinutes: Float |
|||
) |
|||
|
|||
/** |
|||
* Compose版本的睡眠图表组件 |
|||
* 完全复刻Android SleepChartView的功能和样式 |
|||
* |
|||
* @param sleepData 睡眠数据段列表 |
|||
* @param modifier Modifier |
|||
* @param barWidthMultiplier 柱宽度系数,用于缩放视图 |
|||
* @param barHeight 柱高度系数,用于设置柱间距 |
|||
*/ |
|||
@Composable |
|||
fun SleepChart( |
|||
sleepData: List<SleepSegment>, |
|||
modifier: Modifier = Modifier, |
|||
barWidthMultiplier: Float = 1f, |
|||
barHeight: Float = 10f |
|||
) { |
|||
val density = LocalDensity.current |
|||
|
|||
// 颜色配置 - 与原版保持一致 |
|||
val stateColors = listOf( |
|||
Color(0xFFFFAB91), // 清醒状态 - 珊瑚色 |
|||
Color(0xFFCE93D8), // 浅睡状态 - 浅紫色 |
|||
Color(0xFF9575CD), // 深睡状态 - 中紫色 |
|||
Color(0xFF5E35B1) // REM状态 - 深紫色 |
|||
) |
|||
|
|||
// 常量配置 - 与原版保持一致 |
|||
val barMargin = 0f |
|||
val cornerRadius = with(density) { 3.dp.toPx() } |
|||
val connectorWidth = with(density) { 0.5.dp.toPx() } |
|||
val rowSpace = with(density) { barHeight.dp.toPx() } |
|||
|
|||
// 计算总时长 |
|||
val totalDuration = sleepData.sumOf { it.durationMinutes.toDouble() }.toFloat() |
|||
|
|||
Canvas( |
|||
modifier = modifier |
|||
) { |
|||
if (sleepData.isEmpty()) return@Canvas |
|||
|
|||
// 创建垂直线性渐变 |
|||
val gradient = Brush.verticalGradient( |
|||
colors = stateColors, |
|||
startY = 0f, |
|||
endY = size.height |
|||
) |
|||
|
|||
// 计算状态高度 |
|||
val stateHeight = size.height / 4f |
|||
|
|||
// 计算每分钟对应的宽度 |
|||
val minuteWidth = size.width / max(totalDuration, 1f) |
|||
|
|||
var startX = 0f |
|||
|
|||
// 绘制每个睡眠段 |
|||
for (i in sleepData.indices) { |
|||
val segment = sleepData[i] |
|||
val nextSegment = if (i < sleepData.size - 1) sleepData[i + 1] else null |
|||
|
|||
// 计算当前柱子的宽度和位置 |
|||
val barWidth = segment.durationMinutes * minuteWidth * barWidthMultiplier |
|||
|
|||
// 计算柱子的顶部和底部位置 |
|||
val top = segment.state * stateHeight |
|||
val bottom = top + stateHeight - rowSpace |
|||
|
|||
// 柱子矩形区域 |
|||
val barRect = Rect( |
|||
offset = Offset(startX - connectorWidth, top), |
|||
size = Size(barWidth - barMargin, bottom - top) |
|||
) |
|||
|
|||
// 绘制选择性圆角的柱子 |
|||
if (nextSegment != null) { |
|||
if (segment.state != nextSegment.state) { |
|||
// 根据下一个柱子的位置决定哪些角是圆角 |
|||
val isNextSegmentAbove = nextSegment.state < segment.state |
|||
|
|||
drawBarWithSelectiveCorners( |
|||
rect = barRect, |
|||
topLeftRadius = if (i == 0) cornerRadius else cornerRadius, |
|||
topRightRadius = if (isNextSegmentAbove) 0f else cornerRadius, |
|||
bottomRightRadius = if (isNextSegmentAbove) cornerRadius else 0f, |
|||
bottomLeftRadius = if (i == 0) cornerRadius else cornerRadius, |
|||
brush = gradient |
|||
) |
|||
} |
|||
} else { |
|||
// 最后一个柱子,所有角都是圆角 |
|||
drawRoundRect( |
|||
brush = gradient, |
|||
topLeft = barRect.topLeft, |
|||
size = barRect.size, |
|||
cornerRadius = CornerRadius(cornerRadius) |
|||
) |
|||
} |
|||
|
|||
// 如果有下一段且状态不同,绘制连接柱 |
|||
if (nextSegment != null && segment.state != nextSegment.state) { |
|||
val endX = startX + barWidth |
|||
val nextTop = nextSegment.state * stateHeight |
|||
val nextBottom = nextTop + stateHeight - rowSpace |
|||
|
|||
// 连接柱的坐标 |
|||
val connectorRect = Rect( |
|||
offset = Offset( |
|||
endX - connectorWidth, |
|||
if (segment.state <= nextSegment.state) { |
|||
top + cornerRadius |
|||
} else { |
|||
nextTop + cornerRadius |
|||
} |
|||
), |
|||
size = Size( |
|||
connectorWidth, |
|||
if (segment.state >= nextSegment.state) { |
|||
bottom - cornerRadius - (if (segment.state <= nextSegment.state) top + cornerRadius else nextTop + cornerRadius) |
|||
} else { |
|||
nextBottom - cornerRadius - (if (segment.state <= nextSegment.state) top + cornerRadius else nextTop + cornerRadius) |
|||
} |
|||
) |
|||
) |
|||
|
|||
// 绘制连接柱 |
|||
drawRect( |
|||
brush = gradient, |
|||
topLeft = connectorRect.topLeft, |
|||
size = connectorRect.size |
|||
) |
|||
|
|||
// 为下一个柱子也设置选择性圆角 |
|||
if (i < sleepData.size - 1) { |
|||
val nextBarWidth = nextSegment.durationMinutes * minuteWidth * barWidthMultiplier |
|||
|
|||
val nextBarRect = Rect( |
|||
offset = Offset(endX, nextTop), |
|||
size = Size(nextBarWidth - barMargin, nextBottom - nextTop) |
|||
) |
|||
|
|||
val isNextSegmentAbove = nextSegment.state < segment.state |
|||
|
|||
if (i < sleepData.size - 2) { |
|||
val afterNextSegment = sleepData[i + 2] |
|||
val afterNextIsAbove = afterNextSegment.state < nextSegment.state |
|||
|
|||
drawBarWithSelectiveCorners( |
|||
rect = nextBarRect, |
|||
topLeftRadius = if (isNextSegmentAbove) cornerRadius else 0f, |
|||
topRightRadius = if (afterNextIsAbove) 0f else cornerRadius, |
|||
bottomRightRadius = if (afterNextIsAbove) cornerRadius else 0f, |
|||
bottomLeftRadius = if (isNextSegmentAbove) 0f else cornerRadius, |
|||
brush = gradient |
|||
) |
|||
} else { |
|||
// 最后一个柱子,右侧全部为圆角 |
|||
drawBarWithSelectiveCorners( |
|||
rect = nextBarRect, |
|||
topLeftRadius = if (isNextSegmentAbove) cornerRadius else 0f, |
|||
topRightRadius = cornerRadius, |
|||
bottomRightRadius = cornerRadius, |
|||
bottomLeftRadius = if (isNextSegmentAbove) 0f else cornerRadius, |
|||
brush = gradient |
|||
) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 更新下一段的起始位置 |
|||
startX += barWidth |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 绘制具有选择性圆角的矩形 |
|||
*/ |
|||
private fun DrawScope.drawBarWithSelectiveCorners( |
|||
rect: Rect, |
|||
topLeftRadius: Float, |
|||
topRightRadius: Float, |
|||
bottomRightRadius: Float, |
|||
bottomLeftRadius: Float, |
|||
brush: Brush |
|||
) { |
|||
val path = Path().apply { |
|||
val left = rect.left |
|||
val top = rect.top |
|||
val right = rect.right |
|||
val bottom = rect.bottom |
|||
|
|||
// 左上角 |
|||
if (topLeftRadius > 0) { |
|||
moveTo(left, top + topLeftRadius) |
|||
quadraticBezierTo(left, top, left + topLeftRadius, top) |
|||
} else { |
|||
moveTo(left, top) |
|||
} |
|||
|
|||
// 右上角 |
|||
if (topRightRadius > 0) { |
|||
lineTo(right - topRightRadius, top) |
|||
quadraticBezierTo(right, top, right, top + topRightRadius) |
|||
} else { |
|||
lineTo(right, top) |
|||
} |
|||
|
|||
// 右下角 |
|||
if (bottomRightRadius > 0) { |
|||
lineTo(right, bottom - bottomRightRadius) |
|||
quadraticBezierTo(right, bottom, right - bottomRightRadius, bottom) |
|||
} else { |
|||
lineTo(right, bottom) |
|||
} |
|||
|
|||
// 左下角 |
|||
if (bottomLeftRadius > 0) { |
|||
lineTo(left + bottomLeftRadius, bottom) |
|||
quadraticBezierTo(left, bottom, left, bottom - bottomLeftRadius) |
|||
} else { |
|||
lineTo(left, bottom) |
|||
} |
|||
|
|||
close() |
|||
} |
|||
|
|||
drawPath( |
|||
path = path, |
|||
brush = brush |
|||
) |
|||
} |
|||
|
|||
|
|||
/** |
|||
* 睡眠状态常量定义 |
|||
* 与原Android版本保持一致 |
|||
*/ |
|||
object SleepStateConstants { |
|||
const val SLEEP_STATE_WAKE = 0 // 清醒 |
|||
const val SLEEP_STATE_REM = 1 // REM睡眠 |
|||
const val SLEEP_STATE_LIGHT = 2 // 浅睡 |
|||
const val SLEEP_STATE_DEEP = 3 // 深睡 |
|||
const val SLEEP_STATE_NAP = 4 // 小憩 |
|||
} |
|||
|
|||
/** |
|||
* SleepStage的简化数据类定义(用于兼容性) |
|||
* 如果您的项目中已有SleepStage定义,可以移除这个类 |
|||
*/ |
|||
data class SleepStage( |
|||
val startT: Float, // 开始时间(小时) |
|||
val endT: Float, // 结束时间(小时) |
|||
val state: Int // 睡眠状态 |
|||
) |
|||
|
|||
/** |
|||
* 将SleepStage转换为SleepSegment的扩展函数 |
|||
* 完全复刻原Android版本的转换逻辑 |
|||
*/ |
|||
fun SleepStage.toSegment(): SleepSegment { |
|||
return SleepSegment( |
|||
state = state, |
|||
durationMinutes = if (endT < 0) { |
|||
// 昨天的阶段 |
|||
(startT * -1) + (endT * -1) |
|||
} else { |
|||
endT - startT |
|||
} |
|||
) |
|||
} |
@ -0,0 +1,202 @@ |
|||
package com.whitefish.app.ui.chart.sleep |
|||
|
|||
import androidx.compose.foundation.Canvas |
|||
import androidx.compose.foundation.layout.* |
|||
import androidx.compose.foundation.shape.RoundedCornerShape |
|||
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 org.jetbrains.compose.ui.tooling.preview.Preview |
|||
|
|||
/** |
|||
* SleepChart的预览和示例组件 |
|||
*/ |
|||
@Preview |
|||
@Composable |
|||
fun SleepChartPreview( |
|||
modifier: Modifier = Modifier |
|||
) { |
|||
// 使用示例睡眠数据 |
|||
val sampleData = remember { createSampleSleepData() } |
|||
|
|||
Card( |
|||
modifier = modifier.padding(16.dp), |
|||
shape = RoundedCornerShape(12.dp), |
|||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) |
|||
) { |
|||
Column( |
|||
modifier = Modifier.padding(16.dp) |
|||
) { |
|||
// 标题 |
|||
Text( |
|||
text = "睡眠质量分析", |
|||
fontSize = 18.sp, |
|||
fontWeight = FontWeight.Bold, |
|||
modifier = Modifier.padding(bottom = 16.dp) |
|||
) |
|||
|
|||
// 睡眠图表 |
|||
SleepChart( |
|||
sleepData = sampleData, |
|||
modifier = Modifier |
|||
.fillMaxWidth() |
|||
.height(120.dp), |
|||
barWidthMultiplier = 1f, |
|||
barHeight = 10f |
|||
) |
|||
|
|||
// 图例 |
|||
SleepChartLegend( |
|||
modifier = Modifier |
|||
.fillMaxWidth() |
|||
.padding(top = 16.dp) |
|||
) |
|||
|
|||
// 睡眠统计信息 |
|||
SleepStatistics( |
|||
sleepData = sampleData, |
|||
modifier = Modifier |
|||
.fillMaxWidth() |
|||
.padding(top = 16.dp) |
|||
) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 预览用的示例数据 |
|||
*/ |
|||
fun createSampleSleepData(): List<SleepSegment> { |
|||
return listOf( |
|||
SleepSegment(state = 0, durationMinutes = 30f), // 清醒 |
|||
SleepSegment(state = 2, durationMinutes = 120f), // 深睡 |
|||
SleepSegment(state = 1, durationMinutes = 90f), // 浅睡 |
|||
SleepSegment(state = 3, durationMinutes = 60f), // REM |
|||
SleepSegment(state = 2, durationMinutes = 100f), // 深睡 |
|||
SleepSegment(state = 1, durationMinutes = 80f), // 浅睡 |
|||
SleepSegment(state = 0, durationMinutes = 20f) // 清醒 |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 睡眠图表图例 |
|||
*/ |
|||
@Composable |
|||
private fun SleepChartLegend( |
|||
modifier: Modifier = Modifier |
|||
) { |
|||
val legendItems = listOf( |
|||
LegendItem("清醒", Color(0xFFFFAB91)), |
|||
LegendItem("REM", Color(0xFFCE93D8)), |
|||
LegendItem("浅睡", Color(0xFF9575CD)), |
|||
LegendItem("深睡", Color(0xFF5E35B1)) |
|||
) |
|||
|
|||
Row( |
|||
modifier = modifier, |
|||
horizontalArrangement = Arrangement.SpaceEvenly |
|||
) { |
|||
legendItems.forEach { item -> |
|||
LegendItem( |
|||
label = item.label, |
|||
color = item.color, |
|||
modifier = Modifier.weight(1f) |
|||
) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 图例项 |
|||
*/ |
|||
@Composable |
|||
private fun LegendItem( |
|||
label: String, |
|||
color: Color, |
|||
modifier: Modifier = Modifier |
|||
) { |
|||
Row( |
|||
modifier = modifier, |
|||
verticalAlignment = Alignment.CenterVertically, |
|||
horizontalArrangement = Arrangement.Center |
|||
) { |
|||
Canvas( |
|||
modifier = Modifier.size(12.dp) |
|||
) { |
|||
drawRect(color = color) |
|||
} |
|||
Spacer(modifier = Modifier.width(4.dp)) |
|||
Text( |
|||
text = label, |
|||
fontSize = 12.sp, |
|||
color = Color.Gray |
|||
) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 睡眠统计信息 |
|||
*/ |
|||
@Composable |
|||
private fun SleepStatistics( |
|||
sleepData: List<SleepSegment>, |
|||
modifier: Modifier = Modifier |
|||
) { |
|||
// 计算各状态总时长 |
|||
val stateTime = remember(sleepData) { |
|||
val times = mutableMapOf<Int, Float>() |
|||
sleepData.forEach { segment -> |
|||
times[segment.state] = (times[segment.state] ?: 0f) + segment.durationMinutes |
|||
} |
|||
times |
|||
} |
|||
|
|||
val totalTime = remember(sleepData) { |
|||
sleepData.sumOf { it.durationMinutes.toDouble() }.toFloat() |
|||
} |
|||
|
|||
// 格式化时间显示 |
|||
fun formatTime(minutes: Float): String { |
|||
val hours = (minutes / 60).toInt() |
|||
val mins = (minutes % 60).toInt() |
|||
return if (hours > 0) "${hours}小时${mins}分钟" else "${mins}分钟" |
|||
} |
|||
|
|||
Column(modifier = modifier) { |
|||
Text( |
|||
text = "睡眠统计", |
|||
fontSize = 14.sp, |
|||
fontWeight = FontWeight.Medium, |
|||
modifier = Modifier.padding(bottom = 8.dp) |
|||
) |
|||
|
|||
Row( |
|||
modifier = Modifier.fillMaxWidth(), |
|||
horizontalArrangement = Arrangement.SpaceBetween |
|||
) { |
|||
Column { |
|||
Text("总睡眠时间", fontSize = 12.sp, color = Color.Gray) |
|||
Text(formatTime(totalTime), fontSize = 14.sp, fontWeight = FontWeight.Medium) |
|||
} |
|||
|
|||
Column { |
|||
Text("深睡时间", fontSize = 12.sp, color = Color.Gray) |
|||
Text(formatTime(stateTime[SleepStateConstants.SLEEP_STATE_DEEP] ?: 0f), |
|||
fontSize = 14.sp, fontWeight = FontWeight.Medium) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 图例数据类 |
|||
*/ |
|||
private data class LegendItem( |
|||
val label: String, |
|||
val color: Color |
|||
) |
@ -0,0 +1,186 @@ |
|||
package com.whitefish.app.ui.home |
|||
|
|||
import androidx.compose.animation.core.* |
|||
import androidx.compose.foundation.Image |
|||
import androidx.compose.foundation.background |
|||
import androidx.compose.foundation.clickable |
|||
import androidx.compose.foundation.layout.* |
|||
import androidx.compose.foundation.shape.CircleShape |
|||
import androidx.compose.material3.* |
|||
import androidx.compose.runtime.* |
|||
import androidx.compose.ui.Alignment |
|||
import androidx.compose.ui.Modifier |
|||
import androidx.compose.ui.draw.rotate |
|||
import androidx.compose.ui.graphics.Color |
|||
import androidx.compose.ui.layout.ContentScale |
|||
import androidx.compose.ui.text.style.TextAlign |
|||
import androidx.compose.ui.unit.dp |
|||
import androidx.compose.ui.unit.sp |
|||
import androidx.lifecycle.viewmodel.compose.viewModel |
|||
import org.jetbrains.compose.resources.painterResource |
|||
import org.jetbrains.compose.ui.tooling.preview.Preview |
|||
import ringappkmp.composeapp.generated.resources.Res |
|||
import com.whitefish.app.ui.home.state.StateScreen |
|||
import com.whitefish.app.ui.home.exercise.ExerciseScreen |
|||
import com.whitefish.app.ui.home.recovery.RecoveryScreen |
|||
import com.whitefish.app.ui.home.setting.SettingScreen |
|||
import org.jetbrains.compose.resources.DrawableResource |
|||
import ringappkmp.composeapp.generated.resources.bg |
|||
import ringappkmp.composeapp.generated.resources.ic_nav_exercise |
|||
import ringappkmp.composeapp.generated.resources.ic_nav_recovery |
|||
import ringappkmp.composeapp.generated.resources.ic_nav_settings |
|||
import ringappkmp.composeapp.generated.resources.ic_nav_state |
|||
|
|||
@Composable |
|||
fun HomeScreen( |
|||
modifier: Modifier = Modifier, |
|||
viewModel: HomeViewModel = viewModel { HomeViewModel() } |
|||
) { |
|||
val uiState by viewModel.uiState.collectAsState() |
|||
|
|||
Box(modifier = modifier.fillMaxSize()) { |
|||
Image( |
|||
painter = painterResource(Res.drawable.bg), |
|||
contentDescription = null, |
|||
modifier = Modifier.fillMaxSize(), |
|||
contentScale = ContentScale.Crop |
|||
) |
|||
|
|||
Column( |
|||
modifier = Modifier |
|||
.fillMaxSize() |
|||
) { |
|||
|
|||
Box( |
|||
modifier = Modifier |
|||
.weight(1f) |
|||
.fillMaxWidth() |
|||
) { |
|||
when (uiState.selectedTab) { |
|||
HomeTab.STATE -> StateScreen() |
|||
HomeTab.EXERCISE -> ExerciseScreen() |
|||
HomeTab.RECOVERY -> RecoveryScreen() |
|||
HomeTab.SETTING -> SettingScreen() |
|||
} |
|||
} |
|||
|
|||
// 底部导航栏 |
|||
BottomNavigationBar( |
|||
selectedTab = uiState.selectedTab, |
|||
onTabSelected = viewModel::selectTab |
|||
) |
|||
|
|||
} |
|||
|
|||
// 加载动画覆盖层 |
|||
if (uiState.isLoading) { |
|||
LoadingOverlay() |
|||
} |
|||
} |
|||
} |
|||
|
|||
|
|||
@Composable |
|||
private fun BottomNavigationBar( |
|||
selectedTab: HomeTab, |
|||
onTabSelected: (HomeTab) -> Unit |
|||
) { |
|||
Row( |
|||
modifier = Modifier |
|||
.fillMaxWidth() |
|||
.background(Color(0xFFE8E0E6)) |
|||
.padding(top = 24.dp), |
|||
horizontalArrangement = Arrangement.SpaceEvenly |
|||
) { |
|||
HomeTab.entries.forEach { tab -> |
|||
BottomNavItem( |
|||
tab = tab, |
|||
isSelected = selectedTab == tab, |
|||
onClick = { onTabSelected(tab) } |
|||
) |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
private fun BottomNavItem( |
|||
tab: HomeTab, |
|||
isSelected: Boolean, |
|||
onClick: () -> Unit |
|||
) { |
|||
val textColor = Color(0xFF636363) |
|||
|
|||
Column( |
|||
modifier = Modifier |
|||
.clickable { onClick() } |
|||
.padding(vertical = 8.dp), |
|||
horizontalAlignment = Alignment.CenterHorizontally |
|||
) { |
|||
Box( |
|||
modifier = Modifier |
|||
.size(18.dp), |
|||
contentAlignment = Alignment.TopCenter |
|||
) { |
|||
// 这里应该用实际的图标资源,暂时用颜色块表示 |
|||
Icon( |
|||
painter = painterResource(tab.icon), |
|||
contentDescription = null, |
|||
tint = if (isSelected) Color(0xff352764) else Color(0xff636363) |
|||
) |
|||
} |
|||
|
|||
Spacer(modifier = Modifier.height(4.dp)) |
|||
|
|||
Text( |
|||
text = tab.title, |
|||
fontSize = 13.sp, |
|||
color = textColor, |
|||
textAlign = TextAlign.Center |
|||
) |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
private fun LoadingOverlay() { |
|||
val infiniteTransition = rememberInfiniteTransition() |
|||
val rotation by infiniteTransition.animateFloat( |
|||
initialValue = 0f, |
|||
targetValue = 360f, |
|||
animationSpec = infiniteRepeatable( |
|||
animation = tween(1000, easing = LinearEasing), |
|||
repeatMode = RepeatMode.Restart |
|||
) |
|||
) |
|||
|
|||
Box( |
|||
modifier = Modifier |
|||
.fillMaxSize() |
|||
.background(Color(0x800F0F0F)), |
|||
contentAlignment = Alignment.Center |
|||
) { |
|||
Box( |
|||
modifier = Modifier |
|||
.size(78.dp) |
|||
.rotate(rotation) |
|||
.background(Color.White, CircleShape) |
|||
) { |
|||
// 这里应该用实际的加载图标 |
|||
// Icon(painter = painterResource(Res.drawable.ic_loading), contentDescription = null) |
|||
} |
|||
} |
|||
} |
|||
|
|||
enum class HomeTab(val title: String, val icon: DrawableResource) { |
|||
STATE("状态", Res.drawable.ic_nav_state), |
|||
EXERCISE("运动", Res.drawable.ic_nav_exercise), |
|||
RECOVERY("恢复", Res.drawable.ic_nav_recovery), |
|||
SETTING("设置", Res.drawable.ic_nav_settings) |
|||
} |
|||
|
|||
@Preview |
|||
@Composable |
|||
private fun HomeScreenPreview() { |
|||
MaterialTheme { |
|||
HomeScreen() |
|||
} |
|||
} |
@ -0,0 +1,46 @@ |
|||
package com.whitefish.app.ui.home |
|||
|
|||
import androidx.lifecycle.ViewModel |
|||
import kotlinx.coroutines.flow.MutableStateFlow |
|||
import kotlinx.coroutines.flow.StateFlow |
|||
import kotlinx.coroutines.flow.asStateFlow |
|||
|
|||
data class HomeUiState( |
|||
val selectedTab: HomeTab = HomeTab.STATE, |
|||
val isLoading: Boolean = false, |
|||
val isConnected: Boolean = false |
|||
) |
|||
|
|||
class HomeViewModel : ViewModel() { |
|||
|
|||
private val _uiState = MutableStateFlow(HomeUiState()) |
|||
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow() |
|||
|
|||
fun selectTab(tab: HomeTab) { |
|||
_uiState.value = _uiState.value.copy(selectedTab = tab) |
|||
} |
|||
|
|||
fun setLoading(isLoading: Boolean) { |
|||
_uiState.value = _uiState.value.copy(isLoading = isLoading) |
|||
} |
|||
|
|||
fun setConnectionState(isConnected: Boolean) { |
|||
_uiState.value = _uiState.value.copy( |
|||
isConnected = isConnected, |
|||
isLoading = !isConnected |
|||
) |
|||
} |
|||
|
|||
// 模拟连接状态变化 |
|||
fun startConnection() { |
|||
setLoading(true) |
|||
// 在实际应用中,这里会连接到蓝牙设备 |
|||
// 暂时模拟连接成功 |
|||
// connectToDevice() |
|||
} |
|||
|
|||
// 缓存数据(对应Android的cacheData方法) |
|||
fun cacheData() { |
|||
// 实现数据缓存逻辑 |
|||
} |
|||
} |
@ -0,0 +1,134 @@ |
|||
package com.whitefish.app.ui.home.exercise |
|||
|
|||
import androidx.compose.foundation.background |
|||
import androidx.compose.foundation.layout.* |
|||
import androidx.compose.foundation.lazy.LazyColumn |
|||
import androidx.compose.foundation.lazy.items |
|||
import androidx.compose.foundation.shape.RoundedCornerShape |
|||
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 androidx.lifecycle.viewmodel.compose.viewModel |
|||
import org.jetbrains.compose.ui.tooling.preview.Preview |
|||
|
|||
@Composable |
|||
fun ExerciseScreen( |
|||
modifier: Modifier = Modifier, |
|||
viewModel: ExerciseViewModel = viewModel { ExerciseViewModel() } |
|||
) { |
|||
val uiState by viewModel.uiState.collectAsState() |
|||
|
|||
LazyColumn( |
|||
modifier = modifier |
|||
.fillMaxSize() |
|||
.background(Color(0xFF1A1A1A)) |
|||
.padding(horizontal = 16.dp), |
|||
contentPadding = PaddingValues(vertical = 16.dp), |
|||
verticalArrangement = Arrangement.spacedBy(12.dp) |
|||
) { |
|||
items(uiState.exerciseData) { exercise -> |
|||
ExerciseCard(exercise = exercise) |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
private fun ExerciseCard( |
|||
exercise: ExerciseData, |
|||
modifier: Modifier = Modifier |
|||
) { |
|||
Card( |
|||
modifier = modifier.fillMaxWidth(), |
|||
shape = RoundedCornerShape(12.dp), |
|||
colors = CardDefaults.cardColors( |
|||
containerColor = Color(0xFF2A2A2A) |
|||
) |
|||
) { |
|||
Column( |
|||
modifier = Modifier |
|||
.fillMaxWidth() |
|||
.padding(16.dp) |
|||
) { |
|||
Row( |
|||
modifier = Modifier.fillMaxWidth(), |
|||
horizontalArrangement = Arrangement.SpaceBetween, |
|||
verticalAlignment = Alignment.CenterVertically |
|||
) { |
|||
Text( |
|||
text = exercise.type, |
|||
color = Color.White, |
|||
fontSize = 16.sp, |
|||
fontWeight = FontWeight.Medium |
|||
) |
|||
Text( |
|||
text = exercise.date, |
|||
color = Color.Gray, |
|||
fontSize = 12.sp |
|||
) |
|||
} |
|||
|
|||
Spacer(modifier = Modifier.height(8.dp)) |
|||
|
|||
Row( |
|||
modifier = Modifier.fillMaxWidth(), |
|||
horizontalArrangement = Arrangement.SpaceBetween |
|||
) { |
|||
ExerciseMetric( |
|||
label = "时长", |
|||
value = exercise.duration |
|||
) |
|||
ExerciseMetric( |
|||
label = "卡路里", |
|||
value = "${exercise.calories} kcal" |
|||
) |
|||
ExerciseMetric( |
|||
label = "距离", |
|||
value = "${exercise.distance} km" |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
private fun ExerciseMetric( |
|||
label: String, |
|||
value: String |
|||
) { |
|||
Column( |
|||
horizontalAlignment = Alignment.CenterHorizontally |
|||
) { |
|||
Text( |
|||
text = value, |
|||
color = Color.White, |
|||
fontSize = 14.sp, |
|||
fontWeight = FontWeight.Bold |
|||
) |
|||
Text( |
|||
text = label, |
|||
color = Color.Gray, |
|||
fontSize = 12.sp |
|||
) |
|||
} |
|||
} |
|||
|
|||
data class ExerciseData( |
|||
val type: String, |
|||
val date: String, |
|||
val duration: String, |
|||
val calories: Int, |
|||
val distance: Float |
|||
) |
|||
|
|||
@Preview |
|||
@Composable |
|||
private fun ExerciseScreenPreview() { |
|||
MaterialTheme { |
|||
ExerciseScreen() |
|||
} |
|||
} |
@ -0,0 +1,63 @@ |
|||
package com.whitefish.app.ui.home.exercise |
|||
|
|||
import androidx.lifecycle.ViewModel |
|||
import kotlinx.coroutines.flow.MutableStateFlow |
|||
import kotlinx.coroutines.flow.StateFlow |
|||
import kotlinx.coroutines.flow.asStateFlow |
|||
|
|||
data class ExerciseUiState( |
|||
val exerciseData: List<ExerciseData> = emptyList(), |
|||
val isLoading: Boolean = false |
|||
) |
|||
|
|||
class ExerciseViewModel : ViewModel() { |
|||
|
|||
private val _uiState = MutableStateFlow(ExerciseUiState()) |
|||
val uiState: StateFlow<ExerciseUiState> = _uiState.asStateFlow() |
|||
|
|||
init { |
|||
loadExerciseData() |
|||
} |
|||
|
|||
private fun loadExerciseData() { |
|||
// 模拟加载运动数据 |
|||
val mockData = listOf( |
|||
ExerciseData( |
|||
type = "跑步", |
|||
date = "今天", |
|||
duration = "30分钟", |
|||
calories = 245, |
|||
distance = 3.2f |
|||
), |
|||
ExerciseData( |
|||
type = "骑行", |
|||
date = "昨天", |
|||
duration = "45分钟", |
|||
calories = 320, |
|||
distance = 8.5f |
|||
), |
|||
ExerciseData( |
|||
type = "游泳", |
|||
date = "2天前", |
|||
duration = "25分钟", |
|||
calories = 180, |
|||
distance = 1.0f |
|||
), |
|||
ExerciseData( |
|||
type = "健走", |
|||
date = "3天前", |
|||
duration = "60分钟", |
|||
calories = 150, |
|||
distance = 4.8f |
|||
) |
|||
) |
|||
|
|||
_uiState.value = _uiState.value.copy(exerciseData = mockData) |
|||
} |
|||
|
|||
fun refresh() { |
|||
_uiState.value = _uiState.value.copy(isLoading = true) |
|||
loadExerciseData() |
|||
_uiState.value = _uiState.value.copy(isLoading = false) |
|||
} |
|||
} |
@ -0,0 +1,131 @@ |
|||
package com.whitefish.app.ui.home.recovery |
|||
|
|||
import androidx.compose.foundation.background |
|||
import androidx.compose.foundation.layout.* |
|||
import androidx.compose.foundation.lazy.LazyColumn |
|||
import androidx.compose.foundation.lazy.items |
|||
import androidx.compose.foundation.shape.RoundedCornerShape |
|||
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 androidx.lifecycle.viewmodel.compose.viewModel |
|||
import org.jetbrains.compose.ui.tooling.preview.Preview |
|||
|
|||
@Composable |
|||
fun RecoveryScreen( |
|||
modifier: Modifier = Modifier, |
|||
viewModel: RecoveryViewModel = viewModel { RecoveryViewModel() } |
|||
) { |
|||
val uiState by viewModel.uiState.collectAsState() |
|||
|
|||
LazyColumn( |
|||
modifier = modifier |
|||
.fillMaxSize() |
|||
.background(Color(0xFF1A1A1A)) |
|||
.padding(horizontal = 16.dp), |
|||
contentPadding = PaddingValues(vertical = 16.dp), |
|||
verticalArrangement = Arrangement.spacedBy(12.dp) |
|||
) { |
|||
items(uiState.recoveryData) { recovery -> |
|||
RecoveryCard(recovery = recovery) |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
private fun RecoveryCard( |
|||
recovery: RecoveryData, |
|||
modifier: Modifier = Modifier |
|||
) { |
|||
Card( |
|||
modifier = modifier.fillMaxWidth(), |
|||
shape = RoundedCornerShape(12.dp), |
|||
colors = CardDefaults.cardColors( |
|||
containerColor = Color(0xFF2A2A2A) |
|||
) |
|||
) { |
|||
Column( |
|||
modifier = Modifier |
|||
.fillMaxWidth() |
|||
.padding(16.dp) |
|||
) { |
|||
Text( |
|||
text = recovery.title, |
|||
color = Color.White, |
|||
fontSize = 16.sp, |
|||
fontWeight = FontWeight.Medium |
|||
) |
|||
|
|||
Spacer(modifier = Modifier.height(8.dp)) |
|||
|
|||
Text( |
|||
text = recovery.description, |
|||
color = Color.Gray, |
|||
fontSize = 14.sp |
|||
) |
|||
|
|||
Spacer(modifier = Modifier.height(12.dp)) |
|||
|
|||
Row( |
|||
modifier = Modifier.fillMaxWidth(), |
|||
horizontalArrangement = Arrangement.SpaceBetween |
|||
) { |
|||
RecoveryMetric( |
|||
label = "恢复指数", |
|||
value = recovery.recoveryIndex.toString() |
|||
) |
|||
RecoveryMetric( |
|||
label = "建议休息", |
|||
value = recovery.restTime |
|||
) |
|||
RecoveryMetric( |
|||
label = "状态", |
|||
value = recovery.status |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
private fun RecoveryMetric( |
|||
label: String, |
|||
value: String |
|||
) { |
|||
Column( |
|||
horizontalAlignment = Alignment.CenterHorizontally |
|||
) { |
|||
Text( |
|||
text = value, |
|||
color = Color.White, |
|||
fontSize = 14.sp, |
|||
fontWeight = FontWeight.Bold |
|||
) |
|||
Text( |
|||
text = label, |
|||
color = Color.Gray, |
|||
fontSize = 12.sp |
|||
) |
|||
} |
|||
} |
|||
|
|||
data class RecoveryData( |
|||
val title: String, |
|||
val description: String, |
|||
val recoveryIndex: Int, |
|||
val restTime: String, |
|||
val status: String |
|||
) |
|||
|
|||
@Preview |
|||
@Composable |
|||
private fun RecoveryScreenPreview() { |
|||
MaterialTheme { |
|||
RecoveryScreen() |
|||
} |
|||
} |
@ -0,0 +1,56 @@ |
|||
package com.whitefish.app.ui.home.recovery |
|||
|
|||
import androidx.lifecycle.ViewModel |
|||
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() { |
|||
|
|||
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) |
|||
} |
|||
} |
@ -0,0 +1,121 @@ |
|||
package com.whitefish.app.ui.home.setting |
|||
|
|||
import androidx.compose.foundation.background |
|||
import androidx.compose.foundation.clickable |
|||
import androidx.compose.foundation.layout.* |
|||
import androidx.compose.foundation.lazy.LazyColumn |
|||
import androidx.compose.foundation.lazy.items |
|||
import androidx.compose.foundation.shape.RoundedCornerShape |
|||
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 androidx.lifecycle.viewmodel.compose.viewModel |
|||
import org.jetbrains.compose.ui.tooling.preview.Preview |
|||
|
|||
@Composable |
|||
fun SettingScreen( |
|||
modifier: Modifier = Modifier, |
|||
viewModel: SettingViewModel = viewModel { SettingViewModel() } |
|||
) { |
|||
val uiState by viewModel.uiState.collectAsState() |
|||
|
|||
LazyColumn( |
|||
modifier = modifier |
|||
.fillMaxSize() |
|||
.background(Color(0xFF1A1A1A)) |
|||
.padding(horizontal = 16.dp), |
|||
contentPadding = PaddingValues(vertical = 16.dp), |
|||
verticalArrangement = Arrangement.spacedBy(8.dp) |
|||
) { |
|||
items(uiState.settingItems) { setting -> |
|||
SettingItem( |
|||
setting = setting, |
|||
onItemClick = { viewModel.onSettingClick(setting) } |
|||
) |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
private fun SettingItem( |
|||
setting: SettingItemData, |
|||
onItemClick: () -> Unit, |
|||
modifier: Modifier = Modifier |
|||
) { |
|||
Card( |
|||
modifier = modifier |
|||
.fillMaxWidth() |
|||
.clickable { onItemClick() }, |
|||
shape = RoundedCornerShape(12.dp), |
|||
colors = CardDefaults.cardColors( |
|||
containerColor = Color(0xFF2A2A2A) |
|||
) |
|||
) { |
|||
Row( |
|||
modifier = Modifier |
|||
.fillMaxWidth() |
|||
.padding(16.dp), |
|||
horizontalArrangement = Arrangement.SpaceBetween, |
|||
verticalAlignment = Alignment.CenterVertically |
|||
) { |
|||
Column( |
|||
modifier = Modifier.weight(1f) |
|||
) { |
|||
Text( |
|||
text = setting.title, |
|||
color = Color.White, |
|||
fontSize = 16.sp, |
|||
fontWeight = FontWeight.Medium |
|||
) |
|||
|
|||
if (setting.subtitle.isNotEmpty()) { |
|||
Spacer(modifier = Modifier.height(4.dp)) |
|||
Text( |
|||
text = setting.subtitle, |
|||
color = Color.Gray, |
|||
fontSize = 14.sp |
|||
) |
|||
} |
|||
} |
|||
|
|||
if (setting.hasSwitch) { |
|||
Switch( |
|||
checked = setting.isEnabled, |
|||
onCheckedChange = { /* Handle switch change */ }, |
|||
colors = SwitchDefaults.colors( |
|||
checkedThumbColor = Color.White, |
|||
checkedTrackColor = Color(0xFF352764), |
|||
uncheckedThumbColor = Color.Gray, |
|||
uncheckedTrackColor = Color.DarkGray |
|||
) |
|||
) |
|||
} else { |
|||
Text( |
|||
text = ">", |
|||
color = Color.Gray, |
|||
fontSize = 18.sp |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
data class SettingItemData( |
|||
val title: String, |
|||
val subtitle: String = "", |
|||
val hasSwitch: Boolean = false, |
|||
val isEnabled: Boolean = false |
|||
) |
|||
|
|||
@Preview |
|||
@Composable |
|||
private fun SettingScreenPreview() { |
|||
MaterialTheme { |
|||
SettingScreen() |
|||
} |
|||
} |
@ -0,0 +1,82 @@ |
|||
package com.whitefish.app.ui.home.setting |
|||
|
|||
import androidx.lifecycle.ViewModel |
|||
import kotlinx.coroutines.flow.MutableStateFlow |
|||
import kotlinx.coroutines.flow.StateFlow |
|||
import kotlinx.coroutines.flow.asStateFlow |
|||
|
|||
data class SettingUiState( |
|||
val settingItems: List<SettingItemData> = emptyList(), |
|||
val isLoading: Boolean = false |
|||
) |
|||
|
|||
class SettingViewModel : ViewModel() { |
|||
|
|||
private val _uiState = MutableStateFlow(SettingUiState()) |
|||
val uiState: StateFlow<SettingUiState> = _uiState.asStateFlow() |
|||
|
|||
init { |
|||
loadSettingItems() |
|||
} |
|||
|
|||
private fun loadSettingItems() { |
|||
// 模拟加载设置项数据 |
|||
val mockData = listOf( |
|||
SettingItemData( |
|||
title = "设备连接", |
|||
subtitle = "管理蓝牙设备连接" |
|||
), |
|||
SettingItemData( |
|||
title = "数据同步", |
|||
subtitle = "自动同步健康数据", |
|||
hasSwitch = true, |
|||
isEnabled = true |
|||
), |
|||
SettingItemData( |
|||
title = "通知设置", |
|||
subtitle = "运动提醒和健康通知" |
|||
), |
|||
SettingItemData( |
|||
title = "隐私设置", |
|||
subtitle = "数据隐私和权限管理" |
|||
), |
|||
SettingItemData( |
|||
title = "夜间模式", |
|||
subtitle = "自动切换深色主题", |
|||
hasSwitch = true, |
|||
isEnabled = false |
|||
), |
|||
SettingItemData( |
|||
title = "关于应用", |
|||
subtitle = "版本信息和帮助" |
|||
), |
|||
SettingItemData( |
|||
title = "退出登录", |
|||
subtitle = "" |
|||
) |
|||
) |
|||
|
|||
_uiState.value = _uiState.value.copy(settingItems = mockData) |
|||
} |
|||
|
|||
fun onSettingClick(setting: SettingItemData) { |
|||
// 处理设置项点击事件 |
|||
when (setting.title) { |
|||
"设备连接" -> { |
|||
// 跳转到设备连接页面 |
|||
} |
|||
"通知设置" -> { |
|||
// 跳转到通知设置页面 |
|||
} |
|||
"隐私设置" -> { |
|||
// 跳转到隐私设置页面 |
|||
} |
|||
"关于应用" -> { |
|||
// 跳转到关于页面 |
|||
} |
|||
"退出登录" -> { |
|||
// 处理退出登录 |
|||
} |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,140 @@ |
|||
package com.whitefish.app.ui.home.state |
|||
|
|||
import androidx.compose.foundation.background |
|||
import androidx.compose.foundation.layout.* |
|||
import androidx.compose.foundation.lazy.grid.GridCells |
|||
import androidx.compose.foundation.lazy.grid.GridItemSpan |
|||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid |
|||
import androidx.compose.foundation.lazy.grid.items |
|||
import androidx.compose.foundation.shape.RoundedCornerShape |
|||
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.TextStyle |
|||
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 androidx.lifecycle.viewmodel.compose.viewModel |
|||
import com.patrykandpatrick.vico.multiplatform.cartesian.layer.LineCartesianLayer |
|||
import com.whitefish.app.ui.chart.ComposeMultiplatformBasicLineChart |
|||
import com.whitefish.app.ui.chart.sleep.SleepChart |
|||
import com.whitefish.app.ui.chart.sleep.SleepSegment |
|||
import org.jetbrains.compose.ui.tooling.preview.Preview |
|||
import com.whitefish.app.ui.home.state.components.ExerciseGoalCard |
|||
import com.whitefish.app.ui.home.state.components.RecoveryScoreCard |
|||
|
|||
@Composable |
|||
fun StateScreen( |
|||
modifier: Modifier = Modifier, |
|||
viewModel: StateViewModel = viewModel { StateViewModel() } |
|||
) { |
|||
val uiState by viewModel.uiState.collectAsState() |
|||
|
|||
LazyVerticalGrid( |
|||
columns = GridCells.Fixed(2), |
|||
modifier = modifier |
|||
.fillMaxSize() |
|||
.padding(horizontal = 16.dp), |
|||
contentPadding = PaddingValues(vertical = 16.dp), |
|||
horizontalArrangement = Arrangement.spacedBy(8.dp), |
|||
verticalArrangement = Arrangement.spacedBy(8.dp), |
|||
) { |
|||
// 运动目标卡片 - 跨越两列 |
|||
item(span = { GridItemSpan(2) }) { |
|||
ExerciseGoalCard( |
|||
data = uiState.exerciseGoalData, |
|||
modifier = Modifier.fillMaxWidth() |
|||
) |
|||
} |
|||
// 恢复分数卡片 - 跨越两列 |
|||
item(span = { GridItemSpan(2) }) { |
|||
RecoveryScoreCard( |
|||
data = uiState.recoveryScoreDAta, |
|||
modifier = Modifier |
|||
.fillMaxWidth() |
|||
.padding(top = 8.dp) |
|||
) |
|||
} |
|||
|
|||
// 间距 |
|||
item(span = { GridItemSpan(2) }) { |
|||
Spacer(modifier = Modifier.height(8.dp)) |
|||
} |
|||
|
|||
// 状态卡片 |
|||
items(uiState.stateCards) { card -> |
|||
StateCard( |
|||
card = card, |
|||
modifier = Modifier.height(166.dp) |
|||
) |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
private fun StateCard( |
|||
card: StateCardData, |
|||
modifier: Modifier = Modifier |
|||
) { |
|||
Card( |
|||
modifier = modifier.fillMaxWidth(), |
|||
shape = RoundedCornerShape(24.dp), |
|||
colors = CardDefaults.cardColors( |
|||
containerColor = Color(0x40D9D9D9) |
|||
) |
|||
) { |
|||
Column( |
|||
modifier = Modifier |
|||
.fillMaxSize() |
|||
.padding(16.dp), |
|||
verticalArrangement = Arrangement.SpaceBetween |
|||
) { |
|||
Row { |
|||
Text( |
|||
text = card.title, |
|||
color = Color.White, |
|||
fontSize = 18.sp, |
|||
fontWeight = FontWeight.Medium |
|||
) |
|||
Text( |
|||
modifier = Modifier.align(Alignment.Bottom), |
|||
text = card.date, |
|||
color = Color.White, |
|||
fontSize = 11.sp, |
|||
fontWeight = FontWeight.Medium |
|||
) |
|||
} |
|||
when (val type = card.type) { |
|||
is StateCardType.HeartRate -> ComposeMultiplatformBasicLineChart() |
|||
is StateCardType.SleepState -> SleepChart( |
|||
type.data, |
|||
modifier = Modifier.fillMaxSize() |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
data class StateCardData( |
|||
val title: String, |
|||
val date: String, |
|||
val subtitle: String = "", |
|||
val isFullWidth: Boolean = false, |
|||
val type: StateCardType |
|||
) |
|||
|
|||
sealed class StateCardType() { |
|||
data class HeartRate(val data: List<Int>) : StateCardType() |
|||
data class SleepState(val data: List<SleepSegment>) : StateCardType() |
|||
} |
|||
|
|||
@Preview |
|||
@Composable |
|||
private fun StateScreenPreview() { |
|||
MaterialTheme { |
|||
StateScreen() |
|||
} |
|||
} |
@ -0,0 +1,56 @@ |
|||
package com.whitefish.app.ui.home.state |
|||
|
|||
import androidx.lifecycle.ViewModel |
|||
import com.whitefish.app.ui.chart.sleep.createSampleSleepData |
|||
import kotlinx.coroutines.flow.MutableStateFlow |
|||
import kotlinx.coroutines.flow.StateFlow |
|||
import kotlinx.coroutines.flow.asStateFlow |
|||
import com.whitefish.app.ui.home.state.components.ExerciseGoalData |
|||
import com.whitefish.app.ui.home.state.components.RecoveryScoreCard |
|||
import com.whitefish.app.ui.home.state.components.RecoveryScoreData |
|||
|
|||
data class StateUiState( |
|||
val stateCards: List<StateCardData> = emptyList(), |
|||
val exerciseGoalData: ExerciseGoalData = ExerciseGoalData(), |
|||
val recoveryScoreDAta: RecoveryScoreData = RecoveryScoreData(), |
|||
val isLoading: Boolean = false, |
|||
) |
|||
|
|||
class StateViewModel : ViewModel() { |
|||
|
|||
private val _uiState = MutableStateFlow(StateUiState()) |
|||
val uiState: StateFlow<StateUiState> = _uiState.asStateFlow() |
|||
|
|||
init { |
|||
loadStateData() |
|||
} |
|||
|
|||
private fun loadStateData() { |
|||
// 模拟加载状态数据,对应Android StateFragment的数据 |
|||
val mockData = listOf( |
|||
StateCardData( |
|||
title = "心率", |
|||
date = "8/9", |
|||
subtitle = "bpm", |
|||
isFullWidth = false, |
|||
type = StateCardType.HeartRate(emptyList()) |
|||
), |
|||
StateCardData( |
|||
title = "睡眠", |
|||
date = "8/9", |
|||
subtitle = "昨晚", |
|||
isFullWidth = false, |
|||
type = StateCardType.SleepState(createSampleSleepData()) |
|||
) |
|||
) |
|||
|
|||
_uiState.value = _uiState.value.copy(stateCards = mockData) |
|||
} |
|||
|
|||
fun refresh() { |
|||
_uiState.value = _uiState.value.copy(isLoading = true) |
|||
// 模拟刷新数据 |
|||
loadStateData() |
|||
_uiState.value = _uiState.value.copy(isLoading = false) |
|||
} |
|||
} |
@ -0,0 +1,94 @@ |
|||
package com.whitefish.app.ui.home.state.components |
|||
|
|||
import androidx.compose.animation.core.animateFloatAsState |
|||
import androidx.compose.animation.core.tween |
|||
import androidx.compose.foundation.Canvas |
|||
import androidx.compose.foundation.layout.* |
|||
import androidx.compose.material3.Text |
|||
import androidx.compose.runtime.* |
|||
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.unit.dp |
|||
import androidx.compose.ui.unit.sp |
|||
|
|||
@Composable |
|||
fun CircularProgressIndicator( |
|||
progress: Float, |
|||
value: String, |
|||
unit: String, |
|||
modifier: Modifier = Modifier, |
|||
strokeWidth: Float = 14.dp.value, |
|||
color: Color = Color(0xFF6ECFB7), |
|||
backgroundColor: Color = Color(0xFFFFFFFF) |
|||
) { |
|||
var animatedProgress by remember { mutableStateOf(0f) } |
|||
|
|||
val animatedProgressValue by animateFloatAsState( |
|||
targetValue = animatedProgress, |
|||
animationSpec = tween(durationMillis = 1000), |
|||
label = "progress" |
|||
) |
|||
|
|||
LaunchedEffect(progress) { |
|||
animatedProgress = progress |
|||
} |
|||
|
|||
Box( |
|||
modifier = modifier.size(200.dp), |
|||
contentAlignment = Alignment.Center |
|||
) { |
|||
// 绘制圆形进度条 |
|||
Canvas( |
|||
modifier = Modifier.fillMaxSize() |
|||
) { |
|||
val centerX = size.width / 2 |
|||
val centerY = size.height / 2 |
|||
val radius = (size.minDimension - strokeWidth) / 2 |
|||
|
|||
// 背景圆环 |
|||
drawCircle( |
|||
color = backgroundColor, |
|||
radius = radius, |
|||
center = androidx.compose.ui.geometry.Offset(centerX, centerY), |
|||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round) |
|||
) |
|||
|
|||
// 进度圆弧 |
|||
val sweepAngle = 360 * animatedProgressValue |
|||
drawArc( |
|||
color = color, |
|||
startAngle = -90f, |
|||
sweepAngle = sweepAngle, |
|||
useCenter = false, |
|||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round), |
|||
topLeft = androidx.compose.ui.geometry.Offset( |
|||
centerX - radius, |
|||
centerY - radius |
|||
), |
|||
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2) |
|||
) |
|||
} |
|||
|
|||
// 中心文本 |
|||
Column( |
|||
horizontalAlignment = Alignment.CenterHorizontally |
|||
) { |
|||
Text( |
|||
text = value, |
|||
color = Color.White, |
|||
fontSize = 40.sp, |
|||
fontWeight = FontWeight.Bold |
|||
) |
|||
Text( |
|||
text = unit, |
|||
color = Color.White, |
|||
fontSize = 16.sp, |
|||
fontWeight = FontWeight.Medium |
|||
) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,166 @@ |
|||
package com.whitefish.app.ui.home.state.components |
|||
|
|||
import androidx.compose.foundation.Image |
|||
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.CardDefaults |
|||
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.layout.ContentScale |
|||
import androidx.compose.ui.text.font.FontWeight |
|||
import androidx.compose.ui.unit.dp |
|||
import androidx.compose.ui.unit.sp |
|||
import org.jetbrains.compose.resources.DrawableResource |
|||
import org.jetbrains.compose.resources.painterResource |
|||
import org.jetbrains.compose.ui.tooling.preview.Preview |
|||
import ringappkmp.composeapp.generated.resources.Res |
|||
import ringappkmp.composeapp.generated.resources.bg_exercise_target |
|||
import ringappkmp.composeapp.generated.resources.compose_multiplatform |
|||
|
|||
data class ExerciseGoalData( |
|||
val title: String = "运动目标", |
|||
val caloriesBurned: Int = 152, |
|||
val caloriesGoal: Int = 300, |
|||
val steps: Int = 250, |
|||
val duration: String = "42min", |
|||
val score: Int = 84 |
|||
) |
|||
|
|||
@Composable |
|||
fun ExerciseGoalCard( |
|||
data: ExerciseGoalData, |
|||
modifier: Modifier = Modifier |
|||
) { |
|||
val progress = data.caloriesBurned.toFloat() / data.caloriesGoal.toFloat() |
|||
|
|||
Card( |
|||
modifier = modifier.fillMaxWidth(), |
|||
shape = RoundedCornerShape(40.dp), |
|||
colors = CardDefaults.cardColors( |
|||
containerColor = Color.Transparent |
|||
), |
|||
) { |
|||
Box { |
|||
Image( |
|||
painter = painterResource(Res.drawable.bg_exercise_target), |
|||
contentDescription = null, |
|||
modifier = Modifier.fillMaxSize(), |
|||
contentScale = ContentScale.Crop |
|||
) |
|||
Column( |
|||
modifier = Modifier |
|||
.fillMaxWidth() |
|||
.padding(24.dp), |
|||
horizontalAlignment = Alignment.CenterHorizontally |
|||
) { |
|||
// 标题 |
|||
Text( |
|||
text = data.title, |
|||
color = Color.White, |
|||
fontSize = 16.sp, |
|||
fontWeight = FontWeight.Medium |
|||
) |
|||
|
|||
Spacer(modifier = Modifier.height(16.dp)) |
|||
|
|||
// 圆形进度条 |
|||
CircularProgressIndicator( |
|||
progress = progress, |
|||
value = data.caloriesBurned.toString(), |
|||
unit = "千卡", |
|||
color = Color(0xFF6ECFB7), |
|||
modifier = Modifier.size(136.dp) |
|||
) |
|||
|
|||
Spacer(modifier = Modifier.height(24.dp)) |
|||
|
|||
// 底部指标 |
|||
Card( |
|||
shape = RoundedCornerShape(20.dp), |
|||
colors = CardDefaults.cardColors(containerColor = Color(0x60D9D9D9)), |
|||
modifier = Modifier.fillMaxWidth().height(80.dp).padding(horizontal = 13.dp) |
|||
) { |
|||
Row( |
|||
modifier = Modifier.fillMaxWidth().fillMaxHeight(), |
|||
horizontalArrangement = Arrangement.SpaceEvenly, |
|||
verticalAlignment = Alignment.CenterVertically |
|||
) { |
|||
MetricItem( |
|||
label = "运动步数", |
|||
value = data.steps.toString(), |
|||
modifier = Modifier.weight(1f) |
|||
) |
|||
|
|||
VerticalDivider() |
|||
|
|||
MetricItem( |
|||
label = "运动时间", |
|||
value = data.duration, |
|||
modifier = Modifier.weight(1f) |
|||
) |
|||
|
|||
VerticalDivider() |
|||
|
|||
MetricItem( |
|||
label = "运动得分", |
|||
value = data.score.toString(), |
|||
modifier = Modifier.weight(1f) |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
|
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
private fun MetricItem( |
|||
label: String, |
|||
value: String, |
|||
modifier: Modifier = Modifier |
|||
) { |
|||
Column( |
|||
modifier = modifier, |
|||
horizontalAlignment = Alignment.CenterHorizontally, |
|||
verticalArrangement = Arrangement.Center |
|||
) { |
|||
Text( |
|||
text = label, |
|||
color = Color.Gray, |
|||
fontSize = 12.sp, |
|||
fontWeight = FontWeight.Medium |
|||
) |
|||
Spacer(modifier = Modifier.height(4.dp)) |
|||
Text( |
|||
text = value, |
|||
color = Color.White, |
|||
fontSize = 18.sp, |
|||
fontWeight = FontWeight.Bold |
|||
) |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
private fun VerticalDivider() { |
|||
Box( |
|||
modifier = Modifier |
|||
.width(1.dp) |
|||
.height(30.dp) |
|||
.background(Color(0xFFFFFFFF)) |
|||
) |
|||
} |
|||
|
|||
@Preview |
|||
@Composable |
|||
private fun ExerciseGoalCardPreview() { |
|||
ExerciseGoalCard( |
|||
data = ExerciseGoalData() |
|||
) |
|||
} |
@ -0,0 +1,109 @@ |
|||
package com.whitefish.app.ui.home.state.components |
|||
|
|||
import androidx.compose.foundation.Image |
|||
import androidx.compose.foundation.layout.Arrangement |
|||
import androidx.compose.foundation.layout.Box |
|||
import androidx.compose.foundation.layout.Column |
|||
import androidx.compose.foundation.layout.Row |
|||
import androidx.compose.foundation.layout.Spacer |
|||
import androidx.compose.foundation.layout.fillMaxHeight |
|||
import androidx.compose.foundation.layout.fillMaxSize |
|||
import androidx.compose.foundation.layout.fillMaxWidth |
|||
import androidx.compose.foundation.layout.height |
|||
import androidx.compose.foundation.layout.padding |
|||
import androidx.compose.foundation.layout.size |
|||
import androidx.compose.foundation.shape.RoundedCornerShape |
|||
import androidx.compose.material3.Card |
|||
import androidx.compose.material3.CardDefaults |
|||
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.layout.ContentScale |
|||
import androidx.compose.ui.text.font.FontWeight |
|||
import androidx.compose.ui.unit.dp |
|||
import androidx.compose.ui.unit.sp |
|||
import org.jetbrains.compose.resources.painterResource |
|||
import org.jetbrains.compose.ui.tooling.preview.Preview |
|||
import ringappkmp.composeapp.generated.resources.Res |
|||
import ringappkmp.composeapp.generated.resources.bg_exercise_target |
|||
import ringappkmp.composeapp.generated.resources.bg_recovery_score |
|||
|
|||
data class RecoveryScoreData( |
|||
val title:String = "恢复得分", |
|||
val score: Int = 0, |
|||
val tip:String = "你当天的身体状态似乎不太好,请注意保持适量的运动,晚上早点休息,养成良好的锻炼和睡眠规律。" |
|||
) |
|||
|
|||
|
|||
@Composable |
|||
fun RecoveryScoreCard(data: RecoveryScoreData, modifier: Modifier = Modifier){ |
|||
Card( |
|||
modifier = modifier.fillMaxWidth(), |
|||
shape = RoundedCornerShape(40.dp), |
|||
colors = CardDefaults.cardColors( |
|||
containerColor = Color.Transparent |
|||
), |
|||
) { |
|||
Box { |
|||
Image( |
|||
painter = painterResource(Res.drawable.bg_recovery_score), |
|||
contentDescription = null, |
|||
modifier = Modifier.fillMaxSize(), |
|||
contentScale = ContentScale.Crop |
|||
) |
|||
Column( |
|||
modifier = Modifier |
|||
.fillMaxWidth() |
|||
.padding(24.dp), |
|||
horizontalAlignment = Alignment.CenterHorizontally |
|||
) { |
|||
// 标题 |
|||
Text( |
|||
text = data.title, |
|||
color = Color.White, |
|||
fontSize = 16.sp, |
|||
fontWeight = FontWeight.Medium |
|||
) |
|||
|
|||
Spacer(modifier = Modifier.height(16.dp)) |
|||
|
|||
// 圆形进度条 |
|||
CircularProgressIndicator( |
|||
progress = data.score.toFloat(), |
|||
value = data.score.toString(), |
|||
unit = "分", |
|||
color = Color(0xFF352764), |
|||
modifier = Modifier.size(136.dp) |
|||
) |
|||
|
|||
Spacer(modifier = Modifier.height(24.dp)) |
|||
|
|||
// 底部指标 |
|||
Card( |
|||
shape = RoundedCornerShape(20.dp), |
|||
colors = CardDefaults.cardColors(containerColor = Color(0x60D9D9D9)), |
|||
modifier = Modifier.fillMaxWidth().height(80.dp).padding(horizontal = 13.dp) |
|||
) { |
|||
Row( |
|||
modifier = Modifier.fillMaxWidth().fillMaxHeight(), |
|||
horizontalArrangement = Arrangement.SpaceEvenly, |
|||
verticalAlignment = Alignment.CenterVertically |
|||
) { |
|||
Text(data.tip, color = Color.White, modifier = Modifier.padding(horizontal = 14.dp)) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
} |
|||
|
|||
@Preview |
|||
@Composable |
|||
private fun ExerciseGoalCardPreview() { |
|||
RecoveryScoreCard( |
|||
RecoveryScoreData() |
|||
) |
|||
} |