feat:UI 完全重构&&完善需求

This commit is contained in:
amos
2025-12-26 17:11:07 +08:00
parent 4227de462f
commit f6040f6fc7
12 changed files with 1371 additions and 586 deletions

View File

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

View File

@@ -132,7 +132,8 @@ data class WeeklyPlanDetail(
val plan: WeeklyPlan,
@SerialName("weight_change") val weightChange: Double? = null,
@SerialName("is_qualified") val isQualified: Boolean = false,
@SerialName("is_past") val isPast: Boolean = false
@SerialName("is_past") val isPast: Boolean = false,
@SerialName("initial_weight_editable") val initialWeightEditable: Boolean = true
)
@Serializable

View File

@@ -34,8 +34,10 @@ sealed class Tab(val route: String, val label: String) {
object Routes {
const val LOGIN = "login"
const val CREATE_EPOCH = "create_epoch"
const val EPOCH_DETAIL = "epoch_detail/{epochId}"
const val WEEK_PLAN_DETAIL = "week_plan_detail/{epochId}/{planId}"
fun epochDetail(epochId: Long) = "epoch_detail/$epochId"
fun weekPlanDetail(epochId: Long, planId: Long) = "week_plan_detail/$epochId/$planId"
}
@@ -66,7 +68,8 @@ fun MainNavigation(
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val showBottomNav = currentRoute in tabs.map { it.route }
// 除了创建纪元页面外,都显示底部导航栏
val showBottomNav = currentRoute != Routes.CREATE_EPOCH
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
@@ -74,7 +77,7 @@ fun MainNavigation(
startDestination = Tab.Epoch.route,
modifier = Modifier
.fillMaxSize()
.padding(bottom = if (showBottomNav) 64.dp else 0.dp)
.padding(bottom = if (showBottomNav) 56.dp else 0.dp)
) {
composable(Tab.Epoch.route) {
EpochScreen(
@@ -82,7 +85,7 @@ fun MainNavigation(
isLoading = isLoading,
onEpochClick = { epoch ->
epochViewModel.setActiveEpoch(epoch)
navController.navigate(Tab.Plan.route)
navController.navigate(Routes.epochDetail(epoch.id))
},
onCreateNew = {
navController.navigate(Routes.CREATE_EPOCH)
@@ -132,6 +135,24 @@ fun MainNavigation(
)
}
composable(
route = Routes.EPOCH_DETAIL,
arguments = listOf(navArgument("epochId") { type = NavType.LongType })
) {
EpochDetailScreen(
epoch = activeEpoch,
epochDetail = epochDetail,
weeklyPlans = weeklyPlans,
onBack = { navController.popBackStack() },
onWeekClick = { planDetail ->
epochViewModel.selectPlan(planDetail)
activeEpoch?.let { epoch ->
navController.navigate(Routes.weekPlanDetail(epoch.id, planDetail.plan.id))
}
}
)
}
composable(
route = Routes.WEEK_PLAN_DETAIL,
arguments = listOf(
@@ -142,6 +163,7 @@ fun MainNavigation(
val epochId = backStackEntry.arguments?.getLong("epochId") ?: 0L
WeekPlanDetailScreen(
epoch = activeEpoch,
planDetail = selectedPlan,
onBack = { navController.popBackStack() },
onRecordWeight = { weight ->
@@ -163,8 +185,18 @@ fun MainNavigation(
}
}
// 底部导航栏
// 底部导航栏 - 简洁设计
if (showBottomNav) {
// 判断当前选中的 tab
val selectedTab = when {
currentRoute == Tab.Epoch.route -> Tab.Epoch
currentRoute == Tab.Plan.route -> Tab.Plan
currentRoute == Tab.Profile.route -> Tab.Profile
currentRoute?.startsWith("epoch_detail") == true -> Tab.Epoch
currentRoute?.startsWith("week_plan_detail") == true -> Tab.Plan
else -> Tab.Epoch
}
Column(
modifier = Modifier.align(Alignment.BottomCenter)
) {
@@ -177,28 +209,29 @@ fun MainNavigation(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.height(64.dp),
.height(56.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
tabs.forEach { tab ->
val selected = currentRoute == tab.route
val selected = selectedTab == tab
NavBarItem(
label = tab.label,
selected = selected,
icon = when (tab) {
Tab.Epoch -> TabIcon.Circle
Tab.Plan -> TabIcon.Square
Tab.Profile -> TabIcon.Triangle
Tab.Epoch -> TabIcon.Epoch
Tab.Plan -> TabIcon.Plan
Tab.Profile -> TabIcon.Profile
},
onClick = {
// 简化导航逻辑,清除回退栈并导航到目标 tab
navController.navigate(tab.route) {
popUpTo(Tab.Epoch.route) { saveState = true }
// 清除所有回退栈到起始页面
popUpTo(navController.graph.startDestinationId) {
inclusive = false
}
launchSingleTop = true
restoreState = true
}
},
modifier = Modifier.weight(1f)
}
)
}
}
@@ -208,73 +241,174 @@ fun MainNavigation(
}
}
enum class TabIcon { Circle, Square, Triangle }
enum class TabIcon { Epoch, Plan, Profile }
@Composable
private fun NavBarItem(
label: String,
selected: Boolean,
icon: TabIcon,
onClick: () -> Unit,
modifier: Modifier = Modifier
onClick: () -> Unit
) {
val color = if (selected) Slate800 else Slate400
val color = if (selected) Slate900 else Slate400
val size = 24.dp
val strokeWidth = 2.dp
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
Box(
modifier = Modifier
.size(48.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(vertical = 8.dp)
),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.size(size)) {
val stroke = Stroke(
width = strokeWidth.toPx(),
cap = androidx.compose.ui.graphics.StrokeCap.Round,
join = androidx.compose.ui.graphics.StrokeJoin.Round
)
when (icon) {
TabIcon.Circle -> {
Canvas(modifier = Modifier.size(20.dp)) {
TabIcon.Epoch -> {
// 无限符号 ∞ - 代表纪元/周期
val w = this.size.width
val h = this.size.height
val path = Path().apply {
// 左边的圆弧
moveTo(w * 0.5f, h * 0.5f)
cubicTo(
w * 0.5f, h * 0.2f,
w * 0.1f, h * 0.2f,
w * 0.1f, h * 0.5f
)
cubicTo(
w * 0.1f, h * 0.8f,
w * 0.5f, h * 0.8f,
w * 0.5f, h * 0.5f
)
// 右边的圆弧
cubicTo(
w * 0.5f, h * 0.8f,
w * 0.9f, h * 0.8f,
w * 0.9f, h * 0.5f
)
cubicTo(
w * 0.9f, h * 0.2f,
w * 0.5f, h * 0.2f,
w * 0.5f, h * 0.5f
)
}
drawPath(path = path, color = color, style = stroke)
// 选中时在中心画一个小点
if (selected) {
drawCircle(
color = color,
style = Stroke(width = 2.dp.toPx())
radius = 2.5.dp.toPx(),
center = androidx.compose.ui.geometry.Offset(w * 0.5f, h * 0.5f)
)
}
}
TabIcon.Square -> {
Canvas(modifier = Modifier.size(18.dp)) {
drawRect(
TabIcon.Plan -> {
// 日历图标 - 代表计划
val w = this.size.width
val h = this.size.height
val padding = strokeWidth.toPx()
val cornerRadius = 3.dp.toPx()
// 日历主体
drawRoundRect(
color = color,
topLeft = androidx.compose.ui.geometry.Offset(padding, h * 0.2f),
size = androidx.compose.ui.geometry.Size(w - padding * 2, h * 0.75f),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius),
style = stroke
)
// 顶部横线(日历头部)
drawLine(
color = color,
start = androidx.compose.ui.geometry.Offset(padding, h * 0.38f),
end = androidx.compose.ui.geometry.Offset(w - padding, h * 0.38f),
strokeWidth = strokeWidth.toPx()
)
// 两个挂钩
drawLine(
color = color,
start = androidx.compose.ui.geometry.Offset(w * 0.3f, h * 0.08f),
end = androidx.compose.ui.geometry.Offset(w * 0.3f, h * 0.28f),
strokeWidth = strokeWidth.toPx(),
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
drawLine(
color = color,
start = androidx.compose.ui.geometry.Offset(w * 0.7f, h * 0.08f),
end = androidx.compose.ui.geometry.Offset(w * 0.7f, h * 0.28f),
strokeWidth = strokeWidth.toPx(),
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
// 选中时画日历内的点
if (selected) {
drawCircle(
color = color,
style = Stroke(width = 2.dp.toPx())
radius = 2.5.dp.toPx(),
center = androidx.compose.ui.geometry.Offset(w * 0.35f, h * 0.58f)
)
drawCircle(
color = color,
radius = 2.5.dp.toPx(),
center = androidx.compose.ui.geometry.Offset(w * 0.65f, h * 0.58f)
)
drawCircle(
color = color,
radius = 2.5.dp.toPx(),
center = androidx.compose.ui.geometry.Offset(w * 0.35f, h * 0.78f)
)
}
}
TabIcon.Triangle -> {
Canvas(modifier = Modifier.size(20.dp)) {
val path = Path().apply {
moveTo(size.width / 2, 1f)
lineTo(size.width - 1f, size.height - 1f)
lineTo(1f, size.height - 1f)
close()
}
drawPath(
path = path,
TabIcon.Profile -> {
// 用户图标 - 代表个人中心
val w = this.size.width
val h = this.size.height
// 头部圆形
drawCircle(
color = color,
radius = w * 0.2f,
center = androidx.compose.ui.geometry.Offset(w * 0.5f, h * 0.3f),
style = stroke
)
// 身体弧线
val bodyPath = Path().apply {
moveTo(w * 0.15f, h * 0.95f)
quadraticTo(
w * 0.15f, h * 0.55f,
w * 0.5f, h * 0.55f
)
quadraticTo(
w * 0.85f, h * 0.55f,
w * 0.85f, h * 0.95f
)
}
drawPath(path = bodyPath, color = color, style = stroke)
// 选中时填充头部
if (selected) {
drawCircle(
color = color,
style = Stroke(width = 2.dp.toPx())
radius = w * 0.12f,
center = androidx.compose.ui.geometry.Offset(w * 0.5f, h * 0.3f)
)
}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
fontSize = 12.sp,
color = color
)
}
}

View File

@@ -5,8 +5,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -35,53 +34,44 @@ fun CreateEpochScreen(
var showEndDatePicker by remember { mutableStateOf(false) }
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val displayFormatter = DateTimeFormatter.ofPattern("yyyy / MM / dd")
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.statusBarsPadding()
.padding(horizontal = 20.dp)
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(8.dp))
// 返回按钮
Row(
verticalAlignment = Alignment.CenterVertically,
// 返回按钮 - 负边距让图标视觉上对齐文字
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "返回",
tint = Slate900,
modifier = Modifier
.offset(x = (-8).dp)
.size(32.dp)
.clickable(onClick = onBack)
.padding(vertical = 8.dp)
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回",
tint = Slate600,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "取消",
fontSize = 15.sp,
color = Slate600
)
}
)
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(8.dp))
// 标题
Text(
text = "开启新纪元",
fontSize = 26.sp,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.5).sp,
color = Slate900
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "为你的健康阶段设定一个开端",
fontSize = 14.sp,
color = Slate500,
modifier = Modifier.padding(top = 2.dp)
color = Slate500
)
Spacer(modifier = Modifier.height(32.dp))
@@ -90,55 +80,56 @@ fun CreateEpochScreen(
Text(
text = "纪元名称",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = Slate700
fontWeight = FontWeight.SemiBold,
color = Slate500,
letterSpacing = 0.5.sp
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
placeholder = { Text("例如:盛夏减脂阶段", color = Slate400, fontSize = 15.sp) },
placeholder = { Text("例如:盛夏减脂阶段", color = Slate400, fontSize = 16.sp) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Slate300,
focusedBorderColor = Slate900,
unfocusedBorderColor = Slate200,
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White
),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 15.sp)
textStyle = LocalTextStyle.current.copy(fontSize = 16.sp)
)
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(24.dp))
// 起始日期
Text(
text = "起始日期",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = Slate700
fontWeight = FontWeight.SemiBold,
color = Slate500,
letterSpacing = 0.5.sp
)
Spacer(modifier = Modifier.height(8.dp))
DatePickerField(
value = startDate?.format(displayFormatter) ?: "",
placeholder = "年 / 月 / 日",
DateInputField(
value = startDate,
onClick = { showStartDatePicker = true }
)
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(24.dp))
// 截止日期
Text(
text = "截止日期",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = Slate700
fontWeight = FontWeight.SemiBold,
color = Slate500,
letterSpacing = 0.5.sp
)
Spacer(modifier = Modifier.height(8.dp))
DatePickerField(
value = endDate?.format(displayFormatter) ?: "",
placeholder = "年 / 月 / 日",
DateInputField(
value = endDate,
onClick = { showEndDatePicker = true }
)
@@ -148,11 +139,11 @@ fun CreateEpochScreen(
Text(
text = it,
color = ErrorRed,
fontSize = 13.sp
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.height(40.dp))
// 确认按钮
Button(
@@ -167,9 +158,9 @@ fun CreateEpochScreen(
},
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
.height(56.dp),
enabled = startDate != null && endDate != null && !isLoading,
shape = RoundedCornerShape(14.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Slate900,
disabledContainerColor = Slate300
@@ -178,19 +169,17 @@ fun CreateEpochScreen(
if (isLoading) {
CircularProgressIndicator(
color = Color.White,
modifier = Modifier.size(22.dp),
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Text(
text = "确认开启",
fontSize = 15.sp,
fontWeight = FontWeight.Medium
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(28.dp))
}
// 日期选择器
@@ -218,36 +207,30 @@ fun CreateEpochScreen(
}
@Composable
private fun DatePickerField(
value: String,
placeholder: String,
private fun DateInputField(
value: LocalDate?,
onClick: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(10.dp),
shape = RoundedCornerShape(16.dp),
color = Color.White,
border = ButtonDefaults.outlinedButtonBorder.copy(width = 1.dp)
border = ButtonDefaults.outlinedButtonBorder.copy(
width = 1.dp,
brush = androidx.compose.ui.graphics.SolidColor(Slate200)
)
) {
Row(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
.padding(16.dp)
) {
Text(
text = value.ifEmpty { placeholder },
fontSize = 15.sp,
color = if (value.isEmpty()) Slate400 else Slate900
)
Icon(
Icons.Default.DateRange,
contentDescription = "选择日期",
tint = Slate400,
modifier = Modifier.size(20.dp)
text = value?.format(DateTimeFormatter.ofPattern("yyyy / MM / dd")) ?: "年 / 月 / 日",
fontSize = 16.sp,
color = if (value == null) Slate400 else Slate900
)
}
}
@@ -267,28 +250,56 @@ private fun DatePickerDialog(
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
Button(
onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val date = LocalDate.ofEpochDay(millis / (24 * 60 * 60 * 1000))
onDateSelected(date)
}
}
},
colors = ButtonDefaults.buttonColors(containerColor = Slate900),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.padding(end = 8.dp, bottom = 8.dp)
) {
Text("确定", color = Brand500)
Text("确定", fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消", color = Slate500)
OutlinedButton(
onClick = onDismiss,
shape = RoundedCornerShape(8.dp),
border = ButtonDefaults.outlinedButtonBorder.copy(
width = 1.dp,
brush = androidx.compose.ui.graphics.SolidColor(Slate300)
),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Slate600),
modifier = Modifier.padding(bottom = 8.dp)
) {
Text("取消", fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
},
shape = RoundedCornerShape(24.dp),
colors = DatePickerDefaults.colors(
containerColor = Color.White
)
) {
DatePicker(
state = datePickerState,
colors = DatePickerDefaults.colors(
selectedDayContainerColor = Brand500,
todayDateBorderColor = Brand500
containerColor = Color.White,
titleContentColor = Slate900,
headlineContentColor = Slate900,
weekdayContentColor = Slate500,
subheadContentColor = Slate500,
yearContentColor = Slate700,
currentYearContentColor = Slate900,
selectedYearContentColor = Color.White,
selectedYearContainerColor = Slate900,
dayContentColor = Slate700,
selectedDayContentColor = Color.White,
selectedDayContainerColor = Slate900,
todayContentColor = Slate900,
todayDateBorderColor = Slate900
)
)
}

View File

@@ -0,0 +1,359 @@
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.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.healthflow.app.data.model.EpochDetail
import com.healthflow.app.data.model.WeeklyPlanDetail
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
@Composable
fun EpochDetailScreen(
epoch: WeightEpoch?,
epochDetail: EpochDetail?,
weeklyPlans: List<WeeklyPlanDetail>,
onBack: () -> Unit,
onWeekClick: (WeeklyPlanDetail) -> Unit
) {
if (epoch == null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = Brand500)
}
return
}
// 格式化日期
val dateRange = remember(epoch) {
try {
val start = LocalDate.parse(epoch.startDate.take(10))
val end = LocalDate.parse(epoch.endDate.take(10))
val formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd")
"${start.format(formatter)} - ${end.format(formatter)}"
} catch (e: Exception) {
"${epoch.startDate} - ${epoch.endDate}"
}
}
// 计算已减重
val actualLoss = epochDetail?.actualChange ?: 0.0
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.statusBarsPadding()
.padding(horizontal = 24.dp)
) {
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)
)
Spacer(modifier = Modifier.height(8.dp))
// 纪元名称
Text(
text = epoch.name.ifEmpty { "未命名纪元" },
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.5).sp,
color = Slate900
)
Spacer(modifier = Modifier.height(4.dp))
// 日期范围
Text(
text = dateRange,
fontSize = 14.sp,
color = Slate500
)
Spacer(modifier = Modifier.height(32.dp))
// 统计数据
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
StatItem(
value = formatWeight(epoch.initialWeight),
label = "初始"
)
StatItem(
value = formatWeight(epoch.targetWeight),
label = "目标"
)
StatItem(
value = if (actualLoss >= 0) "-${formatWeight(actualLoss)}" else "+${formatWeight(-actualLoss)}",
label = "已减",
valueColor = if (actualLoss >= 0) Brand500 else ErrorRed
)
}
Spacer(modifier = Modifier.height(40.dp))
// 每周列表
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
contentPadding = PaddingValues(bottom = 24.dp)
) {
itemsIndexed(weeklyPlans) { index, planDetail ->
WeekItem(
weekIndex = index + 1,
planDetail = planDetail,
epoch = epoch,
onClick = { onWeekClick(planDetail) }
)
}
}
}
}
@Composable
private fun StatItem(
value: String,
label: String,
valueColor: Color = Slate900
) {
Column {
Text(
text = value,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = valueColor
)
Text(
text = label,
fontSize = 12.sp,
color = Slate500,
letterSpacing = 0.5.sp
)
}
}
// 周状态枚举
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))
val end = LocalDate.parse(plan.endDate.take(10))
val formatter = DateTimeFormatter.ofPattern("MM.dd")
"${start.format(formatter)} - ${end.format(formatter)}"
} 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 // 正在进行中
}
} 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
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "${weekIndex}",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = if (weekStatus == WeekStatus.FUTURE) Slate400 else Slate900
)
Text(
text = dateRange,
fontSize = 12.sp,
color = if (weekStatus == WeekStatus.FUTURE) Slate300 else Slate500
)
}
// 右侧显示状态
when (weekStatus) {
WeekStatus.PAST -> {
// 已过去的周:显示体重变化
if (hasRecord && weightChange != null) {
Text(
text = if (weightChange >= 0) "-${formatWeight(weightChange)} kg" else "+${formatWeight(-weightChange)} kg",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = if (weightChange >= 0) Brand500 else ErrorRed
)
} else {
Text(
text = "未记录",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Slate400
)
}
}
WeekStatus.CURRENT -> {
// 进行中
Text(
text = "进行中",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Brand500
)
}
WeekStatus.FUTURE -> {
// 未开启
Text(
text = "未开启",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Slate300
)
}
}
}
// 印章 - 只有已过去且有目标的周才显示
if (showStamp) {
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
)
}
} 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
)
}
}
}
}
HorizontalDivider(color = Slate200, thickness = 1.dp)
}
}
private fun formatWeight(weight: Double): String {
return if (weight == weight.toLong().toDouble()) {
weight.toLong().toString()
} else {
String.format("%.1f", kotlin.math.abs(weight))
}
}

View File

@@ -10,11 +10,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -40,15 +43,17 @@ fun EpochScreen(
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(56.dp))
Spacer(modifier = Modifier.height(16.dp))
// 标题
Text(
text = "HealthFlow",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.5).sp,
color = Slate900
)
@@ -56,11 +61,11 @@ fun EpochScreen(
Text(
text = "追踪你的健康演变",
fontSize = 15.sp,
color = Slate400
fontSize = 14.sp,
color = Slate500
)
Spacer(modifier = Modifier.height(28.dp))
Spacer(modifier = Modifier.height(32.dp))
when {
isLoading && epochs.isEmpty() -> {
@@ -90,7 +95,7 @@ fun EpochScreen(
Button(
onClick = onCreateNew,
colors = ButtonDefaults.buttonColors(containerColor = Slate900),
shape = RoundedCornerShape(12.dp)
shape = RoundedCornerShape(16.dp)
) {
Text("开启新纪元", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp))
}
@@ -99,7 +104,7 @@ fun EpochScreen(
}
else -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
contentPadding = PaddingValues(bottom = 100.dp)
) {
items(epochs, key = { it.id }) { epoch ->
@@ -113,24 +118,22 @@ fun EpochScreen(
}
}
// FAB - 右下角圆形按钮
// FAB
FloatingActionButton(
onClick = onCreateNew,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 24.dp)
.size(56.dp),
.padding(end = 24.dp, bottom = 100.dp)
.size(60.dp),
containerColor = Slate900,
contentColor = Color.White,
shape = CircleShape,
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 4.dp
)
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 10.dp)
) {
Icon(
Icons.Default.Add,
contentDescription = "创建纪元",
modifier = Modifier.size(24.dp)
modifier = Modifier.size(32.dp)
)
}
}
@@ -174,74 +177,107 @@ private fun EpochCard(
.clip(RoundedCornerShape(16.dp))
.clickable { onClick() },
shape = RoundedCornerShape(16.dp),
color = Slate50
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()) {
// 左侧绿色指示条
Box(
modifier = Modifier
.width(4.dp)
.height(108.dp)
.background(if (isActive) Brand500 else Slate200)
)
if (isActive) {
Box(
modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.padding(vertical = 24.dp)
.background(Brand500, RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp))
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp, end = 20.dp, top = 18.dp, bottom = 18.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = epoch.name.ifEmpty { "未命名纪元" },
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Slate900
)
when {
isCompleted -> Icon(
Box(modifier = Modifier.weight(1f)) {
// 已完成印章
if (isCompleted) {
Column(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 10.dp)
.rotate(-15f)
.alpha(0.12f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Check,
contentDescription = "已完成",
tint = Brand500.copy(alpha = 0.6f),
modifier = Modifier.size(40.dp)
contentDescription = null,
tint = Brand500,
modifier = Modifier.size(80.dp)
)
isActive -> Text(
text = "进行中",
fontSize = 14.sp,
color = Brand500,
fontWeight = FontWeight.Medium
Spacer(modifier = Modifier.height((-5).dp))
Text(
text = "合格",
fontSize = 32.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = 2.sp,
color = Brand500
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = dateRange,
fontSize = 14.sp,
color = Slate400
)
Spacer(modifier = Modifier.height(16.dp))
// 进度条
Box(
Column(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(Slate200)
.padding(24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = epoch.name.ifEmpty { "未命名纪元" },
fontSize = 19.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.3).sp,
color = Slate900
)
if (isActive && !isCompleted) {
Text(
text = "进行中",
fontSize = 14.sp,
color = Brand500,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = dateRange,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = Slate500
)
Spacer(modifier = Modifier.height(18.dp))
// 进度条
Box(
modifier = Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(Slate900)
)
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(10.dp))
.background(Slate100)
) {
Box(
modifier = Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(Slate900, RoundedCornerShape(10.dp))
)
}
}
}
}

View File

@@ -3,8 +3,6 @@ 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.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -18,9 +16,7 @@ import androidx.compose.ui.unit.sp
import com.healthflow.app.data.model.*
import com.healthflow.app.ui.theme.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.time.temporal.WeekFields
@Composable
fun PlanScreen(
@@ -37,96 +33,70 @@ fun PlanScreen(
return
}
val now = LocalDate.now()
val currentYear = now.year
val currentWeek = now.get(WeekFields.ISO.weekOfWeekBasedYear())
// 后续周计划
val futurePlans = weeklyPlans.filter { plan ->
plan.plan.year > currentYear ||
(plan.plan.year == currentYear && plan.plan.week > currentWeek)
}.take(5)
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.statusBarsPadding()
.padding(horizontal = 20.dp)
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(16.dp))
// 标题
Text(
text = "计划中心",
fontSize = 26.sp,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.5).sp,
color = Slate900
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "专注于本阶段的周计划",
text = "专注于本周计划",
fontSize = 14.sp,
color = Slate500,
modifier = Modifier.padding(top = 2.dp)
color = Slate500
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(32.dp))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 24.dp)
) {
// 正在进行中
item {
Text(
text = "正在进行中",
fontSize = 13.sp,
color = Slate500
// 正在进行中
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))
)
Spacer(modifier = Modifier.height(8.dp))
}
// 当前周计划卡片
item {
if (currentWeekPlan != null) {
CurrentWeekCard(
epoch = activeEpoch,
plan = currentWeekPlan,
onClick = { onPlanClick(currentWeekPlan) }
)
} else {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = Slate50
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text(text = "本周暂无计划", color = Slate500, fontSize = 14.sp)
}
}
}
}
// 后续序列
if (futurePlans.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "后续序列",
fontSize = 13.sp,
color = Slate500
)
Spacer(modifier = Modifier.height(4.dp))
}
items(futurePlans) { plan ->
FuturePlanItem(plan = plan, onClick = { onPlanClick(plan) })
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text(text = "本周暂无计划", color = Slate500, fontSize = 14.sp)
}
}
}
@@ -138,8 +108,7 @@ private fun EmptyPlanState(onCreateEpoch: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.statusBarsPadding(),
.background(Color.White),
contentAlignment = Alignment.Center
) {
Column(
@@ -162,7 +131,7 @@ private fun EmptyPlanState(onCreateEpoch: () -> Unit) {
Button(
onClick = onCreateEpoch,
colors = ButtonDefaults.buttonColors(containerColor = Slate900),
shape = RoundedCornerShape(12.dp),
shape = RoundedCornerShape(16.dp),
modifier = Modifier.height(48.dp)
) {
Text(
@@ -206,36 +175,46 @@ private fun CurrentWeekCard(
current - target
}
// 周序号
// 周序号 - 基于天数计算
val weekIndex = remember(plan, epoch) {
try {
val epochStart = LocalDate.parse(epoch.startDate.take(10))
val planStart = LocalDate.parse(plan.plan.startDate.take(10))
(ChronoUnit.WEEKS.between(epochStart, planStart) + 1).toInt()
val daysBetween = ChronoUnit.DAYS.between(epochStart, planStart)
(daysBetween / 7 + 1).toInt()
} catch (e: Exception) { plan.plan.week }
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(16.dp))
.clickable { onClick() },
shape = RoundedCornerShape(12.dp),
color = Slate50
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()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
) {
// 左侧绿色指示条
Box(
modifier = Modifier
.width(4.dp)
.height(110.dp)
.background(Brand500)
.fillMaxHeight()
.background(Brand500, RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp))
)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp, vertical = 14.dp)
.padding(24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -244,20 +223,21 @@ private fun CurrentWeekCard(
) {
Text(
text = "${epoch.name} - 第 $weekIndex",
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
fontSize = 19.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.3).sp,
color = Slate900
)
Text(
text = "$dayInWeek",
fontSize = 13.sp,
fontSize = 14.sp,
color = Brand500,
fontWeight = FontWeight.Medium
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(8.dp))
// 目标信息
val targetText = buildString {
@@ -270,25 +250,26 @@ private fun CurrentWeekCard(
}
Text(
text = targetText,
fontSize = 13.sp,
color = Slate600
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = Slate500
)
Spacer(modifier = Modifier.height(14.dp))
Spacer(modifier = Modifier.height(18.dp))
// 进度条
Box(
modifier = Modifier
.fillMaxWidth()
.height(5.dp)
.clip(RoundedCornerShape(2.5.dp))
.background(Slate200)
.height(6.dp)
.clip(RoundedCornerShape(10.dp))
.background(Slate100)
) {
Box(
modifier = Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(Brand500)
.background(Brand500, RoundedCornerShape(10.dp))
)
}
}
@@ -296,53 +277,6 @@ private fun CurrentWeekCard(
}
}
@Composable
private fun FuturePlanItem(
plan: WeeklyPlanDetail,
onClick: () -> Unit
) {
val dateRange = remember(plan) {
try {
val start = LocalDate.parse(plan.plan.startDate.take(10))
val end = LocalDate.parse(plan.plan.endDate.take(10))
val formatter = DateTimeFormatter.ofPattern("MM.dd")
"${start.format(formatter)} - ${end.format(formatter)}"
} catch (e: Exception) { "" }
}
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "${plan.plan.week}",
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
color = Slate800
)
Text(
text = dateRange,
fontSize = 13.sp,
color = Slate500
)
}
Text(
text = "待启动",
fontSize = 13.sp,
color = Slate400
)
}
HorizontalDivider(color = Slate100, thickness = 1.dp)
}
}
private fun formatWeight(weight: Double): String {
return if (weight == weight.toLong().toDouble()) {
weight.toLong().toString()

View File

@@ -1,14 +1,19 @@
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ExitToApp
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
@@ -32,28 +37,68 @@ fun ProfileScreen(
.fillMaxSize()
.background(Color.White)
.statusBarsPadding()
.padding(horizontal = 20.dp)
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(16.dp))
// 标题
Text(
text = "个人中心",
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
color = Slate900
)
// 顶部:标题 + 操作按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "个人中心",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.5).sp,
color = Slate900
)
// 右侧操作按钮
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// 检查更新按钮
IconButton(
onClick = onCheckUpdate,
modifier = Modifier.size(40.dp)
) {
Icon(
Icons.Outlined.Refresh,
contentDescription = "检查更新",
tint = Slate500,
modifier = Modifier.size(22.dp)
)
}
// 退出登录按钮
IconButton(
onClick = onLogout,
modifier = Modifier.size(40.dp)
) {
Icon(
Icons.AutoMirrored.Outlined.ExitToApp,
contentDescription = "退出登录",
tint = Slate500,
modifier = Modifier.size(22.dp)
)
}
}
}
Spacer(modifier = Modifier.height(28.dp))
Spacer(modifier = Modifier.height(40.dp))
// 用户信息
Row(verticalAlignment = Alignment.CenterVertically) {
// 头像
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 40.dp)
) {
// 头像 - 72dp with shadow
Box(
modifier = Modifier
.size(64.dp)
.size(72.dp)
.shadow(10.dp, CircleShape)
.clip(CircleShape)
.background(Slate100)
.background(Slate200)
) {
if (user?.avatarUrl?.isNotEmpty() == true) {
AsyncImage(
@@ -69,7 +114,7 @@ fun ProfileScreen(
) {
Text(
text = user?.nickname?.firstOrNull()?.toString() ?: "U",
fontSize = 24.sp,
fontSize = 28.sp,
fontWeight = FontWeight.Medium,
color = Slate400
)
@@ -77,29 +122,29 @@ fun ProfileScreen(
}
}
Spacer(modifier = Modifier.width(14.dp))
Spacer(modifier = Modifier.width(20.dp))
Column {
Text(
text = user?.nickname ?: "用户",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = (-0.5).sp,
color = Slate900
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "坚持 ${stats.persistDays}",
fontSize = 13.sp,
fontSize = 14.sp,
color = Slate500
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// 统计卡片
// 统计卡片 - 2列网格
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
StatCard(
title = "今年减重",
@@ -117,28 +162,10 @@ fun ProfileScreen(
)
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(40.dp))
// 年度进度卡片
YearProgressCard(daysRemaining = stats.daysRemaining)
Spacer(modifier = Modifier.weight(1f))
// 退出登录按钮
TextButton(
onClick = onLogout,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "退出登录",
color = ErrorRed,
fontSize = 15.sp
)
}
HorizontalDivider(color = Slate100, thickness = 1.dp)
Spacer(modifier = Modifier.height(24.dp))
}
}
@@ -152,29 +179,35 @@ private fun StatCard(
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(12.dp),
color = Slate50
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))
)
) {
Column(modifier = Modifier.padding(14.dp)) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = title,
fontSize = 13.sp,
color = Slate500
fontSize = 12.sp,
color = Slate500,
letterSpacing = 0.5.sp
)
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = value,
fontSize = 26.sp,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = valueColor
)
Spacer(modifier = Modifier.width(3.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(
text = unit,
fontSize = 14.sp,
color = valueColor,
modifier = Modifier.padding(bottom = 3.dp)
modifier = Modifier.padding(bottom = 2.dp)
)
}
}
@@ -187,49 +220,52 @@ private fun YearProgressCard(daysRemaining: Int) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = Navy900
shape = RoundedCornerShape(16.dp),
color = Slate900
) {
Box(modifier = Modifier.fillMaxWidth()) {
// 右上角年份标签
Surface(
// 右上角年份标签 - 圆角在左下
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(10.dp),
shape = RoundedCornerShape(6.dp),
color = Slate700
.background(
Color.White.copy(alpha = 0.1f),
RoundedCornerShape(bottomStart = 16.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = "$currentYear PROGRESS",
fontSize = 11.sp,
color = Slate300,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp)
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.8f)
)
}
Column(modifier = Modifier.padding(20.dp)) {
Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 32.dp)) {
Text(
text = "今年剩余天数",
fontSize = 13.sp,
color = Slate400
fontSize = 12.sp,
color = Slate400,
letterSpacing = 0.5.sp
)
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = String.format("%02d", daysRemaining),
fontSize = 52.sp,
fontSize = 42.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.width(6.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "DAYS",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = Slate400,
modifier = Modifier.padding(bottom = 10.dp)
color = Color.White.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 6.dp)
)
}
}

View File

@@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.*
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.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -16,11 +16,23 @@ 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 androidx.compose.ui.window.Dialog
import com.healthflow.app.data.model.WeeklyPlanDetail
import com.healthflow.app.data.model.WeightEpoch
import com.healthflow.app.ui.theme.*
import java.time.LocalDate
import java.time.temporal.ChronoUnit
// 周计划状态枚举
private enum class PlanWeekStatus {
PAST, // 已过去
CURRENT, // 进行中
FUTURE // 未开启
}
@Composable
fun WeekPlanDetailScreen(
epoch: WeightEpoch?,
planDetail: WeeklyPlanDetail?,
onBack: () -> Unit,
onRecordWeight: (Double) -> Unit,
@@ -42,56 +54,85 @@ fun WeekPlanDetailScreen(
var showWeightDialog by remember { mutableStateOf(false) }
var showTargetDialog by remember { mutableStateOf(false) }
val weekIndex = plan.week
// 计算相对于纪元的周序号 - 基于天数计算
val weekIndex = remember(plan, epoch) {
if (epoch == null) return@remember plan.week
try {
val epochStart = LocalDate.parse(epoch.startDate.take(10))
val planStart = LocalDate.parse(plan.startDate.take(10))
val daysBetween = ChronoUnit.DAYS.between(epochStart, planStart)
(daysBetween / 7 + 1).toInt()
} catch (e: Exception) { plan.week }
}
// 判断周状态
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) -> PlanWeekStatus.PAST // 已过去
now.isBefore(start) -> PlanWeekStatus.FUTURE // 未开启
else -> PlanWeekStatus.CURRENT // 进行中
}
} catch (e: Exception) { PlanWeekStatus.CURRENT }
}
// 是否允许操作(只有当前周可以操作)
val canOperate = weekStatus == PlanWeekStatus.CURRENT
// 状态描述文字
val statusText = when (weekStatus) {
PlanWeekStatus.PAST -> "本周已结束"
PlanWeekStatus.CURRENT -> "正在为了本周目标努力"
PlanWeekStatus.FUTURE -> "本周尚未开启"
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.statusBarsPadding()
.padding(horizontal = 20.dp)
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(8.dp))
// 返回按钮
Row(
verticalAlignment = Alignment.CenterVertically,
// 返回按钮 - 负边距让图标视觉上对齐文字
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "返回",
tint = Slate900,
modifier = Modifier
.offset(x = (-8).dp)
.size(32.dp)
.clickable(onClick = onBack)
.padding(vertical = 8.dp)
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回",
tint = Slate600,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "返回",
fontSize = 15.sp,
color = Slate600
)
}
)
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(8.dp))
// 标题
Text(
text = "${weekIndex}周计划",
fontSize = 26.sp,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.5).sp,
color = Slate900
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "正在为了本周目标努力",
text = statusText,
fontSize = 14.sp,
color = Slate500,
modifier = Modifier.padding(top = 2.dp)
color = when (weekStatus) {
PlanWeekStatus.CURRENT -> Slate500
PlanWeekStatus.PAST -> Slate400
PlanWeekStatus.FUTURE -> Slate400
}
)
Spacer(modifier = Modifier.height(48.dp))
// Hero 体重显示 - 60px margin
Spacer(modifier = Modifier.height(60.dp))
// 当前体重显示
Column(
@@ -101,19 +142,22 @@ fun WeekPlanDetailScreen(
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = plan.finalWeight?.let { formatWeight(it) } ?: "--",
fontSize = 64.sp,
fontSize = 72.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-2).sp,
color = Slate900
)
Spacer(modifier = Modifier.width(6.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "kg",
fontSize = 22.sp,
fontSize = 24.sp,
color = Slate500,
modifier = Modifier.padding(bottom = 10.dp)
modifier = Modifier.padding(bottom = 12.dp)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "当前体重记录",
fontSize = 14.sp,
@@ -121,91 +165,109 @@ fun WeekPlanDetailScreen(
)
}
Spacer(modifier = Modifier.height(40.dp))
Spacer(modifier = Modifier.height(60.dp))
// 操作按钮
Button(
onClick = { showWeightDialog = true },
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Slate900)
) {
Text(
text = "添加体重记录",
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
Column(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { showWeightDialog = true },
enabled = canOperate,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Slate900,
disabledContainerColor = Slate300
)
) {
Text(
text = "添加体重记录",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = { showTargetDialog = true },
enabled = canOperate,
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)
),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Slate900,
disabledContentColor = Slate400
)
) {
Text(
text = "修改周目标",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedButton(
onClick = { showTargetDialog = true },
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Slate900)
) {
Text(
text = "修改周目标",
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(40.dp))
// 本周指标
Text(
text = "本周指标",
fontSize = 13.sp,
color = Slate500
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Slate500,
letterSpacing = 0.5.sp
)
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(16.dp))
// 周初始
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 14.dp),
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "周初始",
fontSize = 15.sp,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Slate800
color = Slate900
)
Text(
text = plan.initialWeight?.let { "${formatWeight(it)} kg" } ?: "--",
fontSize = 15.sp,
color = Slate600
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Slate900
)
}
HorizontalDivider(color = Slate100, thickness = 1.dp)
HorizontalDivider(color = Slate200, thickness = 1.dp)
// 周目标
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 14.dp),
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "周目标",
fontSize = 15.sp,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Slate800
color = Slate900
)
Text(
text = plan.targetWeight?.let { "${formatWeight(it)} kg" } ?: "--",
fontSize = 15.sp,
color = Slate600
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Slate900
)
}
}
@@ -228,6 +290,7 @@ fun WeekPlanDetailScreen(
TargetEditDialog(
initialWeight = plan.initialWeight,
targetWeight = plan.targetWeight,
initialWeightEditable = planDetail.initialWeightEditable,
onDismiss = { showTargetDialog = false },
onConfirm = { initial, target ->
onUpdateTarget(initial, target)
@@ -244,95 +307,237 @@ private fun WeightInputDialog(
onDismiss: () -> Unit,
onConfirm: (Double) -> Unit
) {
var weightText by remember { mutableStateOf(initialValue?.toString() ?: "") }
var weightText by remember { mutableStateOf(initialValue?.let { formatWeight(it) } ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(text = title, fontWeight = FontWeight.SemiBold, fontSize = 17.sp)
},
text = {
OutlinedTextField(
value = weightText,
onValueChange = { weightText = it },
label = { Text("体重 (kg)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp)
)
},
confirmButton = {
TextButton(
onClick = { weightText.toDoubleOrNull()?.let { onConfirm(it) } },
enabled = weightText.toDoubleOrNull() != null
Dialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White,
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.padding(24.dp)
) {
Text("确定", color = Brand500, fontSize = 15.sp)
// 标题
Text(
text = title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Slate900
)
Spacer(modifier = Modifier.height(24.dp))
// 输入框
OutlinedTextField(
value = weightText,
onValueChange = { newValue ->
if (isValidWeightInput(newValue)) {
weightText = 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
)
)
Spacer(modifier = Modifier.height(24.dp))
// 按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(12.dp),
border = ButtonDefaults.outlinedButtonBorder.copy(
width = 1.dp,
brush = androidx.compose.ui.graphics.SolidColor(Slate300)
),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Slate600)
) {
Text("取消", fontSize = 15.sp, fontWeight = FontWeight.Medium)
}
Button(
onClick = { weightText.toDoubleOrNull()?.let { onConfirm(it) } },
enabled = weightText.toDoubleOrNull() != null,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Slate900,
disabledContainerColor = Slate300
)
) {
Text("确定", fontSize = 15.sp, fontWeight = FontWeight.Medium)
}
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消", color = Slate500, fontSize = 15.sp)
}
},
shape = RoundedCornerShape(16.dp)
)
}
}
}
@Composable
private fun TargetEditDialog(
initialWeight: Double?,
targetWeight: Double?,
initialWeightEditable: Boolean,
onDismiss: () -> Unit,
onConfirm: (initial: Double?, target: Double?) -> Unit
) {
var initialText by remember { mutableStateOf(initialWeight?.toString() ?: "") }
var targetText by remember { mutableStateOf(targetWeight?.toString() ?: "") }
var initialText by remember { mutableStateOf(initialWeight?.let { formatWeight(it) } ?: "") }
var targetText by remember { mutableStateOf(targetWeight?.let { formatWeight(it) } ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(text = "修改周目标", fontWeight = FontWeight.SemiBold, fontSize = 17.sp)
},
text = {
Column {
Dialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White,
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.padding(24.dp)
) {
// 标题
Text(
text = "设置周目标",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Slate900
)
Spacer(modifier = Modifier.height(24.dp))
// 周初始体重 - 根据 initialWeightEditable 决定是否可编辑
OutlinedTextField(
value = initialText,
onValueChange = { initialText = it },
label = { Text("周初始体重 (kg)") },
onValueChange = { newValue ->
if (initialWeightEditable && isValidWeightInput(newValue)) {
initialText = newValue
}
},
label = {
Text(
if (initialWeightEditable) "周初始体重 (kg)" else "周初始体重 (kg) - 来自上周记录",
color = Slate500
)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
enabled = initialWeightEditable,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp)
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Slate900,
unfocusedBorderColor = Slate200,
focusedLabelColor = Slate900,
cursorColor = Slate900,
disabledBorderColor = Slate200,
disabledTextColor = Slate600,
disabledLabelColor = Slate400
),
textStyle = LocalTextStyle.current.copy(
fontSize = 18.sp,
fontWeight = FontWeight.Medium
)
)
Spacer(modifier = Modifier.height(14.dp))
Spacer(modifier = Modifier.height(16.dp))
// 周目标体重
OutlinedTextField(
value = targetText,
onValueChange = { targetText = it },
label = { Text("周目标体重 (kg)") },
onValueChange = { newValue ->
if (isValidWeightInput(newValue)) {
targetText = newValue
}
},
label = { Text("周目标体重 (kg)", color = Slate500) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp)
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
)
)
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(initialText.toDoubleOrNull(), targetText.toDoubleOrNull())
Spacer(modifier = Modifier.height(24.dp))
// 按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(12.dp),
border = ButtonDefaults.outlinedButtonBorder.copy(
width = 1.dp,
brush = androidx.compose.ui.graphics.SolidColor(Slate300)
),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Slate600)
) {
Text("取消", fontSize = 15.sp, fontWeight = FontWeight.Medium)
}
Button(
onClick = {
onConfirm(initialText.toDoubleOrNull(), targetText.toDoubleOrNull())
},
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Slate900)
) {
Text("确定", fontSize = 15.sp, fontWeight = FontWeight.Medium)
}
}
) {
Text("确定", color = Brand500, fontSize = 15.sp)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消", color = Slate500, fontSize = 15.sp)
}
},
shape = RoundedCornerShape(16.dp)
)
}
}
}
// 验证体重输入:只允许数字和小数点,最多两位小数
private fun isValidWeightInput(input: String): Boolean {
if (input.isEmpty()) return true
// 匹配:整数 或 小数点后最多两位
val regex = Regex("^\\d*\\.?\\d{0,2}$")
return regex.matches(input)
}
private fun formatWeight(weight: Double): String {

View File

@@ -137,6 +137,44 @@ upload_to_r2() {
echo "${R2_PUBLIC_URL}/${remote_path}"
}
# 清理旧 APK只保留最近 3 个版本
cleanup_old_apks() {
echo "🧹 清理旧 APK..."
# 列出所有 APK 文件并按时间排序
local apk_list=$(AWS_ACCESS_KEY_ID=$R2_ACCESS_KEY_ID \
AWS_SECRET_ACCESS_KEY=$R2_ACCESS_KEY_SECRET \
aws s3 ls "s3://${R2_BUCKET_NAME}/healthFlow/releases/" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
| grep "\.apk$" \
| sort -k1,2 \
| awk '{print $4}')
# 计算 APK 数量
local count=$(echo "$apk_list" | grep -c "\.apk$" || echo "0")
if [ "$count" -gt 3 ]; then
# 需要删除的数量
local to_delete=$((count - 3))
echo " 发现 ${count} 个 APK将删除最旧的 ${to_delete}"
# 获取要删除的文件列表(最旧的几个)
local files_to_delete=$(echo "$apk_list" | head -n $to_delete)
for file in $files_to_delete; do
echo " 删除: ${file}"
AWS_ACCESS_KEY_ID=$R2_ACCESS_KEY_ID \
AWS_SECRET_ACCESS_KEY=$R2_ACCESS_KEY_SECRET \
aws s3 rm "s3://${R2_BUCKET_NAME}/healthFlow/releases/${file}" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
done
echo "✅ 清理完成,保留最近 3 个版本"
else
echo " 当前只有 ${count} 个 APK无需清理"
fi
}
# 更新服务器版本信息,返回 0 成功1 失败
update_server_version() {
local version_code=$1
@@ -249,6 +287,9 @@ if [ "$BUILD_APK" = true ]; then
# 上传 APK
upload_to_r2 $new_version
echo "✅ 上传完成: $download_url"
# 清理旧 APK
cleanup_old_apks
echo ""
echo "🎉 APK 发布完成!"

View File

@@ -170,6 +170,8 @@ func (h *EpochHandler) GetWeeklyPlans(c *gin.Context) {
now := time.Now()
currentYear, currentWeek := now.ISOWeek()
// 先收集所有计划
var allPlans []*model.WeeklyPlan
for rows.Next() {
var p model.WeeklyPlan
var initialWeight, targetWeight, finalWeight sql.NullFloat64
@@ -186,10 +188,26 @@ func (h *EpochHandler) GetWeeklyPlans(c *gin.Context) {
if finalWeight.Valid {
p.FinalWeight = &finalWeight.Float64
}
allPlans = append(allPlans, &p)
}
// 处理每个计划,设置初始体重(从上一周的最终体重获取)
for i, p := range allPlans {
// 检查上一周是否有最终体重记录
hasPrevFinalWeight := false
if i > 0 {
prevPlan := allPlans[i-1]
if prevPlan.FinalWeight != nil {
// 上一周有最终体重,作为本周初始体重
p.InitialWeight = prevPlan.FinalWeight
hasPrevFinalWeight = true
}
}
detail := model.WeeklyPlanDetail{
Plan: &p,
IsPast: p.Year < currentYear || (p.Year == currentYear && p.Week < currentWeek),
Plan: p,
IsPast: p.Year < currentYear || (p.Year == currentYear && p.Week < currentWeek),
InitialWeightEditable: !hasPrevFinalWeight, // 上一周没有记录时才可编辑
}
// 计算本周减重
@@ -212,6 +230,7 @@ func (h *EpochHandler) GetWeeklyPlans(c *gin.Context) {
// 更新每周计划
func (h *EpochHandler) UpdateWeeklyPlan(c *gin.Context) {
userID := middleware.GetUserID(c)
epochID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
planID, _ := strconv.ParseInt(c.Param("planId"), 10, 64)
var req model.UpdateWeeklyPlanRequest
@@ -220,8 +239,32 @@ func (h *EpochHandler) UpdateWeeklyPlan(c *gin.Context) {
return
}
// 获取当前计划信息
var year, week int
err := h.db.QueryRow("SELECT year, week FROM weekly_plans WHERE id = ? AND user_id = ?", planID, userID).Scan(&year, &week)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plan not found"})
return
}
// 检查是否允许修改初始体重
// 如果上一周有最终体重记录,则不允许修改本周初始体重
if req.InitialWeight != nil {
var prevFinalWeight sql.NullFloat64
h.db.QueryRow(`
SELECT final_weight FROM weekly_plans
WHERE epoch_id = ? AND user_id = ? AND (year < ? OR (year = ? AND week < ?))
ORDER BY year DESC, week DESC LIMIT 1
`, epochID, userID, year, year, week).Scan(&prevFinalWeight)
if prevFinalWeight.Valid {
// 上一周有记录,不允许修改初始体重,使用上一周的最终体重
req.InitialWeight = nil
}
}
// 更新计划
_, err := h.db.Exec(`
_, err = h.db.Exec(`
UPDATE weekly_plans
SET initial_weight = COALESCE(?, initial_weight),
target_weight = COALESCE(?, target_weight),
@@ -238,40 +281,24 @@ func (h *EpochHandler) UpdateWeeklyPlan(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "plan updated"})
}
// 生成每周计划
// 生成每周计划 - 不自动设置目标,初始体重从上周最终体重获取
func (h *EpochHandler) generateWeeklyPlans(epochID, userID int64, startDate, endDate string, initialWeight, targetWeight float64) {
start, _ := time.Parse("2006-01-02", startDate)
end, _ := time.Parse("2006-01-02", endDate)
// 计算总周数
totalDays := int(end.Sub(start).Hours() / 24)
totalWeeks := (totalDays + 6) / 7
if totalWeeks < 1 {
totalWeeks = 1
}
// 每周减重目标
weeklyLoss := (initialWeight - targetWeight) / float64(totalWeeks)
currentDate := start
prevWeight := initialWeight
for currentDate.Before(end) || currentDate.Equal(end) {
year, week := currentDate.ISOWeek()
weekStart := getWeekStart(currentDate)
weekEnd := weekStart.AddDate(0, 0, 6)
weekTarget := prevWeight - weeklyLoss
if weekTarget < targetWeight {
weekTarget = targetWeight
}
// 不设置初始体重和目标体重,由用户手动设置
h.db.Exec(`
INSERT OR IGNORE INTO weekly_plans (epoch_id, user_id, year, week, start_date, end_date, initial_weight, target_weight)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, epochID, userID, year, week, weekStart.Format("2006-01-02"), weekEnd.Format("2006-01-02"), prevWeight, weekTarget)
INSERT OR IGNORE INTO weekly_plans (epoch_id, user_id, year, week, start_date, end_date)
VALUES (?, ?, ?, ?, ?, ?)
`, epochID, userID, year, week, weekStart.Format("2006-01-02"), weekEnd.Format("2006-01-02"))
prevWeight = weekTarget
currentDate = currentDate.AddDate(0, 0, 7)
}
}

View File

@@ -61,10 +61,11 @@ type WeeklyPlan struct {
// 周计划详情
type WeeklyPlanDetail struct {
Plan *WeeklyPlan `json:"plan"`
WeightChange *float64 `json:"weight_change"` // 本周减重
IsQualified bool `json:"is_qualified"` // 是否达标
IsPast bool `json:"is_past"` // 是否已过
Plan *WeeklyPlan `json:"plan"`
WeightChange *float64 `json:"weight_change"` // 本周减重
IsQualified bool `json:"is_qualified"` // 是否达标
IsPast bool `json:"is_past"` // 是否已过
InitialWeightEditable bool `json:"initial_weight_editable"` // 初始体重是否可编辑
}
// 体重目标 (保留兼容)