增加减重纪元概念&页面重新设计

This commit is contained in:
amos wong
2025-12-21 14:30:52 +08:00
parent 44fb9a4976
commit b97578706b
12 changed files with 1423 additions and 1014 deletions

View File

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

View File

@@ -104,7 +104,10 @@ data class EpochDetail(
@SerialName("current_weight") val currentWeight: Double? = null,
@SerialName("actual_change") val actualChange: Double? = null,
@SerialName("distance_to_goal") val distanceToGoal: Double? = null,
@SerialName("is_qualified") val isQualified: Boolean = false
@SerialName("is_qualified") val isQualified: Boolean = false,
@SerialName("epoch_total_loss") val epochTotalLoss: Double? = null,
@SerialName("year_total_loss") val yearTotalLoss: Double? = null,
@SerialName("all_time_total_loss") val allTimeTotalLoss: Double? = null
)
// 每周计划

View File

@@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@@ -22,6 +23,8 @@ import com.healthflow.app.data.model.User
import com.healthflow.app.ui.screen.*
import com.healthflow.app.ui.theme.*
import com.healthflow.app.ui.viewmodel.EpochViewModel
import java.time.LocalDate
import java.time.temporal.WeekFields
sealed class Screen(
val route: String,
@@ -29,23 +32,23 @@ sealed class Screen(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector
) {
data object Weight : Screen("weight", "体重", Icons.Filled.MonitorWeight, Icons.Outlined.MonitorWeight)
data object Epoch : Screen("epoch", "纪元", Icons.Filled.Flag, Icons.Outlined.Flag)
data object Home : Screen("home", "首页", Icons.Filled.Home, Icons.Outlined.Home)
data object Profile : Screen("profile", "我的", Icons.Filled.Person, Icons.Outlined.Person)
}
// 非底部导航的路由
object Routes {
const val SETUP = "setup"
const val EPOCH_LIST = "epoch_list"
const val EPOCH_DETAIL = "epoch_detail/{epochId}"
const val WEEKLY_PLANS = "weekly_plans/{epochId}"
const val WEEK_PLAN_DETAIL = "week_plan_detail/{epochId}/{planId}"
fun epochDetail(epochId: Long) = "epoch_detail/$epochId"
fun weeklyPlans(epochId: Long) = "weekly_plans/$epochId"
fun weekPlanDetail(epochId: Long, planId: Long) = "week_plan_detail/$epochId/$planId"
}
val bottomNavItems = listOf(Screen.Weight, Screen.Epoch, Screen.Profile)
val bottomNavItems = listOf(Screen.Home, Screen.Profile)
@Composable
fun MainNavigation(
@@ -60,6 +63,7 @@ fun MainNavigation(
val epochList by epochViewModel.epochList.collectAsState()
val epochDetail by epochViewModel.epochDetail.collectAsState()
val weeklyPlans by epochViewModel.weeklyPlans.collectAsState()
val selectedPlan by epochViewModel.selectedPlan.collectAsState()
val isLoading by epochViewModel.isLoading.collectAsState()
val error by epochViewModel.error.collectAsState()
@@ -68,11 +72,26 @@ fun MainNavigation(
epochViewModel.checkActiveEpoch()
}
// 根据是否有活跃纪元决定起始页面
// 加载活跃纪元详情
LaunchedEffect(activeEpoch) {
activeEpoch?.let { epoch ->
epochViewModel.loadEpochDetail(epoch.id)
epochViewModel.loadWeeklyPlans(epoch.id)
}
}
// 获取当前周计划
val currentWeekPlan = remember(weeklyPlans) {
val now = LocalDate.now()
val currentYear = now.year
val currentWeek = now.get(WeekFields.ISO.weekOfWeekBasedYear())
weeklyPlans.find { it.plan.year == currentYear && it.plan.week == currentWeek }
}
val startDestination = when (hasActiveEpoch) {
null -> Screen.Weight.route // 加载中,先显示体重页
null -> Screen.Home.route
false -> Routes.SETUP
true -> Screen.Weight.route
true -> Screen.Home.route
}
Box(modifier = Modifier.fillMaxSize()) {
@@ -85,13 +104,14 @@ fun MainNavigation(
composable(Routes.SETUP) {
SetupScreen(
onSetupComplete = {
navController.navigate(Screen.Weight.route) {
navController.navigate(Screen.Home.route) {
popUpTo(Routes.SETUP) { inclusive = true }
}
},
onCreateEpoch = { name, initial, target, start, end ->
epochViewModel.createEpoch(name, initial, target, start, end) {
navController.navigate(Screen.Weight.route) {
epochViewModel.checkActiveEpoch()
navController.navigate(Screen.Home.route) {
popUpTo(Routes.SETUP) { inclusive = true }
}
}
@@ -101,13 +121,40 @@ fun MainNavigation(
)
}
// 体重页面
composable(Screen.Weight.route) {
WeightScreen()
// 首页
composable(Screen.Home.route) {
HomeScreen(
epochDetail = epochDetail,
currentWeekPlan = currentWeekPlan,
isLoading = isLoading,
onViewAllPlans = {
activeEpoch?.let { epoch ->
navController.navigate(Routes.weeklyPlans(epoch.id))
}
},
onViewEpochDetail = {
activeEpoch?.let { epoch ->
navController.navigate(Routes.epochDetail(epoch.id))
}
},
onViewHistory = {
navController.navigate(Routes.EPOCH_LIST)
},
onRecordWeight = { weight ->
activeEpoch?.let { epoch ->
currentWeekPlan?.let { plan ->
epochViewModel.updateWeeklyPlan(epoch.id, plan.plan.id, weight) {
epochViewModel.loadWeeklyPlans(epoch.id)
epochViewModel.loadEpochDetail(epoch.id)
}
}
}
}
)
}
// 纪元入口页面 - 显示纪元列表
composable(Screen.Epoch.route) {
// 纪元列表
composable(Routes.EPOCH_LIST) {
LaunchedEffect(Unit) {
epochViewModel.loadEpochList()
}
@@ -150,12 +197,42 @@ fun MainNavigation(
val epochId = backStackEntry.arguments?.getLong("epochId") ?: 0L
LaunchedEffect(epochId) {
epochViewModel.loadWeeklyPlans(epochId)
epochViewModel.loadEpochDetail(epochId)
}
WeeklyPlanScreen(
epoch = epochDetail?.epoch ?: activeEpoch,
epochDetail = epochDetail,
plans = weeklyPlans,
onBack = { navController.popBackStack() },
onPlanClick = { /* 可以添加编辑功能 */ }
onPlanClick = { planDetail ->
epochViewModel.selectPlan(planDetail)
navController.navigate(Routes.weekPlanDetail(epochId, planDetail.plan.id))
}
)
}
// 周计划详情页面
composable(
route = Routes.WEEK_PLAN_DETAIL,
arguments = listOf(
navArgument("epochId") { type = NavType.LongType },
navArgument("planId") { type = NavType.LongType }
)
) { backStackEntry ->
val epochId = backStackEntry.arguments?.getLong("epochId") ?: 0L
WeekPlanDetailScreen(
planDetail = selectedPlan,
epochDetail = epochDetail,
onBack = { navController.popBackStack() },
onUpdateWeight = { weight ->
selectedPlan?.let { plan ->
epochViewModel.updateWeeklyPlan(epochId, plan.plan.id, weight) {
epochViewModel.loadWeeklyPlans(epochId)
epochViewModel.loadEpochDetail(epochId)
}
}
}
)
}
@@ -169,20 +246,16 @@ fun MainNavigation(
}
}
// Bottom Navigation - 只在主页面显示
// Bottom Navigation
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val showBottomNav = currentRoute in listOf(
Screen.Weight.route,
Screen.Epoch.route,
Screen.Profile.route
)
val showBottomNav = currentRoute in listOf(Screen.Home.route, Screen.Profile.route)
if (showBottomNav && hasActiveEpoch != false) {
Surface(
modifier = Modifier
.fillMaxWidth()
.align(androidx.compose.ui.Alignment.BottomCenter),
.align(Alignment.BottomCenter),
color = MaterialTheme.colorScheme.surface
) {
Column {
@@ -191,10 +264,9 @@ fun MainNavigation(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.height(64.dp)
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
.height(56.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
bottomNavItems.forEach { screen ->
val selected = navBackStackEntry?.destination?.hierarchy?.any {

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
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.Add
import androidx.compose.material.icons.outlined.Flag
import androidx.compose.material3.*
@@ -39,29 +40,47 @@ fun EpochListScreen(
// Header
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
) {
Box(
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 16.dp)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.clickable { onBack() },
contentAlignment = Alignment.Center
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "减重纪元",
fontSize = 20.sp,
text = "历史纪元",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.Center)
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = onCreateNew,
modifier = Modifier.align(Alignment.CenterEnd)
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Default.Add,
contentDescription = "新建纪元",
tint = Brand500
tint = Brand500,
modifier = Modifier.size(22.dp)
)
}
}

View File

@@ -1,20 +1,50 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.*
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.healthflow.app.data.model.EpochDetail
import com.healthflow.app.data.model.WeeklyPlanDetail
import com.healthflow.app.ui.theme.*
import java.time.LocalDate
import java.time.temporal.WeekFields
private fun formatDate(dateStr: String): String {
return dateStr.substringBefore("T").trim()
}
@Composable
fun HomeScreen() {
fun HomeScreen(
epochDetail: EpochDetail?,
currentWeekPlan: WeeklyPlanDetail?,
isLoading: Boolean,
onViewAllPlans: () -> Unit,
onViewEpochDetail: () -> Unit,
onViewHistory: () -> Unit,
onRecordWeight: (Double) -> Unit
) {
var showWeightDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
@@ -23,7 +53,7 @@ fun HomeScreen() {
// Header
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
) {
Box(
modifier = Modifier
@@ -33,7 +63,7 @@ fun HomeScreen() {
contentAlignment = Alignment.Center
) {
Text(
text = "首页",
text = "HealthFlow",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
@@ -43,42 +73,495 @@ fun HomeScreen() {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
// Content
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (isLoading && epochDetail == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = Brand500, strokeWidth = 2.dp)
}
} else if (epochDetail == null) {
EmptyState()
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 纪元进度卡片
EpochProgressCard(
epochDetail = epochDetail,
onClick = onViewEpochDetail
)
// 本周体重卡片
CurrentWeekCard(
plan = currentWeekPlan,
onRecordClick = { showWeightDialog = true }
)
// 统计数据
StatsCard(epochDetail = epochDetail)
// 快捷入口
QuickActions(
onViewAllPlans = onViewAllPlans,
onViewHistory = onViewHistory
)
Spacer(modifier = Modifier.height(80.dp))
}
}
}
// 记录体重弹窗
if (showWeightDialog) {
WeightInputDialog(
currentWeight = currentWeekPlan?.plan?.finalWeight,
onDismiss = { showWeightDialog = false },
onConfirm = { weight ->
onRecordWeight(weight)
showWeightDialog = false
}
)
}
}
@Composable
private fun EmptyState() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
) {
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Text(text = "⚖️", fontSize = 28.sp)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "开始你的减重之旅",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "创建一个减重纪元开始记录",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun EpochProgressCard(
epochDetail: EpochDetail,
onClick: () -> Unit
) {
val epoch = epochDetail.epoch
val totalDays = epochDetail.epochDaysPassed + epochDetail.daysRemaining
val progress = if (totalDays > 0) epochDetail.epochDaysPassed.toFloat() / totalDays else 0f
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = epoch.name.ifEmpty { "减重纪元" },
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "${formatDate(epoch.startDate)} ~ ${formatDate(epoch.endDate)}",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
Icons.AutoMirrored.Outlined.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
// 进度条
Box(
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(Brand100),
contentAlignment = Alignment.Center
) {
Text(text = "💚", fontSize = 36.sp)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "欢迎使用 HealthFlow",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
.fillMaxWidth(progress.coerceIn(0f, 1f))
.fillMaxHeight()
.clip(RoundedCornerShape(3.dp))
.background(Brand500)
)
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "健康功能即将上线",
fontSize = 14.sp,
text = "已进行 ${epochDetail.epochDaysPassed}",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "剩余 ${epochDetail.daysRemaining}",
fontSize = 12.sp,
color = if (epochDetail.daysRemaining <= 7) ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun CurrentWeekCard(
plan: WeeklyPlanDetail?,
onRecordClick: () -> Unit
) {
val currentYear = LocalDate.now().year
val currentWeek = LocalDate.now().get(WeekFields.ISO.weekOfWeekBasedYear())
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "本周体重",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "${currentYear}年第${currentWeek}",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(20.dp))
if (plan != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
WeightItem(
label = "初始",
value = plan.plan.initialWeight,
color = MaterialTheme.colorScheme.onSurface
)
WeightItem(
label = "目标",
value = plan.plan.targetWeight,
color = Brand500
)
WeightItem(
label = "实际",
value = plan.plan.finalWeight,
color = MaterialTheme.colorScheme.onSurface
)
}
// 本周减重
if (plan.weightChange != null) {
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "本周减重 ",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val change = plan.weightChange
val isLoss = change > 0
Text(
text = "${if (isLoss) "-" else "+"}${String.format("%.1f", kotlin.math.abs(change))} kg",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = if (isLoss) SuccessGreen else ErrorRed
)
}
}
} else {
Text(
text = "暂无本周数据",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
Spacer(modifier = Modifier.height(16.dp))
// 记录按钮
Button(
onClick = onRecordClick,
modifier = Modifier
.fillMaxWidth()
.height(44.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Brand500)
) {
Text(
text = "记录体重",
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}
@Composable
private fun WeightItem(
label: String,
value: Double?,
color: Color
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = label,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value?.let { String.format("%.1f", it) } ?: "--",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = color
)
Text(
text = "kg",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun StatsCard(epochDetail: EpochDetail) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = "减重统计",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
label = "本纪元",
value = epochDetail.epochTotalLoss
)
StatItem(
label = "本年度",
value = epochDetail.yearTotalLoss
)
StatItem(
label = "累计",
value = epochDetail.allTimeTotalLoss
)
}
}
}
}
@Composable
private fun StatItem(label: String, value: Double?) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val displayValue = value ?: 0.0
val isLoss = displayValue > 0
Text(
text = if (displayValue != 0.0) {
"${if (isLoss) "-" else "+"}${String.format("%.1f", kotlin.math.abs(displayValue))}"
} else "--",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = when {
displayValue > 0 -> SuccessGreen
displayValue < 0 -> ErrorRed
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
Text(
text = label,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun QuickActions(
onViewAllPlans: () -> Unit,
onViewHistory: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
QuickActionItem(
icon = Icons.Outlined.CalendarMonth,
label = "周计划",
onClick = onViewAllPlans,
modifier = Modifier.weight(1f)
)
QuickActionItem(
icon = Icons.Outlined.History,
label = "历史纪元",
onClick = onViewHistory,
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun QuickActionItem(
icon: ImageVector,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
tint = Brand500,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = label,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@Composable
private fun WeightInputDialog(
currentWeight: Double?,
onDismiss: () -> Unit,
onConfirm: (Double) -> Unit
) {
var weightText by remember { mutableStateOf(currentWeight?.toString() ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
shape = RoundedCornerShape(20.dp),
containerColor = MaterialTheme.colorScheme.surface,
title = {
Text(
text = "记录体重",
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp
)
},
text = {
OutlinedTextField(
value = weightText,
onValueChange = { weightText = it },
label = { Text("体重 (kg)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
focusedLabelColor = Brand500,
cursorColor = Brand500
)
)
},
confirmButton = {
TextButton(
onClick = { weightText.toDoubleOrNull()?.let { onConfirm(it) } },
enabled = weightText.toDoubleOrNull() != null
) {
Text(
"确定",
color = if (weightText.toDoubleOrNull() != null) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
)
}

View File

@@ -4,15 +4,16 @@ import android.app.DatePickerDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@@ -31,31 +32,29 @@ fun SetupScreen(
isLoading: Boolean = false,
error: String? = null
) {
var epochName by remember { mutableStateOf("") }
var initialWeight by remember { mutableStateOf("") }
var targetWeight by remember { mutableStateOf("") }
var startDate by remember { mutableStateOf(LocalDate.now().format(DateTimeFormatter.ISO_DATE)) }
var endDate by remember { mutableStateOf("") }
val startDate = remember { LocalDate.now().format(DateTimeFormatter.ISO_DATE) }
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(60.dp))
Spacer(modifier = Modifier.height(40.dp))
// 欢迎标题
Text(
text = "🎯",
fontSize = 48.sp
)
Text(text = "🎯", fontSize = 48.sp)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "开始你的减重之旅",
text = "创建减重纪元",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
@@ -64,19 +63,37 @@ fun SetupScreen(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "设置你的初始体重、目标体重和截止日期",
text = "设置纪元名称、目标和时间范围",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(48.dp))
Spacer(modifier = Modifier.height(32.dp))
// 纪元名称
OutlinedTextField(
value = epochName,
onValueChange = { epochName = it },
label = { Text("纪元名称") },
placeholder = { Text("2024年度减重计划") },
singleLine = true,
enabled = !isLoading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
focusedLabelColor = Brand500
)
)
Spacer(modifier = Modifier.height(16.dp))
// 初始体重
OutlinedTextField(
value = initialWeight,
onValueChange = { initialWeight = it },
label = { Text("当前体重 (kg)") },
label = { Text("初始体重 (kg)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
enabled = !isLoading,
@@ -108,64 +125,110 @@ fun SetupScreen(
Spacer(modifier = Modifier.height(16.dp))
// 截止日期
Box(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !isLoading) {
val calendar = Calendar.getInstance()
calendar.add(Calendar.MONTH, 3) // 默认3个月后
DatePickerDialog(
context,
{ _, year, month, day ->
endDate = String.format("%04d-%02d-%02d", year, month + 1, day)
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH)
).show()
}
// 日期选择行
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = endDate,
onValueChange = { },
label = { Text("截止日期") },
readOnly = true,
enabled = false,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
),
trailingIcon = {
Icon(
Icons.Outlined.CalendarMonth,
contentDescription = "选择日期",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
// 开始日期
Box(
modifier = Modifier
.weight(1f)
.clickable(enabled = !isLoading) {
val calendar = Calendar.getInstance()
DatePickerDialog(
context,
{ _, year, month, day ->
startDate = String.format("%04d-%02d-%02d", year, month + 1, day)
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH)
).show()
}
) {
OutlinedTextField(
value = startDate,
onValueChange = { },
label = { Text("开始日期") },
readOnly = true,
enabled = false,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant
),
trailingIcon = {
Icon(
Icons.Outlined.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
// 结束日期
Box(
modifier = Modifier
.weight(1f)
.clickable(enabled = !isLoading) {
val calendar = Calendar.getInstance()
calendar.add(Calendar.MONTH, 3)
DatePickerDialog(
context,
{ _, year, month, day ->
endDate = String.format("%04d-%02d-%02d", year, month + 1, day)
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH)
).show()
}
) {
OutlinedTextField(
value = endDate,
onValueChange = { },
label = { Text("结束日期") },
readOnly = true,
enabled = false,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant
),
trailingIcon = {
Icon(
Icons.Outlined.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(12.dp))
// 提示
// 提示信息
val initial = initialWeight.toDoubleOrNull()
val target = targetWeight.toDoubleOrNull()
if (initial != null && target != null && target < initial) {
val diff = initial - target
Text(
text = "目标减重 ${String.format("%.1f", diff)} kg",
text = "目标减重 ${String.format("%.1f", initial - target)} kg",
fontSize = 13.sp,
color = Brand500
)
}
// 错误提示
if (error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
@@ -176,16 +239,19 @@ fun SetupScreen(
}
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.height(24.dp))
// 开始按钮
val isValid = initialWeight.toDoubleOrNull() != null &&
// 创建按钮
val isValid = epochName.isNotBlank() &&
initialWeight.toDoubleOrNull() != null &&
targetWeight.toDoubleOrNull() != null &&
startDate.isNotEmpty() &&
endDate.isNotEmpty()
Button(
onClick = {
onCreateEpoch(
"减重纪元",
epochName.ifBlank { "减重纪元" },
initialWeight.toDouble(),
targetWeight.toDouble(),
startDate,
@@ -207,7 +273,7 @@ fun SetupScreen(
)
} else {
Text(
text = "开始减重",
text = "创建纪元",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)

View File

@@ -0,0 +1,447 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
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.ui.theme.*
private fun formatDate(dateStr: String): String {
return dateStr.substringBefore("T").trim()
}
@Composable
fun WeekPlanDetailScreen(
planDetail: WeeklyPlanDetail?,
epochDetail: EpochDetail?,
onBack: () -> Unit,
onUpdateWeight: (Double) -> Unit
) {
var showWeightDialog by remember { mutableStateOf(false) }
val plan = planDetail?.plan
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
// Header
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.clickable { onBack() },
contentAlignment = Alignment.Center
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = plan?.let { "${it.week}" } ?: "周计划",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(36.dp))
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
if (plan == null || epochDetail == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Brand500, strokeWidth = 2.dp)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 周信息卡片
WeekInfoCard(planDetail)
// 统计数据
StatsCard(epochDetail)
// 体重详情
WeightCard(planDetail)
// 记录按钮
if (!planDetail.isPast) {
Button(
onClick = { showWeightDialog = true },
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Brand500)
) {
Text("记录本周体重", fontSize = 15.sp, fontWeight = FontWeight.Medium)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
if (showWeightDialog && plan != null) {
WeightDialog(
currentWeight = plan.finalWeight,
onDismiss = { showWeightDialog = false },
onConfirm = { weight ->
onUpdateWeight(weight)
showWeightDialog = false
}
)
}
}
@Composable
private fun WeekInfoCard(planDetail: WeeklyPlanDetail) {
val plan = planDetail.plan
val showStamp = planDetail.isPast && plan.finalWeight != null
Box {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "📅", fontSize = 36.sp)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${plan.year}年 第${plan.week}",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${formatDate(plan.startDate)} ~ ${formatDate(plan.endDate)}",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (planDetail.isPast) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "已结束",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
if (showStamp) {
LargeStamp(
isQualified = planDetail.isQualified,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(12.dp)
)
}
}
}
@Composable
private fun LargeStamp(isQualified: Boolean, modifier: Modifier = Modifier) {
val color = if (isQualified) SuccessGreen else ErrorRed
val text = if (isQualified) "合格" else "不合格"
Box(
modifier = modifier
.size(if (isQualified) 56.dp else 64.dp)
.rotate(-20f),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = color.copy(alpha = 0.85f),
radius = size.minDimension / 2,
style = Stroke(width = 3.dp.toPx())
)
drawCircle(
color = color.copy(alpha = 0.6f),
radius = size.minDimension / 2 - 5.dp.toPx(),
style = Stroke(width = 1.5.dp.toPx())
)
}
Text(
text = text,
fontSize = if (isQualified) 13.sp else 11.sp,
fontWeight = FontWeight.Bold,
color = color.copy(alpha = 0.9f),
textAlign = TextAlign.Center
)
}
}
@Composable
private fun StatsCard(epochDetail: EpochDetail) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "统计数据",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(value = "${epochDetail.yearDaysPassed}", label = "今年已过")
StatItem(value = "${epochDetail.epochDaysPassed}", label = "纪元已过")
StatItem(
value = "${epochDetail.daysRemaining}",
label = "剩余",
highlight = epochDetail.daysRemaining <= 7
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
WeightStatItem(label = "本纪元", value = epochDetail.epochTotalLoss)
WeightStatItem(label = "本年度", value = epochDetail.yearTotalLoss)
WeightStatItem(label = "累计", value = epochDetail.allTimeTotalLoss)
}
}
}
}
@Composable
private fun StatItem(value: String, label: String, highlight: Boolean = false) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = if (highlight) ErrorRed else MaterialTheme.colorScheme.onSurface
)
Text(
text = label,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun WeightStatItem(label: String, value: Double?) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val v = value ?: 0.0
val isLoss = v > 0
Text(
text = if (v != 0.0) {
"${if (isLoss) "-" else "+"}${String.format("%.1f", kotlin.math.abs(v))}"
} else "--",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = when {
v > 0 -> SuccessGreen
v < 0 -> ErrorRed
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
Text(
text = label,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun WeightCard(planDetail: WeeklyPlanDetail) {
val plan = planDetail.plan
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "本周体重",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
WeightColumn(label = "初始", value = plan.initialWeight)
WeightColumn(label = "目标", value = plan.targetWeight, highlight = true)
WeightColumn(label = "实际", value = plan.finalWeight)
}
if (planDetail.weightChange != null) {
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "本周减重 ",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val change = planDetail.weightChange
val isLoss = change > 0
Text(
text = "${if (isLoss) "-" else "+"}${String.format("%.1f", kotlin.math.abs(change))} kg",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = if (isLoss) SuccessGreen else ErrorRed
)
}
}
}
}
}
@Composable
private fun WeightColumn(label: String, value: Double?, highlight: Boolean = false) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = label,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value?.let { String.format("%.1f", it) } ?: "--",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = if (highlight) Brand500 else MaterialTheme.colorScheme.onSurface
)
Text(
text = "kg",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun WeightDialog(
currentWeight: Double?,
onDismiss: () -> Unit,
onConfirm: (Double) -> Unit
) {
var text by remember { mutableStateOf(currentWeight?.toString() ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
shape = RoundedCornerShape(20.dp),
containerColor = MaterialTheme.colorScheme.surface,
title = {
Text("记录体重", fontWeight = FontWeight.SemiBold, fontSize = 18.sp)
},
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("体重 (kg)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
focusedLabelColor = Brand500,
cursorColor = Brand500
)
)
},
confirmButton = {
TextButton(
onClick = { text.toDoubleOrNull()?.let { onConfirm(it) } },
enabled = text.toDoubleOrNull() != null
) {
Text(
"确定",
color = if (text.toDoubleOrNull() != null) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
)
}

View File

@@ -1,32 +1,34 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.Canvas
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.CircleShape
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.outlined.*
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.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.Brand500
import com.healthflow.app.ui.theme.SuccessGreen
import com.healthflow.app.ui.theme.ErrorRed
import com.healthflow.app.ui.theme.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.WeekFields
import java.util.*
// 格式化日期字符串
private fun formatDate(dateStr: String): String {
return dateStr.substringBefore("T").trim()
}
@@ -34,6 +36,7 @@ private fun formatDate(dateStr: String): String {
@Composable
fun WeeklyPlanScreen(
epoch: WeightEpoch?,
epochDetail: EpochDetail?,
plans: List<WeeklyPlanDetail>,
onBack: () -> Unit,
onPlanClick: (WeeklyPlanDetail) -> Unit
@@ -49,73 +52,44 @@ fun WeeklyPlanScreen(
// Header
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 4.dp, vertical = 8.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.clickable { onBack() },
contentAlignment = Alignment.Center
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回",
tint = MaterialTheme.colorScheme.onSurface
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "每周计划",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(36.dp))
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
// 纪元时间范围
if (epoch != null) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(12.dp),
color = Brand500.copy(alpha = 0.1f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.DateRange,
contentDescription = null,
tint = Brand500,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = epoch.name.ifEmpty { "减重纪元" },
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Brand500
)
Text(
text = "${formatDate(epoch.startDate)} ~ ${formatDate(epoch.endDate)}",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
if (plans.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
@@ -134,13 +108,13 @@ fun WeeklyPlanScreen(
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(plans) { planDetail ->
val plan = planDetail.plan
val isCurrent = plan.year == currentYear && plan.week == currentWeek
WeeklyPlanCard(
WeekCard(
planDetail = planDetail,
isCurrent = isCurrent,
onClick = { onPlanClick(planDetail) }
@@ -153,140 +127,113 @@ fun WeeklyPlanScreen(
}
@Composable
private fun WeeklyPlanCard(
private fun WeekCard(
planDetail: WeeklyPlanDetail,
isCurrent: Boolean,
onClick: () -> Unit
) {
val plan = planDetail.plan
val showStamp = planDetail.isPast && plan.finalWeight != null
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = if (isCurrent) Brand500.copy(alpha = 0.05f) else MaterialTheme.colorScheme.surface,
tonalElevation = if (isCurrent) 0.dp else 1.dp,
color = when {
isCurrent -> Brand500.copy(alpha = 0.05f)
planDetail.isPast -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
else -> MaterialTheme.colorScheme.surface
},
tonalElevation = if (isCurrent || planDetail.isPast) 0.dp else 1.dp,
onClick = onClick
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${plan.week}",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = if (isCurrent) Brand500 else MaterialTheme.colorScheme.onSurface
)
if (isCurrent) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(Brand500)
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(text = "本周", fontSize = 10.sp, color = Color.White)
}
}
}
Text(
text = "${plan.week}",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = if (isCurrent) Brand500 else MaterialTheme.colorScheme.onSurface
text = "${formatDate(plan.startDate)} ~ ${formatDate(plan.endDate)}",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (isCurrent) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(Brand500)
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
WeightItem(label = "初始", value = plan.initialWeight)
WeightItem(label = "目标", value = plan.targetWeight, color = Brand500)
WeightItem(label = "实际", value = plan.finalWeight)
Column(horizontalAlignment = Alignment.End) {
Text(
text = "减重",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val change = planDetail.weightChange
if (change != null) {
val isLoss = change > 0
Text(
text = "本周",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onPrimary
text = "${if (isLoss) "-" else "+"}${String.format("%.1f", kotlin.math.abs(change))}",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = if (isLoss) SuccessGreen else ErrorRed
)
} else {
Text(
text = "--",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// 状态标签
if (planDetail.isPast && plan.finalWeight != null) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(
if (planDetail.isQualified) SuccessGreen.copy(alpha = 0.1f)
else ErrorRed.copy(alpha = 0.1f)
)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = if (planDetail.isQualified) "已达标" else "未达标",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = if (planDetail.isQualified) SuccessGreen else ErrorRed
)
}
} else if (!planDetail.isPast && !isCurrent) {
Text(
text = "待进行",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${formatDate(plan.startDate)} ~ ${formatDate(plan.endDate)}",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
// 体重信息
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
WeekWeightItem(
label = "初始",
value = plan.initialWeight?.let { String.format("%.1f", it) } ?: "--"
// 盖章
if (showStamp) {
StampMark(
isQualified = planDetail.isQualified,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
WeekWeightItem(
label = "目标",
value = plan.targetWeight?.let { String.format("%.1f", it) } ?: "--",
valueColor = Brand500
)
WeekWeightItem(
label = "实际",
value = plan.finalWeight?.let { String.format("%.1f", it) } ?: "--"
)
// 本周减重
Column(horizontalAlignment = Alignment.End) {
Text(
text = "减重",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val change = planDetail.weightChange
if (change != null) {
val isLoss = change > 0
Text(
text = "${if (isLoss) "-" else "+"}${String.format("%.1f", kotlin.math.abs(change))}",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = if (isLoss) SuccessGreen else ErrorRed
)
} else {
Text(
text = "--",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
@Composable
private fun WeekWeightItem(
private fun WeightItem(
label: String,
value: String,
valueColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface
value: Double?,
color: Color = MaterialTheme.colorScheme.onSurface
) {
Column {
Text(
@@ -295,10 +242,46 @@ private fun WeekWeightItem(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
text = value?.let { String.format("%.1f", it) } ?: "--",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = valueColor
color = color
)
}
}
@Composable
private fun StampMark(
isQualified: Boolean,
modifier: Modifier = Modifier
) {
val stampColor = if (isQualified) SuccessGreen else ErrorRed
val stampText = if (isQualified) "合格" else "不合格"
Box(
modifier = modifier
.size(if (isQualified) 44.dp else 52.dp)
.rotate(-15f),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = stampColor.copy(alpha = 0.8f),
radius = size.minDimension / 2,
style = Stroke(width = 2.5.dp.toPx())
)
drawCircle(
color = stampColor.copy(alpha = 0.5f),
radius = size.minDimension / 2 - 4.dp.toPx(),
style = Stroke(width = 1.dp.toPx())
)
}
Text(
text = stampText,
fontSize = if (isQualified) 11.sp else 9.sp,
fontWeight = FontWeight.Bold,
color = stampColor.copy(alpha = 0.9f),
textAlign = TextAlign.Center
)
}
}

View File

@@ -1,714 +0,0 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Flag
import androidx.compose.material.icons.outlined.Scale
import androidx.compose.material.icons.outlined.Start
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.healthflow.app.ui.theme.*
import com.healthflow.app.ui.viewmodel.WeightViewModel
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.time.temporal.WeekFields
@Composable
fun WeightScreen(
viewModel: WeightViewModel = viewModel()
) {
val weekData by viewModel.currentWeekData.collectAsState()
val stats by viewModel.stats.collectAsState()
val goal by viewModel.goal.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val currentYear by viewModel.currentYear.collectAsState()
val currentWeek by viewModel.currentWeek.collectAsState()
var showWeightDialog by remember { mutableStateOf(false) }
var showGoalDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loadData()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
// Header
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface
) {
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "体重管理",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
}
}
if (isLoading && weekData == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = Brand500)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp, vertical = 16.dp)
) {
// 主卡片
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp
) {
Column(
modifier = Modifier.padding(20.dp)
) {
// 周选择器
WeekSelector(
year = currentYear,
week = currentWeek,
startDate = weekData?.startDate ?: "",
endDate = weekData?.endDate ?: "",
onPrevWeek = { viewModel.goToPrevWeek() },
onNextWeek = { viewModel.goToNextWeek() }
)
Spacer(modifier = Modifier.height(16.dp))
// 统计摘要
StatsSummary(
year = currentYear,
week = currentWeek,
totalChange = stats?.totalChange
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
thickness = 1.dp
)
Spacer(modifier = Modifier.height(24.dp))
// 本周初始体重(上周最终体重)
InfoRowReadOnly(
icon = Icons.Outlined.Scale,
iconColor = Slate400,
label = "本周初始",
value = weekData?.initialWeight?.let { "${String.format("%.1f", it)} kg" } ?: "无数据",
valueColor = if (weekData?.initialWeight != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
// 本周目标体重
InfoRow(
icon = Icons.Outlined.Flag,
iconColor = Brand500,
label = "本周目标",
value = goal?.targetWeight?.let { "${String.format("%.1f", it)} kg" } ?: "未设置",
valueColor = if (goal?.targetWeight != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
onClick = { showGoalDialog = true }
)
Spacer(modifier = Modifier.height(16.dp))
// 本周最终体重
InfoRow(
icon = Icons.Outlined.Scale,
iconColor = SuccessGreen,
label = "本周最终",
value = if (weekData?.hasRecord == true && weekData?.weight != null)
"${String.format("%.1f", weekData!!.weight)} kg" else "未记录",
valueColor = if (weekData?.hasRecord == true) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
onClick = { showWeightDialog = true }
)
// 周结果状态(仅显示过去的周)
val now = LocalDate.now()
val weekFields = WeekFields.ISO
val isCurrentWeek = currentYear == now.get(weekFields.weekBasedYear()) &&
currentWeek == now.get(weekFields.weekOfWeekBasedYear())
val isPastWeek = !isCurrentWeek && (currentYear < now.get(weekFields.weekBasedYear()) ||
(currentYear == now.get(weekFields.weekBasedYear()) && currentWeek < now.get(weekFields.weekOfWeekBasedYear())))
if (isPastWeek && weekData?.hasRecord == true && goal?.targetWeight != null) {
Spacer(modifier = Modifier.height(20.dp))
WeekResultCard(
currentWeight = weekData?.weight ?: 0.0,
targetWeight = goal?.targetWeight ?: 0.0,
weightChange = weekData?.weightChange
)
}
}
}
Spacer(modifier = Modifier.height(20.dp))
// 快捷操作按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ActionButton(
text = "记录体重",
color = Brand500,
modifier = Modifier.weight(1f),
onClick = { showWeightDialog = true }
)
ActionButton(
text = "设置目标",
color = Brand500,
outlined = true,
modifier = Modifier.weight(1f),
onClick = { showGoalDialog = true }
)
}
Spacer(modifier = Modifier.weight(1f))
// 底部留白给导航栏
Spacer(modifier = Modifier.height(80.dp))
}
}
}
// 记录体重弹窗
if (showWeightDialog) {
WeightInputDialog(
initialWeight = weekData?.weight,
onDismiss = { showWeightDialog = false },
onConfirm = { weight ->
viewModel.recordWeight(weight)
showWeightDialog = false
}
)
}
// 设置目标弹窗
if (showGoalDialog) {
GoalInputDialog(
initialTarget = goal?.targetWeight,
onDismiss = { showGoalDialog = false },
onConfirm = { target ->
val today = LocalDate.now().format(DateTimeFormatter.ISO_DATE)
viewModel.setGoal(target, today)
showGoalDialog = false
}
)
}
}
@Composable
private fun WeekResultCard(
currentWeight: Double,
targetWeight: Double,
weightChange: Double?
) {
val isQualified = currentWeight <= targetWeight
val resultColor = if (isQualified) SuccessGreen else ErrorRed
val resultEmoji = if (isQualified) "" else ""
val resultText = if (isQualified) "已合格" else "未合格"
val resultBgColor = if (isQualified) SuccessGreen.copy(alpha = 0.1f) else ErrorRed.copy(alpha = 0.1f)
// 本周实际减重weightChange > 0 表示减重,显示为 -X.Xkg
val changeText = weightChange?.let { change ->
if (change > 0) "-${String.format("%.1f", change)}kg"
else if (change < 0) "+${String.format("%.1f", -change)}kg"
else "0kg"
} ?: "无数据"
val changeColor = when {
weightChange == null -> MaterialTheme.colorScheme.onSurfaceVariant
weightChange > 0 -> SuccessGreen // 减重为绿色
weightChange < 0 -> ErrorRed // 增重为红色
else -> MaterialTheme.colorScheme.onSurface
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = resultBgColor
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// 左侧:本周结果
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = resultEmoji,
fontSize = 18.sp
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = resultText,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = resultColor
)
}
// 右侧:本周实际减重
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "本周:",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = changeText,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = changeColor
)
}
}
}
}
@Composable
private fun WeekSelector(
year: Int,
week: Int,
startDate: String,
endDate: String,
onPrevWeek: () -> Unit,
onNextWeek: () -> Unit
) {
val now = LocalDate.now()
val weekFields = WeekFields.ISO
val isCurrentWeek = year == now.get(weekFields.weekBasedYear()) &&
week == now.get(weekFields.weekOfWeekBasedYear())
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// 左箭头
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onPrevWeek() },
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.ChevronLeft,
contentDescription = "上一周",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 中间日期
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = formatDateRange(startDate, endDate),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "${year}年 第${week}",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 右箭头
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(
if (isCurrentWeek) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else MaterialTheme.colorScheme.surfaceVariant
)
.clickable(enabled = !isCurrentWeek) { onNextWeek() },
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.ChevronRight,
contentDescription = "下一周",
modifier = Modifier.size(20.dp),
tint = if (isCurrentWeek) MaterialTheme.colorScheme.outlineVariant
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun StatsSummary(
year: Int,
week: Int,
totalChange: Double?
) {
val now = LocalDate.now()
val startOfYear = LocalDate.of(year, 1, 1)
val daysPassed = ChronoUnit.DAYS.between(startOfYear, now).toInt()
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = Brand500.copy(alpha = 0.08f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(value = "$daysPassed", label = "已过天数")
Box(
modifier = Modifier
.width(1.dp)
.height(36.dp)
.background(Brand500.copy(alpha = 0.2f))
)
val changeText = totalChange?.let {
val absChange = kotlin.math.abs(it)
if (it < 0) "-${String.format("%.1f", absChange)}"
else if (it > 0) "+${String.format("%.1f", absChange)}"
else "0"
} ?: "--"
StatItem(
value = changeText,
label = "总变化(kg)",
valueColor = when {
totalChange == null -> MaterialTheme.colorScheme.onSurface
totalChange < 0 -> SuccessGreen
totalChange > 0 -> ErrorRed
else -> MaterialTheme.colorScheme.onSurface
}
)
}
}
}
@Composable
private fun StatItem(
value: String,
label: String,
valueColor: Color = MaterialTheme.colorScheme.onSurface
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = valueColor
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = label,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun InfoRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
iconColor: Color,
label: String,
value: String,
valueColor: Color,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable { onClick() }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(iconColor.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = iconColor
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = label,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = value,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = valueColor
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
Icons.Outlined.Edit,
contentDescription = "编辑",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.outline
)
}
}
@Composable
private fun InfoRowReadOnly(
icon: androidx.compose.ui.graphics.vector.ImageVector,
iconColor: Color,
label: String,
value: String,
valueColor: Color
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(iconColor.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = iconColor
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = label,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = value,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = valueColor
)
}
}
@Composable
private fun ActionButton(
text: String,
color: Color,
modifier: Modifier = Modifier,
outlined: Boolean = false,
onClick: () -> Unit
) {
if (outlined) {
OutlinedButton(
onClick = onClick,
modifier = modifier.height(48.dp),
shape = RoundedCornerShape(12.dp),
border = ButtonDefaults.outlinedButtonBorder.copy(
brush = androidx.compose.ui.graphics.SolidColor(color)
)
) {
Text(
text = text,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
color = color
)
}
} else {
Button(
onClick = onClick,
modifier = modifier.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = color)
) {
Text(
text = text,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
}
}
@Composable
private fun WeightInputDialog(
initialWeight: Double?,
onDismiss: () -> Unit,
onConfirm: (Double) -> Unit
) {
var weightText by remember { mutableStateOf(initialWeight?.toString() ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
"记录体重",
fontWeight = FontWeight.Bold
)
},
text = {
OutlinedTextField(
value = weightText,
onValueChange = { weightText = it },
label = { Text("体重 (kg)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
focusedLabelColor = Brand500
)
)
},
confirmButton = {
TextButton(
onClick = {
weightText.toDoubleOrNull()?.let { onConfirm(it) }
},
enabled = weightText.toDoubleOrNull() != null
) {
Text("确定", color = Brand500, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
shape = RoundedCornerShape(20.dp)
)
}
@Composable
private fun GoalInputDialog(
initialTarget: Double?,
onDismiss: () -> Unit,
onConfirm: (Double) -> Unit
) {
var targetText by remember { mutableStateOf(initialTarget?.toString() ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
"设置目标体重",
fontWeight = FontWeight.Bold
)
},
text = {
OutlinedTextField(
value = targetText,
onValueChange = { targetText = it },
label = { Text("目标体重 (kg)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
focusedLabelColor = Brand500
)
)
},
confirmButton = {
TextButton(
onClick = {
targetText.toDoubleOrNull()?.let { onConfirm(it) }
},
enabled = targetText.toDoubleOrNull() != null
) {
Text("确定", color = Brand500, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
shape = RoundedCornerShape(20.dp)
)
}
private fun formatDateRange(startDate: String, endDate: String): String {
if (startDate.isEmpty() || endDate.isEmpty()) return ""
return try {
val start = LocalDate.parse(startDate)
val end = LocalDate.parse(endDate)
"${start.monthValue}.${start.dayOfMonth} - ${end.monthValue}.${end.dayOfMonth}"
} catch (e: Exception) {
"$startDate ~ $endDate"
}
}

View File

@@ -21,6 +21,9 @@ class EpochViewModel : ViewModel() {
private val _weeklyPlans = MutableStateFlow<List<WeeklyPlanDetail>>(emptyList())
val weeklyPlans: StateFlow<List<WeeklyPlanDetail>> = _weeklyPlans
private val _selectedPlan = MutableStateFlow<WeeklyPlanDetail?>(null)
val selectedPlan: StateFlow<WeeklyPlanDetail?> = _selectedPlan
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
@@ -131,6 +134,10 @@ class EpochViewModel : ViewModel() {
val response = ApiClient.api.getWeeklyPlans(epochId)
if (response.isSuccessful) {
_weeklyPlans.value = response.body() ?: emptyList()
// 更新 selectedPlan如果有的话
_selectedPlan.value?.let { selected ->
_selectedPlan.value = _weeklyPlans.value.find { it.plan.id == selected.plan.id }
}
}
} catch (e: Exception) {
e.printStackTrace()
@@ -138,6 +145,10 @@ class EpochViewModel : ViewModel() {
}
}
fun selectPlan(planDetail: WeeklyPlanDetail) {
_selectedPlan.value = planDetail
}
fun updateWeeklyPlan(
epochId: Long,
planId: Long,

View File

@@ -313,6 +313,42 @@ func (h *EpochHandler) calculateEpochDetail(epoch *model.WeightEpoch) *model.Epo
distanceToGoal := latestWeight - epoch.TargetWeight
detail.DistanceToGoal = &distanceToGoal
detail.IsQualified = latestWeight <= epoch.TargetWeight
// 本纪元总减重
epochLoss := epoch.InitialWeight - latestWeight
detail.EpochTotalLoss = &epochLoss
}
// 计算本年度总减重(所有今年纪元的减重总和)
var yearLoss float64
h.db.QueryRow(`
SELECT COALESCE(SUM(e.initial_weight - COALESCE(
(SELECT wp.final_weight FROM weekly_plans wp
WHERE wp.epoch_id = e.id AND wp.final_weight IS NOT NULL
ORDER BY wp.year DESC, wp.week DESC LIMIT 1),
e.initial_weight
)), 0)
FROM weight_epochs e
WHERE e.user_id = ? AND strftime('%Y', e.start_date) = ?
`, epoch.UserID, strconv.Itoa(now.Year())).Scan(&yearLoss)
if yearLoss != 0 {
detail.YearTotalLoss = &yearLoss
}
// 计算累计总减重(所有纪元的减重总和)
var allTimeLoss float64
h.db.QueryRow(`
SELECT COALESCE(SUM(e.initial_weight - COALESCE(
(SELECT wp.final_weight FROM weekly_plans wp
WHERE wp.epoch_id = e.id AND wp.final_weight IS NOT NULL
ORDER BY wp.year DESC, wp.week DESC LIMIT 1),
e.initial_weight
)), 0)
FROM weight_epochs e
WHERE e.user_id = ?
`, epoch.UserID).Scan(&allTimeLoss)
if allTimeLoss != 0 {
detail.AllTimeTotalLoss = &allTimeLoss
}
return detail

View File

@@ -30,14 +30,17 @@ type WeightEpoch struct {
// 纪元详情(包含统计)
type EpochDetail struct {
Epoch *WeightEpoch `json:"epoch"`
YearDaysPassed int `json:"year_days_passed"` // 今年已过天数
EpochDaysPassed int `json:"epoch_days_passed"` // 纪元已过天数
DaysRemaining int `json:"days_remaining"` // 剩余天数
CurrentWeight *float64 `json:"current_weight"` // 当前体重(最新记录)
ActualChange *float64 `json:"actual_change"` // 实际减重
DistanceToGoal *float64 `json:"distance_to_goal"` // 距离目标
IsQualified bool `json:"is_qualified"` // 是否合格
Epoch *WeightEpoch `json:"epoch"`
YearDaysPassed int `json:"year_days_passed"` // 今年已过天数
EpochDaysPassed int `json:"epoch_days_passed"` // 纪元已过天数
DaysRemaining int `json:"days_remaining"` // 剩余天数
CurrentWeight *float64 `json:"current_weight"` // 当前体重(最新记录)
ActualChange *float64 `json:"actual_change"` // 实际减重
DistanceToGoal *float64 `json:"distance_to_goal"` // 距离目标
IsQualified bool `json:"is_qualified"` // 是否合格
EpochTotalLoss *float64 `json:"epoch_total_loss"` // 本纪元总减重
YearTotalLoss *float64 `json:"year_total_loss"` // 本年度总减重
AllTimeTotalLoss *float64 `json:"all_time_total_loss"` // 累计总减重
}
// 每周计划