feat:UI 完全重构&&完善需求
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
41
release.sh
41
release.sh
@@ -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 发布完成!"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"` // 初始体重是否可编辑
|
||||
}
|
||||
|
||||
// 体重目标 (保留兼容)
|
||||
|
||||
Reference in New Issue
Block a user