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