增加编辑和删除纪元的功能

This commit is contained in:
amos wong
2025-12-27 14:21:45 +08:00
parent e4222f226c
commit 4f0529b636
9 changed files with 429 additions and 113 deletions

View File

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

View File

@@ -36,6 +36,15 @@ interface ApiService {
@GET("epoch/{id}")
suspend fun getEpochDetail(@Path("id") id: Long): Response<EpochDetail>
@PUT("epoch/{id}")
suspend fun updateEpoch(
@Path("id") id: Long,
@Body request: UpdateEpochRequest
): Response<MessageResponse>
@DELETE("epoch/{id}")
suspend fun deleteEpoch(@Path("id") id: Long): Response<MessageResponse>
@GET("epoch/{id}/plans")
suspend fun getWeeklyPlans(@Path("id") epochId: Long): Response<List<WeeklyPlanDetail>>

View File

@@ -95,6 +95,15 @@ data class CreateEpochRequest(
@SerialName("end_date") val endDate: String
)
@Serializable
data class UpdateEpochRequest(
val name: String? = null,
@SerialName("initial_weight") val initialWeight: Double? = null,
@SerialName("target_weight") val targetWeight: Double? = null,
@SerialName("start_date") val startDate: String? = null,
@SerialName("end_date") val endDate: String? = null
)
@Serializable
data class EpochDetail(
val epoch: WeightEpoch,

View File

@@ -149,6 +149,16 @@ fun MainNavigation(
activeEpoch?.let { epoch ->
navController.navigate(Routes.weekPlanDetail(epoch.id, planDetail.plan.id))
}
},
onEditEpoch = { epochId, name, initial, target, start, end ->
epochViewModel.updateEpoch(epochId, name, initial, target, start, end) {
// 更新成功后刷新数据
}
},
onDeleteEpoch = { epochId ->
epochViewModel.deleteEpoch(epochId) {
navController.popBackStack()
}
}
)
}

View File

@@ -1,15 +1,19 @@
package com.healthflow.app.ui.screen
import android.app.DatePickerDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -17,7 +21,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.healthflow.app.data.model.EpochDetail
@@ -26,7 +32,7 @@ import com.healthflow.app.data.model.WeightEpoch
import com.healthflow.app.ui.theme.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.*
@Composable
fun EpochDetailScreen(
@@ -34,8 +40,13 @@ fun EpochDetailScreen(
epochDetail: EpochDetail?,
weeklyPlans: List<WeeklyPlanDetail>,
onBack: () -> Unit,
onWeekClick: (WeeklyPlanDetail) -> Unit
onWeekClick: (WeeklyPlanDetail) -> Unit,
onEditEpoch: (Long, String?, Double?, Double?, String?, String?) -> Unit = { _, _, _, _, _, _ -> },
onDeleteEpoch: (Long) -> Unit = {}
) {
var showEditDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
if (epoch == null) {
Box(
modifier = Modifier
@@ -48,7 +59,6 @@ fun EpochDetailScreen(
return
}
// 格式化日期
val dateRange = remember(epoch) {
try {
val start = LocalDate.parse(epoch.startDate.take(10))
@@ -60,8 +70,6 @@ fun EpochDetailScreen(
}
}
// 计算已减重
val actualLoss = epochDetail?.actualChange ?: 0.0
Column(
@@ -73,20 +81,44 @@ fun EpochDetailScreen(
) {
Spacer(modifier = Modifier.height(8.dp))
// 返回按钮
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "返回",
tint = Slate900,
modifier = Modifier
.offset(x = (-8).dp)
.size(32.dp)
.clickable(onClick = onBack)
)
// 顶部栏:返回 + 编辑/删除
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "返回",
tint = Slate900,
modifier = Modifier
.offset(x = (-8).dp)
.size(32.dp)
.clickable(onClick = onBack)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
Icons.Outlined.Edit,
contentDescription = "编辑",
tint = Slate500,
modifier = Modifier
.size(24.dp)
.clickable { showEditDialog = true }
)
Icon(
Icons.Outlined.Delete,
contentDescription = "删除",
tint = ErrorRed,
modifier = Modifier
.size(24.dp)
.clickable { showDeleteDialog = true }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// 纪元名称
Text(
text = epoch.name.ifEmpty { "未命名纪元" },
fontSize = 28.sp,
@@ -97,7 +129,6 @@ fun EpochDetailScreen(
Spacer(modifier = Modifier.height(4.dp))
// 日期范围
Text(
text = dateRange,
fontSize = 14.sp,
@@ -106,7 +137,6 @@ fun EpochDetailScreen(
Spacer(modifier = Modifier.height(32.dp))
// 统计数据
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
@@ -128,7 +158,6 @@ fun EpochDetailScreen(
Spacer(modifier = Modifier.height(40.dp))
// 每周列表
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
contentPadding = PaddingValues(bottom = 24.dp)
@@ -137,12 +166,200 @@ fun EpochDetailScreen(
WeekItem(
weekIndex = index + 1,
planDetail = planDetail,
epoch = epoch,
onClick = { onWeekClick(planDetail) }
)
}
}
}
// 编辑弹窗
if (showEditDialog) {
EditEpochDialog(
epoch = epoch,
onDismiss = { showEditDialog = false },
onConfirm = { name, initial, target, start, end ->
onEditEpoch(epoch.id, name, initial, target, start, end)
showEditDialog = false
}
)
}
// 删除确认弹窗
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
shape = RoundedCornerShape(16.dp),
containerColor = Color.White,
title = {
Text("删除纪元", fontWeight = FontWeight.SemiBold)
},
text = {
Text("确定要删除「${epoch.name.ifEmpty { "未命名纪元" }}」吗?此操作不可恢复。")
},
confirmButton = {
TextButton(onClick = {
onDeleteEpoch(epoch.id)
showDeleteDialog = false
}) {
Text("删除", color = ErrorRed, fontWeight = FontWeight.SemiBold)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("取消", color = Slate500)
}
}
)
}
}
@Composable
private fun EditEpochDialog(
epoch: WeightEpoch,
onDismiss: () -> Unit,
onConfirm: (String?, Double?, Double?, String?, String?) -> Unit
) {
var name by remember { mutableStateOf(epoch.name) }
var initialWeight by remember { mutableStateOf(epoch.initialWeight.toString()) }
var targetWeight by remember { mutableStateOf(epoch.targetWeight.toString()) }
var startDate by remember { mutableStateOf(epoch.startDate.take(10)) }
var endDate by remember { mutableStateOf(epoch.endDate.take(10)) }
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
shape = RoundedCornerShape(16.dp),
containerColor = Color.White,
title = {
Text("编辑纪元", fontWeight = FontWeight.SemiBold)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("纪元名称") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
focusedLabelColor = Brand500
)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = initialWeight,
onValueChange = { initialWeight = it },
label = { Text("初始体重") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(10.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
focusedLabelColor = Brand500
)
)
OutlinedTextField(
value = targetWeight,
onValueChange = { targetWeight = it },
label = { Text("目标体重") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(10.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
focusedLabelColor = Brand500
)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
modifier = Modifier
.weight(1f)
.clickable {
val parts = startDate.split("-")
val cal = Calendar.getInstance()
if (parts.size == 3) {
cal.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
}
DatePickerDialog(context, { _, y, m, d ->
startDate = String.format("%04d-%02d-%02d", y, m + 1, d)
}, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show()
}
) {
OutlinedTextField(
value = startDate,
onValueChange = {},
label = { Text("开始日期") },
readOnly = true,
enabled = false,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = Slate300,
disabledTextColor = Slate900,
disabledLabelColor = Slate500
)
)
}
Box(
modifier = Modifier
.weight(1f)
.clickable {
val parts = endDate.split("-")
val cal = Calendar.getInstance()
if (parts.size == 3) {
cal.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
}
DatePickerDialog(context, { _, y, m, d ->
endDate = String.format("%04d-%02d-%02d", y, m + 1, d)
}, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show()
}
) {
OutlinedTextField(
value = endDate,
onValueChange = {},
label = { Text("结束日期") },
readOnly = true,
enabled = false,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = Slate300,
disabledTextColor = Slate900,
disabledLabelColor = Slate500
)
)
}
}
}
},
confirmButton = {
TextButton(onClick = {
onConfirm(
name.takeIf { it != epoch.name },
initialWeight.toDoubleOrNull()?.takeIf { it != epoch.initialWeight },
targetWeight.toDoubleOrNull()?.takeIf { it != epoch.targetWeight },
startDate.takeIf { it != epoch.startDate.take(10) },
endDate.takeIf { it != epoch.endDate.take(10) }
)
}) {
Text("保存", color = Brand500, fontWeight = FontWeight.SemiBold)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消", color = Slate500)
}
}
)
}
@Composable
@@ -167,24 +384,16 @@ private fun StatItem(
}
}
// 周状态枚举
private enum class WeekStatus {
PAST, // 已过去
CURRENT, // 进行中
FUTURE // 未开启
}
private enum class WeekStatus { PAST, CURRENT, FUTURE }
@Composable
private fun WeekItem(
weekIndex: Int,
planDetail: WeeklyPlanDetail,
epoch: WeightEpoch,
onClick: () -> Unit
) {
val plan = planDetail.plan
// 格式化日期
val dateRange = remember(plan) {
try {
val start = LocalDate.parse(plan.startDate.take(10))
@@ -194,41 +403,32 @@ private fun WeekItem(
} catch (e: Exception) { "" }
}
// 判断周状态
val weekStatus = remember(plan) {
try {
val now = LocalDate.now()
val start = LocalDate.parse(plan.startDate.take(10))
val end = LocalDate.parse(plan.endDate.take(10))
when {
now.isAfter(end) -> WeekStatus.PAST // 当前日期在结束日期之后 = 已过去
now.isBefore(start) -> WeekStatus.FUTURE // 当前日期在开始日期之前 = 未开启
else -> WeekStatus.CURRENT // 正在进行中
now.isAfter(end) -> WeekStatus.PAST
now.isBefore(start) -> WeekStatus.FUTURE
else -> WeekStatus.CURRENT
}
} catch (e: Exception) { WeekStatus.CURRENT }
}
// 计算本周变化
val weightChange = planDetail.weightChange
val hasRecord = plan.finalWeight != null
val hasTarget = plan.targetWeight != null
// 只有已过去的周才显示合格/不合格印章
val showStamp = weekStatus == WeekStatus.PAST && hasTarget
val isQualified = hasRecord && hasTarget && planDetail.isQualified
val isFailed = hasTarget && (!hasRecord || !planDetail.isQualified)
// 未开启的周不允许点击
val isClickable = weekStatus != WeekStatus.FUTURE
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.then(
if (isClickable) Modifier.clickable(onClick = onClick)
else Modifier
)
.then(if (isClickable) Modifier.clickable(onClick = onClick) else Modifier)
) {
Row(
modifier = Modifier
@@ -251,10 +451,8 @@ private fun WeekItem(
)
}
// 右侧显示状态
when (weekStatus) {
WeekStatus.PAST -> {
// 已过去的周:显示体重变化
if (hasRecord && weightChange != null) {
Text(
text = if (weightChange >= 0) "-${formatWeight(weightChange)} kg" else "+${formatWeight(-weightChange)} kg",
@@ -263,89 +461,38 @@ private fun WeekItem(
color = if (weightChange >= 0) Brand500 else ErrorRed
)
} else {
Text(
text = "未记录",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Slate400
)
Text(text = "未记录", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Slate400)
}
}
WeekStatus.CURRENT -> {
// 进行中
Text(
text = "进行中",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Brand500
)
Text(text = "进行中", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Brand500)
}
WeekStatus.FUTURE -> {
// 未开启
Text(
text = "未开启",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Slate300
)
Text(text = "未开启", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Slate300)
}
}
}
// 印章 - 只有已过去且有目标的周才显示
if (showStamp) {
val stampModifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 80.dp)
.rotate(-15f)
.alpha(0.12f)
if (isQualified) {
// 合格印章
Column(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 80.dp)
.rotate(-15f)
.alpha(0.12f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Brand500,
modifier = Modifier.size(48.dp)
)
Text(
text = "合格",
fontSize = 16.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.sp,
color = Brand500
)
Column(modifier = stampModifier, horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Check, null, tint = Brand500, modifier = Modifier.size(48.dp))
Text("合格", fontSize = 16.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = 1.sp, color = Brand500)
}
} else if (isFailed) {
// 不合格印章
Column(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 80.dp)
.rotate(-15f)
.alpha(0.12f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Close,
contentDescription = null,
tint = ErrorRed,
modifier = Modifier.size(48.dp)
)
Text(
text = "不合格",
fontSize = 14.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.sp,
color = ErrorRed
)
Column(modifier = stampModifier, horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Close, null, tint = ErrorRed, modifier = Modifier.size(48.dp))
Text("不合格", fontSize = 14.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = 1.sp, color = ErrorRed)
}
}
}
}
HorizontalDivider(color = Slate200, thickness = 1.dp)
}
}

View File

@@ -237,6 +237,69 @@ class EpochViewModel : ViewModel() {
}
}
fun updateEpoch(
epochId: Long,
name: String?,
initialWeight: Double?,
targetWeight: Double?,
startDate: String?,
endDate: String?,
onSuccess: () -> Unit
) {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
val request = UpdateEpochRequest(
name = name,
initialWeight = initialWeight,
targetWeight = targetWeight,
startDate = startDate,
endDate = endDate
)
val response = api.updateEpoch(epochId, request)
if (response.isSuccessful) {
loadAll()
onSuccess()
} else {
_error.value = "更新失败"
}
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun deleteEpoch(epochId: Long, onSuccess: () -> Unit) {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
val response = api.deleteEpoch(epochId)
if (response.isSuccessful) {
// 从列表中移除
_epochList.value = _epochList.value.filter { it.id != epochId }
// 如果删除的是活跃纪元,清空相关状态
if (_activeEpoch.value?.id == epochId) {
_activeEpoch.value = null
_epochDetail.value = null
_weeklyPlans.value = emptyList()
_currentWeekPlan.value = null
}
onSuccess()
} else {
_error.value = "删除失败"
}
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun clearError() {
_error.value = null
}

View File

@@ -150,6 +150,74 @@ func (h *EpochHandler) GetEpochList(c *gin.Context) {
c.JSON(http.StatusOK, epochs)
}
// 更新纪元
func (h *EpochHandler) UpdateEpoch(c *gin.Context) {
userID := middleware.GetUserID(c)
epochID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var req model.UpdateEpochRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查纪元是否存在且属于当前用户
var exists int
err := h.db.QueryRow("SELECT 1 FROM weight_epochs WHERE id = ? AND user_id = ?", epochID, userID).Scan(&exists)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "epoch not found"})
return
}
// 更新纪元
_, err = h.db.Exec(`
UPDATE weight_epochs
SET name = COALESCE(?, name),
initial_weight = COALESCE(?, initial_weight),
target_weight = COALESCE(?, target_weight),
start_date = COALESCE(?, start_date),
end_date = COALESCE(?, end_date)
WHERE id = ? AND user_id = ?
`, req.Name, req.InitialWeight, req.TargetWeight, req.StartDate, req.EndDate, epochID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update epoch"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "epoch updated"})
}
// 删除纪元
func (h *EpochHandler) DeleteEpoch(c *gin.Context) {
userID := middleware.GetUserID(c)
epochID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
// 检查纪元是否存在且属于当前用户
var exists int
err := h.db.QueryRow("SELECT 1 FROM weight_epochs WHERE id = ? AND user_id = ?", epochID, userID).Scan(&exists)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "epoch not found"})
return
}
// 先删除关联的周计划
_, err = h.db.Exec("DELETE FROM weekly_plans WHERE epoch_id = ? AND user_id = ?", epochID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete weekly plans"})
return
}
// 删除纪元
_, err = h.db.Exec("DELETE FROM weight_epochs WHERE id = ? AND user_id = ?", epochID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete epoch"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "epoch deleted"})
}
// 获取纪元的每周计划列表
func (h *EpochHandler) GetWeeklyPlans(c *gin.Context) {
userID := middleware.GetUserID(c)

View File

@@ -155,6 +155,14 @@ type UpdateWeeklyPlanRequest struct {
Note string `json:"note"`
}
type UpdateEpochRequest struct {
Name *string `json:"name"`
InitialWeight *float64 `json:"initial_weight"`
TargetWeight *float64 `json:"target_weight"`
StartDate *string `json:"start_date"`
EndDate *string `json:"end_date"`
}
// 体重相关请求 (保留兼容)
type SetWeightGoalRequest struct {
TargetWeight float64 `json:"target_weight" binding:"required,gt=0"`

View File

@@ -71,6 +71,8 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
auth.GET("/epoch/active", epochHandler.GetActiveEpoch)
auth.GET("/epoch/list", epochHandler.GetEpochList)
auth.GET("/epoch/:id", epochHandler.GetEpochDetail)
auth.PUT("/epoch/:id", epochHandler.UpdateEpoch)
auth.DELETE("/epoch/:id", epochHandler.DeleteEpoch)
auth.GET("/epoch/:id/plans", epochHandler.GetWeeklyPlans)
auth.PUT("/epoch/:id/plan/:planId", epochHandler.UpdateWeeklyPlan)