From 898aa65f44005d41d1c0d18ecd8ffeb46ffe1769 Mon Sep 17 00:00:00 2001 From: amos wong Date: Sat, 17 Jan 2026 18:21:40 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E5=8A=A8=E6=89=93=E5=8D=A1=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96,=E4=B8=8D=E5=86=8D=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E7=85=A7=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 6 +- .../com/healthflow/app/data/model/Models.kt | 8 +- .../app/ui/navigation/Navigation.kt | 30 +- .../app/ui/screen/ExerciseScreen.kt | 404 +++++++++--------- .../app/ui/viewmodel/ExerciseViewModel.kt | 6 +- server/internal/database/database.go | 27 +- server/internal/handler/epoch.go | 50 ++- server/internal/handler/exercise.go | 12 +- server/internal/model/model.go | 22 +- 9 files changed, 296 insertions(+), 269 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index bac492f..c048a2d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,11 +13,11 @@ android { applicationId = "com.healthflow.app" minSdk = 26 targetSdk = 35 - versionCode = 114 - versionName = "3.1.4" + versionCode = 116 + versionName = "3.1.6" buildConfigField("String", "API_BASE_URL", "\"https://health.amos.us.kg/api/\"") - buildConfigField("int", "VERSION_CODE", "114") + buildConfigField("int", "VERSION_CODE", "116") } signingConfigs { diff --git a/android/app/src/main/java/com/healthflow/app/data/model/Models.kt b/android/app/src/main/java/com/healthflow/app/data/model/Models.kt index b6cf356..08033af 100644 --- a/android/app/src/main/java/com/healthflow/app/data/model/Models.kt +++ b/android/app/src/main/java/com/healthflow/app/data/model/Models.kt @@ -218,7 +218,9 @@ data class ExerciseCheckin( val id: Long = 0, @SerialName("user_id") val userId: Long = 0, @SerialName("checkin_date") val checkinDate: String, - @SerialName("image_url") val imageUrl: String = "", + @SerialName("exercise_type") val exerciseType: String = "", // "aerobic" 或 "anaerobic" + @SerialName("body_part") val bodyPart: String = "", // 无氧运动部位: "leg", "chest", "back", "abs" + val duration: Int = 0, // 有氧运动时长(分钟) val note: String = "", @SerialName("created_at") val createdAt: String = "" ) @@ -226,7 +228,9 @@ data class ExerciseCheckin( @Serializable data class CreateExerciseCheckinRequest( @SerialName("checkin_date") val checkinDate: String, - @SerialName("image_url") val imageUrl: String = "", + @SerialName("exercise_type") val exerciseType: String, + @SerialName("body_part") val bodyPart: String = "", + val duration: Int = 0, val note: String = "" ) diff --git a/android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt b/android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt index 43239e8..4f3ce51 100644 --- a/android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt +++ b/android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt @@ -26,9 +26,9 @@ import com.healthflow.app.ui.theme.* import com.healthflow.app.ui.viewmodel.EpochViewModel sealed class Tab(val route: String, val label: String) { + data object Exercise : Tab("tab_exercise", "运动") data object Epoch : Tab("tab_epoch", "纪元") data object Plan : Tab("tab_plan", "计划") - data object Exercise : Tab("tab_exercise", "运动") data object Profile : Tab("tab_profile", "我的") } @@ -42,7 +42,7 @@ object Routes { fun weekPlanDetail(epochId: Long, planId: Long) = "week_plan_detail/$epochId/$planId" } -val tabs = listOf(Tab.Epoch, Tab.Plan, Tab.Exercise, Tab.Profile) +val tabs = listOf(Tab.Exercise, Tab.Epoch, Tab.Plan, Tab.Profile) @Composable fun MainNavigation( @@ -76,11 +76,23 @@ fun MainNavigation( Box(modifier = Modifier.fillMaxSize()) { NavHost( navController = navController, - startDestination = Tab.Epoch.route, + startDestination = Tab.Exercise.route, modifier = Modifier .fillMaxSize() .padding(bottom = if (showBottomNav) 56.dp else 0.dp) ) { + composable(Tab.Exercise.route) { + val exerciseViewModel: com.healthflow.app.ui.viewmodel.ExerciseViewModel = viewModel() + val exerciseStats by exerciseViewModel.stats.collectAsState() + + // 当运动统计更新时,同步到 EpochViewModel + LaunchedEffect(exerciseStats.thisYearDays) { + epochViewModel.updateExerciseDays(exerciseStats.thisYearDays) + } + + ExerciseScreen(viewModel = exerciseViewModel) + } + composable(Tab.Epoch.route) { EpochScreen( epochs = epochList, @@ -154,18 +166,6 @@ fun MainNavigation( ) } - composable(Tab.Exercise.route) { - val exerciseViewModel: com.healthflow.app.ui.viewmodel.ExerciseViewModel = viewModel() - val exerciseStats by exerciseViewModel.stats.collectAsState() - - // 当运动统计更新时,同步到 EpochViewModel - LaunchedEffect(exerciseStats.thisYearDays) { - epochViewModel.updateExerciseDays(exerciseStats.thisYearDays) - } - - ExerciseScreen(viewModel = exerciseViewModel) - } - composable(Routes.CREATE_EPOCH) { CreateEpochScreen( isLoading = isLoading, diff --git a/android/app/src/main/java/com/healthflow/app/ui/screen/ExerciseScreen.kt b/android/app/src/main/java/com/healthflow/app/ui/screen/ExerciseScreen.kt index 7aa2900..4a86a81 100644 --- a/android/app/src/main/java/com/healthflow/app/ui/screen/ExerciseScreen.kt +++ b/android/app/src/main/java/com/healthflow/app/ui/screen/ExerciseScreen.kt @@ -1,8 +1,5 @@ package com.healthflow.app.ui.screen -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -27,26 +24,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel -import coil.compose.AsyncImage -import com.healthflow.app.data.api.ApiClient import com.healthflow.app.data.model.ExerciseCheckin import com.healthflow.app.ui.theme.* import com.healthflow.app.ui.viewmodel.ExerciseViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.MultipartBody -import okhttp3.RequestBody.Companion.asRequestBody -import java.io.File import java.time.LocalDate import java.time.format.DateTimeFormatter import java.time.format.TextStyle @@ -245,8 +230,8 @@ fun ExerciseScreen( CheckinDialog( isLoading = isCheckinLoading, onDismiss = { showCheckinDialog = false }, - onCheckin = { imageUrl, note -> - viewModel.checkin(imageUrl, note) { + onCheckin = { exerciseType, bodyPart, duration, note -> + viewModel.checkin(exerciseType, bodyPart, duration, note) { showCheckinDialog = false } } @@ -468,6 +453,22 @@ private fun getHeatmapColor(level: Int): Color = when (level) { private fun CheckinItem(checkin: ExerciseCheckin, onClick: () -> Unit) { val date = try { LocalDate.parse(checkin.checkinDate.take(10)) } catch (e: Exception) { LocalDate.now() } val dayOfWeek = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.CHINESE) + + // 运动类型和详情 + val typeText = when (checkin.exerciseType) { + "aerobic" -> "有氧 ${checkin.duration}分钟" + "anaerobic" -> { + val partText = when (checkin.bodyPart) { + "leg" -> "腿" + "chest" -> "胸" + "back" -> "背" + "abs" -> "腹" + else -> "" + } + "无氧 $partText" + } + else -> "运动打卡" + } Row( modifier = Modifier.fillMaxWidth().clickable { onClick() }.padding(vertical = 8.dp), @@ -482,21 +483,12 @@ private fun CheckinItem(checkin: ExerciseCheckin, onClick: () -> Unit) { modifier = Modifier.weight(1f).clip(RoundedCornerShape(12.dp)).background(Slate50).padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - if (checkin.imageUrl.isNotEmpty()) { - AsyncImage( - model = checkin.imageUrl, - contentDescription = null, - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.width(12.dp)) - } Column(modifier = Modifier.weight(1f)) { Text( - text = if (checkin.note.isNotEmpty()) checkin.note else "运动打卡 ✓", + text = if (checkin.note.isNotEmpty()) checkin.note else typeText, fontSize = 14.sp, color = Slate700, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Text(text = "${date.monthValue}月${date.dayOfMonth}日", fontSize = 12.sp, color = Slate400) + Text(text = "${date.monthValue}月${date.dayOfMonth}日 · $typeText", fontSize = 12.sp, color = Slate400) } Box( modifier = Modifier.size(24.dp).clip(CircleShape).background(SuccessGreen), @@ -517,7 +509,27 @@ private fun CheckinDetailDialog( ) { val date = try { LocalDate.parse(checkin.checkinDate.take(10)) } catch (e: Exception) { LocalDate.now() } var showDeleteConfirm by remember { mutableStateOf(false) } - var showFullscreenImage by remember { mutableStateOf(false) } + + // 运动类型和详情 + val typeText = when (checkin.exerciseType) { + "aerobic" -> "有氧运动" + "anaerobic" -> "无氧运动" + else -> "运动" + } + + val detailText = when (checkin.exerciseType) { + "aerobic" -> "${checkin.duration} 分钟" + "anaerobic" -> { + when (checkin.bodyPart) { + "leg" -> "腿部训练" + "chest" -> "胸部训练" + "back" -> "背部训练" + "abs" -> "腹部训练" + else -> "" + } + } + else -> "" + } ModalBottomSheet( onDismissRequest = onDismiss, @@ -551,50 +563,57 @@ private fun CheckinDetailDialog( } } - // Image - 点击可全屏查看 - if (checkin.imageUrl.isNotEmpty()) { - AsyncImage( - model = checkin.imageUrl, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .height(280.dp) - .clip(RoundedCornerShape(16.dp)) - .clickable { showFullscreenImage = true }, - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.height(16.dp)) - } - - // Note - 完整显示 - if (checkin.note.isNotEmpty()) { - Text( - text = checkin.note, - fontSize = 16.sp, - color = Slate700, - lineHeight = 24.sp - ) - } else if (checkin.imageUrl.isEmpty()) { - Box( - modifier = Modifier.fillMaxWidth().height(120.dp).clip(RoundedCornerShape(16.dp)).background(Slate50), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon(Icons.Default.Check, contentDescription = null, tint = SuccessGreen, modifier = Modifier.size(40.dp)) - Spacer(modifier = Modifier.height(8.dp)) - Text(text = "已完成打卡", fontSize = 16.sp, color = Slate500, fontWeight = FontWeight.Medium) + // 运动信息卡片 + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Slate50) + .padding(20.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.size(48.dp).clip(CircleShape).background(Brand500), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text(text = typeText, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Slate900) + if (detailText.isNotEmpty()) { + Text(text = detailText, fontSize = 14.sp, color = Slate500) + } } } } - } - } - // 全屏图片查看 - if (showFullscreenImage && checkin.imageUrl.isNotEmpty()) { - FullscreenImageDialog( - imageUrl = checkin.imageUrl, - onDismiss = { showFullscreenImage = false } - ) + // 备注 + if (checkin.note.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Slate50) + .padding(16.dp) + ) { + Text(text = "备注", fontSize = 12.sp, color = Slate500, fontWeight = FontWeight.Medium) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = checkin.note, + fontSize = 15.sp, + color = Slate700, + lineHeight = 22.sp + ) + } + } + } } // Delete confirmation @@ -617,86 +636,18 @@ private fun CheckinDetailDialog( } } -@Composable -private fun FullscreenImageDialog( - imageUrl: String, - onDismiss: () -> Unit -) { - androidx.compose.ui.window.Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - .clickable { onDismiss() }, - contentAlignment = Alignment.Center - ) { - AsyncImage( - model = imageUrl, - contentDescription = null, - modifier = Modifier.fillMaxWidth(), - contentScale = ContentScale.Fit - ) - } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun CheckinDialog( isLoading: Boolean, onDismiss: () -> Unit, - onCheckin: (imageUrl: String, note: String) -> Unit + onCheckin: (exerciseType: String, bodyPart: String, duration: Int, note: String) -> Unit ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - - var selectedImageUri by remember { mutableStateOf(null) } - var uploadedImageUrl by remember { mutableStateOf("") } + var exerciseType by remember { mutableStateOf("aerobic") } // "aerobic" 或 "anaerobic" + var bodyPart by remember { mutableStateOf("") } + var duration by remember { mutableStateOf("40") } var note by remember { mutableStateOf("") } - var isUploading by remember { mutableStateOf(false) } - var uploadError by remember { mutableStateOf(null) } - - val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri -> - uri?.let { - selectedImageUri = it - uploadedImageUrl = "" - uploadError = null - scope.launch { - isUploading = true - try { - val file = withContext(Dispatchers.IO) { - val inputStream = context.contentResolver.openInputStream(uri) - val tempFile = File.createTempFile("upload", ".jpg", context.cacheDir) - tempFile.outputStream().use { output -> inputStream?.copyTo(output) } - inputStream?.close() - tempFile - } - val requestBody = file.asRequestBody("image/*".toMediaTypeOrNull()) - val part = MultipartBody.Part.createFormData("file", file.name, requestBody) - val response = ApiClient.api.upload(part) - if (response.isSuccessful) { - uploadedImageUrl = response.body()?.url ?: "" - if (uploadedImageUrl.isEmpty()) { - uploadError = "上传失败" - } - } else { - uploadError = "上传失败: ${response.code()}" - } - file.delete() - } catch (e: Exception) { - e.printStackTrace() - uploadError = "上传失败: ${e.message}" - } - isUploading = false - } - } - } ModalBottomSheet( onDismissRequest = onDismiss, @@ -711,74 +662,63 @@ private fun CheckinDialog( ) { Text(text = "运动打卡", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Slate900, modifier = Modifier.padding(bottom = 20.dp)) - // Image upload - Box( - modifier = Modifier.fillMaxWidth().height(180.dp).clip(RoundedCornerShape(16.dp)).background(Slate50) - .border(2.dp, when { - uploadError != null -> ErrorRed - uploadedImageUrl.isNotEmpty() -> SuccessGreen - selectedImageUri != null -> Brand500 - else -> Slate200 - }, RoundedCornerShape(16.dp)) - .clickable { imagePickerLauncher.launch("image/*") }, - contentAlignment = Alignment.Center + // 运动类型选择 + Text(text = "运动类型", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = Slate700, modifier = Modifier.padding(bottom = 12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - if (selectedImageUri != null) { - AsyncImage( - model = selectedImageUri, - contentDescription = null, - modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp)), - contentScale = ContentScale.Crop - ) - if (isUploading) { - Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = Color.White, modifier = Modifier.size(40.dp), strokeWidth = 3.dp) - Spacer(modifier = Modifier.height(8.dp)) - Text("上传中...", color = Color.White, fontSize = 12.sp) - } - } - } else if (uploadedImageUrl.isNotEmpty()) { - // 上传成功标记 - Box( - modifier = Modifier.align(Alignment.TopStart).padding(8.dp).clip(RoundedCornerShape(4.dp)) - .background(SuccessGreen).padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text("已上传", color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.Medium) - } - } - if (!isUploading) { - Box( - modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).size(32.dp).clip(CircleShape) - .background(Color.Black.copy(alpha = 0.5f)).clickable { imagePickerLauncher.launch("image/*") }, - contentAlignment = Alignment.Center - ) { - Icon(Icons.Default.Add, contentDescription = "更换", tint = Color.White, modifier = Modifier.size(18.dp)) - } - } - } else { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box(modifier = Modifier.size(48.dp).clip(CircleShape).background(Slate100), contentAlignment = Alignment.Center) { - Icon(Icons.Default.Add, contentDescription = null, tint = Slate400, modifier = Modifier.size(24.dp)) - } - Spacer(modifier = Modifier.height(8.dp)) - Text(text = "上传运动照片", color = Slate500, fontSize = 14.sp, fontWeight = FontWeight.Medium) - } - } - } - - // 上传错误提示 - if (uploadError != null) { - Text( - text = uploadError!!, - color = ErrorRed, - fontSize = 12.sp, - modifier = Modifier.padding(top = 4.dp) + TypeButton( + text = "有氧运动", + selected = exerciseType == "aerobic", + onClick = { exerciseType = "aerobic"; bodyPart = "" }, + modifier = Modifier.weight(1f) + ) + TypeButton( + text = "无氧运动", + selected = exerciseType == "anaerobic", + onClick = { exerciseType = "anaerobic"; duration = "" }, + modifier = Modifier.weight(1f) ) } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) + // 有氧运动 - 时长输入 + if (exerciseType == "aerobic") { + Text(text = "运动时长", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = Slate700, modifier = Modifier.padding(bottom = 12.dp)) + OutlinedTextField( + value = duration, + onValueChange = { if (it.all { char -> char.isDigit() }) duration = it }, + placeholder = { Text("输入运动时长(分钟)", color = Slate400, fontSize = 15.sp) }, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Brand500, unfocusedBorderColor = Slate200, + focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent + ), + shape = RoundedCornerShape(12.dp), + singleLine = true, + suffix = { Text("分钟", color = Slate500, fontSize = 14.sp) } + ) + } + + // 无氧运动 - 部位选择 + if (exerciseType == "anaerobic") { + Text(text = "训练部位", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = Slate700, modifier = Modifier.padding(bottom = 12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + BodyPartButton("腿", "leg", bodyPart) { bodyPart = it } + BodyPartButton("胸", "chest", bodyPart) { bodyPart = it } + BodyPartButton("背", "back", bodyPart) { bodyPart = it } + BodyPartButton("腹", "abs", bodyPart) { bodyPart = it } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // 备注 OutlinedTextField( value = note, onValueChange = { note = it }, @@ -794,14 +734,24 @@ private fun CheckinDialog( Spacer(modifier = Modifier.height(20.dp)) + // 打卡按钮 + val canSubmit = when (exerciseType) { + "aerobic" -> duration.isNotEmpty() && duration.toIntOrNull() != null && duration.toInt() > 0 + "anaerobic" -> bodyPart.isNotEmpty() + else -> false + } + Button( - onClick = { onCheckin(uploadedImageUrl, note) }, - enabled = !isLoading && !isUploading, + onClick = { + val durationInt = if (exerciseType == "aerobic") duration.toIntOrNull() ?: 0 else 0 + onCheckin(exerciseType, bodyPart, durationInt, note) + }, + enabled = !isLoading && canSubmit, modifier = Modifier.fillMaxWidth().height(52.dp), colors = ButtonDefaults.buttonColors(containerColor = Slate900, disabledContainerColor = Slate300), shape = RoundedCornerShape(12.dp) ) { - if (isLoading || isUploading) { + if (isLoading) { CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp), strokeWidth = 2.dp) } else { Text(text = "完成打卡", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) @@ -810,3 +760,43 @@ private fun CheckinDialog( } } } + +@Composable +private fun TypeButton( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Button( + onClick = onClick, + modifier = modifier.height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (selected) Brand500 else Slate100, + contentColor = if (selected) Color.White else Slate600 + ), + shape = RoundedCornerShape(12.dp) + ) { + Text(text = text, fontSize = 15.sp, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun RowScope.BodyPartButton( + text: String, + value: String, + selected: String, + onSelect: (String) -> Unit +) { + Button( + onClick = { onSelect(value) }, + modifier = Modifier.weight(1f).height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (selected == value) Brand500 else Slate100, + contentColor = if (selected == value) Color.White else Slate600 + ), + shape = RoundedCornerShape(12.dp) + ) { + Text(text = text, fontSize = 15.sp, fontWeight = FontWeight.Medium) + } +} diff --git a/android/app/src/main/java/com/healthflow/app/ui/viewmodel/ExerciseViewModel.kt b/android/app/src/main/java/com/healthflow/app/ui/viewmodel/ExerciseViewModel.kt index f16217a..f3999a3 100644 --- a/android/app/src/main/java/com/healthflow/app/ui/viewmodel/ExerciseViewModel.kt +++ b/android/app/src/main/java/com/healthflow/app/ui/viewmodel/ExerciseViewModel.kt @@ -96,7 +96,7 @@ class ExerciseViewModel : ViewModel() { _todayCheckedIn.value = _heatmapData.value[today]?.let { it > 0 } ?: false } - fun checkin(imageUrl: String, note: String, onSuccess: () -> Unit) { + fun checkin(exerciseType: String, bodyPart: String, duration: Int, note: String, onSuccess: () -> Unit) { viewModelScope.launch { _isCheckinLoading.value = true _error.value = null @@ -104,7 +104,9 @@ class ExerciseViewModel : ViewModel() { val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) val request = CreateExerciseCheckinRequest( checkinDate = today, - imageUrl = imageUrl, + exerciseType = exerciseType, + bodyPart = bodyPart, + duration = duration, note = note ) val response = ApiClient.api.createExerciseCheckin(request) diff --git a/server/internal/database/database.go b/server/internal/database/database.go index 2c17ce2..a5f3daf 100644 --- a/server/internal/database/database.go +++ b/server/internal/database/database.go @@ -123,7 +123,9 @@ func migrate(db *sql.DB) error { id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, checkin_date DATE NOT NULL, - image_url TEXT DEFAULT '', + exercise_type TEXT NOT NULL, + body_part TEXT DEFAULT '', + duration INTEGER DEFAULT 0, note TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) @@ -153,21 +155,22 @@ func migrate(db *sql.DB) error { return nil } -// migrateExerciseCheckins 移除 exercise_checkins 表的 UNIQUE 约束 +// migrateExerciseCheckins 移除 exercise_checkins 表的 UNIQUE 约束并更新表结构 func migrateExerciseCheckins(db *sql.DB) { - // 检查表是否有 UNIQUE 约束 + // 检查表结构 var tableSql string err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='exercise_checkins'").Scan(&tableSql) if err != nil { return } - // 如果表定义中包含 UNIQUE,需要重建表 - if !strings.Contains(tableSql, "UNIQUE") { + // 如果表定义中包含 image_url 或 UNIQUE,需要重建表 + needsMigration := strings.Contains(tableSql, "image_url") || strings.Contains(tableSql, "UNIQUE") + if !needsMigration { return } - // 重建表以移除 UNIQUE 约束 + // 重建表以更新结构 tx, err := db.Begin() if err != nil { return @@ -180,13 +183,15 @@ func migrateExerciseCheckins(db *sql.DB) { return } - // 2. 创建新表(无 UNIQUE 约束) + // 2. 创建新表(新结构) _, err = tx.Exec(` CREATE TABLE exercise_checkins ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, checkin_date DATE NOT NULL, - image_url TEXT DEFAULT '', + exercise_type TEXT NOT NULL DEFAULT 'aerobic', + body_part TEXT DEFAULT '', + duration INTEGER DEFAULT 0, note TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) @@ -196,10 +201,10 @@ func migrateExerciseCheckins(db *sql.DB) { return } - // 3. 复制数据 + // 3. 复制数据(旧数据默认为有氧运动) _, err = tx.Exec(` - INSERT INTO exercise_checkins (id, user_id, checkin_date, image_url, note, created_at) - SELECT id, user_id, checkin_date, image_url, note, created_at FROM exercise_checkins_old + INSERT INTO exercise_checkins (id, user_id, checkin_date, exercise_type, note, created_at) + SELECT id, user_id, checkin_date, 'aerobic', note, created_at FROM exercise_checkins_old `) if err != nil { return diff --git a/server/internal/handler/epoch.go b/server/internal/handler/epoch.go index 3e2b354..655d657 100644 --- a/server/internal/handler/epoch.go +++ b/server/internal/handler/epoch.go @@ -414,20 +414,42 @@ func (h *EpochHandler) calculateEpochDetail(epoch *model.WeightEpoch) *model.Epo detail.EpochTotalLoss = &epochLoss } - // 计算本年度总减重(所有今年纪元的减重总和) - var yearLoss float64 - h.db.QueryRow(` - SELECT COALESCE(SUM(e.initial_weight - COALESCE( - (SELECT wp.final_weight FROM weekly_plans wp - WHERE wp.epoch_id = e.id AND wp.final_weight IS NOT NULL - ORDER BY wp.year DESC, wp.week DESC LIMIT 1), - e.initial_weight - )), 0) - FROM weight_epochs e - WHERE e.user_id = ? AND strftime('%Y', e.start_date) = ? - `, epoch.UserID, strconv.Itoa(now.Year())).Scan(&yearLoss) - if yearLoss != 0 { - detail.YearTotalLoss = &yearLoss + // 计算本年度总减重 + // 策略:找到年初的体重(今年第一周或去年最后一周)- 今年最新体重 + var yearStartWeight, yearLatestWeight sql.NullFloat64 + + // 1. 先获取今年最新体重 + err = h.db.QueryRow(` + SELECT final_weight + FROM weekly_plans + WHERE user_id = ? AND year = ? AND final_weight IS NOT NULL + ORDER BY week DESC LIMIT 1 + `, epoch.UserID, now.Year()).Scan(&yearLatestWeight) + + if err == nil && yearLatestWeight.Valid { + // 2. 获取年初体重:优先用今年第一周,如果没有则用去年最后一周 + err = h.db.QueryRow(` + SELECT final_weight + FROM weekly_plans + WHERE user_id = ? AND year = ? AND final_weight IS NOT NULL + ORDER BY week ASC LIMIT 1 + `, epoch.UserID, now.Year()).Scan(&yearStartWeight) + + // 如果今年第一周没有,用去年最后一周 + if err != nil || !yearStartWeight.Valid { + h.db.QueryRow(` + SELECT final_weight + FROM weekly_plans + WHERE user_id = ? AND year = ? AND final_weight IS NOT NULL + ORDER BY week DESC LIMIT 1 + `, epoch.UserID, now.Year()-1).Scan(&yearStartWeight) + } + + // 如果有年初体重,计算减重 + if yearStartWeight.Valid && yearStartWeight.Float64 > 0 { + yearLoss := yearStartWeight.Float64 - yearLatestWeight.Float64 + detail.YearTotalLoss = &yearLoss + } } // 计算累计总减重(所有纪元的减重总和) diff --git a/server/internal/handler/exercise.go b/server/internal/handler/exercise.go index cb98414..71f11a2 100644 --- a/server/internal/handler/exercise.go +++ b/server/internal/handler/exercise.go @@ -33,9 +33,9 @@ func (h *ExerciseHandler) CreateCheckin(c *gin.Context) { // 每次都创建新记录,支持一天多次打卡 result, err := h.db.Exec(` - INSERT INTO exercise_checkins (user_id, checkin_date, image_url, note) - VALUES (?, ?, ?, ?) - `, userID, req.CheckinDate, req.ImageURL, req.Note) + INSERT INTO exercise_checkins (user_id, checkin_date, exercise_type, body_part, duration, note) + VALUES (?, ?, ?, ?, ?, ?) + `, userID, req.CheckinDate, req.ExerciseType, req.BodyPart, req.Duration, req.Note) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "打卡失败", "detail": err.Error()}) return @@ -50,10 +50,10 @@ func (h *ExerciseHandler) GetCheckins(c *gin.Context) { userID := c.GetInt64("user_id") query := ` - SELECT id, user_id, checkin_date, image_url, note, created_at + SELECT id, user_id, checkin_date, exercise_type, body_part, duration, note, created_at FROM exercise_checkins WHERE user_id = ? - ORDER BY checkin_date DESC + ORDER BY checkin_date DESC, created_at DESC LIMIT 50 ` @@ -68,7 +68,7 @@ func (h *ExerciseHandler) GetCheckins(c *gin.Context) { for rows.Next() { var checkin model.ExerciseCheckin if err := rows.Scan(&checkin.ID, &checkin.UserID, &checkin.CheckinDate, - &checkin.ImageURL, &checkin.Note, &checkin.CreatedAt); err != nil { + &checkin.ExerciseType, &checkin.BodyPart, &checkin.Duration, &checkin.Note, &checkin.CreatedAt); err != nil { continue } checkins = append(checkins, checkin) diff --git a/server/internal/model/model.go b/server/internal/model/model.go index 7d6aedf..31c6066 100644 --- a/server/internal/model/model.go +++ b/server/internal/model/model.go @@ -179,18 +179,22 @@ type RecordWeightRequest struct { // 运动打卡 type ExerciseCheckin struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - CheckinDate string `json:"checkin_date"` - ImageURL string `json:"image_url"` - Note string `json:"note"` - CreatedAt string `json:"created_at"` + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + CheckinDate string `json:"checkin_date"` + ExerciseType string `json:"exercise_type"` // "aerobic" 或 "anaerobic" + BodyPart string `json:"body_part"` // 无氧运动部位: "leg", "chest", "back", "abs" + Duration int `json:"duration"` // 有氧运动时长(分钟) + Note string `json:"note"` + CreatedAt string `json:"created_at"` } type CreateExerciseCheckinRequest struct { - CheckinDate string `json:"checkin_date" binding:"required"` - ImageURL string `json:"image_url"` - Note string `json:"note"` + CheckinDate string `json:"checkin_date" binding:"required"` + ExerciseType string `json:"exercise_type" binding:"required"` + BodyPart string `json:"body_part"` + Duration int `json:"duration"` + Note string `json:"note"` } type ExerciseHeatmapData struct {