减重纪元&减重周计划

This commit is contained in:
amos wong
2025-12-21 13:03:52 +08:00
parent 4d2ad6194b
commit 44fb9a4976
16 changed files with 2482 additions and 395 deletions

View File

@@ -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 {

View File

@@ -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?>

View File

@@ -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,

View File

@@ -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)
)
}
}
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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
)
}
}
}
}
}

View File

@@ -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))
}
}

View File

@@ -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
)
}
}

View File

@@ -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)
)
}

View File

@@ -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()
}
}
}
}

View File

@@ -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

View File

@@ -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);

View 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))
}

View File

@@ -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
}
}

View File

@@ -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"`

View File

@@ -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)