项目初始化不啊
This commit is contained in:
@@ -13,11 +13,11 @@ android {
|
||||
applicationId = "com.healthflow.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
versionCode = 4
|
||||
versionName = "1.0.3"
|
||||
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://health.amos.us.kg/api/\"")
|
||||
buildConfigField("int", "VERSION_CODE", "1")
|
||||
buildConfigField("int", "VERSION_CODE", "4")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
BIN
android/app/release.keystore
Normal file
BIN
android/app/release.keystore
Normal file
Binary file not shown.
@@ -3,6 +3,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".HealthFlowApp"
|
||||
@@ -22,6 +23,16 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.healthflow.app
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -9,17 +10,24 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.healthflow.app.data.UpdateManager
|
||||
import com.healthflow.app.data.api.ApiClient
|
||||
import com.healthflow.app.data.local.TokenManager
|
||||
import com.healthflow.app.data.model.VersionInfo
|
||||
import com.healthflow.app.ui.components.UpdateDialog
|
||||
import com.healthflow.app.ui.navigation.MainNavigation
|
||||
import com.healthflow.app.ui.screen.LoginScreen
|
||||
import com.healthflow.app.ui.theme.HealthFlowTheme
|
||||
import com.healthflow.app.ui.viewmodel.AuthViewModel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var updateManager: UpdateManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
@@ -28,6 +36,9 @@ class MainActivity : ComponentActivity() {
|
||||
val tokenManager = TokenManager(this)
|
||||
ApiClient.init(tokenManager)
|
||||
|
||||
// 初始化 UpdateManager
|
||||
updateManager = UpdateManager(this)
|
||||
|
||||
// 检查是否已登录
|
||||
val initialToken = runBlocking { tokenManager.token.first() }
|
||||
|
||||
@@ -35,6 +46,7 @@ class MainActivity : ComponentActivity() {
|
||||
val authViewModel: AuthViewModel = viewModel()
|
||||
val isLoggedIn by authViewModel.isLoggedIn.collectAsState()
|
||||
val user by authViewModel.user.collectAsState()
|
||||
var updateInfo by remember { mutableStateOf<VersionInfo?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (initialToken != null) {
|
||||
@@ -42,15 +54,50 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
LaunchedEffect(Unit) {
|
||||
updateInfo = updateManager.checkUpdate()
|
||||
}
|
||||
|
||||
HealthFlowTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
// 更新弹窗
|
||||
updateInfo?.let { info ->
|
||||
UpdateDialog(
|
||||
versionInfo = info,
|
||||
onUpdate = {
|
||||
updateManager.downloadAndInstall(info)
|
||||
if (!info.forceUpdate) {
|
||||
updateInfo = null
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
updateInfo = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoggedIn && user != null) {
|
||||
MainNavigation(
|
||||
user = user,
|
||||
onLogout = { authViewModel.logout() }
|
||||
onLogout = { authViewModel.logout() },
|
||||
onCheckUpdate = {
|
||||
lifecycleScope.launch {
|
||||
val info = updateManager.checkUpdate()
|
||||
if (info != null) {
|
||||
updateInfo = info
|
||||
} else {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"已是最新版本",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
LoginScreen(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.healthflow.app.data
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import androidx.core.content.FileProvider
|
||||
import com.healthflow.app.BuildConfig
|
||||
import com.healthflow.app.data.api.ApiClient
|
||||
import com.healthflow.app.data.model.VersionInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class UpdateManager(private val context: Context) {
|
||||
|
||||
suspend fun checkUpdate(): VersionInfo? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = ApiClient.apiNoAuth.getVersion()
|
||||
if (response.isSuccessful) {
|
||||
val versionInfo = response.body()
|
||||
if (versionInfo != null && versionInfo.versionCode > BuildConfig.VERSION_CODE) {
|
||||
versionInfo
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAndInstall(versionInfo: VersionInfo) {
|
||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
// 删除旧的 APK 文件
|
||||
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "healthflow_update.apk")
|
||||
if (apkFile.exists()) {
|
||||
apkFile.delete()
|
||||
}
|
||||
|
||||
val request = DownloadManager.Request(Uri.parse(versionInfo.downloadUrl))
|
||||
.setTitle("HealthFlow 更新")
|
||||
.setDescription("正在下载 v${versionInfo.versionName}")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, "healthflow_update.apk")
|
||||
.setAllowedOverMetered(true)
|
||||
.setAllowedOverRoaming(true)
|
||||
|
||||
val downloadId = downloadManager.enqueue(request)
|
||||
|
||||
// 注册下载完成广播
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
||||
if (id == downloadId) {
|
||||
installApk(apkFile)
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.registerReceiver(
|
||||
receiver,
|
||||
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
|
||||
Context.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun installApk(apkFile: File) {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
val apkUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile)
|
||||
} else {
|
||||
Uri.fromFile(apkFile)
|
||||
}
|
||||
|
||||
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
object ApiClient {
|
||||
private lateinit var tokenManager: TokenManager
|
||||
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
@@ -59,6 +59,26 @@ object ApiClient {
|
||||
retrofit.create(ApiService::class.java)
|
||||
}
|
||||
|
||||
// 不需要认证的 API 客户端(用于版本检查等)
|
||||
private val okHttpClientNoAuth by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val retrofitNoAuth by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.client(okHttpClientNoAuth)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
}
|
||||
|
||||
val apiNoAuth: ApiService by lazy {
|
||||
retrofitNoAuth.create(ApiService::class.java)
|
||||
}
|
||||
|
||||
fun init(tokenManager: TokenManager) {
|
||||
this.tokenManager = tokenManager
|
||||
}
|
||||
|
||||
@@ -23,6 +23,28 @@ interface ApiService {
|
||||
@PUT("user/avatar")
|
||||
suspend fun updateAvatar(@Body request: Map<String, String>): Response<MessageResponse>
|
||||
|
||||
// Weight
|
||||
@GET("weight/goal")
|
||||
suspend fun getWeightGoal(): Response<WeightGoal?>
|
||||
|
||||
@POST("weight/goal")
|
||||
suspend fun setWeightGoal(@Body request: SetWeightGoalRequest): Response<MessageResponse>
|
||||
|
||||
@POST("weight/record")
|
||||
suspend fun recordWeight(@Body request: RecordWeightRequest): Response<MessageResponse>
|
||||
|
||||
@GET("weight/week")
|
||||
suspend fun getWeekData(
|
||||
@Query("year") year: Int,
|
||||
@Query("week") week: Int
|
||||
): Response<WeekWeightData>
|
||||
|
||||
@GET("weight/stats")
|
||||
suspend fun getWeightStats(): Response<WeightStats>
|
||||
|
||||
@GET("weight/history")
|
||||
suspend fun getWeightHistory(@Query("limit") limit: Int = 8): Response<List<WeightRecord>>
|
||||
|
||||
// Upload
|
||||
@Multipart
|
||||
@POST("upload")
|
||||
|
||||
@@ -68,3 +68,61 @@ data class MessageResponse(
|
||||
data class ErrorResponse(
|
||||
val error: String
|
||||
)
|
||||
|
||||
// 体重相关
|
||||
@Serializable
|
||||
data class WeightGoal(
|
||||
val id: Long = 0,
|
||||
@SerialName("user_id") val userId: Long = 0,
|
||||
@SerialName("target_weight") val targetWeight: Double,
|
||||
@SerialName("start_date") val startDate: String,
|
||||
@SerialName("created_at") val createdAt: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SetWeightGoalRequest(
|
||||
@SerialName("target_weight") val targetWeight: Double,
|
||||
@SerialName("start_date") val startDate: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WeightRecord(
|
||||
val id: Long = 0,
|
||||
@SerialName("user_id") val userId: Long = 0,
|
||||
val weight: Double,
|
||||
val year: Int,
|
||||
val week: Int,
|
||||
@SerialName("record_date") val recordDate: String = "",
|
||||
val note: String = "",
|
||||
@SerialName("created_at") val createdAt: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RecordWeightRequest(
|
||||
val weight: Double,
|
||||
val year: Int,
|
||||
val week: Int,
|
||||
val note: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WeekWeightData(
|
||||
val year: Int,
|
||||
val week: Int,
|
||||
@SerialName("start_date") val startDate: String,
|
||||
@SerialName("end_date") val endDate: String,
|
||||
val weight: Double? = null,
|
||||
@SerialName("prev_weight") val prevWeight: Double? = null,
|
||||
@SerialName("weight_change") val weightChange: Double? = null,
|
||||
@SerialName("has_record") val hasRecord: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WeightStats(
|
||||
@SerialName("total_weeks") val totalWeeks: Int = 0,
|
||||
@SerialName("total_change") val totalChange: Double? = null,
|
||||
@SerialName("first_weight") val firstWeight: Double? = null,
|
||||
@SerialName("latest_weight") val latestWeight: Double? = null,
|
||||
@SerialName("target_weight") val targetWeight: Double? = null,
|
||||
@SerialName("start_date") val startDate: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.healthflow.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.healthflow.app.data.model.VersionInfo
|
||||
import com.healthflow.app.ui.theme.Brand500
|
||||
|
||||
@Composable
|
||||
fun UpdateDialog(
|
||||
versionInfo: VersionInfo,
|
||||
onUpdate: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = { if (!versionInfo.forceUpdate) onDismiss() }) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "🎉",
|
||||
fontSize = 48.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "发现新版本",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "v${versionInfo.versionName}",
|
||||
fontSize = 16.sp,
|
||||
color = Brand500,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
if (versionInfo.updateLog.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = versionInfo.updateLog,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = onUpdate,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Brand500),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "立即更新",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (!versionInfo.forceUpdate) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(
|
||||
text = "稍后再说",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,33 +25,35 @@ sealed class Screen(
|
||||
val selectedIcon: ImageVector,
|
||||
val unselectedIcon: ImageVector
|
||||
) {
|
||||
data object Home : Screen("home", "首页", Icons.Filled.Home, Icons.Outlined.Home)
|
||||
data object Weight : Screen("weight", "体重", Icons.Filled.MonitorWeight, Icons.Outlined.MonitorWeight)
|
||||
data object Profile : Screen("profile", "我的", Icons.Filled.Person, Icons.Outlined.Person)
|
||||
}
|
||||
|
||||
val bottomNavItems = listOf(Screen.Home, Screen.Profile)
|
||||
val bottomNavItems = listOf(Screen.Weight, Screen.Profile)
|
||||
|
||||
@Composable
|
||||
fun MainNavigation(
|
||||
user: User?,
|
||||
onLogout: () -> Unit
|
||||
onLogout: () -> Unit,
|
||||
onCheckUpdate: () -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Home.route,
|
||||
startDestination = Screen.Weight.route,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
composable(Screen.Home.route) {
|
||||
HomeScreen()
|
||||
composable(Screen.Weight.route) {
|
||||
WeightScreen()
|
||||
}
|
||||
|
||||
composable(Screen.Profile.route) {
|
||||
ProfileScreen(
|
||||
user = user,
|
||||
onLogout = onLogout
|
||||
onLogout = onLogout,
|
||||
onCheckUpdate = onCheckUpdate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -23,7 +24,8 @@ import com.healthflow.app.ui.theme.*
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
user: User?,
|
||||
onLogout: () -> Unit
|
||||
onLogout: () -> Unit,
|
||||
onCheckUpdate: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -39,7 +41,22 @@ fun ProfileScreen(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(36.dp))
|
||||
// Check Update
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.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 = "我的",
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.outlined.Flag
|
||||
import androidx.compose.material.icons.outlined.TrendingDown
|
||||
import androidx.compose.material.icons.outlined.TrendingUp
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.healthflow.app.data.model.WeightRecord
|
||||
import com.healthflow.app.ui.theme.*
|
||||
import com.healthflow.app.ui.viewmodel.WeightViewModel
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.WeekFields
|
||||
|
||||
@Composable
|
||||
fun WeightScreen(
|
||||
viewModel: WeightViewModel = viewModel()
|
||||
) {
|
||||
val weekData by viewModel.currentWeekData.collectAsState()
|
||||
val stats by viewModel.stats.collectAsState()
|
||||
val goal by viewModel.goal.collectAsState()
|
||||
val history by viewModel.history.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val currentYear by viewModel.currentYear.collectAsState()
|
||||
val currentWeek by viewModel.currentWeek.collectAsState()
|
||||
|
||||
var showWeightDialog by remember { mutableStateOf(false) }
|
||||
var showGoalDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadData()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
// Header
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "体重",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
|
||||
|
||||
if (isLoading && weekData == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = Brand500)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp)
|
||||
) {
|
||||
// 周选择器
|
||||
WeekSelector(
|
||||
year = currentYear,
|
||||
week = currentWeek,
|
||||
startDate = weekData?.startDate ?: "",
|
||||
endDate = weekData?.endDate ?: "",
|
||||
onPrevWeek = { viewModel.goToPrevWeek() },
|
||||
onNextWeek = { viewModel.goToNextWeek() }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 本周体重卡片
|
||||
WeightCard(
|
||||
weight = weekData?.weight,
|
||||
weightChange = weekData?.weightChange,
|
||||
hasRecord = weekData?.hasRecord ?: false,
|
||||
onRecordClick = { showWeightDialog = true }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 目标进度
|
||||
GoalProgress(
|
||||
currentWeight = weekData?.weight ?: stats?.latestWeight,
|
||||
targetWeight = goal?.targetWeight ?: stats?.targetWeight,
|
||||
onEditGoal = { showGoalDialog = true }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 趋势图
|
||||
if (history.isNotEmpty()) {
|
||||
Text(
|
||||
text = "趋势图",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
WeightChart(records = history.reversed())
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 统计信息
|
||||
stats?.let { s ->
|
||||
StatsRow(stats = s)
|
||||
}
|
||||
|
||||
// 底部留白
|
||||
Spacer(modifier = Modifier.height(100.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录体重弹窗
|
||||
if (showWeightDialog) {
|
||||
WeightInputDialog(
|
||||
initialWeight = weekData?.weight,
|
||||
onDismiss = { showWeightDialog = false },
|
||||
onConfirm = { weight ->
|
||||
viewModel.recordWeight(weight)
|
||||
showWeightDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 设置目标弹窗
|
||||
if (showGoalDialog) {
|
||||
GoalInputDialog(
|
||||
initialTarget = goal?.targetWeight,
|
||||
onDismiss = { showGoalDialog = false },
|
||||
onConfirm = { target ->
|
||||
val today = LocalDate.now().format(DateTimeFormatter.ISO_DATE)
|
||||
viewModel.setGoal(target, today)
|
||||
showGoalDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekSelector(
|
||||
year: Int,
|
||||
week: Int,
|
||||
startDate: String,
|
||||
endDate: String,
|
||||
onPrevWeek: () -> Unit,
|
||||
onNextWeek: () -> Unit
|
||||
) {
|
||||
val now = LocalDate.now()
|
||||
val weekFields = WeekFields.ISO
|
||||
val isCurrentWeek = year == now.get(weekFields.weekBasedYear()) &&
|
||||
week == now.get(weekFields.weekOfWeekBasedYear())
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onPrevWeek) {
|
||||
Icon(
|
||||
Icons.Default.ChevronLeft,
|
||||
contentDescription = "上一周",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "第${week}周",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = formatDateRange(startDate, endDate),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onNextWeek,
|
||||
enabled = !isCurrentWeek
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = "下一周",
|
||||
tint = if (isCurrentWeek) MaterialTheme.colorScheme.outlineVariant
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeightCard(
|
||||
weight: Double?,
|
||||
weightChange: Double?,
|
||||
hasRecord: Boolean,
|
||||
onRecordClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onRecordClick() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (hasRecord && weight != null) {
|
||||
Text(
|
||||
text = "本周体重",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
text = String.format("%.1f", weight),
|
||||
fontSize = 48.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "kg",
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 变化值
|
||||
weightChange?.let { change ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val isDown = change < 0
|
||||
val color = if (isDown) SuccessGreen else ErrorRed
|
||||
val icon = if (isDown) Icons.Outlined.TrendingDown else Icons.Outlined.TrendingUp
|
||||
val sign = if (change > 0) "+" else ""
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(color.copy(alpha = 0.1f))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = color
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${sign}${String.format("%.1f", change)} kg vs上周",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "点击修改",
|
||||
fontSize = 12.sp,
|
||||
color = Brand500
|
||||
)
|
||||
} else {
|
||||
// 未记录
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Brand500.copy(alpha = 0.1f))
|
||||
.padding(12.dp),
|
||||
tint = Brand500
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "记录本周体重",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Brand500
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GoalProgress(
|
||||
currentWeight: Double?,
|
||||
targetWeight: Double?,
|
||||
onEditGoal: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.Flag,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = Brand500
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (targetWeight != null) "目标: ${String.format("%.1f", targetWeight)} kg"
|
||||
else "设置目标",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onEditGoal) {
|
||||
Text(
|
||||
text = if (targetWeight != null) "修改" else "设置",
|
||||
fontSize = 13.sp,
|
||||
color = Brand500
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentWeight != null && targetWeight != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val diff = currentWeight - targetWeight
|
||||
val progress = if (diff > 0) {
|
||||
// 还需要减重
|
||||
0.5f // 简化处理,实际可以根据初始体重计算
|
||||
} else {
|
||||
1f // 已达标
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
color = Brand500,
|
||||
trackColor = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (diff > 0) "还差 ${String.format("%.1f", diff)} kg"
|
||||
else "🎉 已达成目标!",
|
||||
fontSize = 13.sp,
|
||||
color = if (diff > 0) MaterialTheme.colorScheme.onSurfaceVariant else SuccessGreen
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeightChart(records: List<WeightRecord>) {
|
||||
if (records.isEmpty()) return
|
||||
|
||||
val weights = records.map { it.weight }
|
||||
val minWeight = weights.minOrNull() ?: 0.0
|
||||
val maxWeight = weights.maxOrNull() ?: 100.0
|
||||
val range = (maxWeight - minWeight).coerceAtLeast(2.0)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val stepX = width / (records.size - 1).coerceAtLeast(1)
|
||||
|
||||
// 绘制折线
|
||||
val path = Path()
|
||||
records.forEachIndexed { index, record ->
|
||||
val x = index * stepX
|
||||
val y = height - ((record.weight - minWeight) / range * height).toFloat()
|
||||
|
||||
if (index == 0) {
|
||||
path.moveTo(x, y)
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = path,
|
||||
color = Brand500,
|
||||
style = Stroke(width = 3f)
|
||||
)
|
||||
|
||||
// 绘制点
|
||||
records.forEachIndexed { index, record ->
|
||||
val x = index * stepX
|
||||
val y = height - ((record.weight - minWeight) / range * height).toFloat()
|
||||
|
||||
drawCircle(
|
||||
color = Brand500,
|
||||
radius = 6f,
|
||||
center = Offset(x, y)
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = 3f,
|
||||
center = Offset(x, y)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsRow(stats: com.healthflow.app.data.model.WeightStats) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
StatItem(
|
||||
value = "${stats.totalWeeks}",
|
||||
label = "已记录周数"
|
||||
)
|
||||
stats.totalChange?.let {
|
||||
StatItem(
|
||||
value = "${if (it > 0) "+" else ""}${String.format("%.1f", it)}",
|
||||
label = "总变化(kg)",
|
||||
valueColor = if (it < 0) SuccessGreen else if (it > 0) ErrorRed else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatItem(
|
||||
value: String,
|
||||
label: String,
|
||||
valueColor: Color = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = valueColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeightInputDialog(
|
||||
initialWeight: Double?,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Double) -> Unit
|
||||
) {
|
||||
var weightText by remember { mutableStateOf(initialWeight?.toString() ?: "") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("记录体重") },
|
||||
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
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
weightText.toDoubleOrNull()?.let { onConfirm(it) }
|
||||
},
|
||||
enabled = weightText.toDoubleOrNull() != null
|
||||
) {
|
||||
Text("确定", color = Brand500)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GoalInputDialog(
|
||||
initialTarget: Double?,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Double) -> Unit
|
||||
) {
|
||||
var targetText by remember { mutableStateOf(initialTarget?.toString() ?: "") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("设置目标体重") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = targetText,
|
||||
onValueChange = { targetText = it },
|
||||
label = { Text("目标体重 (kg)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Brand500
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
targetText.toDoubleOrNull()?.let { onConfirm(it) }
|
||||
},
|
||||
enabled = targetText.toDoubleOrNull() != null
|
||||
) {
|
||||
Text("确定", color = Brand500)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatDateRange(startDate: String, endDate: String): String {
|
||||
if (startDate.isEmpty() || endDate.isEmpty()) return ""
|
||||
return try {
|
||||
val start = LocalDate.parse(startDate)
|
||||
val end = LocalDate.parse(endDate)
|
||||
"${start.monthValue}.${start.dayOfMonth} - ${end.monthValue}.${end.dayOfMonth}"
|
||||
} catch (e: Exception) {
|
||||
"$startDate ~ $endDate"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
6
android/app/src/main/res/xml/file_paths.xml
Normal file
6
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path
|
||||
name="downloads"
|
||||
path="Download/" />
|
||||
</paths>
|
||||
@@ -58,8 +58,34 @@ func migrate(db *sql.DB) error {
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 体重目标表
|
||||
CREATE TABLE IF NOT EXISTS weight_goals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
target_weight REAL NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- 体重记录表 (每周一条)
|
||||
CREATE TABLE IF NOT EXISTS weight_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
weight REAL NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
week INTEGER NOT NULL,
|
||||
record_date DATE NOT NULL,
|
||||
note TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, year, week),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_weight_records_user ON weight_records(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_weight_records_week ON weight_records(year, week);
|
||||
|
||||
-- 初始化默认设置
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_register', 'true');
|
||||
|
||||
275
server/internal/handler/weight.go
Normal file
275
server/internal/handler/weight.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"healthflow/internal/config"
|
||||
"healthflow/internal/middleware"
|
||||
"healthflow/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WeightHandler struct {
|
||||
db *sql.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewWeightHandler(db *sql.DB, cfg *config.Config) *WeightHandler {
|
||||
return &WeightHandler{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
// 设置体重目标
|
||||
func (h *WeightHandler) SetGoal(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req model.SetWeightGoalRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除旧目标,插入新目标
|
||||
h.db.Exec("DELETE FROM weight_goals WHERE user_id = ?", userID)
|
||||
|
||||
_, err := h.db.Exec(
|
||||
"INSERT INTO weight_goals (user_id, target_weight, start_date) VALUES (?, ?, ?)",
|
||||
userID, req.TargetWeight, req.StartDate,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set goal"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "goal set"})
|
||||
}
|
||||
|
||||
// 获取体重目标
|
||||
func (h *WeightHandler) GetGoal(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var goal model.WeightGoal
|
||||
err := h.db.QueryRow(
|
||||
"SELECT id, user_id, target_weight, start_date, created_at FROM weight_goals WHERE user_id = ?",
|
||||
userID,
|
||||
).Scan(&goal.ID, &goal.UserID, &goal.TargetWeight, &goal.StartDate, &goal.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusOK, nil)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, goal)
|
||||
}
|
||||
|
||||
// 记录体重
|
||||
func (h *WeightHandler) RecordWeight(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req model.RecordWeightRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 计算记录日期(该周的周一)
|
||||
recordDate := getWeekStartDate(req.Year, req.Week)
|
||||
|
||||
// 使用 REPLACE 实现 upsert
|
||||
_, err := h.db.Exec(`
|
||||
INSERT OR REPLACE INTO weight_records (user_id, weight, year, week, record_date, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, userID, req.Weight, req.Year, req.Week, recordDate, req.Note)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record weight"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "weight recorded"})
|
||||
}
|
||||
|
||||
// 获取某周的体重数据
|
||||
func (h *WeightHandler) GetWeekData(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
year, _ := strconv.Atoi(c.Query("year"))
|
||||
week, _ := strconv.Atoi(c.Query("week"))
|
||||
|
||||
if year == 0 || week == 0 {
|
||||
// 默认当前周
|
||||
now := time.Now()
|
||||
year, week = now.ISOWeek()
|
||||
}
|
||||
|
||||
data := h.getWeekWeightData(userID, year, week)
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// 获取体重统计
|
||||
func (h *WeightHandler) GetStats(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
stats := model.WeightStats{}
|
||||
|
||||
// 获取目标
|
||||
var targetWeight float64
|
||||
var startDate string
|
||||
err := h.db.QueryRow(
|
||||
"SELECT target_weight, start_date FROM weight_goals WHERE user_id = ?",
|
||||
userID,
|
||||
).Scan(&targetWeight, &startDate)
|
||||
if err == nil {
|
||||
stats.TargetWeight = &targetWeight
|
||||
stats.StartDate = &startDate
|
||||
}
|
||||
|
||||
// 获取记录数
|
||||
h.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM weight_records WHERE user_id = ?",
|
||||
userID,
|
||||
).Scan(&stats.TotalWeeks)
|
||||
|
||||
// 获取第一条和最后一条记录
|
||||
var firstWeight, latestWeight float64
|
||||
err = h.db.QueryRow(
|
||||
"SELECT weight FROM weight_records WHERE user_id = ? ORDER BY year, week ASC LIMIT 1",
|
||||
userID,
|
||||
).Scan(&firstWeight)
|
||||
if err == nil {
|
||||
stats.FirstWeight = &firstWeight
|
||||
}
|
||||
|
||||
err = h.db.QueryRow(
|
||||
"SELECT weight FROM weight_records WHERE user_id = ? ORDER BY year, week DESC LIMIT 1",
|
||||
userID,
|
||||
).Scan(&latestWeight)
|
||||
if err == nil {
|
||||
stats.LatestWeight = &latestWeight
|
||||
}
|
||||
|
||||
// 计算总变化
|
||||
if stats.FirstWeight != nil && stats.LatestWeight != nil {
|
||||
change := *stats.LatestWeight - *stats.FirstWeight
|
||||
stats.TotalChange = &change
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// 获取历史记录(最近N周)
|
||||
func (h *WeightHandler) GetHistory(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "8"))
|
||||
|
||||
rows, err := h.db.Query(`
|
||||
SELECT id, weight, year, week, record_date, note, created_at
|
||||
FROM weight_records
|
||||
WHERE user_id = ?
|
||||
ORDER BY year DESC, week DESC
|
||||
LIMIT ?
|
||||
`, userID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []model.WeightRecord
|
||||
for rows.Next() {
|
||||
var r model.WeightRecord
|
||||
rows.Scan(&r.ID, &r.Weight, &r.Year, &r.Week, &r.RecordDate, &r.Note, &r.CreatedAt)
|
||||
r.UserID = userID
|
||||
records = append(records, r)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
|
||||
// 辅助函数:获取某周的体重数据(包含对比)
|
||||
func (h *WeightHandler) getWeekWeightData(userID int64, year, week int) model.WeekWeightData {
|
||||
data := model.WeekWeightData{
|
||||
Year: year,
|
||||
Week: week,
|
||||
StartDate: getWeekStartDate(year, week),
|
||||
EndDate: getWeekEndDate(year, week),
|
||||
HasRecord: false,
|
||||
}
|
||||
|
||||
// 获取本周体重
|
||||
var weight float64
|
||||
err := h.db.QueryRow(
|
||||
"SELECT weight FROM weight_records WHERE user_id = ? AND year = ? AND week = ?",
|
||||
userID, year, week,
|
||||
).Scan(&weight)
|
||||
if err == nil {
|
||||
data.Weight = &weight
|
||||
data.HasRecord = true
|
||||
}
|
||||
|
||||
// 获取上周体重
|
||||
prevYear, prevWeek := getPrevWeek(year, week)
|
||||
var prevWeight float64
|
||||
err = h.db.QueryRow(
|
||||
"SELECT weight FROM weight_records WHERE user_id = ? AND year = ? AND week = ?",
|
||||
userID, prevYear, prevWeek,
|
||||
).Scan(&prevWeight)
|
||||
if err == nil {
|
||||
data.PrevWeight = &prevWeight
|
||||
if data.Weight != nil {
|
||||
change := *data.Weight - prevWeight
|
||||
data.WeightChange = &change
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// 辅助函数:获取某周的开始日期(周一)
|
||||
func getWeekStartDate(year, week int) string {
|
||||
// ISO week: 第一周包含该年第一个周四
|
||||
jan4 := time.Date(year, 1, 4, 0, 0, 0, 0, time.UTC)
|
||||
_, jan4Week := jan4.ISOWeek()
|
||||
|
||||
// 计算第一周的周一
|
||||
weekday := int(jan4.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
firstMonday := jan4.AddDate(0, 0, -(weekday-1)-(jan4Week-1)*7)
|
||||
|
||||
// 计算目标周的周一
|
||||
targetMonday := firstMonday.AddDate(0, 0, (week-1)*7)
|
||||
return targetMonday.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// 辅助函数:获取某周的结束日期(周日)
|
||||
func getWeekEndDate(year, week int) string {
|
||||
startDate := getWeekStartDate(year, week)
|
||||
t, _ := time.Parse("2006-01-02", startDate)
|
||||
return t.AddDate(0, 0, 6).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// 辅助函数:获取上一周
|
||||
func getPrevWeek(year, week int) (int, int) {
|
||||
if week > 1 {
|
||||
return year, week - 1
|
||||
}
|
||||
// 上一年的最后一周
|
||||
prevYear := year - 1
|
||||
// 计算上一年有多少周
|
||||
dec31 := time.Date(prevYear, 12, 31, 0, 0, 0, 0, time.UTC)
|
||||
_, lastWeek := dec31.ISOWeek()
|
||||
if lastWeek == 1 {
|
||||
// 12月31日属于下一年的第一周,取12月24日
|
||||
dec24 := time.Date(prevYear, 12, 24, 0, 0, 0, 0, time.UTC)
|
||||
_, lastWeek = dec24.ISOWeek()
|
||||
}
|
||||
return prevYear, lastWeek
|
||||
}
|
||||
@@ -13,6 +13,49 @@ type User struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// 体重目标
|
||||
type WeightGoal struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
TargetWeight float64 `json:"target_weight"`
|
||||
StartDate string `json:"start_date"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// 体重记录
|
||||
type WeightRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Weight float64 `json:"weight"`
|
||||
Year int `json:"year"`
|
||||
Week int `json:"week"`
|
||||
RecordDate string `json:"record_date"`
|
||||
Note string `json:"note"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// 周数据(包含对比信息)
|
||||
type WeekWeightData struct {
|
||||
Year int `json:"year"`
|
||||
Week int `json:"week"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Weight *float64 `json:"weight"`
|
||||
PrevWeight *float64 `json:"prev_weight"`
|
||||
WeightChange *float64 `json:"weight_change"`
|
||||
HasRecord bool `json:"has_record"`
|
||||
}
|
||||
|
||||
// 体重统计
|
||||
type WeightStats struct {
|
||||
TotalWeeks int `json:"total_weeks"`
|
||||
TotalChange *float64 `json:"total_change"`
|
||||
FirstWeight *float64 `json:"first_weight"`
|
||||
LatestWeight *float64 `json:"latest_weight"`
|
||||
TargetWeight *float64 `json:"target_weight"`
|
||||
StartDate *string `json:"start_date"`
|
||||
}
|
||||
|
||||
// 请求/响应结构
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=20"`
|
||||
@@ -39,3 +82,16 @@ type ProfileResponse struct {
|
||||
User *User `json:"user"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// 体重相关请求
|
||||
type SetWeightGoalRequest struct {
|
||||
TargetWeight float64 `json:"target_weight" binding:"required,gt=0"`
|
||||
StartDate string `json:"start_date" binding:"required"`
|
||||
}
|
||||
|
||||
type RecordWeightRequest struct {
|
||||
Weight float64 `json:"weight" binding:"required,gt=0"`
|
||||
Year int `json:"year" binding:"required"`
|
||||
Week int `json:"week" binding:"required,min=1,max=53"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
|
||||
userHandler := handler.NewUserHandler(db, cfg)
|
||||
uploadHandler := handler.NewUploadHandler(cfg)
|
||||
versionHandler := handler.NewVersionHandler(db, cfg)
|
||||
weightHandler := handler.NewWeightHandler(db, cfg)
|
||||
|
||||
// R2 文件代理 (公开访问)
|
||||
r.GET("/files/*filepath", uploadHandler.GetFile)
|
||||
@@ -56,6 +57,14 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
|
||||
auth.PUT("/user/profile", userHandler.UpdateProfile)
|
||||
auth.PUT("/user/avatar", userHandler.UpdateAvatar)
|
||||
|
||||
// 体重
|
||||
auth.GET("/weight/goal", weightHandler.GetGoal)
|
||||
auth.POST("/weight/goal", weightHandler.SetGoal)
|
||||
auth.POST("/weight/record", weightHandler.RecordWeight)
|
||||
auth.GET("/weight/week", weightHandler.GetWeekData)
|
||||
auth.GET("/weight/stats", weightHandler.GetStats)
|
||||
auth.GET("/weight/history", weightHandler.GetHistory)
|
||||
|
||||
// 上传
|
||||
auth.POST("/upload", uploadHandler.Upload)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user