运动打卡功能优化,不再上传照片
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 = ""
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Uri?>(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<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(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// 计算累计总减重(所有纪元的减重总和)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user