From a5cc393add41d899b5301e21b41b99af3a9b1941 Mon Sep 17 00:00:00 2001 From: amos wong Date: Fri, 2 Jan 2026 17:35:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=BF=90=E5=8A=A8=E6=89=93?= =?UTF-8?q?=E5=8D=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.production | 4 +- android/app/build.gradle.kts | 6 +- .../java/com/healthflow/app/HealthFlowApp.kt | 7 +- .../com/healthflow/app/data/api/ApiService.kt | 19 + .../com/healthflow/app/data/model/Models.kt | 34 + .../app/ui/navigation/Navigation.kt | 52 +- .../app/ui/screen/ExerciseScreen.kt | 812 ++++++++++++++++++ .../healthflow/app/ui/screen/ProfileScreen.kt | 18 + .../app/ui/viewmodel/EpochViewModel.kt | 13 +- .../app/ui/viewmodel/ExerciseViewModel.kt | 186 ++++ healthflow_minimal_ui.html | 116 ++- release.sh | 72 +- server/internal/database/database.go | 89 +- server/internal/handler/exercise.go | 239 ++++++ server/internal/handler/upload.go | 2 +- server/internal/model/model.go | 31 + server/internal/router/router.go | 8 + 17 files changed, 1651 insertions(+), 57 deletions(-) create mode 100644 android/app/src/main/java/com/healthflow/app/ui/screen/ExerciseScreen.kt create mode 100644 android/app/src/main/java/com/healthflow/app/ui/viewmodel/ExerciseViewModel.kt create mode 100644 server/internal/handler/exercise.go diff --git a/.env.production b/.env.production index 07cc430..71714b5 100644 --- a/.env.production +++ b/.env.production @@ -10,5 +10,5 @@ BASE_URL=https://health.amos.us.kg R2_ACCOUNT_ID=ebf33b5ee4eb26f32af0c6e06102e000 R2_ACCESS_KEY_ID=8acbc8a9386d60d0e8dac6bd8165c618 R2_ACCESS_KEY_SECRET=72935e23b5b4be8fda99008e75285e8ac778f8926656c42780b25785bb149443 -R2_BUCKET_NAME=healthflow -R2_PUBLIC_URL=https://cdn-health.amos.us.kg +R2_BUCKET_NAME=memory +R2_PUBLIC_URL=https://cdn.amos.us.kg diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3f3f041..bac492f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,11 +13,11 @@ android { applicationId = "com.healthflow.app" minSdk = 26 targetSdk = 35 - versionCode = 106 - versionName = "3.0.6" + versionCode = 114 + versionName = "3.1.4" buildConfigField("String", "API_BASE_URL", "\"https://health.amos.us.kg/api/\"") - buildConfigField("int", "VERSION_CODE", "106") + buildConfigField("int", "VERSION_CODE", "114") } signingConfigs { diff --git a/android/app/src/main/java/com/healthflow/app/HealthFlowApp.kt b/android/app/src/main/java/com/healthflow/app/HealthFlowApp.kt index 5132b59..329e332 100644 --- a/android/app/src/main/java/com/healthflow/app/HealthFlowApp.kt +++ b/android/app/src/main/java/com/healthflow/app/HealthFlowApp.kt @@ -12,7 +12,7 @@ import java.util.concurrent.TimeUnit class HealthFlowApp : Application(), ImageLoaderFactory { override fun newImageLoader(): ImageLoader { val okHttpClient = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) + .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() @@ -21,17 +21,18 @@ class HealthFlowApp : Application(), ImageLoaderFactory { .okHttpClient(okHttpClient) .memoryCache { MemoryCache.Builder(this) - .maxSizePercent(0.30) + .maxSizePercent(0.25) // 25% 内存 .build() } .diskCache { DiskCache.Builder() .directory(cacheDir.resolve("image_cache")) - .maxSizeBytes(100 * 1024 * 1024) // 100MB + .maxSizePercent(0.05) // 5% 磁盘空间 .build() } .memoryCachePolicy(CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED) + .respectCacheHeaders(false) // 忽略服务器缓存头,优先使用本地缓存 .crossfade(150) .build() } diff --git a/android/app/src/main/java/com/healthflow/app/data/api/ApiService.kt b/android/app/src/main/java/com/healthflow/app/data/api/ApiService.kt index ee14d83..fe9f52e 100644 --- a/android/app/src/main/java/com/healthflow/app/data/api/ApiService.kt +++ b/android/app/src/main/java/com/healthflow/app/data/api/ApiService.kt @@ -85,4 +85,23 @@ interface ApiService { // Version @GET("version") suspend fun getVersion(): Response + + // Exercise Checkin (运动打卡) + @POST("exercise/checkin") + suspend fun createExerciseCheckin(@Body request: CreateExerciseCheckinRequest): Response + + @GET("exercise/checkins") + suspend fun getExerciseCheckins( + @Query("year") year: Int? = null, + @Query("month") month: Int? = null + ): Response> + + @GET("exercise/heatmap") + suspend fun getExerciseHeatmap(@Query("year") year: String): Response> + + @GET("exercise/stats") + suspend fun getExerciseStats(): Response + + @DELETE("exercise/checkin/{id}") + suspend fun deleteExerciseCheckin(@Path("id") id: Long): Response } diff --git a/android/app/src/main/java/com/healthflow/app/data/model/Models.kt b/android/app/src/main/java/com/healthflow/app/data/model/Models.kt index 92548aa..b6cf356 100644 --- a/android/app/src/main/java/com/healthflow/app/data/model/Models.kt +++ b/android/app/src/main/java/com/healthflow/app/data/model/Models.kt @@ -211,3 +211,37 @@ data class WeightStats( @SerialName("target_weight") val targetWeight: Double? = null, @SerialName("start_date") val startDate: String? = null ) + +// 运动打卡相关 +@Serializable +data class ExerciseCheckin( + val id: Long = 0, + @SerialName("user_id") val userId: Long = 0, + @SerialName("checkin_date") val checkinDate: String, + @SerialName("image_url") val imageUrl: String = "", + val note: String = "", + @SerialName("created_at") val createdAt: String = "" +) + +@Serializable +data class CreateExerciseCheckinRequest( + @SerialName("checkin_date") val checkinDate: String, + @SerialName("image_url") val imageUrl: String = "", + val note: String = "" +) + +@Serializable +data class ExerciseHeatmapData( + val date: String, + val count: Int +) + +@Serializable +data class ExerciseStats( + @SerialName("total_checkins") val totalCheckins: Int = 0, + @SerialName("current_streak") val currentStreak: Int = 0, + @SerialName("longest_streak") val longestStreak: Int = 0, + @SerialName("this_month") val thisMonth: Int = 0, + @SerialName("this_week") val thisWeek: Int = 0, + @SerialName("this_year_days") val thisYearDays: Int = 0 +) diff --git a/android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt b/android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt index 2238c50..43239e8 100644 --- a/android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt +++ b/android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt @@ -28,6 +28,7 @@ import com.healthflow.app.ui.viewmodel.EpochViewModel sealed class Tab(val route: String, val label: String) { data object Epoch : Tab("tab_epoch", "纪元") data object Plan : Tab("tab_plan", "计划") + data object Exercise : Tab("tab_exercise", "运动") data object Profile : Tab("tab_profile", "我的") } @@ -41,7 +42,7 @@ object Routes { fun weekPlanDetail(epochId: Long, planId: Long) = "week_plan_detail/$epochId/$planId" } -val tabs = listOf(Tab.Epoch, Tab.Plan, Tab.Profile) +val tabs = listOf(Tab.Epoch, Tab.Plan, Tab.Exercise, Tab.Profile) @Composable fun MainNavigation( @@ -153,6 +154,18 @@ fun MainNavigation( ) } + composable(Tab.Exercise.route) { + val exerciseViewModel: com.healthflow.app.ui.viewmodel.ExerciseViewModel = viewModel() + val exerciseStats by exerciseViewModel.stats.collectAsState() + + // 当运动统计更新时,同步到 EpochViewModel + LaunchedEffect(exerciseStats.thisYearDays) { + epochViewModel.updateExerciseDays(exerciseStats.thisYearDays) + } + + ExerciseScreen(viewModel = exerciseViewModel) + } + composable(Routes.CREATE_EPOCH) { CreateEpochScreen( isLoading = isLoading, @@ -233,6 +246,7 @@ fun MainNavigation( val selectedTab = when { currentRoute == Tab.Epoch.route -> Tab.Epoch currentRoute == Tab.Plan.route -> Tab.Plan + currentRoute == Tab.Exercise.route -> Tab.Exercise currentRoute == Tab.Profile.route -> Tab.Profile currentRoute?.startsWith("epoch_detail") == true -> Tab.Epoch currentRoute?.startsWith("week_plan_detail") == true -> Tab.Plan @@ -262,6 +276,7 @@ fun MainNavigation( icon = when (tab) { Tab.Epoch -> TabIcon.Epoch Tab.Plan -> TabIcon.Plan + Tab.Exercise -> TabIcon.Exercise Tab.Profile -> TabIcon.Profile }, onClick = { @@ -283,7 +298,7 @@ fun MainNavigation( } } -enum class TabIcon { Epoch, Plan, Profile } +enum class TabIcon { Epoch, Plan, Exercise, Profile } @Composable private fun NavBarItem( @@ -413,6 +428,39 @@ private fun NavBarItem( ) } } + + TabIcon.Exercise -> { + // 哑铃图标 - 代表运动 + val w = this.size.width + val h = this.size.height + + // 中间横杆 + drawLine( + color = color, + start = androidx.compose.ui.geometry.Offset(w * 0.25f, h * 0.5f), + end = androidx.compose.ui.geometry.Offset(w * 0.75f, h * 0.5f), + strokeWidth = strokeWidth.toPx(), + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + + // 左边的重量块 + drawRoundRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(w * 0.08f, h * 0.3f), + size = androidx.compose.ui.geometry.Size(w * 0.18f, h * 0.4f), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx()), + style = if (selected) androidx.compose.ui.graphics.drawscope.Fill else stroke + ) + + // 右边的重量块 + drawRoundRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(w * 0.74f, h * 0.3f), + size = androidx.compose.ui.geometry.Size(w * 0.18f, h * 0.4f), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx()), + style = if (selected) androidx.compose.ui.graphics.drawscope.Fill else stroke + ) + } TabIcon.Profile -> { // 用户图标 - 代表个人中心 diff --git a/android/app/src/main/java/com/healthflow/app/ui/screen/ExerciseScreen.kt b/android/app/src/main/java/com/healthflow/app/ui/screen/ExerciseScreen.kt new file mode 100644 index 0000000..7aa2900 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/ui/screen/ExerciseScreen.kt @@ -0,0 +1,812 @@ +package com.healthflow.app.ui.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.LocalFireDepartment +import androidx.compose.material.icons.outlined.FitnessCenter +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.healthflow.app.data.api.ApiClient +import com.healthflow.app.data.model.ExerciseCheckin +import com.healthflow.app.ui.theme.* +import com.healthflow.app.ui.viewmodel.ExerciseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.time.temporal.WeekFields +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExerciseScreen( + viewModel: ExerciseViewModel = viewModel() +) { + val heatmap by viewModel.heatmapData.collectAsState() + val selectedYear by viewModel.selectedYear.collectAsState() + val selectedQuarter by viewModel.selectedQuarter.collectAsState() + val recentCheckins by viewModel.recentCheckins.collectAsState() + val stats by viewModel.stats.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val todayCheckedIn by viewModel.todayCheckedIn.collectAsState() + val isCheckinLoading by viewModel.isCheckinLoading.collectAsState() + + var showCheckinDialog by remember { mutableStateOf(false) } + var selectedCheckin by remember { mutableStateOf(null) } + var selectedDate by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.loadAll() + } + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + // Header + Text( + text = "运动", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Slate900, + modifier = Modifier + .statusBarsPadding() + .padding(horizontal = 20.dp, vertical = 16.dp) + ) + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = Brand500) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 100.dp) + ) { + // Stats Row + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatCard( + icon = Icons.Outlined.FitnessCenter, + iconColor = Brand500, + value = stats.totalCheckins.toString(), + label = "总打卡", + modifier = Modifier.weight(1f) + ) + StatCard( + icon = Icons.Outlined.LocalFireDepartment, + iconColor = WarningYellow, + value = stats.currentStreak.toString(), + label = "连续天数", + modifier = Modifier.weight(1f) + ) + StatCard( + icon = Icons.Outlined.CalendarMonth, + iconColor = SuccessGreen, + value = stats.thisMonth.toString(), + label = "本月", + modifier = Modifier.weight(1f) + ) + } + } + + // Quarter Selector + item { + QuarterSelector( + year = selectedYear, + quarter = selectedQuarter, + canGoNext = viewModel.canGoNext(), + onPrevious = { viewModel.previousQuarter() }, + onNext = { viewModel.nextQuarter() } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + // Heatmap + item(key = "heatmap_${heatmap.hashCode()}") { + QuarterHeatmapGrid( + year = selectedYear, + quarter = selectedQuarter, + data = heatmap, + checkins = recentCheckins, + onDateClick = { date, checkin -> + if (checkin != null) { + selectedCheckin = checkin + } else { + selectedDate = date + } + } + ) + Spacer(modifier = Modifier.height(32.dp)) + } + + // Recent Checkins Title + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "最近打卡", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Slate900 + ) + Text( + text = "共${recentCheckins.size}条", + fontSize = 12.sp, + color = Slate500 + ) + } + } + + // Recent checkins list + items(recentCheckins.take(10)) { checkin -> + CheckinItem( + checkin = checkin, + onClick = { selectedCheckin = checkin } + ) + } + + if (recentCheckins.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "还没有打卡记录,开始运动吧!", + color = Slate400, + fontSize = 14.sp + ) + } + } + } + } + } + } + + // FAB - Checkin Button + FloatingActionButton( + onClick = { showCheckinDialog = true }, + modifier = Modifier + .align(Alignment.BottomEnd) + .navigationBarsPadding() + .padding(end = 20.dp, bottom = 72.dp), + containerColor = if (todayCheckedIn) SuccessGreen else Brand500, + contentColor = Color.White, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 6.dp, + pressedElevation = 12.dp + ) + ) { + Icon( + imageVector = if (todayCheckedIn) Icons.Default.Check else Icons.Default.Add, + contentDescription = "打卡" + ) + } + } + + // Checkin Dialog + if (showCheckinDialog) { + CheckinDialog( + isLoading = isCheckinLoading, + onDismiss = { showCheckinDialog = false }, + onCheckin = { imageUrl, note -> + viewModel.checkin(imageUrl, note) { + showCheckinDialog = false + } + } + ) + } + + // Checkin Detail Dialog + selectedCheckin?.let { checkin -> + CheckinDetailDialog( + checkin = checkin, + onDismiss = { selectedCheckin = null }, + onDelete = { + viewModel.deleteCheckin(checkin.id) { + selectedCheckin = null + } + } + ) + } +} + + +@Composable +private fun StatCard( + icon: ImageVector, + iconColor: Color, + value: String, + label: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(Slate50) + .border(1.dp, Slate200, RoundedCornerShape(16.dp)) + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = value, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Slate900 + ) + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = Slate500, + letterSpacing = 0.5.sp + ) + } +} + +@Composable +private fun QuarterSelector( + year: Int, + quarter: Int, + canGoNext: Boolean, + onPrevious: () -> Unit, + onNext: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onPrevious, modifier = Modifier.size(32.dp)) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = "上一季度", + tint = Slate500, + modifier = Modifier.size(20.dp) + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "${year}年", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Slate500) + Text(text = getQuarterMonthRange(quarter), fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Slate900) + } + IconButton(onClick = onNext, enabled = canGoNext, modifier = Modifier.size(32.dp)) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "下一季度", + tint = if (canGoNext) Slate500 else Slate300, + modifier = Modifier.size(20.dp) + ) + } + } +} + +private fun getQuarterMonthRange(quarter: Int): String = when (quarter) { + 1 -> "1月 - 3月"; 2 -> "4月 - 6月"; 3 -> "7月 - 9月"; 4 -> "10月 - 12月"; else -> "" +} + +@Composable +private fun QuarterHeatmapGrid( + year: Int, + quarter: Int, + data: Map, + checkins: List, + onDateClick: (String, ExerciseCheckin?) -> Unit +) { + val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val today = LocalDate.now() + // 使用日期前10个字符作为key,确保格式匹配 + val checkinMap = checkins.associateBy { it.checkinDate.take(10) } + + val startMonth = (quarter - 1) * 3 + 1 + val endMonth = quarter * 3 + val quarterStart = LocalDate.of(year, startMonth, 1) + val quarterEndDate = LocalDate.of(year, endMonth, 1).plusMonths(1).minusDays(1) + + val adjustedStart = quarterStart.with(WeekFields.of(Locale.getDefault()).dayOfWeek(), 1) + .let { if (it > quarterStart) it.minusWeeks(1) else it } + + val weeks = mutableListOf>() + var currentWeekStart = adjustedStart + + while (currentWeekStart <= quarterEndDate.plusDays(6)) { + val week = mutableListOf() + for (i in 0..6) { + val date = currentWeekStart.plusDays(i.toLong()) + if (date.monthValue in startMonth..endMonth && date.year == year) { + week.add(date) + } else { + week.add(null) + } + } + if (week.any { it != null }) weeks.add(week) + currentWeekStart = currentWeekStart.plusWeeks(1) + if (currentWeekStart.isAfter(quarterEndDate.plusWeeks(1))) break + } + + val cellSize = 16.dp + val spacing = 3.dp + val gridWidthDp = (weeks.size * 19 - 3).dp + + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Row(modifier = Modifier.width(gridWidthDp).padding(bottom = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) { + for (month in startMonth..endMonth) { + Text(text = "${month}月", fontSize = 12.sp, fontWeight = FontWeight.Medium, color = Slate400) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(spacing)) { + weeks.forEach { week -> + Column(verticalArrangement = Arrangement.spacedBy(spacing)) { + week.forEach { date -> + if (date != null) { + val dateStr = date.format(dateFormatter) + val count = data[dateStr] ?: 0 + val isFuture = date.isAfter(today) + HeatmapCell( + count = count, + size = cellSize, + isFuture = isFuture, + onClick = { + if (count > 0) onDateClick(dateStr, checkinMap[dateStr]) + } + ) + } else { + Spacer(modifier = Modifier.size(cellSize)) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Text(text = "少", fontSize = 10.sp, color = Slate400) + Spacer(modifier = Modifier.width(8.dp)) + listOf(0, 1, 2, 3, 4).forEach { level -> + Box(modifier = Modifier.size(10.dp).clip(RoundedCornerShape(2.dp)).background(getHeatmapColor(level))) + Spacer(modifier = Modifier.width(4.dp)) + } + Text(text = "多", fontSize = 10.sp, color = Slate400) + Spacer(modifier = Modifier.width(16.dp)) + Box(modifier = Modifier.size(10.dp).clip(RoundedCornerShape(2.dp)).background(FutureGray).border(1.dp, Color(0xFFB0B8C0), RoundedCornerShape(2.dp))) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = "未来", fontSize = 10.sp, color = Slate400) + } + } +} + +@Composable +private fun HeatmapCell(count: Int, size: androidx.compose.ui.unit.Dp, isFuture: Boolean, onClick: () -> Unit) { + val level = when { isFuture -> -1; count == 0 -> 0; count == 1 -> 1; count == 2 -> 2; count <= 4 -> 3; else -> 4 } + Box( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(3.dp)) + .background(getHeatmapColor(level)) + .then(if (isFuture) Modifier.border(1.dp, Color(0xFFB0B8C0), RoundedCornerShape(3.dp)) else Modifier) + .clickable(enabled = count > 0 && !isFuture) { onClick() } + ) +} + +private val GitHubGreen0 = Color(0xFFEBEDF0) +private val GitHubGreen1 = Color(0xFF9BE9A8) +private val GitHubGreen2 = Color(0xFF40C463) +private val GitHubGreen3 = Color(0xFF30A14E) +private val GitHubGreen4 = Color(0xFF216E39) +private val FutureGray = Color(0xFFD0D7DE) + +@Composable +private fun getHeatmapColor(level: Int): Color = when (level) { + -1 -> FutureGray; 0 -> GitHubGreen0; 1 -> GitHubGreen1; 2 -> GitHubGreen2; 3 -> GitHubGreen3; 4 -> GitHubGreen4; else -> GitHubGreen0 +} + + +@Composable +private fun CheckinItem(checkin: ExerciseCheckin, onClick: () -> Unit) { + val date = try { LocalDate.parse(checkin.checkinDate.take(10)) } catch (e: Exception) { LocalDate.now() } + val dayOfWeek = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.CHINESE) + + Row( + modifier = Modifier.fillMaxWidth().clickable { onClick() }.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.width(48.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = dayOfWeek, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Slate500) + Text(text = date.dayOfMonth.toString(), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Slate900) + } + Spacer(modifier = Modifier.width(16.dp)) + Row( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(12.dp)).background(Slate50).padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (checkin.imageUrl.isNotEmpty()) { + AsyncImage( + model = checkin.imageUrl, + contentDescription = null, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (checkin.note.isNotEmpty()) checkin.note else "运动打卡 ✓", + fontSize = 14.sp, color = Slate700, maxLines = 1, overflow = TextOverflow.Ellipsis + ) + Text(text = "${date.monthValue}月${date.dayOfMonth}日", fontSize = 12.sp, color = Slate400) + } + Box( + modifier = Modifier.size(24.dp).clip(CircleShape).background(SuccessGreen), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Default.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CheckinDetailDialog( + checkin: ExerciseCheckin, + onDismiss: () -> Unit, + onDelete: () -> Unit +) { + val date = try { LocalDate.parse(checkin.checkinDate.take(10)) } catch (e: Exception) { LocalDate.now() } + var showDeleteConfirm by remember { mutableStateOf(false) } + var showFullscreenImage by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = Color.White, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + dragHandle = { + Box(modifier = Modifier.padding(vertical = 12.dp).width(40.dp).height(4.dp).clip(RoundedCornerShape(2.dp)).background(Slate300)) + } + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp).padding(bottom = 32.dp).navigationBarsPadding() + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(text = "打卡详情", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Slate900) + Text( + text = "${date.year}年${date.monthValue}月${date.dayOfMonth}日", + fontSize = 14.sp, color = Slate500 + ) + } + IconButton( + onClick = { showDeleteConfirm = true }, + modifier = Modifier.size(40.dp).clip(CircleShape).background(ErrorRed.copy(alpha = 0.1f)) + ) { + Icon(Icons.Default.Delete, contentDescription = "删除", tint = ErrorRed, modifier = Modifier.size(20.dp)) + } + } + + // Image - 点击可全屏查看 + if (checkin.imageUrl.isNotEmpty()) { + AsyncImage( + model = checkin.imageUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(280.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable { showFullscreenImage = true }, + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + // Note - 完整显示 + if (checkin.note.isNotEmpty()) { + Text( + text = checkin.note, + fontSize = 16.sp, + color = Slate700, + lineHeight = 24.sp + ) + } else if (checkin.imageUrl.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().height(120.dp).clip(RoundedCornerShape(16.dp)).background(Slate50), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Default.Check, contentDescription = null, tint = SuccessGreen, modifier = Modifier.size(40.dp)) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "已完成打卡", fontSize = 16.sp, color = Slate500, fontWeight = FontWeight.Medium) + } + } + } + } + } + + // 全屏图片查看 + if (showFullscreenImage && checkin.imageUrl.isNotEmpty()) { + FullscreenImageDialog( + imageUrl = checkin.imageUrl, + onDismiss = { showFullscreenImage = false } + ) + } + + // Delete confirmation + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("删除打卡", fontWeight = FontWeight.Bold) }, + text = { Text("确定要删除这条打卡记录吗?此操作不可撤销。") }, + confirmButton = { + TextButton(onClick = { showDeleteConfirm = false; onDelete() }) { + Text("删除", color = ErrorRed) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text("取消", color = Slate500) + } + } + ) + } +} + +@Composable +private fun FullscreenImageDialog( + imageUrl: String, + onDismiss: () -> Unit +) { + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Fit + ) + } + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CheckinDialog( + isLoading: Boolean, + onDismiss: () -> Unit, + onCheckin: (imageUrl: String, note: String) -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var selectedImageUri by remember { mutableStateOf(null) } + var uploadedImageUrl by remember { mutableStateOf("") } + var note by remember { mutableStateOf("") } + var isUploading by remember { mutableStateOf(false) } + var uploadError by remember { mutableStateOf(null) } + + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + uri?.let { + selectedImageUri = it + uploadedImageUrl = "" + uploadError = null + scope.launch { + isUploading = true + try { + val file = withContext(Dispatchers.IO) { + val inputStream = context.contentResolver.openInputStream(uri) + val tempFile = File.createTempFile("upload", ".jpg", context.cacheDir) + tempFile.outputStream().use { output -> inputStream?.copyTo(output) } + inputStream?.close() + tempFile + } + val requestBody = file.asRequestBody("image/*".toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData("file", file.name, requestBody) + val response = ApiClient.api.upload(part) + if (response.isSuccessful) { + uploadedImageUrl = response.body()?.url ?: "" + if (uploadedImageUrl.isEmpty()) { + uploadError = "上传失败" + } + } else { + uploadError = "上传失败: ${response.code()}" + } + file.delete() + } catch (e: Exception) { + e.printStackTrace() + uploadError = "上传失败: ${e.message}" + } + isUploading = false + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = Color.White, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + dragHandle = { + Box(modifier = Modifier.padding(vertical = 12.dp).width(40.dp).height(4.dp).clip(RoundedCornerShape(2.dp)).background(Slate300)) + } + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp).padding(bottom = 24.dp).navigationBarsPadding() + ) { + Text(text = "运动打卡", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Slate900, modifier = Modifier.padding(bottom = 20.dp)) + + // Image upload + Box( + modifier = Modifier.fillMaxWidth().height(180.dp).clip(RoundedCornerShape(16.dp)).background(Slate50) + .border(2.dp, when { + uploadError != null -> ErrorRed + uploadedImageUrl.isNotEmpty() -> SuccessGreen + selectedImageUri != null -> Brand500 + else -> Slate200 + }, RoundedCornerShape(16.dp)) + .clickable { imagePickerLauncher.launch("image/*") }, + contentAlignment = Alignment.Center + ) { + if (selectedImageUri != null) { + AsyncImage( + model = selectedImageUri, + contentDescription = null, + modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Crop + ) + if (isUploading) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = Color.White, modifier = Modifier.size(40.dp), strokeWidth = 3.dp) + Spacer(modifier = Modifier.height(8.dp)) + Text("上传中...", color = Color.White, fontSize = 12.sp) + } + } + } else if (uploadedImageUrl.isNotEmpty()) { + // 上传成功标记 + Box( + modifier = Modifier.align(Alignment.TopStart).padding(8.dp).clip(RoundedCornerShape(4.dp)) + .background(SuccessGreen).padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text("已上传", color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.Medium) + } + } + if (!isUploading) { + Box( + modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).size(32.dp).clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)).clickable { imagePickerLauncher.launch("image/*") }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.Default.Add, contentDescription = "更换", tint = Color.White, modifier = Modifier.size(18.dp)) + } + } + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.size(48.dp).clip(CircleShape).background(Slate100), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Add, contentDescription = null, tint = Slate400, modifier = Modifier.size(24.dp)) + } + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "上传运动照片", color = Slate500, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + } + } + + // 上传错误提示 + if (uploadError != null) { + Text( + text = uploadError!!, + color = ErrorRed, + fontSize = 12.sp, + modifier = Modifier.padding(top = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = note, + onValueChange = { note = it }, + placeholder = { Text("添加备注(可选)", color = Slate400, fontSize = 15.sp) }, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Brand500, unfocusedBorderColor = Slate200, + focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent + ), + shape = RoundedCornerShape(12.dp), + maxLines = 2 + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = { onCheckin(uploadedImageUrl, note) }, + enabled = !isLoading && !isUploading, + modifier = Modifier.fillMaxWidth().height(52.dp), + colors = ButtonDefaults.buttonColors(containerColor = Slate900, disabledContainerColor = Slate300), + shape = RoundedCornerShape(12.dp) + ) { + if (isLoading || isUploading) { + CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Text(text = "完成打卡", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } + } + } + } +} diff --git a/android/app/src/main/java/com/healthflow/app/ui/screen/ProfileScreen.kt b/android/app/src/main/java/com/healthflow/app/ui/screen/ProfileScreen.kt index 5ccf538..ee64f1c 100644 --- a/android/app/src/main/java/com/healthflow/app/ui/screen/ProfileScreen.kt +++ b/android/app/src/main/java/com/healthflow/app/ui/screen/ProfileScreen.kt @@ -147,6 +147,24 @@ fun ProfileScreen( ) } + Spacer(modifier = Modifier.height(16.dp)) + + // 运动统计卡片 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + StatCard( + title = "今年运动", + value = stats.exerciseDays.toString(), + unit = "天", + valueColor = SuccessGreen, + modifier = Modifier.weight(1f) + ) + // 占位,保持布局对称 + Spacer(modifier = Modifier.weight(1f)) + } + Spacer(modifier = Modifier.height(40.dp)) // 年度进度卡片 diff --git a/android/app/src/main/java/com/healthflow/app/ui/viewmodel/EpochViewModel.kt b/android/app/src/main/java/com/healthflow/app/ui/viewmodel/EpochViewModel.kt index ef2991a..7c748b3 100644 --- a/android/app/src/main/java/com/healthflow/app/ui/viewmodel/EpochViewModel.kt +++ b/android/app/src/main/java/com/healthflow/app/ui/viewmodel/EpochViewModel.kt @@ -13,7 +13,8 @@ data class ProfileStats( val yearLoss: Double = 0.0, val totalLoss: Double = 0.0, val daysRemaining: Int = 0, - val persistDays: Int = 0 + val persistDays: Int = 0, + val exerciseDays: Int = 0 // 今年运动天数 ) class EpochViewModel : ViewModel() { @@ -43,6 +44,8 @@ class EpochViewModel : ViewModel() { private val _profileStats = MutableStateFlow(ProfileStats()) val profileStats: StateFlow = _profileStats + private val _exerciseDays = MutableStateFlow(0) + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading @@ -150,10 +153,16 @@ class EpochViewModel : ViewModel() { yearLoss = detail?.yearTotalLoss ?: 0.0, totalLoss = detail?.allTimeTotalLoss ?: 0.0, daysRemaining = daysRemaining.toInt(), - persistDays = persistDays + persistDays = persistDays, + exerciseDays = _exerciseDays.value ) } + fun updateExerciseDays(days: Int) { + _exerciseDays.value = days + calculateProfileStats() + } + fun setActiveEpoch(epoch: WeightEpoch) { _activeEpoch.value = epoch viewModelScope.launch { diff --git a/android/app/src/main/java/com/healthflow/app/ui/viewmodel/ExerciseViewModel.kt b/android/app/src/main/java/com/healthflow/app/ui/viewmodel/ExerciseViewModel.kt new file mode 100644 index 0000000..f16217a --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/ui/viewmodel/ExerciseViewModel.kt @@ -0,0 +1,186 @@ +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.CreateExerciseCheckinRequest +import com.healthflow.app.data.model.ExerciseCheckin +import com.healthflow.app.data.model.ExerciseStats +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class ExerciseViewModel : ViewModel() { + private val _heatmapData = MutableStateFlow>(emptyMap()) + val heatmapData: StateFlow> = _heatmapData + + private val _selectedYear = MutableStateFlow(LocalDate.now().year) + val selectedYear: StateFlow = _selectedYear + + private val _selectedQuarter = MutableStateFlow((LocalDate.now().monthValue - 1) / 3 + 1) + val selectedQuarter: StateFlow = _selectedQuarter + + private val _recentCheckins = MutableStateFlow>(emptyList()) + val recentCheckins: StateFlow> = _recentCheckins + + private val _stats = MutableStateFlow(ExerciseStats()) + val stats: StateFlow = _stats + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val _isCheckinLoading = MutableStateFlow(false) + val isCheckinLoading: StateFlow = _isCheckinLoading + + private val _todayCheckedIn = MutableStateFlow(false) + val todayCheckedIn: StateFlow = _todayCheckedIn + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error + + fun loadAll() { + viewModelScope.launch { + _isLoading.value = true + try { + loadHeatmap() + loadStats() + loadRecentCheckins() + checkTodayStatus() + } finally { + _isLoading.value = false + } + } + } + + private suspend fun loadHeatmap() { + try { + val year = _selectedYear.value.toString() + val response = ApiClient.api.getExerciseHeatmap(year) + if (response.isSuccessful) { + val data = response.body() ?: emptyList() + val newMap = data.associate { it.date to it.count } + // 强制创建新的 Map 实例以触发 StateFlow 更新 + _heatmapData.value = newMap.toMap() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private suspend fun loadStats() { + try { + val response = ApiClient.api.getExerciseStats() + if (response.isSuccessful) { + _stats.value = response.body() ?: ExerciseStats() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private suspend fun loadRecentCheckins() { + try { + val response = ApiClient.api.getExerciseCheckins() + if (response.isSuccessful) { + _recentCheckins.value = response.body() ?: emptyList() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun checkTodayStatus() { + val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + _todayCheckedIn.value = _heatmapData.value[today]?.let { it > 0 } ?: false + } + + fun checkin(imageUrl: String, note: String, onSuccess: () -> Unit) { + viewModelScope.launch { + _isCheckinLoading.value = true + _error.value = null + try { + val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val request = CreateExerciseCheckinRequest( + checkinDate = today, + imageUrl = imageUrl, + note = note + ) + val response = ApiClient.api.createExerciseCheckin(request) + if (response.isSuccessful) { + // 先刷新数据,等待完成后再关闭弹窗 + loadHeatmap() + loadStats() + loadRecentCheckins() + checkTodayStatus() + onSuccess() + } else { + _error.value = "打卡失败: ${response.code()}" + onSuccess() + } + } catch (e: Exception) { + _error.value = e.message ?: "打卡失败" + e.printStackTrace() + onSuccess() + } finally { + _isCheckinLoading.value = false + } + } + } + + fun previousQuarter() { + if (_selectedQuarter.value > 1) { + _selectedQuarter.value-- + } else if (_selectedYear.value > 2020) { + _selectedYear.value-- + _selectedQuarter.value = 4 + viewModelScope.launch { loadHeatmap() } + } + } + + fun nextQuarter() { + val now = LocalDate.now() + val currentQuarter = (now.monthValue - 1) / 3 + 1 + + if (_selectedYear.value < now.year || + (_selectedYear.value == now.year && _selectedQuarter.value < currentQuarter)) { + if (_selectedQuarter.value < 4) { + _selectedQuarter.value++ + } else { + _selectedYear.value++ + _selectedQuarter.value = 1 + viewModelScope.launch { loadHeatmap() } + } + } + } + + fun canGoNext(): Boolean { + val now = LocalDate.now() + val currentQuarter = (now.monthValue - 1) / 3 + 1 + return _selectedYear.value < now.year || + (_selectedYear.value == now.year && _selectedQuarter.value < currentQuarter) + } + + fun deleteCheckin(id: Long, onSuccess: () -> Unit) { + viewModelScope.launch { + try { + val response = ApiClient.api.deleteExerciseCheckin(id) + if (response.isSuccessful) { + // 先刷新数据,等待完成后再回调 + loadHeatmap() + loadStats() + loadRecentCheckins() + checkTodayStatus() + onSuccess() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun clearError() { + _error.value = null + } +} diff --git a/healthflow_minimal_ui.html b/healthflow_minimal_ui.html index f81a906..50291da 100644 --- a/healthflow_minimal_ui.html +++ b/healthflow_minimal_ui.html @@ -642,9 +642,119 @@ + +
+
+

运动

+

记录你的运动轨迹

+
+ + +
+
+ 🏋️ + 42 + 总打卡 +
+
+ 🔥 + 7 + 连续天数 +
+
+ 📅 + 12 + 本月 +
+
+ + +
+ +
+
2026年
+
1月 - 3月
+
+ +
+ + +
+
+ 1月 + 2月 + 3月 +
+
+ + +
+
+ +
+
+
+
+
+ +
+
+ + +
+
+ 最近打卡 + 共12条 +
+ +
+
+
+
周五
+
2
+
+
+
晨跑 5 公里 🏃
+
1月2日
+
+
+ +
+
+
+ +
+
+
+
周四
+
1
+
+
+
健身房力量训练 💪
+
1月1日
+
+
+ +
+
+
+
+ + +
+
+
+ @@ -659,8 +769,10 @@ document.querySelectorAll('.nav-item')[0].classList.add('active'); } else if (screenId === 'plans' || screenId === 'week-detail' || screenId === 'epoch-detail') { document.querySelectorAll('.nav-item')[1].classList.add('active'); - } else if (screenId === 'profile') { + } else if (screenId === 'exercise') { document.querySelectorAll('.nav-item')[2].classList.add('active'); + } else if (screenId === 'profile') { + document.querySelectorAll('.nav-item')[3].classList.add('active'); } window.scrollTo(0, 0); } diff --git a/release.sh b/release.sh index 0e94f5f..adbd365 100755 --- a/release.sh +++ b/release.sh @@ -257,7 +257,7 @@ if [ "$BUILD_APK" = true ]; then # 获取更新日志 echo "" - read -p "📝 请输入更新日志: " update_log + read -p "📝 请输入更新日志 (回车使用默认): " update_log if [ -z "$update_log" ]; then update_log="Bug 修复和性能优化" @@ -267,54 +267,44 @@ if [ "$BUILD_APK" = true ]; then echo "📋 发布信息:" echo " 版本: v${new_version} (code: ${new_code})" echo " 更新日志: ${update_log}" + + # 更新版本号 echo "" - read -p "确认发布 APK? (y/n) " confirm - if [ "$confirm" != "y" ]; then - echo "❌ APK 发布已取消" - BUILD_APK=false - else - # 更新版本号 + echo "📝 更新版本号..." + update_version_in_gradle $new_version $new_code + + # 编译 + build_apk + + # 先更新服务器版本信息,成功后再上传 APK + download_url="${R2_PUBLIC_URL}/healthFlow/releases/healthflow-${new_version}.apk" + if update_server_version $new_code "$new_version" "$download_url" "$update_log"; then + # 上传 APK + upload_to_r2 $new_version + echo "✅ 上传完成: $download_url" + + # 清理旧 APK + cleanup_old_apks + echo "" - echo "📝 更新版本号..." - update_version_in_gradle $new_version $new_code - - # 编译 - build_apk - - # 先更新服务器版本信息,成功后再上传 APK - download_url="${R2_PUBLIC_URL}/healthFlow/releases/healthflow-${new_version}.apk" - if update_server_version $new_code "$new_version" "$download_url" "$update_log"; then - # 上传 APK - upload_to_r2 $new_version - echo "✅ 上传完成: $download_url" - - # 清理旧 APK - cleanup_old_apks - - echo "" - echo "🎉 APK 发布完成!" - echo " 版本: v${new_version}" - echo " 下载: ${download_url}" - else - echo "" - echo "❌ 服务器版本更新失败,APK 未上传" - exit 1 - fi + echo "🎉 APK 发布完成!" + echo " 版本: v${new_version}" + echo " 下载: ${download_url}" + else + echo "" + echo "❌ 服务器版本更新失败,APK 未上传" + exit 1 fi fi # 后端部署流程 if [ "$DEPLOY_BACKEND" = true ]; then echo "" - read -p "确认部署后端? (y/n) " deploy_confirm - if [ "$deploy_confirm" = "y" ]; then - build_docker - deploy_docker - echo "" - echo "🎉 后端部署完成!" - else - echo "❌ 后端部署已取消" - fi + echo "🐳 开始部署后端..." + build_docker + deploy_docker + echo "" + echo "🎉 后端部署完成!" fi echo "" diff --git a/server/internal/database/database.go b/server/internal/database/database.go index 0afb440..2c17ce2 100644 --- a/server/internal/database/database.go +++ b/server/internal/database/database.go @@ -4,6 +4,7 @@ import ( "database/sql" "os" "path/filepath" + "strings" _ "github.com/mattn/go-sqlite3" ) @@ -117,16 +118,102 @@ func migrate(db *sql.DB) error { FOREIGN KEY (user_id) REFERENCES users(id) ); + -- 运动打卡表 + CREATE TABLE IF NOT EXISTS exercise_checkins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + checkin_date DATE NOT NULL, + image_url TEXT DEFAULT '', + note TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + 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_epochs_user ON weight_epochs(user_id); CREATE INDEX IF NOT EXISTS idx_weekly_plans_epoch ON weekly_plans(epoch_id); CREATE INDEX IF NOT EXISTS idx_weight_records_user ON weight_records(user_id); CREATE INDEX IF NOT EXISTS idx_weight_records_week ON weight_records(year, week); + CREATE INDEX IF NOT EXISTS idx_exercise_checkins_user ON exercise_checkins(user_id); + CREATE INDEX IF NOT EXISTS idx_exercise_checkins_date ON exercise_checkins(checkin_date); -- 初始化默认设置 INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_register', 'true'); ` _, err := db.Exec(schema) - return err + if err != nil { + return err + } + + // 迁移:移除 exercise_checkins 表的 UNIQUE 约束(如果存在) + // 检查是否有旧的 UNIQUE 约束,如果有则重建表 + migrateExerciseCheckins(db) + + return nil +} + +// migrateExerciseCheckins 移除 exercise_checkins 表的 UNIQUE 约束 +func migrateExerciseCheckins(db *sql.DB) { + // 检查表是否有 UNIQUE 约束 + var tableSql string + err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='exercise_checkins'").Scan(&tableSql) + if err != nil { + return + } + + // 如果表定义中包含 UNIQUE,需要重建表 + if !strings.Contains(tableSql, "UNIQUE") { + return + } + + // 重建表以移除 UNIQUE 约束 + tx, err := db.Begin() + if err != nil { + return + } + defer tx.Rollback() + + // 1. 重命名旧表 + _, err = tx.Exec("ALTER TABLE exercise_checkins RENAME TO exercise_checkins_old") + if err != nil { + return + } + + // 2. 创建新表(无 UNIQUE 约束) + _, err = tx.Exec(` + CREATE TABLE exercise_checkins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + checkin_date DATE NOT NULL, + image_url TEXT DEFAULT '', + note TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `) + if err != nil { + return + } + + // 3. 复制数据 + _, err = tx.Exec(` + INSERT INTO exercise_checkins (id, user_id, checkin_date, image_url, note, created_at) + SELECT id, user_id, checkin_date, image_url, note, created_at FROM exercise_checkins_old + `) + if err != nil { + return + } + + // 4. 删除旧表 + _, err = tx.Exec("DROP TABLE exercise_checkins_old") + if err != nil { + return + } + + // 5. 重建索引 + tx.Exec("CREATE INDEX IF NOT EXISTS idx_exercise_checkins_user ON exercise_checkins(user_id)") + tx.Exec("CREATE INDEX IF NOT EXISTS idx_exercise_checkins_date ON exercise_checkins(checkin_date)") + + tx.Commit() } diff --git a/server/internal/handler/exercise.go b/server/internal/handler/exercise.go new file mode 100644 index 0000000..cb98414 --- /dev/null +++ b/server/internal/handler/exercise.go @@ -0,0 +1,239 @@ +package handler + +import ( + "database/sql" + "net/http" + "strconv" + "time" + + "healthflow/internal/config" + "healthflow/internal/model" + + "github.com/gin-gonic/gin" +) + +type ExerciseHandler struct { + db *sql.DB + cfg *config.Config +} + +func NewExerciseHandler(db *sql.DB, cfg *config.Config) *ExerciseHandler { + return &ExerciseHandler{db: db, cfg: cfg} +} + +// CreateCheckin 创建运动打卡 +func (h *ExerciseHandler) CreateCheckin(c *gin.Context) { + userID := c.GetInt64("user_id") + + var req model.CreateExerciseCheckinRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 每次都创建新记录,支持一天多次打卡 + result, err := h.db.Exec(` + INSERT INTO exercise_checkins (user_id, checkin_date, image_url, note) + VALUES (?, ?, ?, ?) + `, userID, req.CheckinDate, req.ImageURL, req.Note) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "打卡失败", "detail": err.Error()}) + return + } + + id, _ := result.LastInsertId() + c.JSON(http.StatusOK, gin.H{"message": "打卡成功", "id": id, "date": req.CheckinDate}) +} + +// GetCheckins 获取打卡记录列表 +func (h *ExerciseHandler) GetCheckins(c *gin.Context) { + userID := c.GetInt64("user_id") + + query := ` + SELECT id, user_id, checkin_date, image_url, note, created_at + FROM exercise_checkins + WHERE user_id = ? + ORDER BY checkin_date DESC + LIMIT 50 + ` + + rows, err := h.db.Query(query, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"}) + return + } + defer rows.Close() + + var checkins []model.ExerciseCheckin + for rows.Next() { + var checkin model.ExerciseCheckin + if err := rows.Scan(&checkin.ID, &checkin.UserID, &checkin.CheckinDate, + &checkin.ImageURL, &checkin.Note, &checkin.CreatedAt); err != nil { + continue + } + checkins = append(checkins, checkin) + } + + if checkins == nil { + checkins = []model.ExerciseCheckin{} + } + c.JSON(http.StatusOK, checkins) +} + +// GetHeatmap 获取热力图数据 +func (h *ExerciseHandler) GetHeatmap(c *gin.Context) { + userID := c.GetInt64("user_id") + year := c.Query("year") + if year == "" { + year = strconv.Itoa(time.Now().Year()) + } + + // 使用 substr 提取日期的年份部分进行匹配 + // 同时只取日期部分(前10个字符)作为分组键 + query := ` + SELECT substr(checkin_date, 1, 10) as date, COUNT(*) as count + FROM exercise_checkins + WHERE user_id = ? AND substr(checkin_date, 1, 4) = ? + GROUP BY substr(checkin_date, 1, 10) + ORDER BY date + ` + + rows, err := h.db.Query(query, userID, year) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败", "detail": err.Error()}) + return + } + defer rows.Close() + + var data []model.ExerciseHeatmapData + for rows.Next() { + var item model.ExerciseHeatmapData + if err := rows.Scan(&item.Date, &item.Count); err != nil { + continue + } + data = append(data, item) + } + + if data == nil { + data = []model.ExerciseHeatmapData{} + } + + c.JSON(http.StatusOK, data) +} + +// GetStats 获取运动统计 +func (h *ExerciseHandler) GetStats(c *gin.Context) { + userID := c.GetInt64("user_id") + now := time.Now() + + stats := model.ExerciseStats{} + + // 总打卡次数 + h.db.QueryRow(` + SELECT COUNT(*) FROM exercise_checkins WHERE user_id = ? + `, userID).Scan(&stats.TotalCheckins) + + // 本月打卡 + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + h.db.QueryRow(` + SELECT COUNT(*) FROM exercise_checkins + WHERE user_id = ? AND checkin_date >= ? + `, userID, monthStart.Format("2006-01-02")).Scan(&stats.ThisMonth) + + // 本周打卡 + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + weekStart := now.AddDate(0, 0, -(weekday - 1)) + h.db.QueryRow(` + SELECT COUNT(*) FROM exercise_checkins + WHERE user_id = ? AND checkin_date >= ? + `, userID, weekStart.Format("2006-01-02")).Scan(&stats.ThisWeek) + + // 今年运动天数(去重) + yearStart := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()) + h.db.QueryRow(` + SELECT COUNT(DISTINCT checkin_date) FROM exercise_checkins + WHERE user_id = ? AND checkin_date >= ? + `, userID, yearStart.Format("2006-01-02")).Scan(&stats.ThisYearDays) + + // 计算连续打卡天数 + stats.CurrentStreak = h.calculateStreak(userID) + + c.JSON(http.StatusOK, stats) +} + +// calculateStreak 计算连续打卡天数 +func (h *ExerciseHandler) calculateStreak(userID int64) int { + rows, err := h.db.Query(` + SELECT DISTINCT checkin_date FROM exercise_checkins + WHERE user_id = ? + ORDER BY checkin_date DESC + `, userID) + if err != nil { + return 0 + } + defer rows.Close() + + var dates []string + for rows.Next() { + var date string + if err := rows.Scan(&date); err == nil { + dates = append(dates, date) + } + } + + if len(dates) == 0 { + return 0 + } + + streak := 0 + today := time.Now().Format("2006-01-02") + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + + // 检查今天或昨天是否有打卡 + if dates[0] != today && dates[0] != yesterday { + return 0 + } + + expectedDate := dates[0] + for _, date := range dates { + if date == expectedDate { + streak++ + t, _ := time.Parse("2006-01-02", expectedDate) + expectedDate = t.AddDate(0, 0, -1).Format("2006-01-02") + } else { + break + } + } + + return streak +} + +// DeleteCheckin 删除打卡记录 +func (h *ExerciseHandler) DeleteCheckin(c *gin.Context) { + userID := c.GetInt64("user_id") + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"}) + return + } + + result, err := h.db.Exec(` + DELETE FROM exercise_checkins WHERE id = ? AND user_id = ? + `, id, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) + return + } + + affected, _ := result.RowsAffected() + if affected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/server/internal/handler/upload.go b/server/internal/handler/upload.go index 67b2d49..d4670f6 100644 --- a/server/internal/handler/upload.go +++ b/server/internal/handler/upload.go @@ -66,7 +66,7 @@ func (h *UploadHandler) Upload(c *gin.Context) { // 上传到 R2 if h.s3Client != nil { - key := "uploads/" + filename + key := "memory/healthFlow/pic/" + filename _, err := h.s3Client.PutObject(context.TODO(), &s3.PutObjectInput{ Bucket: aws.String(h.cfg.R2BucketName), diff --git a/server/internal/model/model.go b/server/internal/model/model.go index d0c0c4c..7d6aedf 100644 --- a/server/internal/model/model.go +++ b/server/internal/model/model.go @@ -175,3 +175,34 @@ type RecordWeightRequest struct { Week int `json:"week" binding:"required,min=1,max=53"` Note string `json:"note"` } + + +// 运动打卡 +type ExerciseCheckin struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + CheckinDate string `json:"checkin_date"` + ImageURL string `json:"image_url"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` +} + +type CreateExerciseCheckinRequest struct { + CheckinDate string `json:"checkin_date" binding:"required"` + ImageURL string `json:"image_url"` + Note string `json:"note"` +} + +type ExerciseHeatmapData struct { + Date string `json:"date"` + Count int `json:"count"` +} + +type ExerciseStats struct { + TotalCheckins int `json:"total_checkins"` + CurrentStreak int `json:"current_streak"` + LongestStreak int `json:"longest_streak"` + ThisMonth int `json:"this_month"` + ThisWeek int `json:"this_week"` + ThisYearDays int `json:"this_year_days"` +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go index bd9f308..3d60d18 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -39,6 +39,7 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine { versionHandler := handler.NewVersionHandler(db, cfg) weightHandler := handler.NewWeightHandler(db, cfg) epochHandler := handler.NewEpochHandler(db, cfg) + exerciseHandler := handler.NewExerciseHandler(db, cfg) // R2 文件代理 (公开访问) r.GET("/files/*filepath", uploadHandler.GetFile) @@ -76,6 +77,13 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine { auth.GET("/epoch/:id/plans", epochHandler.GetWeeklyPlans) auth.PUT("/epoch/:id/plan/:planId", epochHandler.UpdateWeeklyPlan) + // 运动打卡 + auth.POST("/exercise/checkin", exerciseHandler.CreateCheckin) + auth.GET("/exercise/checkins", exerciseHandler.GetCheckins) + auth.GET("/exercise/heatmap", exerciseHandler.GetHeatmap) + auth.GET("/exercise/stats", exerciseHandler.GetStats) + auth.DELETE("/exercise/checkin/:id", exerciseHandler.DeleteCheckin) + // 上传 auth.POST("/upload", uploadHandler.Upload)