运动打卡功能优化,不再上传照片

This commit is contained in:
amos wong
2026-01-17 18:21:40 +08:00
parent a5cc393add
commit 898aa65f44
9 changed files with 296 additions and 269 deletions

View File

@@ -13,11 +13,11 @@ android {
applicationId = "com.healthflow.app" applicationId = "com.healthflow.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 114 versionCode = 116
versionName = "3.1.4" versionName = "3.1.6"
buildConfigField("String", "API_BASE_URL", "\"https://health.amos.us.kg/api/\"") buildConfigField("String", "API_BASE_URL", "\"https://health.amos.us.kg/api/\"")
buildConfigField("int", "VERSION_CODE", "114") buildConfigField("int", "VERSION_CODE", "116")
} }
signingConfigs { signingConfigs {

View File

@@ -218,7 +218,9 @@ data class ExerciseCheckin(
val id: Long = 0, val id: Long = 0,
@SerialName("user_id") val userId: Long = 0, @SerialName("user_id") val userId: Long = 0,
@SerialName("checkin_date") val checkinDate: String, @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 = "", val note: String = "",
@SerialName("created_at") val createdAt: String = "" @SerialName("created_at") val createdAt: String = ""
) )
@@ -226,7 +228,9 @@ data class ExerciseCheckin(
@Serializable @Serializable
data class CreateExerciseCheckinRequest( data class CreateExerciseCheckinRequest(
@SerialName("checkin_date") val checkinDate: String, @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 = "" val note: String = ""
) )

View File

@@ -26,9 +26,9 @@ import com.healthflow.app.ui.theme.*
import com.healthflow.app.ui.viewmodel.EpochViewModel import com.healthflow.app.ui.viewmodel.EpochViewModel
sealed class Tab(val route: String, val label: String) { sealed class Tab(val route: String, val label: String) {
data object Exercise : Tab("tab_exercise", "运动")
data object Epoch : Tab("tab_epoch", "纪元") data object Epoch : Tab("tab_epoch", "纪元")
data object Plan : Tab("tab_plan", "计划") data object Plan : Tab("tab_plan", "计划")
data object Exercise : Tab("tab_exercise", "运动")
data object Profile : Tab("tab_profile", "我的") data object Profile : Tab("tab_profile", "我的")
} }
@@ -42,7 +42,7 @@ object Routes {
fun weekPlanDetail(epochId: Long, planId: Long) = "week_plan_detail/$epochId/$planId" 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 @Composable
fun MainNavigation( fun MainNavigation(
@@ -76,11 +76,23 @@ fun MainNavigation(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Tab.Epoch.route, startDestination = Tab.Exercise.route,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(bottom = if (showBottomNav) 56.dp else 0.dp) .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) { composable(Tab.Epoch.route) {
EpochScreen( EpochScreen(
epochs = epochList, 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) { composable(Routes.CREATE_EPOCH) {
CreateEpochScreen( CreateEpochScreen(
isLoading = isLoading, isLoading = isLoading,

View File

@@ -1,8 +1,5 @@
package com.healthflow.app.ui.screen 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -27,26 +24,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel 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.data.model.ExerciseCheckin
import com.healthflow.app.ui.theme.* import com.healthflow.app.ui.theme.*
import com.healthflow.app.ui.viewmodel.ExerciseViewModel 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.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.TextStyle import java.time.format.TextStyle
@@ -245,8 +230,8 @@ fun ExerciseScreen(
CheckinDialog( CheckinDialog(
isLoading = isCheckinLoading, isLoading = isCheckinLoading,
onDismiss = { showCheckinDialog = false }, onDismiss = { showCheckinDialog = false },
onCheckin = { imageUrl, note -> onCheckin = { exerciseType, bodyPart, duration, note ->
viewModel.checkin(imageUrl, note) { viewModel.checkin(exerciseType, bodyPart, duration, note) {
showCheckinDialog = false showCheckinDialog = false
} }
} }
@@ -468,6 +453,22 @@ private fun getHeatmapColor(level: Int): Color = when (level) {
private fun CheckinItem(checkin: ExerciseCheckin, onClick: () -> Unit) { private fun CheckinItem(checkin: ExerciseCheckin, onClick: () -> Unit) {
val date = try { LocalDate.parse(checkin.checkinDate.take(10)) } catch (e: Exception) { LocalDate.now() } val date = try { LocalDate.parse(checkin.checkinDate.take(10)) } catch (e: Exception) { LocalDate.now() }
val dayOfWeek = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.CHINESE) 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( Row(
modifier = Modifier.fillMaxWidth().clickable { onClick() }.padding(vertical = 8.dp), 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), modifier = Modifier.weight(1f).clip(RoundedCornerShape(12.dp)).background(Slate50).padding(12.dp),
verticalAlignment = Alignment.CenterVertically 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)) { Column(modifier = Modifier.weight(1f)) {
Text( 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 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( Box(
modifier = Modifier.size(24.dp).clip(CircleShape).background(SuccessGreen), 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() } val date = try { LocalDate.parse(checkin.checkinDate.take(10)) } catch (e: Exception) { LocalDate.now() }
var showDeleteConfirm by remember { mutableStateOf(false) } 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( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -551,50 +563,57 @@ private fun CheckinDetailDialog(
} }
} }
// Image - 点击可全屏查看 // 运动信息卡片
if (checkin.imageUrl.isNotEmpty()) { Column(
AsyncImage( modifier = Modifier
model = checkin.imageUrl, .fillMaxWidth()
contentDescription = null, .clip(RoundedCornerShape(16.dp))
modifier = Modifier .background(Slate50)
.fillMaxWidth() .padding(20.dp)
.height(280.dp) ) {
.clip(RoundedCornerShape(16.dp)) Row(verticalAlignment = Alignment.CenterVertically) {
.clickable { showFullscreenImage = true }, Box(
contentScale = ContentScale.Crop modifier = Modifier.size(48.dp).clip(CircleShape).background(Brand500),
) contentAlignment = Alignment.Center
Spacer(modifier = Modifier.height(16.dp)) ) {
} Icon(
Icons.Default.Check,
// Note - 完整显示 contentDescription = null,
if (checkin.note.isNotEmpty()) { tint = Color.White,
Text( modifier = Modifier.size(24.dp)
text = checkin.note, )
fontSize = 16.sp, }
color = Slate700, Spacer(modifier = Modifier.width(16.dp))
lineHeight = 24.sp Column {
) Text(text = typeText, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Slate900)
} else if (checkin.imageUrl.isEmpty()) { if (detailText.isNotEmpty()) {
Box( Text(text = detailText, fontSize = 14.sp, color = Slate500)
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)
} }
} }
} }
}
}
// 全屏图片查看 // 备注
if (showFullscreenImage && checkin.imageUrl.isNotEmpty()) { if (checkin.note.isNotEmpty()) {
FullscreenImageDialog( Spacer(modifier = Modifier.height(16.dp))
imageUrl = checkin.imageUrl, Column(
onDismiss = { showFullscreenImage = false } 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 // 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun CheckinDialog( private fun CheckinDialog(
isLoading: Boolean, isLoading: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onCheckin: (imageUrl: String, note: String) -> Unit onCheckin: (exerciseType: String, bodyPart: String, duration: Int, note: String) -> Unit
) { ) {
val context = LocalContext.current var exerciseType by remember { mutableStateOf("aerobic") } // "aerobic" 或 "anaerobic"
val scope = rememberCoroutineScope() var bodyPart by remember { mutableStateOf("") }
var duration by remember { mutableStateOf("40") }
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
var uploadedImageUrl by remember { mutableStateOf("") }
var note by remember { mutableStateOf("") } var note by remember { mutableStateOf("") }
var isUploading by remember { mutableStateOf(false) }
var uploadError by remember { mutableStateOf<String?>(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( ModalBottomSheet(
onDismissRequest = onDismiss, 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)) Text(text = "运动打卡", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Slate900, modifier = Modifier.padding(bottom = 20.dp))
// Image upload // 运动类型选择
Box( Text(text = "运动类型", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = Slate700, modifier = Modifier.padding(bottom = 12.dp))
modifier = Modifier.fillMaxWidth().height(180.dp).clip(RoundedCornerShape(16.dp)).background(Slate50) Row(
.border(2.dp, when { modifier = Modifier.fillMaxWidth(),
uploadError != null -> ErrorRed horizontalArrangement = Arrangement.spacedBy(12.dp)
uploadedImageUrl.isNotEmpty() -> SuccessGreen
selectedImageUri != null -> Brand500
else -> Slate200
}, RoundedCornerShape(16.dp))
.clickable { imagePickerLauncher.launch("image/*") },
contentAlignment = Alignment.Center
) { ) {
if (selectedImageUri != null) { TypeButton(
AsyncImage( text = "有氧运动",
model = selectedImageUri, selected = exerciseType == "aerobic",
contentDescription = null, onClick = { exerciseType = "aerobic"; bodyPart = "" },
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp)), modifier = Modifier.weight(1f)
contentScale = ContentScale.Crop )
) TypeButton(
if (isUploading) { text = "无氧运动",
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) { selected = exerciseType == "anaerobic",
Column(horizontalAlignment = Alignment.CenterHorizontally) { onClick = { exerciseType = "anaerobic"; duration = "" },
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(40.dp), strokeWidth = 3.dp) modifier = Modifier.weight(1f)
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)
) )
} }
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( OutlinedTextField(
value = note, value = note,
onValueChange = { note = it }, onValueChange = { note = it },
@@ -794,14 +734,24 @@ private fun CheckinDialog(
Spacer(modifier = Modifier.height(20.dp)) 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( Button(
onClick = { onCheckin(uploadedImageUrl, note) }, onClick = {
enabled = !isLoading && !isUploading, val durationInt = if (exerciseType == "aerobic") duration.toIntOrNull() ?: 0 else 0
onCheckin(exerciseType, bodyPart, durationInt, note)
},
enabled = !isLoading && canSubmit,
modifier = Modifier.fillMaxWidth().height(52.dp), modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.buttonColors(containerColor = Slate900, disabledContainerColor = Slate300), colors = ButtonDefaults.buttonColors(containerColor = Slate900, disabledContainerColor = Slate300),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
if (isLoading || isUploading) { if (isLoading) {
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp), strokeWidth = 2.dp) CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else { } else {
Text(text = "完成打卡", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) 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)
}
}

View File

@@ -96,7 +96,7 @@ class ExerciseViewModel : ViewModel() {
_todayCheckedIn.value = _heatmapData.value[today]?.let { it > 0 } ?: false _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 { viewModelScope.launch {
_isCheckinLoading.value = true _isCheckinLoading.value = true
_error.value = null _error.value = null
@@ -104,7 +104,9 @@ class ExerciseViewModel : ViewModel() {
val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
val request = CreateExerciseCheckinRequest( val request = CreateExerciseCheckinRequest(
checkinDate = today, checkinDate = today,
imageUrl = imageUrl, exerciseType = exerciseType,
bodyPart = bodyPart,
duration = duration,
note = note note = note
) )
val response = ApiClient.api.createExerciseCheckin(request) val response = ApiClient.api.createExerciseCheckin(request)

View File

@@ -123,7 +123,9 @@ func migrate(db *sql.DB) error {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
checkin_date DATE 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 '', note TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
@@ -153,21 +155,22 @@ func migrate(db *sql.DB) error {
return nil return nil
} }
// migrateExerciseCheckins 移除 exercise_checkins 表的 UNIQUE 约束 // migrateExerciseCheckins 移除 exercise_checkins 表的 UNIQUE 约束并更新表结构
func migrateExerciseCheckins(db *sql.DB) { func migrateExerciseCheckins(db *sql.DB) {
// 检查表是否有 UNIQUE 约束 // 检查表结构
var tableSql string var tableSql string
err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='exercise_checkins'").Scan(&tableSql) err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='exercise_checkins'").Scan(&tableSql)
if err != nil { if err != nil {
return return
} }
// 如果表定义中包含 UNIQUE需要重建表 // 如果表定义中包含 image_url 或 UNIQUE需要重建表
if !strings.Contains(tableSql, "UNIQUE") { needsMigration := strings.Contains(tableSql, "image_url") || strings.Contains(tableSql, "UNIQUE")
if !needsMigration {
return return
} }
// 重建表以移除 UNIQUE 约束 // 重建表以更新结构
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
return return
@@ -180,13 +183,15 @@ func migrateExerciseCheckins(db *sql.DB) {
return return
} }
// 2. 创建新表(无 UNIQUE 约束 // 2. 创建新表(新结构
_, err = tx.Exec(` _, err = tx.Exec(`
CREATE TABLE exercise_checkins ( CREATE TABLE exercise_checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
checkin_date DATE 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 '', note TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
@@ -196,10 +201,10 @@ func migrateExerciseCheckins(db *sql.DB) {
return return
} }
// 3. 复制数据 // 3. 复制数据(旧数据默认为有氧运动)
_, err = tx.Exec(` _, err = tx.Exec(`
INSERT INTO exercise_checkins (id, user_id, checkin_date, image_url, note, created_at) INSERT INTO exercise_checkins (id, user_id, checkin_date, exercise_type, note, created_at)
SELECT id, user_id, checkin_date, image_url, note, created_at FROM exercise_checkins_old SELECT id, user_id, checkin_date, 'aerobic', note, created_at FROM exercise_checkins_old
`) `)
if err != nil { if err != nil {
return return

View File

@@ -414,20 +414,42 @@ func (h *EpochHandler) calculateEpochDetail(epoch *model.WeightEpoch) *model.Epo
detail.EpochTotalLoss = &epochLoss detail.EpochTotalLoss = &epochLoss
} }
// 计算本年度总减重(所有今年纪元的减重总和) // 计算本年度总减重
var yearLoss float64 // 策略:找到年初的体重(今年第一周或去年最后一周)- 今年最新体重
h.db.QueryRow(` var yearStartWeight, yearLatestWeight sql.NullFloat64
SELECT COALESCE(SUM(e.initial_weight - COALESCE(
(SELECT wp.final_weight FROM weekly_plans wp // 1. 先获取今年最新体重
WHERE wp.epoch_id = e.id AND wp.final_weight IS NOT NULL err = h.db.QueryRow(`
ORDER BY wp.year DESC, wp.week DESC LIMIT 1), SELECT final_weight
e.initial_weight FROM weekly_plans
)), 0) WHERE user_id = ? AND year = ? AND final_weight IS NOT NULL
FROM weight_epochs e ORDER BY week DESC LIMIT 1
WHERE e.user_id = ? AND strftime('%Y', e.start_date) = ? `, epoch.UserID, now.Year()).Scan(&yearLatestWeight)
`, epoch.UserID, strconv.Itoa(now.Year())).Scan(&yearLoss)
if yearLoss != 0 { if err == nil && yearLatestWeight.Valid {
detail.YearTotalLoss = &yearLoss // 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
}
} }
// 计算累计总减重(所有纪元的减重总和) // 计算累计总减重(所有纪元的减重总和)

View File

@@ -33,9 +33,9 @@ func (h *ExerciseHandler) CreateCheckin(c *gin.Context) {
// 每次都创建新记录,支持一天多次打卡 // 每次都创建新记录,支持一天多次打卡
result, err := h.db.Exec(` result, err := h.db.Exec(`
INSERT INTO exercise_checkins (user_id, checkin_date, image_url, note) INSERT INTO exercise_checkins (user_id, checkin_date, exercise_type, body_part, duration, note)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`, userID, req.CheckinDate, req.ImageURL, req.Note) `, userID, req.CheckinDate, req.ExerciseType, req.BodyPart, req.Duration, req.Note)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "打卡失败", "detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "打卡失败", "detail": err.Error()})
return return
@@ -50,10 +50,10 @@ func (h *ExerciseHandler) GetCheckins(c *gin.Context) {
userID := c.GetInt64("user_id") userID := c.GetInt64("user_id")
query := ` 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 FROM exercise_checkins
WHERE user_id = ? WHERE user_id = ?
ORDER BY checkin_date DESC ORDER BY checkin_date DESC, created_at DESC
LIMIT 50 LIMIT 50
` `
@@ -68,7 +68,7 @@ func (h *ExerciseHandler) GetCheckins(c *gin.Context) {
for rows.Next() { for rows.Next() {
var checkin model.ExerciseCheckin var checkin model.ExerciseCheckin
if err := rows.Scan(&checkin.ID, &checkin.UserID, &checkin.CheckinDate, 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 continue
} }
checkins = append(checkins, checkin) checkins = append(checkins, checkin)

View File

@@ -179,18 +179,22 @@ type RecordWeightRequest struct {
// 运动打卡 // 运动打卡
type ExerciseCheckin struct { type ExerciseCheckin struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
CheckinDate string `json:"checkin_date"` CheckinDate string `json:"checkin_date"`
ImageURL string `json:"image_url"` ExerciseType string `json:"exercise_type"` // "aerobic" 或 "anaerobic"
Note string `json:"note"` BodyPart string `json:"body_part"` // 无氧运动部位: "leg", "chest", "back", "abs"
CreatedAt string `json:"created_at"` Duration int `json:"duration"` // 有氧运动时长(分钟)
Note string `json:"note"`
CreatedAt string `json:"created_at"`
} }
type CreateExerciseCheckinRequest struct { type CreateExerciseCheckinRequest struct {
CheckinDate string `json:"checkin_date" binding:"required"` CheckinDate string `json:"checkin_date" binding:"required"`
ImageURL string `json:"image_url"` ExerciseType string `json:"exercise_type" binding:"required"`
Note string `json:"note"` BodyPart string `json:"body_part"`
Duration int `json:"duration"`
Note string `json:"note"`
} }
type ExerciseHeatmapData struct { type ExerciseHeatmapData struct {