项目初始化不啊

This commit is contained in:
amos wong
2025-12-21 11:17:20 +08:00
parent 0c733f9a36
commit 4d2ad6194b
18 changed files with 1581 additions and 14 deletions

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = "我的",

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path
name="downloads"
path="Download/" />
</paths>

View File

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

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

View File

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

View File

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