feat:新 UI
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user