减重纪元&减重周计划
This commit is contained in:
@@ -13,11 +13,11 @@ android {
|
||||
applicationId = "com.healthflow.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 4
|
||||
versionName = "1.0.3"
|
||||
versionCode = 17
|
||||
versionName = "1.1.6"
|
||||
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://health.amos.us.kg/api/\"")
|
||||
buildConfigField("int", "VERSION_CODE", "4")
|
||||
buildConfigField("int", "VERSION_CODE", "17")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -23,7 +23,30 @@ interface ApiService {
|
||||
@PUT("user/avatar")
|
||||
suspend fun updateAvatar(@Body request: Map<String, String>): Response<MessageResponse>
|
||||
|
||||
// Weight
|
||||
// Epoch (纪元)
|
||||
@POST("epoch")
|
||||
suspend fun createEpoch(@Body request: CreateEpochRequest): Response<MessageResponse>
|
||||
|
||||
@GET("epoch/active")
|
||||
suspend fun getActiveEpoch(): Response<WeightEpoch?>
|
||||
|
||||
@GET("epoch/list")
|
||||
suspend fun getEpochList(): Response<List<WeightEpoch>>
|
||||
|
||||
@GET("epoch/{id}")
|
||||
suspend fun getEpochDetail(@Path("id") id: Long): Response<EpochDetail>
|
||||
|
||||
@GET("epoch/{id}/plans")
|
||||
suspend fun getWeeklyPlans(@Path("id") epochId: Long): Response<List<WeeklyPlanDetail>>
|
||||
|
||||
@PUT("epoch/{id}/plan/{planId}")
|
||||
suspend fun updateWeeklyPlan(
|
||||
@Path("id") epochId: Long,
|
||||
@Path("planId") planId: Long,
|
||||
@Body request: UpdateWeeklyPlanRequest
|
||||
): Response<MessageResponse>
|
||||
|
||||
// Weight (保留兼容)
|
||||
@GET("weight/goal")
|
||||
suspend fun getWeightGoal(): Response<WeightGoal?>
|
||||
|
||||
|
||||
@@ -61,7 +61,8 @@ data class VersionInfo(
|
||||
|
||||
@Serializable
|
||||
data class MessageResponse(
|
||||
val message: String
|
||||
val message: String,
|
||||
val id: Long? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -69,7 +70,77 @@ data class ErrorResponse(
|
||||
val error: String
|
||||
)
|
||||
|
||||
// 体重相关
|
||||
// 减重纪元
|
||||
@Serializable
|
||||
data class WeightEpoch(
|
||||
val id: Long = 0,
|
||||
@SerialName("user_id") val userId: Long = 0,
|
||||
val name: String = "",
|
||||
@SerialName("initial_weight") val initialWeight: Double,
|
||||
@SerialName("target_weight") val targetWeight: Double,
|
||||
@SerialName("start_date") val startDate: String,
|
||||
@SerialName("end_date") val endDate: String,
|
||||
@SerialName("final_weight") val finalWeight: Double? = null,
|
||||
@SerialName("is_active") val isActive: Boolean = true,
|
||||
@SerialName("is_completed") val isCompleted: Boolean = false,
|
||||
@SerialName("created_at") val createdAt: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateEpochRequest(
|
||||
val name: String = "",
|
||||
@SerialName("initial_weight") val initialWeight: Double,
|
||||
@SerialName("target_weight") val targetWeight: Double,
|
||||
@SerialName("start_date") val startDate: String,
|
||||
@SerialName("end_date") val endDate: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EpochDetail(
|
||||
val epoch: WeightEpoch,
|
||||
@SerialName("year_days_passed") val yearDaysPassed: Int = 0,
|
||||
@SerialName("epoch_days_passed") val epochDaysPassed: Int = 0,
|
||||
@SerialName("days_remaining") val daysRemaining: Int = 0,
|
||||
@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
|
||||
)
|
||||
|
||||
// 每周计划
|
||||
@Serializable
|
||||
data class WeeklyPlan(
|
||||
val id: Long = 0,
|
||||
@SerialName("epoch_id") val epochId: Long = 0,
|
||||
@SerialName("user_id") val userId: Long = 0,
|
||||
val year: Int,
|
||||
val week: Int,
|
||||
@SerialName("start_date") val startDate: String,
|
||||
@SerialName("end_date") val endDate: String,
|
||||
@SerialName("initial_weight") val initialWeight: Double? = null,
|
||||
@SerialName("target_weight") val targetWeight: Double? = null,
|
||||
@SerialName("final_weight") val finalWeight: Double? = null,
|
||||
val note: String = "",
|
||||
@SerialName("created_at") val createdAt: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WeeklyPlanDetail(
|
||||
val plan: WeeklyPlan,
|
||||
@SerialName("weight_change") val weightChange: Double? = null,
|
||||
@SerialName("is_qualified") val isQualified: Boolean = false,
|
||||
@SerialName("is_past") val isPast: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateWeeklyPlanRequest(
|
||||
@SerialName("initial_weight") val initialWeight: Double? = null,
|
||||
@SerialName("target_weight") val targetWeight: Double? = null,
|
||||
@SerialName("final_weight") val finalWeight: Double? = null,
|
||||
val note: String = ""
|
||||
)
|
||||
|
||||
// 体重相关(保留兼容)
|
||||
@Serializable
|
||||
data class WeightGoal(
|
||||
val id: Long = 0,
|
||||
@@ -111,6 +182,7 @@ data class WeekWeightData(
|
||||
val week: Int,
|
||||
@SerialName("start_date") val startDate: String,
|
||||
@SerialName("end_date") val endDate: String,
|
||||
@SerialName("initial_weight") val initialWeight: Double? = null,
|
||||
val weight: Double? = null,
|
||||
@SerialName("prev_weight") val prevWeight: Double? = null,
|
||||
@SerialName("weight_change") val weightChange: Double? = null,
|
||||
|
||||
@@ -9,15 +9,19 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
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
|
||||
|
||||
sealed class Screen(
|
||||
val route: String,
|
||||
@@ -26,10 +30,22 @@ sealed class Screen(
|
||||
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 Profile : Screen("profile", "我的", Icons.Filled.Person, Icons.Outlined.Person)
|
||||
}
|
||||
|
||||
val bottomNavItems = listOf(Screen.Weight, Screen.Profile)
|
||||
// 非底部导航的路由
|
||||
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}"
|
||||
|
||||
fun epochDetail(epochId: Long) = "epoch_detail/$epochId"
|
||||
fun weeklyPlans(epochId: Long) = "weekly_plans/$epochId"
|
||||
}
|
||||
|
||||
val bottomNavItems = listOf(Screen.Weight, Screen.Epoch, Screen.Profile)
|
||||
|
||||
@Composable
|
||||
fun MainNavigation(
|
||||
@@ -38,17 +54,112 @@ fun MainNavigation(
|
||||
onCheckUpdate: () -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val epochViewModel: EpochViewModel = viewModel()
|
||||
val hasActiveEpoch by epochViewModel.hasActiveEpoch.collectAsState()
|
||||
val activeEpoch by epochViewModel.activeEpoch.collectAsState()
|
||||
val epochList by epochViewModel.epochList.collectAsState()
|
||||
val epochDetail by epochViewModel.epochDetail.collectAsState()
|
||||
val weeklyPlans by epochViewModel.weeklyPlans.collectAsState()
|
||||
val isLoading by epochViewModel.isLoading.collectAsState()
|
||||
val error by epochViewModel.error.collectAsState()
|
||||
|
||||
// 检查是否有活跃纪元
|
||||
LaunchedEffect(Unit) {
|
||||
epochViewModel.checkActiveEpoch()
|
||||
}
|
||||
|
||||
// 根据是否有活跃纪元决定起始页面
|
||||
val startDestination = when (hasActiveEpoch) {
|
||||
null -> Screen.Weight.route // 加载中,先显示体重页
|
||||
false -> Routes.SETUP
|
||||
true -> Screen.Weight.route
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Weight.route,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// 首次设置页面
|
||||
composable(Routes.SETUP) {
|
||||
SetupScreen(
|
||||
onSetupComplete = {
|
||||
navController.navigate(Screen.Weight.route) {
|
||||
popUpTo(Routes.SETUP) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onCreateEpoch = { name, initial, target, start, end ->
|
||||
epochViewModel.createEpoch(name, initial, target, start, end) {
|
||||
navController.navigate(Screen.Weight.route) {
|
||||
popUpTo(Routes.SETUP) { inclusive = true }
|
||||
}
|
||||
}
|
||||
},
|
||||
isLoading = isLoading,
|
||||
error = error
|
||||
)
|
||||
}
|
||||
|
||||
// 体重页面
|
||||
composable(Screen.Weight.route) {
|
||||
WeightScreen()
|
||||
}
|
||||
|
||||
// 纪元入口页面 - 显示纪元列表
|
||||
composable(Screen.Epoch.route) {
|
||||
LaunchedEffect(Unit) {
|
||||
epochViewModel.loadEpochList()
|
||||
}
|
||||
EpochListScreen(
|
||||
epochs = epochList,
|
||||
onBack = { navController.popBackStack() },
|
||||
onEpochClick = { epochId ->
|
||||
navController.navigate(Routes.epochDetail(epochId))
|
||||
},
|
||||
onCreateNew = {
|
||||
navController.navigate(Routes.SETUP)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 纪元详情页面
|
||||
composable(
|
||||
route = Routes.EPOCH_DETAIL,
|
||||
arguments = listOf(navArgument("epochId") { type = NavType.LongType })
|
||||
) { backStackEntry ->
|
||||
val epochId = backStackEntry.arguments?.getLong("epochId") ?: 0L
|
||||
LaunchedEffect(epochId) {
|
||||
epochViewModel.loadEpochDetail(epochId)
|
||||
}
|
||||
EpochDetailScreen(
|
||||
epochDetail = epochDetail,
|
||||
isLoading = isLoading,
|
||||
onBack = { navController.popBackStack() },
|
||||
onViewPlans = {
|
||||
navController.navigate(Routes.weeklyPlans(epochId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 每周计划页面
|
||||
composable(
|
||||
route = Routes.WEEKLY_PLANS,
|
||||
arguments = listOf(navArgument("epochId") { type = NavType.LongType })
|
||||
) { backStackEntry ->
|
||||
val epochId = backStackEntry.arguments?.getLong("epochId") ?: 0L
|
||||
LaunchedEffect(epochId) {
|
||||
epochViewModel.loadWeeklyPlans(epochId)
|
||||
}
|
||||
WeeklyPlanScreen(
|
||||
epoch = epochDetail?.epoch ?: activeEpoch,
|
||||
plans = weeklyPlans,
|
||||
onBack = { navController.popBackStack() },
|
||||
onPlanClick = { /* 可以添加编辑功能 */ }
|
||||
)
|
||||
}
|
||||
|
||||
// 个人中心
|
||||
composable(Screen.Profile.route) {
|
||||
ProfileScreen(
|
||||
user = user,
|
||||
@@ -58,49 +169,57 @@ 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
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(androidx.compose.ui.Alignment.BottomCenter),
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Column {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.height(64.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
bottomNavItems.forEach { screen ->
|
||||
val selected = navBackStackEntry?.destination?.hierarchy?.any {
|
||||
it.route == screen.route
|
||||
} == true
|
||||
if (showBottomNav && hasActiveEpoch != false) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(androidx.compose.ui.Alignment.BottomCenter),
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Column {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.height(64.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
bottomNavItems.forEach { screen ->
|
||||
val selected = navBackStackEntry?.destination?.hierarchy?.any {
|
||||
it.route == screen.route
|
||||
} == true
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
IconButton(
|
||||
onClick = {
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon,
|
||||
contentDescription = screen.title,
|
||||
tint = if (selected) Brand500 else Slate400,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon,
|
||||
contentDescription = screen.title,
|
||||
tint = if (selected) Brand500 else Slate400,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
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.verticalScroll
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.healthflow.app.data.model.EpochDetail
|
||||
import com.healthflow.app.ui.theme.*
|
||||
|
||||
private fun formatDateString(dateStr: String): String {
|
||||
return dateStr.substringBefore("T").trim()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EpochDetailScreen(
|
||||
epochDetail: EpochDetail?,
|
||||
isLoading: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onViewPlans: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
// Header - memory 风格
|
||||
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 = epochDetail?.epoch?.name ?: "纪元详情",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Spacer(modifier = Modifier.width(36.dp))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(28.dp),
|
||||
color = Brand500,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
} else if (epochDetail != null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 主卡片 - 时间进度
|
||||
MainProgressCard(epochDetail)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 统计数据行
|
||||
StatsRow(epochDetail)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 体重详情卡片
|
||||
WeightDetailCard(epochDetail)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 查看每周计划按钮
|
||||
Button(
|
||||
onClick = onViewPlans,
|
||||
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(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainProgressCard(detail: EpochDetail) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp))
|
||||
.padding(vertical = 20.dp, horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Emoji
|
||||
Text(text = "🎯", fontSize = 40.sp)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 纪元名称
|
||||
Text(
|
||||
text = detail.epoch.name.ifEmpty { "减重纪元" },
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 剩余天数 - 大字
|
||||
Text(
|
||||
text = if (detail.daysRemaining > 0) "剩余 ${detail.daysRemaining} 天" else "已结束",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (detail.daysRemaining <= 7) ErrorRed else Brand500
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// 日期范围
|
||||
Text(
|
||||
text = "${formatDateString(detail.epoch.startDate)} ~ ${formatDateString(detail.epoch.endDate)}",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun StatsRow(detail: EpochDetail) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
StatColumn(count = detail.yearDaysPassed, label = "今年已过")
|
||||
StatColumn(count = detail.epochDaysPassed, label = "纪元已过")
|
||||
StatColumn(count = detail.daysRemaining, label = "剩余天数", highlight = detail.daysRemaining <= 7)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatColumn(count: Int, label: String, highlight: Boolean = false) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (highlight) ErrorRed else MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeightDetailCard(detail: EpochDetail) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
// 体重数据行
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
WeightColumn(
|
||||
label = "初始",
|
||||
value = String.format("%.1f", detail.epoch.initialWeight),
|
||||
unit = "kg"
|
||||
)
|
||||
// 分隔线
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(40.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
)
|
||||
WeightColumn(
|
||||
label = "目标",
|
||||
value = String.format("%.1f", detail.epoch.targetWeight),
|
||||
unit = "kg",
|
||||
highlight = true
|
||||
)
|
||||
// 分隔线
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(40.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
)
|
||||
WeightColumn(
|
||||
label = "当前",
|
||||
value = detail.currentWeight?.let { String.format("%.1f", it) } ?: "--",
|
||||
unit = "kg"
|
||||
)
|
||||
}
|
||||
|
||||
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.SpaceBetween
|
||||
) {
|
||||
// 实际减重
|
||||
Column {
|
||||
Text(
|
||||
text = "实际减重",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val actualChange = detail.actualChange
|
||||
if (actualChange != null) {
|
||||
val isLoss = actualChange > 0
|
||||
Text(
|
||||
text = "${if (isLoss) "-" else "+"}${String.format("%.1f", kotlin.math.abs(actualChange))} kg",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (isLoss) SuccessGreen else ErrorRed
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "-- kg",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 距离目标
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "距离目标",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val distance = detail.distanceToGoal
|
||||
if (distance != null) {
|
||||
val isOver = distance > 0
|
||||
Text(
|
||||
text = "${if (isOver) "+" else ""}${String.format("%.1f", distance)} kg",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (isOver) ErrorRed else SuccessGreen
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "-- kg",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 达标状态
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "状态",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val hasData = detail.currentWeight != null
|
||||
Text(
|
||||
text = if (hasData) {
|
||||
if (detail.isQualified) "✅ 达标" else "❌ 未达标"
|
||||
} else "📊 暂无",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (hasData) {
|
||||
if (detail.isQualified) SuccessGreen else ErrorRed
|
||||
} else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeightColumn(
|
||||
label: String,
|
||||
value: String,
|
||||
unit: String,
|
||||
highlight: Boolean = false
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (highlight) Brand500 else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = unit,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.healthflow.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.outlined.Flag
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.healthflow.app.data.model.WeightEpoch
|
||||
import com.healthflow.app.ui.theme.*
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@Composable
|
||||
fun EpochListScreen(
|
||||
epochs: List<WeightEpoch>,
|
||||
onBack: () -> Unit,
|
||||
onEpochClick: (Long) -> Unit,
|
||||
onCreateNew: () -> Unit
|
||||
) {
|
||||
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)
|
||||
) {
|
||||
Text(
|
||||
text = "减重纪元",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
IconButton(
|
||||
onClick = onCreateNew,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "新建纪元",
|
||||
tint = Brand500
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
|
||||
|
||||
if (epochs.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "🎯",
|
||||
fontSize = 48.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "还没有减重纪元",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = onCreateNew) {
|
||||
Text("创建第一个纪元", color = Brand500)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(epochs) { epoch ->
|
||||
EpochCard(
|
||||
epoch = epoch,
|
||||
onClick = { onEpochClick(epoch.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期字符串
|
||||
private fun formatDate(dateStr: String): String {
|
||||
return dateStr.substringBefore("T").trim()
|
||||
}
|
||||
|
||||
// 解析日期字符串为 LocalDate
|
||||
private fun parseDateSafe(dateStr: String): LocalDate {
|
||||
val cleanDate = dateStr.substringBefore("T").trim()
|
||||
return try {
|
||||
LocalDate.parse(cleanDate)
|
||||
} catch (e: Exception) {
|
||||
LocalDate.now()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpochCard(
|
||||
epoch: WeightEpoch,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val startDate = parseDateSafe(epoch.startDate)
|
||||
val endDate = parseDateSafe(epoch.endDate)
|
||||
|
||||
val now = LocalDate.now()
|
||||
val totalDays = ChronoUnit.DAYS.between(startDate, endDate).toInt()
|
||||
val passedDays = ChronoUnit.DAYS.between(startDate, now).toInt().coerceIn(0, totalDays)
|
||||
val progress = if (totalDays > 0) passedDays.toFloat() / totalDays else 0f
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (epoch.isActive) Brand500.copy(alpha = 0.1f) else Slate200),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Flag,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (epoch.isActive) Brand500 else Slate400
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = epoch.name.ifEmpty { "减重纪元" },
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "${formatDate(epoch.startDate)} ~ ${formatDate(epoch.endDate)}",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (epoch.isActive) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Brand500.copy(alpha = 0.1f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "进行中",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Brand500
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 进度条
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.coerceIn(0f, 1f) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp)),
|
||||
color = Brand500,
|
||||
trackColor = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 体重信息
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "初始",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${String.format("%.1f", epoch.initialWeight)} kg",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "目标",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${String.format("%.1f", epoch.targetWeight)} kg",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Brand500
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "需减",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${String.format("%.1f", epoch.initialWeight - epoch.targetWeight)} kg",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.healthflow.app.ui.screen
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.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
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.healthflow.app.ui.theme.*
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun SetupScreen(
|
||||
onSetupComplete: () -> Unit,
|
||||
onCreateEpoch: (String, Double, Double, String, String) -> Unit,
|
||||
isLoading: Boolean = false,
|
||||
error: String? = null
|
||||
) {
|
||||
var initialWeight by remember { mutableStateOf("") }
|
||||
var targetWeight by remember { mutableStateOf("") }
|
||||
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)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(60.dp))
|
||||
|
||||
// 欢迎标题
|
||||
Text(
|
||||
text = "🎯",
|
||||
fontSize = 48.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "开始你的减重之旅",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "设置你的初始体重、目标体重和截止日期",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// 初始体重
|
||||
OutlinedTextField(
|
||||
value = initialWeight,
|
||||
onValueChange = { initialWeight = it },
|
||||
label = { Text("当前体重 (kg)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
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 = targetWeight,
|
||||
onValueChange = { targetWeight = it },
|
||||
label = { Text("目标体重 (kg)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Brand500,
|
||||
focusedLabelColor = Brand500
|
||||
)
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.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",
|
||||
fontSize = 13.sp,
|
||||
color = Brand500
|
||||
)
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
if (error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// 开始按钮
|
||||
val isValid = initialWeight.toDoubleOrNull() != null &&
|
||||
targetWeight.toDoubleOrNull() != null &&
|
||||
endDate.isNotEmpty()
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onCreateEpoch(
|
||||
"减重纪元",
|
||||
initialWeight.toDouble(),
|
||||
targetWeight.toDouble(),
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
},
|
||||
enabled = isValid && !isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Brand500)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "开始减重",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package com.healthflow.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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 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()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WeeklyPlanScreen(
|
||||
epoch: WeightEpoch?,
|
||||
plans: List<WeeklyPlanDetail>,
|
||||
onBack: () -> Unit,
|
||||
onPlanClick: (WeeklyPlanDetail) -> Unit
|
||||
) {
|
||||
val currentYear = LocalDate.now().year
|
||||
val currentWeek = LocalDate.now().get(WeekFields.ISO.weekOfWeekBasedYear())
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
// Header
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "返回",
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "每周计划",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = "📅", fontSize = 48.sp)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "暂无每周计划",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(plans) { planDetail ->
|
||||
val plan = planDetail.plan
|
||||
val isCurrent = plan.year == currentYear && plan.week == currentWeek
|
||||
WeeklyPlanCard(
|
||||
planDetail = planDetail,
|
||||
isCurrent = isCurrent,
|
||||
onClick = { onPlanClick(planDetail) }
|
||||
)
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeeklyPlanCard(
|
||||
planDetail: WeeklyPlanDetail,
|
||||
isCurrent: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val plan = planDetail.plan
|
||||
|
||||
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,
|
||||
onClick = onClick
|
||||
) {
|
||||
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 = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 状态标签
|
||||
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) } ?: "--"
|
||||
)
|
||||
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(
|
||||
label: String,
|
||||
value: String,
|
||||
valueColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = valueColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,36 @@
|
||||
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.filled.Add
|
||||
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.TrendingDown
|
||||
import androidx.compose.material.icons.outlined.TrendingUp
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.healthflow.app.data.model.WeightRecord
|
||||
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
|
||||
@@ -45,7 +40,6 @@ fun WeightScreen(
|
||||
val weekData by viewModel.currentWeekData.collectAsState()
|
||||
val stats by viewModel.stats.collectAsState()
|
||||
val goal by viewModel.goal.collectAsState()
|
||||
val history by viewModel.history.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val currentYear by viewModel.currentYear.collectAsState()
|
||||
val currentWeek by viewModel.currentWeek.collectAsState()
|
||||
@@ -71,20 +65,18 @@ fun WeightScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "体重",
|
||||
fontSize = 18.sp,
|
||||
text = "体重管理",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
|
||||
|
||||
if (isLoading && weekData == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -96,61 +88,126 @@ fun WeightScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp)
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp)
|
||||
) {
|
||||
// 周选择器
|
||||
WeekSelector(
|
||||
year = currentYear,
|
||||
week = currentWeek,
|
||||
startDate = weekData?.startDate ?: "",
|
||||
endDate = weekData?.endDate ?: "",
|
||||
onPrevWeek = { viewModel.goToPrevWeek() },
|
||||
onNextWeek = { viewModel.goToNextWeek() }
|
||||
)
|
||||
// 主卡片
|
||||
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(24.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 本周体重卡片
|
||||
WeightCard(
|
||||
weight = weekData?.weight,
|
||||
weightChange = weekData?.weightChange,
|
||||
hasRecord = weekData?.hasRecord ?: false,
|
||||
onRecordClick = { showWeightDialog = true }
|
||||
)
|
||||
// 统计摘要
|
||||
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))
|
||||
|
||||
// 目标进度
|
||||
GoalProgress(
|
||||
currentWeight = weekData?.weight ?: stats?.latestWeight,
|
||||
targetWeight = goal?.targetWeight ?: stats?.targetWeight,
|
||||
onEditGoal = { showGoalDialog = true }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 趋势图
|
||||
if (history.isNotEmpty()) {
|
||||
Text(
|
||||
text = "趋势图",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
// 快捷操作按钮
|
||||
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.height(12.dp))
|
||||
WeightChart(records = history.reversed())
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// 统计信息
|
||||
stats?.let { s ->
|
||||
StatsRow(stats = s)
|
||||
}
|
||||
|
||||
// 底部留白
|
||||
Spacer(modifier = Modifier.height(100.dp))
|
||||
// 底部留白给导航栏
|
||||
Spacer(modifier = Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +238,76 @@ fun WeightScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
@@ -200,35 +327,55 @@ private fun WeekSelector(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onPrevWeek) {
|
||||
// 左箭头
|
||||
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 = "第${week}周",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
text = formatDateRange(startDate, endDate),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = formatDateRange(startDate, endDate),
|
||||
text = "${year}年 第${week}周",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onNextWeek,
|
||||
enabled = !isCurrentWeek
|
||||
// 右箭头
|
||||
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
|
||||
)
|
||||
@@ -237,264 +384,51 @@ private fun WeekSelector(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeightCard(
|
||||
weight: Double?,
|
||||
weightChange: Double?,
|
||||
hasRecord: Boolean,
|
||||
onRecordClick: () -> Unit
|
||||
private fun StatsSummary(
|
||||
year: Int,
|
||||
week: Int,
|
||||
totalChange: Double?
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onRecordClick() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (hasRecord && weight != null) {
|
||||
Text(
|
||||
text = "本周体重",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
text = String.format("%.1f", weight),
|
||||
fontSize = 48.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "kg",
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
val now = LocalDate.now()
|
||||
val startOfYear = LocalDate.of(year, 1, 1)
|
||||
val daysPassed = ChronoUnit.DAYS.between(startOfYear, now).toInt()
|
||||
|
||||
// 变化值
|
||||
weightChange?.let { change ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val isDown = change < 0
|
||||
val color = if (isDown) SuccessGreen else ErrorRed
|
||||
val icon = if (isDown) Icons.Outlined.TrendingDown else Icons.Outlined.TrendingUp
|
||||
val sign = if (change > 0) "+" else ""
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(color.copy(alpha = 0.1f))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = color
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${sign}${String.format("%.1f", change)} kg vs上周",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "点击修改",
|
||||
fontSize = 12.sp,
|
||||
color = Brand500
|
||||
)
|
||||
} else {
|
||||
// 未记录
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Brand500.copy(alpha = 0.1f))
|
||||
.padding(12.dp),
|
||||
tint = Brand500
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "记录本周体重",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Brand500
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GoalProgress(
|
||||
currentWeight: Double?,
|
||||
targetWeight: Double?,
|
||||
onEditGoal: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Brand500.copy(alpha = 0.08f)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.Flag,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = Brand500
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (targetWeight != null) "目标: ${String.format("%.1f", targetWeight)} kg"
|
||||
else "设置目标",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onEditGoal) {
|
||||
Text(
|
||||
text = if (targetWeight != null) "修改" else "设置",
|
||||
fontSize = 13.sp,
|
||||
color = Brand500
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentWeight != null && targetWeight != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val diff = currentWeight - targetWeight
|
||||
val progress = if (diff > 0) {
|
||||
// 还需要减重
|
||||
0.5f // 简化处理,实际可以根据初始体重计算
|
||||
} else {
|
||||
1f // 已达标
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
color = Brand500,
|
||||
trackColor = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (diff > 0) "还差 ${String.format("%.1f", diff)} kg"
|
||||
else "🎉 已达成目标!",
|
||||
fontSize = 13.sp,
|
||||
color = if (diff > 0) MaterialTheme.colorScheme.onSurfaceVariant else SuccessGreen
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeightChart(records: List<WeightRecord>) {
|
||||
if (records.isEmpty()) return
|
||||
|
||||
val weights = records.map { it.weight }
|
||||
val minWeight = weights.minOrNull() ?: 0.0
|
||||
val maxWeight = weights.maxOrNull() ?: 100.0
|
||||
val range = (maxWeight - minWeight).coerceAtLeast(2.0)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Canvas(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val stepX = width / (records.size - 1).coerceAtLeast(1)
|
||||
|
||||
// 绘制折线
|
||||
val path = Path()
|
||||
records.forEachIndexed { index, record ->
|
||||
val x = index * stepX
|
||||
val y = height - ((record.weight - minWeight) / range * height).toFloat()
|
||||
|
||||
if (index == 0) {
|
||||
path.moveTo(x, y)
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = path,
|
||||
color = Brand500,
|
||||
style = Stroke(width = 3f)
|
||||
StatItem(value = "$daysPassed", label = "已过天数")
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(36.dp)
|
||||
.background(Brand500.copy(alpha = 0.2f))
|
||||
)
|
||||
|
||||
// 绘制点
|
||||
records.forEachIndexed { index, record ->
|
||||
val x = index * stepX
|
||||
val y = height - ((record.weight - minWeight) / range * height).toFloat()
|
||||
|
||||
drawCircle(
|
||||
color = Brand500,
|
||||
radius = 6f,
|
||||
center = Offset(x, y)
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = 3f,
|
||||
center = Offset(x, y)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsRow(stats: com.healthflow.app.data.model.WeightStats) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
StatItem(
|
||||
value = "${stats.totalWeeks}",
|
||||
label = "已记录周数"
|
||||
)
|
||||
stats.totalChange?.let {
|
||||
|
||||
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 = "${if (it > 0) "+" else ""}${String.format("%.1f", it)}",
|
||||
value = changeText,
|
||||
label = "总变化(kg)",
|
||||
valueColor = if (it < 0) SuccessGreen else if (it > 0) ErrorRed else MaterialTheme.colorScheme.onSurface
|
||||
valueColor = when {
|
||||
totalChange == null -> MaterialTheme.colorScheme.onSurface
|
||||
totalChange < 0 -> SuccessGreen
|
||||
totalChange > 0 -> ErrorRed
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -509,11 +443,11 @@ private fun StatItem(
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 24.sp,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = valueColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
@@ -522,6 +456,152 @@ private fun StatItem(
|
||||
}
|
||||
}
|
||||
|
||||
@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?,
|
||||
@@ -532,7 +612,12 @@ private fun WeightInputDialog(
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("记录体重") },
|
||||
title = {
|
||||
Text(
|
||||
"记录体重",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = weightText,
|
||||
@@ -543,7 +628,8 @@ private fun WeightInputDialog(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Brand500
|
||||
focusedBorderColor = Brand500,
|
||||
focusedLabelColor = Brand500
|
||||
)
|
||||
)
|
||||
},
|
||||
@@ -554,14 +640,15 @@ private fun WeightInputDialog(
|
||||
},
|
||||
enabled = weightText.toDoubleOrNull() != null
|
||||
) {
|
||||
Text("确定", color = Brand500)
|
||||
Text("确定", color = Brand500, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
Text("取消", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -575,7 +662,12 @@ private fun GoalInputDialog(
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("设置目标体重") },
|
||||
title = {
|
||||
Text(
|
||||
"设置目标体重",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = targetText,
|
||||
@@ -586,7 +678,8 @@ private fun GoalInputDialog(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Brand500
|
||||
focusedBorderColor = Brand500,
|
||||
focusedLabelColor = Brand500
|
||||
)
|
||||
)
|
||||
},
|
||||
@@ -597,14 +690,15 @@ private fun GoalInputDialog(
|
||||
},
|
||||
enabled = targetText.toDoubleOrNull() != null
|
||||
) {
|
||||
Text("确定", color = Brand500)
|
||||
Text("确定", color = Brand500, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
Text("取消", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.healthflow.app.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.healthflow.app.data.api.ApiClient
|
||||
import com.healthflow.app.data.model.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class EpochViewModel : ViewModel() {
|
||||
private val _activeEpoch = MutableStateFlow<WeightEpoch?>(null)
|
||||
val activeEpoch: StateFlow<WeightEpoch?> = _activeEpoch
|
||||
|
||||
private val _epochDetail = MutableStateFlow<EpochDetail?>(null)
|
||||
val epochDetail: StateFlow<EpochDetail?> = _epochDetail
|
||||
|
||||
private val _epochList = MutableStateFlow<List<WeightEpoch>>(emptyList())
|
||||
val epochList: StateFlow<List<WeightEpoch>> = _epochList
|
||||
|
||||
private val _weeklyPlans = MutableStateFlow<List<WeeklyPlanDetail>>(emptyList())
|
||||
val weeklyPlans: StateFlow<List<WeeklyPlanDetail>> = _weeklyPlans
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading
|
||||
|
||||
private val _hasActiveEpoch = MutableStateFlow<Boolean?>(null)
|
||||
val hasActiveEpoch: StateFlow<Boolean?> = _hasActiveEpoch
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error
|
||||
|
||||
fun clearError() {
|
||||
_error.value = null
|
||||
}
|
||||
|
||||
fun checkActiveEpoch() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val response = ApiClient.api.getActiveEpoch()
|
||||
if (response.isSuccessful) {
|
||||
val epoch = response.body()
|
||||
_activeEpoch.value = epoch
|
||||
_hasActiveEpoch.value = epoch != null && epoch.id > 0
|
||||
} else {
|
||||
// 404 或其他错误表示没有活跃纪元
|
||||
_hasActiveEpoch.value = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
_hasActiveEpoch.value = false
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createEpoch(
|
||||
name: String,
|
||||
initialWeight: Double,
|
||||
targetWeight: Double,
|
||||
startDate: String,
|
||||
endDate: String,
|
||||
onSuccess: () -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
val request = CreateEpochRequest(
|
||||
name = name,
|
||||
initialWeight = initialWeight,
|
||||
targetWeight = targetWeight,
|
||||
startDate = startDate,
|
||||
endDate = endDate
|
||||
)
|
||||
android.util.Log.d("EpochViewModel", "Creating epoch: $request")
|
||||
val response = ApiClient.api.createEpoch(request)
|
||||
android.util.Log.d("EpochViewModel", "Response: ${response.code()} - ${response.body()}")
|
||||
if (response.isSuccessful) {
|
||||
_hasActiveEpoch.value = true
|
||||
checkActiveEpoch()
|
||||
onSuccess()
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
android.util.Log.e("EpochViewModel", "Error: $errorBody")
|
||||
_error.value = "创建失败: ${response.code()}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("EpochViewModel", "Exception", e)
|
||||
_error.value = "网络错误: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadEpochList() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = ApiClient.api.getEpochList()
|
||||
if (response.isSuccessful) {
|
||||
_epochList.value = response.body() ?: emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadEpochDetail(epochId: Long) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val response = ApiClient.api.getEpochDetail(epochId)
|
||||
if (response.isSuccessful) {
|
||||
_epochDetail.value = response.body()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadWeeklyPlans(epochId: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = ApiClient.api.getWeeklyPlans(epochId)
|
||||
if (response.isSuccessful) {
|
||||
_weeklyPlans.value = response.body() ?: emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWeeklyPlan(
|
||||
epochId: Long,
|
||||
planId: Long,
|
||||
finalWeight: Double?,
|
||||
onSuccess: () -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val request = UpdateWeeklyPlanRequest(finalWeight = finalWeight)
|
||||
val response = ApiClient.api.updateWeeklyPlan(epochId, planId, request)
|
||||
if (response.isSuccessful) {
|
||||
loadWeeklyPlans(epochId)
|
||||
onSuccess()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
release.sh
10
release.sh
@@ -10,11 +10,11 @@ set -e
|
||||
R2_ACCOUNT_ID="ebf33b5ee4eb26f32af0c6e06102e000"
|
||||
R2_ACCESS_KEY_ID="8acbc8a9386d60d0e8dac6bd8165c618"
|
||||
R2_ACCESS_KEY_SECRET="72935e23b5b4be8fda99008e75285e8ac778f8926656c42780b25785bb149443"
|
||||
R2_BUCKET_NAME="healthflow"
|
||||
R2_PUBLIC_URL="https://cdn-health.amos.us.kg"
|
||||
R2_BUCKET_NAME="memory"
|
||||
R2_PUBLIC_URL="https://cdn.amos.us.kg"
|
||||
|
||||
# API 配置
|
||||
API_BASE_URL="https://health.amos.us.kg/api"
|
||||
API_BASE_URL="http://95.181.190.226:6601/api"
|
||||
|
||||
# 服务器配置
|
||||
SERVER_HOST="95.181.190.226"
|
||||
@@ -123,7 +123,7 @@ build_apk() {
|
||||
upload_to_r2() {
|
||||
local version=$1
|
||||
local apk_path="android/app/build/outputs/apk/release/app-release.apk"
|
||||
local remote_path="releases/healthflow-${version}.apk"
|
||||
local remote_path="healthFlow/releases/healthflow-${version}.apk"
|
||||
|
||||
echo "☁️ 上传到 R2..."
|
||||
|
||||
@@ -244,7 +244,7 @@ if [ "$BUILD_APK" = true ]; then
|
||||
build_apk
|
||||
|
||||
# 先更新服务器版本信息,成功后再上传 APK
|
||||
download_url="${R2_PUBLIC_URL}/releases/healthflow-${new_version}.apk"
|
||||
download_url="${R2_PUBLIC_URL}/healthFlow/releases/healthflow-${new_version}.apk"
|
||||
if update_server_version $new_code "$new_version" "$download_url" "$update_log"; then
|
||||
# 上传 APK
|
||||
upload_to_r2 $new_version
|
||||
|
||||
@@ -58,7 +58,42 @@ func migrate(db *sql.DB) error {
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 体重目标表
|
||||
-- 减重纪元表
|
||||
CREATE TABLE IF NOT EXISTS weight_epochs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT DEFAULT '',
|
||||
initial_weight REAL NOT NULL,
|
||||
target_weight REAL NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
final_weight REAL,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_completed INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- 每周计划表
|
||||
CREATE TABLE IF NOT EXISTS weekly_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
epoch_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
week INTEGER NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
initial_weight REAL,
|
||||
target_weight REAL,
|
||||
final_weight REAL,
|
||||
note TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(epoch_id, year, week),
|
||||
FOREIGN KEY (epoch_id) REFERENCES weight_epochs(id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- 体重目标表 (保留兼容)
|
||||
CREATE TABLE IF NOT EXISTS weight_goals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
@@ -84,6 +119,8 @@ func migrate(db *sql.DB) error {
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_weight_epochs_user ON weight_epochs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_weekly_plans_epoch ON weekly_plans(epoch_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_weight_records_user ON weight_records(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_weight_records_week ON weight_records(year, week);
|
||||
|
||||
|
||||
346
server/internal/handler/epoch.go
Normal file
346
server/internal/handler/epoch.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"healthflow/internal/config"
|
||||
"healthflow/internal/middleware"
|
||||
"healthflow/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type EpochHandler struct {
|
||||
db *sql.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewEpochHandler(db *sql.DB, cfg *config.Config) *EpochHandler {
|
||||
return &EpochHandler{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
// 创建纪元
|
||||
func (h *EpochHandler) CreateEpoch(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req model.CreateEpochRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 将之前的活跃纪元设为非活跃
|
||||
h.db.Exec("UPDATE weight_epochs SET is_active = 0 WHERE user_id = ? AND is_active = 1", userID)
|
||||
|
||||
// 创建新纪元
|
||||
result, err := h.db.Exec(`
|
||||
INSERT INTO weight_epochs (user_id, name, initial_weight, target_weight, start_date, end_date, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
`, userID, req.Name, req.InitialWeight, req.TargetWeight, req.StartDate, req.EndDate)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create epoch"})
|
||||
return
|
||||
}
|
||||
|
||||
epochID, _ := result.LastInsertId()
|
||||
|
||||
// 生成每周计划
|
||||
h.generateWeeklyPlans(epochID, userID, req.StartDate, req.EndDate, req.InitialWeight, req.TargetWeight)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": epochID, "message": "epoch created"})
|
||||
}
|
||||
|
||||
// 获取当前活跃纪元
|
||||
func (h *EpochHandler) GetActiveEpoch(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var epoch model.WeightEpoch
|
||||
var finalWeight sql.NullFloat64
|
||||
err := h.db.QueryRow(`
|
||||
SELECT id, user_id, name, initial_weight, target_weight, start_date, end_date,
|
||||
final_weight, is_active, is_completed, created_at
|
||||
FROM weight_epochs WHERE user_id = ? AND is_active = 1
|
||||
`, userID).Scan(
|
||||
&epoch.ID, &epoch.UserID, &epoch.Name, &epoch.InitialWeight, &epoch.TargetWeight,
|
||||
&epoch.StartDate, &epoch.EndDate, &finalWeight, &epoch.IsActive, &epoch.IsCompleted, &epoch.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusOK, nil)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
|
||||
if finalWeight.Valid {
|
||||
epoch.FinalWeight = &finalWeight.Float64
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, epoch)
|
||||
}
|
||||
|
||||
// 获取纪元详情
|
||||
func (h *EpochHandler) GetEpochDetail(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
epochID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
|
||||
var epoch model.WeightEpoch
|
||||
var finalWeight sql.NullFloat64
|
||||
err := h.db.QueryRow(`
|
||||
SELECT id, user_id, name, initial_weight, target_weight, start_date, end_date,
|
||||
final_weight, is_active, is_completed, created_at
|
||||
FROM weight_epochs WHERE id = ? AND user_id = ?
|
||||
`, epochID, userID).Scan(
|
||||
&epoch.ID, &epoch.UserID, &epoch.Name, &epoch.InitialWeight, &epoch.TargetWeight,
|
||||
&epoch.StartDate, &epoch.EndDate, &finalWeight, &epoch.IsActive, &epoch.IsCompleted, &epoch.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "epoch not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
|
||||
if finalWeight.Valid {
|
||||
epoch.FinalWeight = &finalWeight.Float64
|
||||
}
|
||||
|
||||
// 计算统计数据
|
||||
detail := h.calculateEpochDetail(&epoch)
|
||||
c.JSON(http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// 获取纪元列表
|
||||
func (h *EpochHandler) GetEpochList(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
rows, err := h.db.Query(`
|
||||
SELECT id, user_id, name, initial_weight, target_weight, start_date, end_date,
|
||||
final_weight, is_active, is_completed, created_at
|
||||
FROM weight_epochs WHERE user_id = ? ORDER BY created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var epochs []model.WeightEpoch
|
||||
for rows.Next() {
|
||||
var e model.WeightEpoch
|
||||
var finalWeight sql.NullFloat64
|
||||
rows.Scan(
|
||||
&e.ID, &e.UserID, &e.Name, &e.InitialWeight, &e.TargetWeight,
|
||||
&e.StartDate, &e.EndDate, &finalWeight, &e.IsActive, &e.IsCompleted, &e.CreatedAt,
|
||||
)
|
||||
if finalWeight.Valid {
|
||||
e.FinalWeight = &finalWeight.Float64
|
||||
}
|
||||
epochs = append(epochs, e)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, epochs)
|
||||
}
|
||||
|
||||
// 获取纪元的每周计划列表
|
||||
func (h *EpochHandler) GetWeeklyPlans(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
epochID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
|
||||
rows, err := h.db.Query(`
|
||||
SELECT id, epoch_id, user_id, year, week, start_date, end_date,
|
||||
initial_weight, target_weight, final_weight, note, created_at
|
||||
FROM weekly_plans WHERE epoch_id = ? AND user_id = ? ORDER BY year, week
|
||||
`, epochID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var plans []model.WeeklyPlanDetail
|
||||
now := time.Now()
|
||||
currentYear, currentWeek := now.ISOWeek()
|
||||
|
||||
for rows.Next() {
|
||||
var p model.WeeklyPlan
|
||||
var initialWeight, targetWeight, finalWeight sql.NullFloat64
|
||||
rows.Scan(
|
||||
&p.ID, &p.EpochID, &p.UserID, &p.Year, &p.Week, &p.StartDate, &p.EndDate,
|
||||
&initialWeight, &targetWeight, &finalWeight, &p.Note, &p.CreatedAt,
|
||||
)
|
||||
if initialWeight.Valid {
|
||||
p.InitialWeight = &initialWeight.Float64
|
||||
}
|
||||
if targetWeight.Valid {
|
||||
p.TargetWeight = &targetWeight.Float64
|
||||
}
|
||||
if finalWeight.Valid {
|
||||
p.FinalWeight = &finalWeight.Float64
|
||||
}
|
||||
|
||||
detail := model.WeeklyPlanDetail{
|
||||
Plan: &p,
|
||||
IsPast: p.Year < currentYear || (p.Year == currentYear && p.Week < currentWeek),
|
||||
}
|
||||
|
||||
// 计算本周减重
|
||||
if p.InitialWeight != nil && p.FinalWeight != nil {
|
||||
change := *p.InitialWeight - *p.FinalWeight
|
||||
detail.WeightChange = &change
|
||||
}
|
||||
|
||||
// 判断是否达标
|
||||
if p.TargetWeight != nil && p.FinalWeight != nil {
|
||||
detail.IsQualified = *p.FinalWeight <= *p.TargetWeight
|
||||
}
|
||||
|
||||
plans = append(plans, detail)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, plans)
|
||||
}
|
||||
|
||||
// 更新每周计划
|
||||
func (h *EpochHandler) UpdateWeeklyPlan(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
planID, _ := strconv.ParseInt(c.Param("planId"), 10, 64)
|
||||
|
||||
var req model.UpdateWeeklyPlanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新计划
|
||||
_, err := h.db.Exec(`
|
||||
UPDATE weekly_plans
|
||||
SET initial_weight = COALESCE(?, initial_weight),
|
||||
target_weight = COALESCE(?, target_weight),
|
||||
final_weight = COALESCE(?, final_weight),
|
||||
note = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
`, req.InitialWeight, req.TargetWeight, req.FinalWeight, req.Note, planID, userID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update plan"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "plan updated"})
|
||||
}
|
||||
|
||||
// 生成每周计划
|
||||
func (h *EpochHandler) generateWeeklyPlans(epochID, userID int64, startDate, endDate string, initialWeight, targetWeight float64) {
|
||||
start, _ := time.Parse("2006-01-02", startDate)
|
||||
end, _ := time.Parse("2006-01-02", endDate)
|
||||
|
||||
// 计算总周数
|
||||
totalDays := int(end.Sub(start).Hours() / 24)
|
||||
totalWeeks := (totalDays + 6) / 7
|
||||
if totalWeeks < 1 {
|
||||
totalWeeks = 1
|
||||
}
|
||||
|
||||
// 每周减重目标
|
||||
weeklyLoss := (initialWeight - targetWeight) / float64(totalWeeks)
|
||||
|
||||
currentDate := start
|
||||
prevWeight := initialWeight
|
||||
|
||||
for currentDate.Before(end) || currentDate.Equal(end) {
|
||||
year, week := currentDate.ISOWeek()
|
||||
weekStart := getWeekStart(currentDate)
|
||||
weekEnd := weekStart.AddDate(0, 0, 6)
|
||||
|
||||
weekTarget := prevWeight - weeklyLoss
|
||||
if weekTarget < targetWeight {
|
||||
weekTarget = targetWeight
|
||||
}
|
||||
|
||||
h.db.Exec(`
|
||||
INSERT OR IGNORE INTO weekly_plans (epoch_id, user_id, year, week, start_date, end_date, initial_weight, target_weight)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, epochID, userID, year, week, weekStart.Format("2006-01-02"), weekEnd.Format("2006-01-02"), prevWeight, weekTarget)
|
||||
|
||||
prevWeight = weekTarget
|
||||
currentDate = currentDate.AddDate(0, 0, 7)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算纪元详情
|
||||
func (h *EpochHandler) calculateEpochDetail(epoch *model.WeightEpoch) *model.EpochDetail {
|
||||
now := time.Now()
|
||||
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.Local)
|
||||
|
||||
// 解析日期,支持多种格式
|
||||
epochStart := parseDate(epoch.StartDate)
|
||||
epochEnd := parseDate(epoch.EndDate)
|
||||
|
||||
detail := &model.EpochDetail{
|
||||
Epoch: epoch,
|
||||
YearDaysPassed: int(now.Sub(startOfYear).Hours() / 24),
|
||||
EpochDaysPassed: int(now.Sub(epochStart).Hours() / 24),
|
||||
DaysRemaining: int(epochEnd.Sub(now).Hours() / 24),
|
||||
}
|
||||
|
||||
if detail.EpochDaysPassed < 0 {
|
||||
detail.EpochDaysPassed = 0
|
||||
}
|
||||
if detail.DaysRemaining < 0 {
|
||||
detail.DaysRemaining = 0
|
||||
}
|
||||
|
||||
// 获取最新体重记录
|
||||
var latestWeight float64
|
||||
err := h.db.QueryRow(`
|
||||
SELECT final_weight FROM weekly_plans
|
||||
WHERE epoch_id = ? AND final_weight IS NOT NULL
|
||||
ORDER BY year DESC, week DESC LIMIT 1
|
||||
`, epoch.ID).Scan(&latestWeight)
|
||||
if err == nil {
|
||||
detail.CurrentWeight = &latestWeight
|
||||
actualChange := epoch.InitialWeight - latestWeight
|
||||
detail.ActualChange = &actualChange
|
||||
distanceToGoal := latestWeight - epoch.TargetWeight
|
||||
detail.DistanceToGoal = &distanceToGoal
|
||||
detail.IsQualified = latestWeight <= epoch.TargetWeight
|
||||
}
|
||||
|
||||
return detail
|
||||
}
|
||||
|
||||
// 解析日期,支持多种格式
|
||||
func parseDate(dateStr string) time.Time {
|
||||
// 尝试多种格式
|
||||
formats := []string{
|
||||
"2006-01-02",
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
time.RFC3339,
|
||||
}
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, dateStr); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
// 默认返回当前时间
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func getWeekStart(t time.Time) time.Time {
|
||||
weekday := int(t.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
return t.AddDate(0, 0, -(weekday - 1))
|
||||
}
|
||||
@@ -202,7 +202,7 @@ func (h *WeightHandler) getWeekWeightData(userID int64, year, week int) model.We
|
||||
HasRecord: false,
|
||||
}
|
||||
|
||||
// 获取本周体重
|
||||
// 获取本周体重(最终体重)
|
||||
var weight float64
|
||||
err := h.db.QueryRow(
|
||||
"SELECT weight FROM weight_records WHERE user_id = ? AND year = ? AND week = ?",
|
||||
@@ -213,7 +213,7 @@ func (h *WeightHandler) getWeekWeightData(userID int64, year, week int) model.We
|
||||
data.HasRecord = true
|
||||
}
|
||||
|
||||
// 获取上周体重
|
||||
// 获取上周体重(作为本周初始体重)
|
||||
prevYear, prevWeek := getPrevWeek(year, week)
|
||||
var prevWeight float64
|
||||
err = h.db.QueryRow(
|
||||
@@ -222,8 +222,11 @@ func (h *WeightHandler) getWeekWeightData(userID int64, year, week int) model.We
|
||||
).Scan(&prevWeight)
|
||||
if err == nil {
|
||||
data.PrevWeight = &prevWeight
|
||||
data.InitialWeight = &prevWeight // 本周初始体重 = 上周最终体重
|
||||
|
||||
// 计算本周实际减重 = 初始体重 - 最终体重(减重为正,增重为负)
|
||||
if data.Weight != nil {
|
||||
change := *data.Weight - prevWeight
|
||||
change := prevWeight - *data.Weight
|
||||
data.WeightChange = &change
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,58 @@ type User struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// 体重目标
|
||||
// 减重纪元
|
||||
type WeightEpoch struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
InitialWeight float64 `json:"initial_weight"`
|
||||
TargetWeight float64 `json:"target_weight"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
FinalWeight *float64 `json:"final_weight"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// 纪元详情(包含统计)
|
||||
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"` // 是否合格
|
||||
}
|
||||
|
||||
// 每周计划
|
||||
type WeeklyPlan struct {
|
||||
ID int64 `json:"id"`
|
||||
EpochID int64 `json:"epoch_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Year int `json:"year"`
|
||||
Week int `json:"week"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
InitialWeight *float64 `json:"initial_weight"` // 周初始体重
|
||||
TargetWeight *float64 `json:"target_weight"` // 周目标体重
|
||||
FinalWeight *float64 `json:"final_weight"` // 周最终体重
|
||||
Note string `json:"note"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// 周计划详情
|
||||
type WeeklyPlanDetail struct {
|
||||
Plan *WeeklyPlan `json:"plan"`
|
||||
WeightChange *float64 `json:"weight_change"` // 本周减重
|
||||
IsQualified bool `json:"is_qualified"` // 是否达标
|
||||
IsPast bool `json:"is_past"` // 是否已过
|
||||
}
|
||||
|
||||
// 体重目标 (保留兼容)
|
||||
type WeightGoal struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
@@ -36,14 +87,15 @@ type WeightRecord struct {
|
||||
|
||||
// 周数据(包含对比信息)
|
||||
type WeekWeightData struct {
|
||||
Year int `json:"year"`
|
||||
Week int `json:"week"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Weight *float64 `json:"weight"`
|
||||
PrevWeight *float64 `json:"prev_weight"`
|
||||
WeightChange *float64 `json:"weight_change"`
|
||||
HasRecord bool `json:"has_record"`
|
||||
Year int `json:"year"`
|
||||
Week int `json:"week"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
InitialWeight *float64 `json:"initial_weight"` // 本周初始体重(上周最终体重)
|
||||
Weight *float64 `json:"weight"` // 本周最终体重
|
||||
PrevWeight *float64 `json:"prev_weight"`
|
||||
WeightChange *float64 `json:"weight_change"` // 本周实际减重 = 初始体重 - 最终体重
|
||||
HasRecord bool `json:"has_record"`
|
||||
}
|
||||
|
||||
// 体重统计
|
||||
@@ -83,7 +135,23 @@ type ProfileResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// 体重相关请求
|
||||
// 纪元相关请求
|
||||
type CreateEpochRequest struct {
|
||||
Name string `json:"name"`
|
||||
InitialWeight float64 `json:"initial_weight" binding:"required,gt=0"`
|
||||
TargetWeight float64 `json:"target_weight" binding:"required,gt=0"`
|
||||
StartDate string `json:"start_date" binding:"required"`
|
||||
EndDate string `json:"end_date" binding:"required"`
|
||||
}
|
||||
|
||||
type UpdateWeeklyPlanRequest struct {
|
||||
InitialWeight *float64 `json:"initial_weight"`
|
||||
TargetWeight *float64 `json:"target_weight"`
|
||||
FinalWeight *float64 `json:"final_weight"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// 体重相关请求 (保留兼容)
|
||||
type SetWeightGoalRequest struct {
|
||||
TargetWeight float64 `json:"target_weight" binding:"required,gt=0"`
|
||||
StartDate string `json:"start_date" binding:"required"`
|
||||
|
||||
@@ -38,6 +38,7 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
|
||||
uploadHandler := handler.NewUploadHandler(cfg)
|
||||
versionHandler := handler.NewVersionHandler(db, cfg)
|
||||
weightHandler := handler.NewWeightHandler(db, cfg)
|
||||
epochHandler := handler.NewEpochHandler(db, cfg)
|
||||
|
||||
// R2 文件代理 (公开访问)
|
||||
r.GET("/files/*filepath", uploadHandler.GetFile)
|
||||
@@ -65,6 +66,14 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
|
||||
auth.GET("/weight/stats", weightHandler.GetStats)
|
||||
auth.GET("/weight/history", weightHandler.GetHistory)
|
||||
|
||||
// 纪元
|
||||
auth.POST("/epoch", epochHandler.CreateEpoch)
|
||||
auth.GET("/epoch/active", epochHandler.GetActiveEpoch)
|
||||
auth.GET("/epoch/list", epochHandler.GetEpochList)
|
||||
auth.GET("/epoch/:id", epochHandler.GetEpochDetail)
|
||||
auth.GET("/epoch/:id/plans", epochHandler.GetWeeklyPlans)
|
||||
auth.PUT("/epoch/:id/plan/:planId", epochHandler.UpdateWeeklyPlan)
|
||||
|
||||
// 上传
|
||||
auth.POST("/upload", uploadHandler.Upload)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user