feat:周计划展示逻辑优化

This commit is contained in:
amos
2025-12-29 17:36:13 +08:00
parent 4f0529b636
commit aa20632b7a
7 changed files with 308 additions and 66 deletions

View File

@@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View File

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

View File

@@ -94,22 +94,52 @@ fun MainNavigation(
}
composable(Tab.Plan.route) {
PlanScreen(
activeEpoch = activeEpoch,
epochDetail = epochDetail,
currentWeekPlan = currentWeekPlan,
weeklyPlans = weeklyPlans,
isLoading = isLoading,
onPlanClick = { planDetail ->
epochViewModel.selectPlan(planDetail)
activeEpoch?.let { epoch ->
navController.navigate(Routes.weekPlanDetail(epoch.id, planDetail.plan.id))
// 直接显示本周计划详情
val epoch = activeEpoch
val plan = currentWeekPlan
if (epoch == null) {
// 没有活跃纪元时显示空状态
PlanScreen(
activeEpoch = null,
epochDetail = null,
currentWeekPlan = null,
weeklyPlans = emptyList(),
isLoading = isLoading,
onPlanClick = {},
onCreateEpoch = {
navController.navigate(Routes.CREATE_EPOCH)
}
},
onCreateEpoch = {
navController.navigate(Routes.CREATE_EPOCH)
}
)
)
} else {
// 有活跃纪元时直接显示本周计划详情
WeekPlanDetailScreen(
epoch = epoch,
planDetail = plan,
onBack = {
// 计划 Tab 不需要返回,切换到纪元 Tab
navController.navigate(Tab.Epoch.route) {
popUpTo(navController.graph.startDestinationId) {
inclusive = false
}
launchSingleTop = true
}
},
onRecordWeight = { weight ->
plan?.let { p ->
epochViewModel.recordWeight(epoch.id, p.plan.id, weight) {
epochViewModel.loadAll()
}
}
},
onUpdateTarget = { initialWeight, targetWeight ->
plan?.let { p ->
epochViewModel.updateWeeklyPlanTarget(epoch.id, p.plan.id, initialWeight, targetWeight) {
epochViewModel.loadAll()
}
}
}
)
}
}
composable(Tab.Profile.route) {

View File

@@ -3,6 +3,7 @@ package com.healthflow.app.ui.screen
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.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -15,7 +16,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.healthflow.app.data.model.*
import com.healthflow.app.ui.theme.*
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.temporal.ChronoUnit
@Composable
@@ -33,6 +37,30 @@ fun PlanScreen(
return
}
// 判断是否解锁下一周
// 周日 22:00 后 或 周一全天 都可以看到下一周计划(方便提前规划)
val now = LocalDateTime.now()
val isNextWeekUnlocked = remember(now) {
val dayOfWeek = now.dayOfWeek
val time = now.toLocalTime()
when {
// 周日 22:00 后
dayOfWeek == DayOfWeek.SUNDAY && time >= LocalTime.of(22, 0) -> true
// 周一全天也可以看到(刚开始新的一周,可以规划)
dayOfWeek == DayOfWeek.MONDAY -> true
else -> false
}
}
// 找到下一周计划
val nextWeekPlan = remember(weeklyPlans, currentWeekPlan) {
if (currentWeekPlan == null) return@remember null
val currentIndex = weeklyPlans.indexOfFirst { it.plan.id == currentWeekPlan.plan.id }
if (currentIndex >= 0 && currentIndex < weeklyPlans.size - 1) {
weeklyPlans[currentIndex + 1]
} else null
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -61,42 +89,71 @@ fun PlanScreen(
Spacer(modifier = Modifier.height(32.dp))
// 正在进行中
Text(
text = "正在进行中",
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Slate500,
letterSpacing = 0.5.sp
)
Spacer(modifier = Modifier.height(16.dp))
// 当前周计划卡片
if (currentWeekPlan != null) {
CurrentWeekCard(
epoch = activeEpoch,
plan = currentWeekPlan,
onClick = { onPlanClick(currentWeekPlan) }
)
} else {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = Color.White,
shadowElevation = 2.dp,
border = ButtonDefaults.outlinedButtonBorder.copy(
width = 1.dp,
brush = androidx.compose.ui.graphics.SolidColor(Slate200.copy(alpha = 0.8f))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(bottom = 100.dp)
) {
// 正在进行中
item {
Text(
text = "正在进行中",
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Slate500,
letterSpacing = 0.5.sp
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text(text = "本周暂无计划", color = Slate500, fontSize = 14.sp)
}
// 当前周计划卡片
item {
if (currentWeekPlan != null) {
CurrentWeekCard(
epoch = activeEpoch,
plan = currentWeekPlan,
onClick = { onPlanClick(currentWeekPlan) }
)
} else {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = Color.White,
shadowElevation = 2.dp,
border = ButtonDefaults.outlinedButtonBorder.copy(
width = 1.dp,
brush = androidx.compose.ui.graphics.SolidColor(Slate200.copy(alpha = 0.8f))
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text(text = "本周暂无计划", color = Slate500, fontSize = 14.sp)
}
}
}
}
// 下一周计划(周日 22:00 后解锁)
if (isNextWeekUnlocked && nextWeekPlan != null) {
item {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "即将开始",
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Slate500,
letterSpacing = 0.5.sp
)
}
item {
NextWeekCard(
epoch = activeEpoch,
plan = nextWeekPlan,
onClick = { onPlanClick(nextWeekPlan) }
)
}
}
}
@@ -277,6 +334,89 @@ private fun CurrentWeekCard(
}
}
@Composable
private fun NextWeekCard(
epoch: WeightEpoch,
plan: WeeklyPlanDetail,
onClick: () -> Unit
) {
// 周序号 - 基于天数计算
val weekIndex = remember(plan, epoch) {
try {
val epochStart = LocalDate.parse(epoch.startDate.take(10))
val planStart = LocalDate.parse(plan.plan.startDate.take(10))
val daysBetween = ChronoUnit.DAYS.between(epochStart, planStart)
(daysBetween / 7 + 1).toInt()
} catch (e: Exception) { plan.plan.week }
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.clickable { onClick() },
shape = RoundedCornerShape(16.dp),
color = Color.White,
shadowElevation = 2.dp,
border = ButtonDefaults.outlinedButtonBorder.copy(
width = 1.dp,
brush = androidx.compose.ui.graphics.SolidColor(Slate200.copy(alpha = 0.8f))
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
) {
// 左侧橙色指示条(即将开始)
Box(
modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.background(Color(0xFFF59E0B), RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp))
)
Column(
modifier = Modifier
.weight(1f)
.padding(24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${epoch.name} - 第 $weekIndex",
fontSize = 19.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.3).sp,
color = Slate900
)
Text(
text = "即将开始",
fontSize = 14.sp,
color = Color(0xFFF59E0B),
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(8.dp))
// 目标信息
val targetText = plan.plan.targetWeight?.let { "目标: ${formatWeight(it)} kg" } ?: "目标待设定"
Text(
text = targetText,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = Slate500
)
}
}
}
}
private fun formatWeight(weight: Double): String {
return if (weight == weight.toLong().toDouble()) {
weight.toLong().toString()

View File

@@ -81,6 +81,9 @@ fun WeekPlanDetailScreen(
// 是否允许操作(只有当前周可以操作)
val canOperate = weekStatus == PlanWeekStatus.CURRENT
// 是否可以修改目标(目标未设置或初始体重可编辑)
val canEditTarget = canOperate && (plan.targetWeight == null || planDetail.initialWeightEditable)
// 状态描述文字
val statusText = when (weekStatus) {
@@ -192,14 +195,14 @@ fun WeekPlanDetailScreen(
OutlinedButton(
onClick = { showTargetDialog = true },
enabled = canOperate,
enabled = canEditTarget,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
border = ButtonDefaults.outlinedButtonBorder.copy(
width = 1.dp,
brush = androidx.compose.ui.graphics.SolidColor(if (canOperate) Slate900 else Slate300)
brush = androidx.compose.ui.graphics.SolidColor(if (canEditTarget) Slate900 else Slate300)
),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Slate900,
@@ -207,7 +210,7 @@ fun WeekPlanDetailScreen(
)
) {
Text(
text = "修改周目标",
text = if (plan.targetWeight != null && !planDetail.initialWeightEditable) "目标已锁定" else "设置周目标",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
@@ -408,6 +411,19 @@ private fun TargetEditDialog(
) {
var initialText by remember { mutableStateOf(initialWeight?.let { formatWeight(it) } ?: "") }
var targetText by remember { mutableStateOf(targetWeight?.let { formatWeight(it) } ?: "") }
var lossText by remember { mutableStateOf("") }
// 目标体重是否已设置(已设置则不可修改)
val targetEditable = targetWeight == null
// 当输入目标减重时,自动计算目标体重
LaunchedEffect(lossText, initialText) {
val loss = lossText.toDoubleOrNull()
val initial = initialText.toDoubleOrNull()
if (loss != null && initial != null && targetEditable) {
targetText = formatWeight(initial - loss)
}
}
Dialog(onDismissRequest = onDismiss) {
Surface(
@@ -467,24 +483,70 @@ private fun TargetEditDialog(
Spacer(modifier = Modifier.height(16.dp))
// 目标减重(仅当目标体重未设置时可用)
if (targetEditable) {
OutlinedTextField(
value = lossText,
onValueChange = { newValue ->
if (isValidWeightInput(newValue)) {
lossText = newValue
}
},
label = { Text("目标减重 (kg)", color = Slate500) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Slate900,
unfocusedBorderColor = Slate200,
focusedLabelColor = Slate900,
cursorColor = Slate900
),
textStyle = LocalTextStyle.current.copy(
fontSize = 18.sp,
fontWeight = FontWeight.Medium
),
supportingText = {
Text(
text = "输入后自动计算目标体重",
fontSize = 12.sp,
color = Slate400
)
}
)
Spacer(modifier = Modifier.height(16.dp))
}
// 周目标体重
OutlinedTextField(
value = targetText,
onValueChange = { newValue ->
if (isValidWeightInput(newValue)) {
if (targetEditable && isValidWeightInput(newValue)) {
targetText = newValue
lossText = "" // 清空目标减重
}
},
label = { Text("周目标体重 (kg)", color = Slate500) },
label = {
Text(
if (targetEditable) "周目标体重 (kg)" else "周目标体重 (kg) - 已锁定",
color = Slate500
)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
enabled = targetEditable,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Slate900,
unfocusedBorderColor = Slate200,
focusedLabelColor = Slate900,
cursorColor = Slate900
cursorColor = Slate900,
disabledBorderColor = Slate200,
disabledTextColor = Slate600,
disabledLabelColor = Slate400
),
textStyle = LocalTextStyle.current.copy(
fontSize = 18.sp,
@@ -516,7 +578,10 @@ private fun TargetEditDialog(
Button(
onClick = {
onConfirm(initialText.toDoubleOrNull(), targetText.toDoubleOrNull())
onConfirm(
if (initialWeightEditable) initialText.toDoubleOrNull() else null,
if (targetEditable) targetText.toDoubleOrNull() else null
)
},
modifier = Modifier
.weight(1f)

View File

@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.temporal.WeekFields
data class ProfileStats(
val yearLoss: Double = 0.0,
@@ -92,12 +91,16 @@ class EpochViewModel : ViewModel() {
val plans = plansResponse.body() ?: emptyList()
_weeklyPlans.value = plans
// 找到当前周计划
// 找到当前周计划 - 根据日期范围判断
val now = LocalDate.now()
val currentYear = now.year
val currentWeek = now.get(WeekFields.ISO.weekOfWeekBasedYear())
_currentWeekPlan.value = plans.find { plan: WeeklyPlanDetail ->
plan.plan.year == currentYear && plan.plan.week == currentWeek
try {
val startDate = LocalDate.parse(plan.plan.startDate.take(10))
val endDate = LocalDate.parse(plan.plan.endDate.take(10))
!now.isBefore(startDate) && !now.isAfter(endDate)
} catch (e: Exception) {
false
}
}
}
} catch (e: Exception) {

View File

@@ -10,7 +10,7 @@ COPY . .
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o healthflow-server .
FROM --platform=linux/amd64 alpine:latest
RUN apk --no-cache add ca-certificates sqlite-libs
RUN apk --no-cache add ca-certificates sqlite-libs sqlite
WORKDIR /app
COPY --from=builder /app/healthflow-server .