增加减重纪元概念&页面重新设计
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
// 每周计划
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"` // 累计总减重
|
||||
}
|
||||
|
||||
// 每周计划
|
||||
|
||||
Reference in New Issue
Block a user