feat:新 UI

This commit is contained in:
amos
2025-12-22 14:11:43 +08:00
parent 728d9cb5e0
commit 12e7c15d2c
15 changed files with 1716 additions and 2752 deletions

View File

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

View File

@@ -1,54 +1,42 @@
package com.healthflow.app.ui.navigation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.R
import com.healthflow.app.data.model.User
import com.healthflow.app.ui.screen.*
import com.healthflow.app.ui.theme.*
import com.healthflow.app.ui.viewmodel.EpochViewModel
import java.time.LocalDate
import java.time.temporal.WeekFields
sealed class Screen(
val route: String,
val title: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector
) {
data object Home : Screen("home", "首页", Icons.Filled.Home, Icons.Outlined.Home)
data object Profile : Screen("profile", "我的", Icons.Filled.Person, Icons.Outlined.Person)
sealed class Tab(val route: String, val label: String) {
data object Epoch : Tab("tab_epoch", "纪元")
data object Plan : Tab("tab_plan", "计划")
data object Profile : Tab("tab_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}"
const val LOGIN = "login"
const val CREATE_EPOCH = "create_epoch"
const val WEEK_PLAN_DETAIL = "week_plan_detail/{epochId}/{planId}"
fun epochDetail(epochId: Long) = "epoch_detail/$epochId"
fun weeklyPlans(epochId: Long) = "weekly_plans/$epochId"
fun weekPlanDetail(epochId: Long, planId: Long) = "week_plan_detail/$epochId/$planId"
}
val bottomNavItems = listOf(Screen.Home, Screen.Profile)
val tabs = listOf(Tab.Epoch, Tab.Plan, Tab.Profile)
@Composable
fun MainNavigation(
@@ -58,155 +46,105 @@ fun MainNavigation(
) {
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 activeEpoch by epochViewModel.activeEpoch.collectAsState()
val epochDetail by epochViewModel.epochDetail.collectAsState()
val weeklyPlans by epochViewModel.weeklyPlans.collectAsState()
val currentWeekPlan by epochViewModel.currentWeekPlan.collectAsState()
val selectedPlan by epochViewModel.selectedPlan.collectAsState()
val isLoading by epochViewModel.isLoading.collectAsState()
val error by epochViewModel.error.collectAsState()
val profileStats by epochViewModel.profileStats.collectAsState()
// 检查是否有活跃纪元
// 初始化加载
LaunchedEffect(Unit) {
epochViewModel.checkActiveEpoch()
epochViewModel.loadAll()
}
// 加载活跃纪元详情
LaunchedEffect(activeEpoch) {
activeEpoch?.let { epoch ->
epochViewModel.loadEpochDetail(epoch.id)
epochViewModel.loadWeeklyPlans(epoch.id)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
// 判断是否显示底部导航
val showBottomNav = currentRoute in tabs.map { it.route }
Scaffold(
bottomBar = {
if (showBottomNav) {
BottomNavBar(
currentRoute = currentRoute,
onTabSelected = { tab ->
navController.navigate(tab.route) {
popUpTo(Tab.Epoch.route) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
// 获取当前周计划
val currentWeekPlan = remember(weeklyPlans) {
val now = LocalDate.now()
val currentYear = now.year
val currentWeek = now.get(WeekFields.ISO.weekOfWeekBasedYear())
weeklyPlans.find { it.plan.year == currentYear && it.plan.week == currentWeek }
}
val startDestination = when (hasActiveEpoch) {
null -> Screen.Home.route
false -> Routes.SETUP
true -> Screen.Home.route
}
Box(modifier = Modifier.fillMaxSize()) {
) { paddingValues ->
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.fillMaxSize()
startDestination = Tab.Epoch.route,
modifier = Modifier.padding(paddingValues)
) {
// 首次设置页面
composable(Routes.SETUP) {
SetupScreen(
onSetupComplete = {
navController.navigate(Screen.Home.route) {
popUpTo(Routes.SETUP) { inclusive = true }
}
},
onCreateEpoch = { name, initial, target, start, end ->
epochViewModel.createEpoch(name, initial, target, start, end) {
epochViewModel.checkActiveEpoch()
navController.navigate(Screen.Home.route) {
popUpTo(Routes.SETUP) { inclusive = true }
}
}
},
isLoading = isLoading,
error = error
)
}
// 首页
composable(Screen.Home.route) {
HomeScreen(
epochDetail = epochDetail,
currentWeekPlan = currentWeekPlan,
isLoading = isLoading,
onViewAllPlans = {
activeEpoch?.let { epoch ->
navController.navigate(Routes.weeklyPlans(epoch.id))
}
},
onViewEpochDetail = {
activeEpoch?.let { epoch ->
navController.navigate(Routes.epochDetail(epoch.id))
}
},
onViewHistory = {
navController.navigate(Routes.EPOCH_LIST)
},
onRecordWeight = { weight ->
activeEpoch?.let { epoch ->
currentWeekPlan?.let { plan ->
epochViewModel.updateWeeklyPlan(epoch.id, plan.plan.id, weight) {
epochViewModel.loadWeeklyPlans(epoch.id)
epochViewModel.loadEpochDetail(epoch.id)
}
}
}
}
)
}
// 纪元列表
composable(Routes.EPOCH_LIST) {
LaunchedEffect(Unit) {
epochViewModel.loadEpochList()
}
EpochListScreen(
// 纪元 Tab
composable(Tab.Epoch.route) {
EpochScreen(
epochs = epochList,
onBack = { navController.popBackStack() },
onEpochClick = { epochId ->
navController.navigate(Routes.epochDetail(epochId))
isLoading = isLoading,
onEpochClick = { epoch ->
epochViewModel.setActiveEpoch(epoch)
navController.navigate(Tab.Plan.route)
},
onCreateNew = {
navController.navigate(Routes.SETUP)
navController.navigate(Routes.CREATE_EPOCH)
}
)
}
// 纪元详情页面
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(
// 计划 Tab
composable(Tab.Plan.route) {
PlanScreen(
activeEpoch = activeEpoch,
epochDetail = epochDetail,
currentWeekPlan = currentWeekPlan,
weeklyPlans = weeklyPlans,
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)
epochViewModel.loadEpochDetail(epochId)
}
WeeklyPlanScreen(
epoch = epochDetail?.epoch ?: activeEpoch,
epochDetail = epochDetail,
plans = weeklyPlans,
onBack = { navController.popBackStack() },
onPlanClick = { planDetail ->
epochViewModel.selectPlan(planDetail)
navController.navigate(Routes.weekPlanDetail(epochId, planDetail.plan.id))
activeEpoch?.let { epoch ->
navController.navigate(Routes.weekPlanDetail(epoch.id, planDetail.plan.id))
}
},
onCreateEpoch = {
navController.navigate(Routes.CREATE_EPOCH)
}
)
}
// 我的 Tab
composable(Tab.Profile.route) {
ProfileScreen(
user = user,
stats = profileStats,
onLogout = onLogout,
onCheckUpdate = onCheckUpdate
)
}
// 创建纪元页面
composable(Routes.CREATE_EPOCH) {
CreateEpochScreen(
isLoading = isLoading,
error = error,
onBack = { navController.popBackStack() },
onCreateEpoch = { name, startDate, endDate ->
epochViewModel.createEpoch(name, startDate, endDate) {
epochViewModel.loadAll()
navController.popBackStack()
}
}
)
}
@@ -223,79 +161,140 @@ fun MainNavigation(
WeekPlanDetailScreen(
planDetail = selectedPlan,
epochDetail = epochDetail,
onBack = { navController.popBackStack() },
onUpdateWeight = { weight ->
onRecordWeight = { weight ->
selectedPlan?.let { plan ->
epochViewModel.updateWeeklyPlan(epochId, plan.plan.id, weight) {
epochViewModel.loadWeeklyPlans(epochId)
epochViewModel.loadEpochDetail(epochId)
epochViewModel.recordWeight(epochId, plan.plan.id, weight) {
epochViewModel.loadAll()
navController.popBackStack()
}
}
},
onUpdateTarget = { initialWeight, targetWeight ->
selectedPlan?.let { plan ->
epochViewModel.updateWeeklyPlanTarget(epochId, plan.plan.id, initialWeight, targetWeight) {
epochViewModel.loadAll()
}
}
}
)
}
}
}
}
// 个人中心
composable(Screen.Profile.route) {
ProfileScreen(
user = user,
onLogout = onLogout,
onCheckUpdate = onCheckUpdate
@Composable
private fun BottomNavBar(
currentRoute: String?,
onTabSelected: (Tab) -> Unit
) {
Surface(
color = Color.White,
shadowElevation = 8.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.height(64.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
tabs.forEach { tab ->
val selected = currentRoute == tab.route
NavBarItem(
label = tab.label,
selected = selected,
icon = when (tab) {
Tab.Epoch -> TabIcon.Circle
Tab.Plan -> TabIcon.Square
Tab.Profile -> TabIcon.Triangle
},
onClick = { onTabSelected(tab) }
)
}
}
}
}
// Bottom Navigation
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val showBottomNav = currentRoute in listOf(Screen.Home.route, Screen.Profile.route)
enum class TabIcon { Circle, Square, Triangle }
if (showBottomNav && hasActiveEpoch != false) {
Surface(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
color = MaterialTheme.colorScheme.surface
) {
Column {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
Row(
@Composable
private fun NavBarItem(
label: String,
selected: Boolean,
icon: TabIcon,
onClick: () -> Unit
) {
val color = if (selected) Slate800 else Slate400
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(horizontal = 24.dp)
) {
IconButton(onClick = onClick) {
when (icon) {
TabIcon.Circle -> {
Box(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.height(56.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
.size(24.dp)
.then(
if (selected) {
Modifier
} else {
Modifier
}
),
contentAlignment = Alignment.Center
) {
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
}
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)
)
androidx.compose.foundation.Canvas(modifier = Modifier.size(20.dp)) {
drawCircle(
color = color,
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx())
)
}
}
}
TabIcon.Square -> {
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center
) {
androidx.compose.foundation.Canvas(modifier = Modifier.size(18.dp)) {
drawRect(
color = color,
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx())
)
}
}
}
TabIcon.Triangle -> {
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center
) {
androidx.compose.foundation.Canvas(modifier = Modifier.size(20.dp)) {
val path = androidx.compose.ui.graphics.Path().apply {
moveTo(size.width / 2, 0f)
lineTo(size.width, size.height)
lineTo(0f, size.height)
close()
}
drawPath(
path = path,
color = color,
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx())
)
}
}
}
}
}
Text(
text = label,
fontSize = 12.sp,
color = color
)
}
}

View File

@@ -0,0 +1,288 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.healthflow.app.ui.theme.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateEpochScreen(
isLoading: Boolean,
error: String?,
onBack: () -> Unit,
onCreateEpoch: (name: String, startDate: String, endDate: String) -> Unit
) {
var name by remember { mutableStateOf("") }
var startDate by remember { mutableStateOf<LocalDate?>(null) }
var endDate by remember { mutableStateOf<LocalDate?>(null) }
var showStartDatePicker by remember { mutableStateOf(false) }
var showEndDatePicker by remember { mutableStateOf(false) }
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val displayFormatter = DateTimeFormatter.ofPattern("yyyy / MM / dd")
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
// 返回按钮
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(onClick = onBack)
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回",
tint = Slate600,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "取消",
fontSize = 16.sp,
color = Slate600
)
}
Spacer(modifier = Modifier.height(24.dp))
// 标题
Text(
text = "开启新纪元",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = Slate900
)
Text(
text = "为你的健康阶段设定一个开端",
fontSize = 14.sp,
color = Slate500,
modifier = Modifier.padding(top = 4.dp)
)
Spacer(modifier = Modifier.height(40.dp))
// 纪元名称
Text(
text = "纪元名称",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = Slate700
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
placeholder = { Text("例如:盛夏减脂阶段", color = Slate400) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Slate300,
unfocusedBorderColor = Slate200,
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White
),
singleLine = true
)
Spacer(modifier = Modifier.height(24.dp))
// 起始日期
Text(
text = "起始日期",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = Slate700
)
Spacer(modifier = Modifier.height(8.dp))
DatePickerField(
value = startDate?.format(displayFormatter) ?: "",
placeholder = "年 / 月 / 日",
onClick = { showStartDatePicker = true }
)
Spacer(modifier = Modifier.height(24.dp))
// 截止日期
Text(
text = "截止日期",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = Slate700
)
Spacer(modifier = Modifier.height(8.dp))
DatePickerField(
value = endDate?.format(displayFormatter) ?: "",
placeholder = "年 / 月 / 日",
onClick = { showEndDatePicker = true }
)
// 错误提示
error?.let {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = it,
color = ErrorRed,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.weight(1f))
// 确认按钮
Button(
onClick = {
if (startDate != null && endDate != null) {
onCreateEpoch(
name.ifEmpty { "新纪元" },
startDate!!.format(dateFormatter),
endDate!!.format(dateFormatter)
)
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = startDate != null && endDate != null && !isLoading,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Slate900,
disabledContainerColor = Slate300
)
) {
if (isLoading) {
CircularProgressIndicator(
color = Color.White,
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Text(
text = "确认开启",
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(32.dp))
}
// 日期选择器
if (showStartDatePicker) {
DatePickerDialog(
initialDate = startDate ?: LocalDate.now(),
onDismiss = { showStartDatePicker = false },
onDateSelected = {
startDate = it
showStartDatePicker = false
}
)
}
if (showEndDatePicker) {
DatePickerDialog(
initialDate = endDate ?: (startDate?.plusWeeks(8) ?: LocalDate.now().plusWeeks(8)),
onDismiss = { showEndDatePicker = false },
onDateSelected = {
endDate = it
showEndDatePicker = false
}
)
}
}
@Composable
private fun DatePickerField(
value: String,
placeholder: String,
onClick: () -> Unit
) {
OutlinedTextField(
value = value,
onValueChange = {},
placeholder = { Text(placeholder, color = Slate400) },
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
enabled = false,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = Slate200,
disabledContainerColor = Color.White,
disabledTextColor = Slate900
),
trailingIcon = {
Icon(
Icons.Default.DateRange,
contentDescription = "选择日期",
tint = Slate400
)
},
singleLine = true
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DatePickerDialog(
initialDate: LocalDate,
onDismiss: () -> Unit,
onDateSelected: (LocalDate) -> Unit
) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = initialDate.toEpochDay() * 24 * 60 * 60 * 1000
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val date = LocalDate.ofEpochDay(millis / (24 * 60 * 60 * 1000))
onDateSelected(date)
}
}
) {
Text("确定", color = Brand500)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消", color = Slate500)
}
}
) {
DatePicker(
state = datePickerState,
colors = DatePickerDefaults.colors(
selectedDayContainerColor = Brand500,
todayDateBorderColor = Brand500
)
)
}
}

View File

@@ -1,368 +0,0 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.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

@@ -1,284 +0,0 @@
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.automirrored.filled.ArrowBack
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.copy(alpha = 0.95f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.clickable { onBack() },
contentAlignment = Alignment.Center
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "历史纪元",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = onCreateNew,
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Default.Add,
contentDescription = "新建纪元",
tint = Brand500,
modifier = Modifier.size(22.dp)
)
}
}
}
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,238 @@
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.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.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 EpochScreen(
epochs: List<WeightEpoch>,
isLoading: Boolean,
onEpochClick: (WeightEpoch) -> Unit,
onCreateNew: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(60.dp))
// 标题
Text(
text = "HealthFlow",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = Slate900
)
Text(
text = "追踪你的健康演变",
fontSize = 14.sp,
color = Slate500,
modifier = Modifier.padding(top = 4.dp)
)
Spacer(modifier = Modifier.height(32.dp))
if (isLoading && epochs.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = Brand500)
}
} else if (epochs.isEmpty()) {
// 空状态
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "还没有纪元",
fontSize = 16.sp,
color = Slate500
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onCreateNew,
colors = ButtonDefaults.buttonColors(containerColor = Slate900)
) {
Text("开启新纪元")
}
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(bottom = 100.dp)
) {
items(epochs) { epoch ->
EpochCard(
epoch = epoch,
onClick = { onEpochClick(epoch) }
)
}
}
}
}
// FAB
FloatingActionButton(
onClick = onCreateNew,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(24.dp),
containerColor = Slate900,
contentColor = Color.White,
shape = CircleShape
) {
Icon(Icons.Default.Add, contentDescription = "创建纪元")
}
}
}
@Composable
private fun EpochCard(
epoch: WeightEpoch,
onClick: () -> Unit
) {
val isActive = epoch.isActive
val isCompleted = epoch.isCompleted
// 计算进度
val progress = remember(epoch) {
try {
val start = LocalDate.parse(epoch.startDate.take(10))
val end = LocalDate.parse(epoch.endDate.take(10))
val now = LocalDate.now()
val totalDays = ChronoUnit.DAYS.between(start, end).toFloat()
val passedDays = ChronoUnit.DAYS.between(start, now).toFloat()
(passedDays / totalDays).coerceIn(0f, 1f)
} catch (e: Exception) {
0f
}
}
// 格式化日期
val dateRange = remember(epoch) {
try {
val start = LocalDate.parse(epoch.startDate.take(10))
val end = LocalDate.parse(epoch.endDate.take(10))
val formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd")
"${start.format(formatter)} - ${end.format(formatter)}"
} catch (e: Exception) {
"${epoch.startDate} - ${epoch.endDate}"
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Slate50),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(modifier = Modifier.fillMaxWidth()) {
// 左侧绿色指示条
Box(
modifier = Modifier
.width(4.dp)
.height(100.dp)
.background(
if (isActive) Brand500 else Slate300,
RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp)
)
)
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = epoch.name.ifEmpty { "未命名纪元" },
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Slate900
)
// 状态标签
if (isCompleted) {
Icon(
Icons.Default.Check,
contentDescription = "已完成",
tint = Brand500,
modifier = Modifier.size(24.dp)
)
} else if (isActive) {
Text(
text = "进行中",
fontSize = 14.sp,
color = Brand500,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = dateRange,
fontSize = 14.sp,
color = Slate500
)
Spacer(modifier = Modifier.height(12.dp))
// 进度条
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(Slate200)
) {
Box(
modifier = Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(
if (isCompleted) Slate400 else Slate900
)
)
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,362 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.healthflow.app.data.model.*
import com.healthflow.app.ui.theme.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.time.temporal.WeekFields
@Composable
fun PlanScreen(
activeEpoch: WeightEpoch?,
epochDetail: EpochDetail?,
currentWeekPlan: WeeklyPlanDetail?,
weeklyPlans: List<WeeklyPlanDetail>,
isLoading: Boolean,
onPlanClick: (WeeklyPlanDetail) -> Unit,
onCreateEpoch: () -> Unit
) {
if (activeEpoch == null) {
// 没有活跃纪元
EmptyPlanState(onCreateEpoch = onCreateEpoch)
return
}
val now = LocalDate.now()
val currentYear = now.year
val currentWeek = now.get(WeekFields.ISO.weekOfWeekBasedYear())
// 分离当前周和后续周
val futurePlans = weeklyPlans.filter { plan ->
plan.plan.year > currentYear ||
(plan.plan.year == currentYear && plan.plan.week > currentWeek)
}.take(5) // 只显示接下来5周
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(60.dp))
// 标题
Text(
text = "计划中心",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = Slate900
)
Text(
text = "专注于本阶段的周计划",
fontSize = 14.sp,
color = Slate500,
modifier = Modifier.padding(top = 4.dp)
)
Spacer(modifier = Modifier.height(32.dp))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(bottom = 24.dp)
) {
// 正在进行中
item {
Text(
text = "正在进行中",
fontSize = 14.sp,
color = Slate500,
modifier = Modifier.padding(bottom = 8.dp)
)
}
// 当前周计划卡片
item {
currentWeekPlan?.let { plan ->
CurrentWeekCard(
epoch = activeEpoch,
plan = plan,
epochDetail = epochDetail,
onClick = { onPlanClick(plan) }
)
} ?: run {
// 没有当前周计划
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Slate50)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "本周暂无计划",
color = Slate500
)
}
}
}
}
// 后续序列
if (futurePlans.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "后续序列",
fontSize = 14.sp,
color = Slate500,
modifier = Modifier.padding(bottom = 8.dp)
)
}
items(futurePlans) { plan ->
FuturePlanItem(
plan = plan,
onClick = { onPlanClick(plan) }
)
}
}
}
}
}
@Composable
private fun EmptyPlanState(onCreateEpoch: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(24.dp)
) {
Text(
text = "还没有进行中的纪元",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = Slate700
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "创建一个纪元来开始你的健康计划",
fontSize = 14.sp,
color = Slate500
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onCreateEpoch,
colors = ButtonDefaults.buttonColors(containerColor = Slate900),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(0.6f)
) {
Text(
text = "开启新纪元",
modifier = Modifier.padding(vertical = 4.dp)
)
}
}
}
}
@Composable
private fun CurrentWeekCard(
epoch: WeightEpoch,
plan: WeeklyPlanDetail,
epochDetail: EpochDetail?,
onClick: () -> Unit
) {
// 计算周内天数
val dayInWeek = remember(plan) {
try {
val start = LocalDate.parse(plan.plan.startDate.take(10))
val now = LocalDate.now()
(ChronoUnit.DAYS.between(start, now) + 1).toInt().coerceIn(1, 7)
} catch (e: Exception) { 1 }
}
// 计算进度
val progress = remember(plan) {
val target = plan.plan.targetWeight ?: return@remember 0f
val initial = plan.plan.initialWeight ?: return@remember 0f
val current = plan.plan.finalWeight ?: initial
if (initial == target) return@remember 1f
((initial - current) / (initial - target)).toFloat().coerceIn(0f, 1f)
}
// 计算还差多少
val distanceToTarget = remember(plan) {
val target = plan.plan.targetWeight ?: return@remember null
val current = plan.plan.finalWeight ?: plan.plan.initialWeight ?: return@remember null
current - target
}
// 周序号
val weekIndex = remember(plan, epoch) {
try {
val epochStart = LocalDate.parse(epoch.startDate.take(10))
val planStart = LocalDate.parse(plan.plan.startDate.take(10))
val weeks = ChronoUnit.WEEKS.between(epochStart, planStart) + 1
weeks.toInt()
} catch (e: Exception) { plan.plan.week }
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Slate50),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(modifier = Modifier.fillMaxWidth()) {
// 左侧绿色指示条
Box(
modifier = Modifier
.width(4.dp)
.height(120.dp)
.background(Brand500, RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp))
)
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${epoch.name} - 第 $weekIndex",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Slate900
)
Text(
text = "$dayInWeek",
fontSize = 14.sp,
color = Brand500,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(8.dp))
// 目标信息
val targetText = buildString {
append("目标: ")
plan.plan.targetWeight?.let { append("${formatWeight(it)} kg") }
distanceToTarget?.let {
if (it > 0) append(" (还差 ${formatWeight(it)} kg)")
else append(" (已达标)")
}
}
Text(
text = targetText,
fontSize = 14.sp,
color = Slate600
)
Spacer(modifier = Modifier.height(16.dp))
// 进度条
Box(
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(Slate200)
) {
Box(
modifier = Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(Brand500)
)
}
}
}
}
}
@Composable
private fun FuturePlanItem(
plan: WeeklyPlanDetail,
onClick: () -> Unit
) {
// 格式化日期
val dateRange = remember(plan) {
try {
val start = LocalDate.parse(plan.plan.startDate.take(10))
val end = LocalDate.parse(plan.plan.endDate.take(10))
val formatter = DateTimeFormatter.ofPattern("MM.dd")
"${start.format(formatter)} - ${end.format(formatter)}"
} catch (e: Exception) {
""
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "${plan.plan.week}",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Slate800
)
Text(
text = dateRange,
fontSize = 14.sp,
color = Slate500
)
}
Text(
text = "待启动",
fontSize = 14.sp,
color = Slate400
)
}
HorizontalDivider(color = Slate100)
}
private fun formatWeight(weight: Double): String {
return if (weight == weight.toLong().toDouble()) {
weight.toLong().toString()
} else {
String.format("%.1f", weight)
}
}

View File

@@ -1,18 +1,15 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -20,148 +17,242 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.healthflow.app.data.model.User
import com.healthflow.app.ui.theme.*
import com.healthflow.app.ui.viewmodel.ProfileStats
import java.time.LocalDate
@Composable
fun ProfileScreen(
user: User?,
stats: ProfileStats,
onLogout: () -> Unit,
onCheckUpdate: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.background(Color.White)
.padding(horizontal = 24.dp)
) {
// Header
Spacer(modifier = Modifier.height(60.dp))
// 标题
Text(
text = "个人中心",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = Slate900
)
Spacer(modifier = Modifier.height(32.dp))
// 用户信息
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Check Update
// 头像
Box(
modifier = Modifier
.size(36.dp)
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.clickable { onCheckUpdate() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.SystemUpdate,
contentDescription = "检查更新",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = "我的",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
// Logout
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(ErrorRed.copy(alpha = 0.1f))
.clickable { onLogout() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Logout,
contentDescription = "退出登录",
modifier = Modifier.size(18.dp),
tint = ErrorRed
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
// Profile Content
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(32.dp))
// Avatar
Box(
modifier = Modifier
.size(96.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
.background(Slate100)
) {
if (user?.avatarUrl?.isNotEmpty() == true) {
AsyncImage(
model = user.avatarUrl,
contentDescription = null,
contentDescription = "头像",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Text(
text = user?.nickname?.firstOrNull()?.toString() ?: "?",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// 默认头像占位
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = user?.nickname?.firstOrNull()?.toString() ?: "U",
fontSize = 28.sp,
fontWeight = FontWeight.Medium,
color = Slate400
)
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = user?.nickname ?: "用户",
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
color = Slate900
)
Text(
text = "坚持 ${stats.persistDays}",
fontSize = 14.sp,
color = Slate500
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(16.dp))
// Nickname
Text(
text = user?.nickname ?: "",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
// 统计卡片
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatCard(
title = "今年减重",
value = formatWeight(stats.yearLoss),
unit = "kg",
valueColor = Brand500,
modifier = Modifier.weight(1f)
)
StatCard(
title = "总减重",
value = formatWeight(stats.totalLoss),
unit = "kg",
valueColor = Slate900,
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(4.dp))
// 年度进度卡片
YearProgressCard(daysRemaining = stats.daysRemaining)
Spacer(modifier = Modifier.weight(1f))
// Username
// 退出登录按钮
TextButton(
onClick = onLogout,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "@${user?.username ?: ""}",
text = "退出登录",
color = ErrorRed,
fontSize = 16.sp
)
}
HorizontalDivider(color = Slate100)
Spacer(modifier = Modifier.height(32.dp))
}
}
@Composable
private fun StatCard(
title: String,
value: String,
unit: String,
valueColor: Color,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Slate50),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = title,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = Slate500
)
Spacer(modifier = Modifier.height(32.dp))
// Info Card
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.Bottom
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
Text(
text = value,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = valueColor
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = unit,
fontSize = 16.sp,
color = valueColor,
modifier = Modifier.padding(bottom = 4.dp)
)
}
}
}
}
@Composable
private fun YearProgressCard(daysRemaining: Int) {
val currentYear = LocalDate.now().year
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Navy900),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Box(modifier = Modifier.fillMaxWidth()) {
// 右上角年份标签
Surface(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(12.dp),
shape = RoundedCornerShape(8.dp),
color = Slate700
) {
Text(
text = "$currentYear PROGRESS",
fontSize = 12.sp,
color = Slate300,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
Column(
modifier = Modifier.padding(24.dp)
) {
Text(
text = "今年剩余天数",
fontSize = 14.sp,
color = Slate400
)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.Bottom
) {
Text(
text = "💚",
fontSize = 32.sp
text = String.format("%02d", daysRemaining),
fontSize = 56.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "健康数据即将上线",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
text = "DAYS",
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = Slate400,
modifier = Modifier.padding(bottom = 12.dp)
)
}
}
}
}
}
private fun formatWeight(weight: Double): String {
return if (weight == weight.toLong().toDouble()) {
weight.toLong().toString()
} else {
String.format("%.1f", weight)
}
}

View File

@@ -1,285 +0,0 @@
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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 epochName by remember { mutableStateOf("") }
var initialWeight by remember { mutableStateOf("") }
var targetWeight by remember { mutableStateOf("") }
var startDate by remember { mutableStateOf(LocalDate.now().format(DateTimeFormatter.ISO_DATE)) }
var endDate by remember { mutableStateOf("") }
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(40.dp))
Text(text = "🎯", fontSize = 48.sp)
Spacer(modifier = Modifier.height(12.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(32.dp))
// 纪元名称
OutlinedTextField(
value = epochName,
onValueChange = { epochName = it },
label = { Text("纪元名称") },
placeholder = { Text("2024年度减重计划") },
singleLine = true,
enabled = !isLoading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
focusedLabelColor = Brand500
)
)
Spacer(modifier = Modifier.height(16.dp))
// 初始体重
OutlinedTextField(
value = initialWeight,
onValueChange = { initialWeight = it },
label = { Text("初始体重 (kg)") },
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))
// 日期选择行
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// 开始日期
Box(
modifier = Modifier
.weight(1f)
.clickable(enabled = !isLoading) {
val calendar = Calendar.getInstance()
DatePickerDialog(
context,
{ _, year, month, day ->
startDate = String.format("%04d-%02d-%02d", year, month + 1, day)
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH)
).show()
}
) {
OutlinedTextField(
value = startDate,
onValueChange = { },
label = { Text("开始日期") },
readOnly = true,
enabled = false,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant
),
trailingIcon = {
Icon(
Icons.Outlined.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
// 结束日期
Box(
modifier = Modifier
.weight(1f)
.clickable(enabled = !isLoading) {
val calendar = Calendar.getInstance()
calendar.add(Calendar.MONTH, 3)
DatePickerDialog(
context,
{ _, year, month, day ->
endDate = String.format("%04d-%02d-%02d", year, month + 1, day)
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH)
).show()
}
) {
OutlinedTextField(
value = endDate,
onValueChange = { },
label = { Text("结束日期") },
readOnly = true,
enabled = false,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant
),
trailingIcon = {
Icon(
Icons.Outlined.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// 提示信息
val initial = initialWeight.toDoubleOrNull()
val target = targetWeight.toDoubleOrNull()
if (initial != null && target != null && target < initial) {
Text(
text = "目标减重 ${String.format("%.1f", initial - target)} 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))
Spacer(modifier = Modifier.height(24.dp))
// 创建按钮
val isValid = epochName.isNotBlank() &&
initialWeight.toDoubleOrNull() != null &&
targetWeight.toDoubleOrNull() != null &&
startDate.isNotEmpty() &&
endDate.isNotEmpty()
Button(
onClick = {
onCreateEpoch(
epochName.ifBlank { "减重纪元" },
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

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

View File

@@ -1,287 +0,0 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.healthflow.app.data.model.EpochDetail
import com.healthflow.app.data.model.WeeklyPlanDetail
import com.healthflow.app.data.model.WeightEpoch
import com.healthflow.app.ui.theme.*
import java.time.LocalDate
import java.time.temporal.WeekFields
private fun formatDate(dateStr: String): String {
return dateStr.substringBefore("T").trim()
}
@Composable
fun WeeklyPlanScreen(
epoch: WeightEpoch?,
epochDetail: EpochDetail?,
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.copy(alpha = 0.95f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.clickable { onBack() },
contentAlignment = Alignment.Center
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "每周计划",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(36.dp))
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
if (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(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(plans) { planDetail ->
val plan = planDetail.plan
val isCurrent = plan.year == currentYear && plan.week == currentWeek
WeekCard(
planDetail = planDetail,
isCurrent = isCurrent,
onClick = { onPlanClick(planDetail) }
)
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
}
@Composable
private fun WeekCard(
planDetail: WeeklyPlanDetail,
isCurrent: Boolean,
onClick: () -> Unit
) {
val plan = planDetail.plan
val showStamp = planDetail.isPast && plan.finalWeight != null
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = when {
isCurrent -> Brand500.copy(alpha = 0.05f)
planDetail.isPast -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
else -> MaterialTheme.colorScheme.surface
},
tonalElevation = if (isCurrent || planDetail.isPast) 0.dp else 1.dp,
onClick = onClick
) {
Box {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${plan.week}",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = if (isCurrent) Brand500 else MaterialTheme.colorScheme.onSurface
)
if (isCurrent) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(Brand500)
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(text = "本周", fontSize = 10.sp, color = Color.White)
}
}
}
Text(
text = "${formatDate(plan.startDate)} ~ ${formatDate(plan.endDate)}",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
WeightItem(label = "初始", value = plan.initialWeight)
WeightItem(label = "目标", value = plan.targetWeight, color = Brand500)
WeightItem(label = "实际", value = plan.finalWeight)
Column(horizontalAlignment = Alignment.End) {
Text(
text = "减重",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val change = planDetail.weightChange
if (change != null) {
val isLoss = change > 0
Text(
text = "${if (isLoss) "-" else "+"}${String.format("%.1f", kotlin.math.abs(change))}",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = if (isLoss) SuccessGreen else ErrorRed
)
} else {
Text(
text = "--",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// 盖章
if (showStamp) {
StampMark(
isQualified = planDetail.isQualified,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
}
}
}
}
@Composable
private fun WeightItem(
label: String,
value: Double?,
color: Color = MaterialTheme.colorScheme.onSurface
) {
Column {
Text(
text = label,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value?.let { String.format("%.1f", it) } ?: "--",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = color
)
}
}
@Composable
private fun StampMark(
isQualified: Boolean,
modifier: Modifier = Modifier
) {
val stampColor = if (isQualified) SuccessGreen else ErrorRed
val stampText = if (isQualified) "合格" else "不合格"
Box(
modifier = modifier
.size(if (isQualified) 44.dp else 52.dp)
.rotate(-15f),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = stampColor.copy(alpha = 0.8f),
radius = size.minDimension / 2,
style = Stroke(width = 2.5.dp.toPx())
)
drawCircle(
color = stampColor.copy(alpha = 0.5f),
radius = size.minDimension / 2 - 4.dp.toPx(),
style = Stroke(width = 1.dp.toPx())
)
}
Text(
text = stampText,
fontSize = if (isQualified) 11.sp else 9.sp,
fontWeight = FontWeight.Bold,
color = stampColor.copy(alpha = 0.9f),
textAlign = TextAlign.Center
)
}
}

View File

@@ -26,6 +26,10 @@ val Slate700 = Color(0xFF334155)
val Slate800 = Color(0xFF1E293B)
val Slate900 = Color(0xFF0F172A)
// Dark Navy - 深色卡片背景
val Navy800 = Color(0xFF1E2A3B)
val Navy900 = Color(0xFF0F172A)
// Semantic Colors
val ErrorRed = Color(0xFFEF4444)
val SuccessGreen = Color(0xFF22C55E)

View File

@@ -7,62 +7,143 @@ import com.healthflow.app.data.model.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.temporal.WeekFields
data class ProfileStats(
val yearLoss: Double = 0.0,
val totalLoss: Double = 0.0,
val daysRemaining: Int = 0,
val persistDays: Int = 0
)
class EpochViewModel : ViewModel() {
private val api = ApiClient.api
private val _epochList = MutableStateFlow<List<WeightEpoch>>(emptyList())
val epochList: StateFlow<List<WeightEpoch>> = _epochList
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 _currentWeekPlan = MutableStateFlow<WeeklyPlanDetail?>(null)
val currentWeekPlan: StateFlow<WeeklyPlanDetail?> = _currentWeekPlan
private val _selectedPlan = MutableStateFlow<WeeklyPlanDetail?>(null)
val selectedPlan: StateFlow<WeeklyPlanDetail?> = _selectedPlan
private val _profileStats = MutableStateFlow(ProfileStats())
val profileStats: StateFlow<ProfileStats> = _profileStats
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() {
fun loadAll() {
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
// 加载纪元列表
val listResponse = api.getEpochList()
if (listResponse.isSuccessful) {
_epochList.value = listResponse.body() ?: emptyList()
}
// 加载活跃纪元
val activeResponse = api.getActiveEpoch()
if (activeResponse.isSuccessful) {
val epoch = activeResponse.body()
_activeEpoch.value = epoch
if (epoch != null) {
loadEpochData(epoch.id)
}
}
// 计算个人统计
calculateProfileStats()
} catch (e: Exception) {
e.printStackTrace()
_hasActiveEpoch.value = false
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
private suspend fun loadEpochData(epochId: Long) {
try {
// 加载纪元详情
val detailResponse = api.getEpochDetail(epochId)
if (detailResponse.isSuccessful) {
_epochDetail.value = detailResponse.body()
}
// 加载周计划
val plansResponse = api.getWeeklyPlans(epochId)
if (plansResponse.isSuccessful) {
val plans = plansResponse.body() ?: emptyList()
_weeklyPlans.value = plans
// 找到当前周计划
val now = LocalDate.now()
val currentYear = now.year
val currentWeek = now.get(WeekFields.ISO.weekOfWeekBasedYear())
_currentWeekPlan.value = plans.find { plan: WeeklyPlanDetail ->
plan.plan.year == currentYear && plan.plan.week == currentWeek
}
}
} catch (e: Exception) {
_error.value = e.message
}
}
private fun calculateProfileStats() {
val detail = _epochDetail.value
val epochs = _epochList.value
// 计算今年剩余天数
val now = LocalDate.now()
val endOfYear = LocalDate.of(now.year, 12, 31)
val daysRemaining = endOfYear.toEpochDay() - now.toEpochDay()
// 计算坚持天数(从第一个纪元开始)
val firstEpoch = epochs.minByOrNull { it.startDate }
val persistDays = if (firstEpoch != null) {
try {
val startDate = LocalDate.parse(firstEpoch.startDate.take(10))
(now.toEpochDay() - startDate.toEpochDay()).toInt().coerceAtLeast(0)
} catch (e: Exception) { 0 }
} else { 0 }
_profileStats.value = ProfileStats(
yearLoss = detail?.yearTotalLoss ?: 0.0,
totalLoss = detail?.allTimeTotalLoss ?: 0.0,
daysRemaining = daysRemaining.toInt(),
persistDays = persistDays
)
}
fun setActiveEpoch(epoch: WeightEpoch) {
_activeEpoch.value = epoch
viewModelScope.launch {
loadEpochData(epoch.id)
}
}
fun selectPlan(plan: WeeklyPlanDetail) {
_selectedPlan.value = plan
}
fun createEpoch(
name: String,
initialWeight: Double,
targetWeight: Double,
startDate: String,
endDate: String,
onSuccess: () -> Unit
@@ -71,101 +152,92 @@ class EpochViewModel : ViewModel() {
_isLoading.value = true
_error.value = null
try {
// 获取最新体重作为初始体重
val latestWeight = _epochDetail.value?.currentWeight
?: _epochList.value.firstOrNull()?.let { epoch ->
api.getEpochDetail(epoch.id).body()?.currentWeight
}
?: 80.0 // 默认值
val request = CreateEpochRequest(
name = name,
initialWeight = initialWeight,
targetWeight = targetWeight,
initialWeight = latestWeight,
targetWeight = latestWeight - 10, // 默认目标减10kg
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()}")
val response = api.createEpoch(request)
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()}"
_error.value = "创建失败"
}
} catch (e: Exception) {
android.util.Log.e("EpochViewModel", "Exception", e)
_error.value = "网络错误: ${e.message}"
_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()
// 更新 selectedPlan如果有的话
_selectedPlan.value?.let { selected ->
_selectedPlan.value = _weeklyPlans.value.find { it.plan.id == selected.plan.id }
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun selectPlan(planDetail: WeeklyPlanDetail) {
_selectedPlan.value = planDetail
}
fun updateWeeklyPlan(
fun recordWeight(
epochId: Long,
planId: Long,
finalWeight: Double?,
weight: Double,
onSuccess: () -> Unit
) {
viewModelScope.launch {
_isLoading.value = true
try {
val request = UpdateWeeklyPlanRequest(finalWeight = finalWeight)
val response = ApiClient.api.updateWeeklyPlan(epochId, planId, request)
val request = UpdateWeeklyPlanRequest(finalWeight = weight)
val response = api.updateWeeklyPlan(epochId, planId, request)
if (response.isSuccessful) {
loadWeeklyPlans(epochId)
onSuccess()
}
} catch (e: Exception) {
e.printStackTrace()
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun updateWeeklyPlanTarget(
epochId: Long,
planId: Long,
initialWeight: Double?,
targetWeight: Double?,
onSuccess: () -> Unit
) {
viewModelScope.launch {
_isLoading.value = true
try {
val request = UpdateWeeklyPlanRequest(
initialWeight = initialWeight,
targetWeight = targetWeight
)
val response = api.updateWeeklyPlan(epochId, planId, request)
if (response.isSuccessful) {
// 更新本地数据
_selectedPlan.value?.let { currentPlan ->
_selectedPlan.value = currentPlan.copy(
plan = currentPlan.plan.copy(
initialWeight = initialWeight ?: currentPlan.plan.initialWeight,
targetWeight = targetWeight ?: currentPlan.plan.targetWeight
)
)
}
onSuccess()
}
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun clearError() {
_error.value = null
}
}

View File

@@ -1,202 +0,0 @@
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
import java.time.LocalDate
import java.time.temporal.WeekFields
import java.util.Locale
class WeightViewModel : ViewModel() {
private val _currentWeekData = MutableStateFlow<WeekWeightData?>(null)
val currentWeekData: StateFlow<WeekWeightData?> = _currentWeekData
private val _stats = MutableStateFlow<WeightStats?>(null)
val stats: StateFlow<WeightStats?> = _stats
private val _goal = MutableStateFlow<WeightGoal?>(null)
val goal: StateFlow<WeightGoal?> = _goal
private val _history = MutableStateFlow<List<WeightRecord>>(emptyList())
val history: StateFlow<List<WeightRecord>> = _history
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _currentYear = MutableStateFlow(0)
val currentYear: StateFlow<Int> = _currentYear
private val _currentWeek = MutableStateFlow(0)
val currentWeek: StateFlow<Int> = _currentWeek
init {
// 初始化为当前周
val now = LocalDate.now()
val weekFields = WeekFields.ISO
_currentYear.value = now.get(weekFields.weekBasedYear())
_currentWeek.value = now.get(weekFields.weekOfWeekBasedYear())
}
fun loadData() {
viewModelScope.launch {
_isLoading.value = true
try {
// 并行加载数据
loadGoal()
loadStats()
loadWeekData(_currentYear.value, _currentWeek.value)
loadHistory()
} finally {
_isLoading.value = false
}
}
}
private suspend fun loadGoal() {
try {
val response = ApiClient.api.getWeightGoal()
if (response.isSuccessful) {
_goal.value = response.body()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private suspend fun loadStats() {
try {
val response = ApiClient.api.getWeightStats()
if (response.isSuccessful) {
_stats.value = response.body()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private suspend fun loadWeekData(year: Int, week: Int) {
try {
val response = ApiClient.api.getWeekData(year, week)
if (response.isSuccessful) {
_currentWeekData.value = response.body()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private suspend fun loadHistory() {
try {
val response = ApiClient.api.getWeightHistory(8)
if (response.isSuccessful) {
_history.value = response.body() ?: emptyList()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun goToPrevWeek() {
viewModelScope.launch {
val (newYear, newWeek) = getPrevWeek(_currentYear.value, _currentWeek.value)
_currentYear.value = newYear
_currentWeek.value = newWeek
loadWeekData(newYear, newWeek)
}
}
fun goToNextWeek() {
viewModelScope.launch {
val (newYear, newWeek) = getNextWeek(_currentYear.value, _currentWeek.value)
// 不能超过当前周
val now = LocalDate.now()
val weekFields = WeekFields.ISO
val currentYear = now.get(weekFields.weekBasedYear())
val currentWeek = now.get(weekFields.weekOfWeekBasedYear())
if (newYear > currentYear || (newYear == currentYear && newWeek > currentWeek)) {
return@launch
}
_currentYear.value = newYear
_currentWeek.value = newWeek
loadWeekData(newYear, newWeek)
}
}
fun goToCurrentWeek() {
viewModelScope.launch {
val now = LocalDate.now()
val weekFields = WeekFields.ISO
_currentYear.value = now.get(weekFields.weekBasedYear())
_currentWeek.value = now.get(weekFields.weekOfWeekBasedYear())
loadWeekData(_currentYear.value, _currentWeek.value)
}
}
fun recordWeight(weight: Double, note: String = "") {
viewModelScope.launch {
try {
val response = ApiClient.api.recordWeight(
RecordWeightRequest(
weight = weight,
year = _currentYear.value,
week = _currentWeek.value,
note = note
)
)
if (response.isSuccessful) {
// 刷新数据
loadWeekData(_currentYear.value, _currentWeek.value)
loadStats()
loadHistory()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun setGoal(targetWeight: Double, startDate: String) {
viewModelScope.launch {
try {
val response = ApiClient.api.setWeightGoal(
SetWeightGoalRequest(targetWeight, startDate)
)
if (response.isSuccessful) {
loadGoal()
loadStats()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun getPrevWeek(year: Int, week: Int): Pair<Int, Int> {
return if (week > 1) {
year to (week - 1)
} else {
// 上一年的最后一周
val lastDayOfPrevYear = LocalDate.of(year - 1, 12, 28)
val weekFields = WeekFields.ISO
(year - 1) to lastDayOfPrevYear.get(weekFields.weekOfWeekBasedYear())
}
}
private fun getNextWeek(year: Int, week: Int): Pair<Int, Int> {
val lastDayOfYear = LocalDate.of(year, 12, 28)
val weekFields = WeekFields.ISO
val maxWeek = lastDayOfYear.get(weekFields.weekOfWeekBasedYear())
return if (week < maxWeek) {
year to (week + 1)
} else {
(year + 1) to 1
}
}
}