增加运动打卡功能

This commit is contained in:
amos wong
2026-01-02 17:35:32 +08:00
parent 3605210681
commit a5cc393add
17 changed files with 1651 additions and 57 deletions

View File

@@ -10,5 +10,5 @@ BASE_URL=https://health.amos.us.kg
R2_ACCOUNT_ID=ebf33b5ee4eb26f32af0c6e06102e000 R2_ACCOUNT_ID=ebf33b5ee4eb26f32af0c6e06102e000
R2_ACCESS_KEY_ID=8acbc8a9386d60d0e8dac6bd8165c618 R2_ACCESS_KEY_ID=8acbc8a9386d60d0e8dac6bd8165c618
R2_ACCESS_KEY_SECRET=72935e23b5b4be8fda99008e75285e8ac778f8926656c42780b25785bb149443 R2_ACCESS_KEY_SECRET=72935e23b5b4be8fda99008e75285e8ac778f8926656c42780b25785bb149443
R2_BUCKET_NAME=healthflow R2_BUCKET_NAME=memory
R2_PUBLIC_URL=https://cdn-health.amos.us.kg R2_PUBLIC_URL=https://cdn.amos.us.kg

View File

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

View File

@@ -12,7 +12,7 @@ import java.util.concurrent.TimeUnit
class HealthFlowApp : Application(), ImageLoaderFactory { class HealthFlowApp : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader { override fun newImageLoader(): ImageLoader {
val okHttpClient = OkHttpClient.Builder() val okHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.build() .build()
@@ -21,17 +21,18 @@ class HealthFlowApp : Application(), ImageLoaderFactory {
.okHttpClient(okHttpClient) .okHttpClient(okHttpClient)
.memoryCache { .memoryCache {
MemoryCache.Builder(this) MemoryCache.Builder(this)
.maxSizePercent(0.30) .maxSizePercent(0.25) // 25% 内存
.build() .build()
} }
.diskCache { .diskCache {
DiskCache.Builder() DiskCache.Builder()
.directory(cacheDir.resolve("image_cache")) .directory(cacheDir.resolve("image_cache"))
.maxSizeBytes(100 * 1024 * 1024) // 100MB .maxSizePercent(0.05) // 5% 磁盘空间
.build() .build()
} }
.memoryCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED)
.respectCacheHeaders(false) // 忽略服务器缓存头,优先使用本地缓存
.crossfade(150) .crossfade(150)
.build() .build()
} }

View File

@@ -85,4 +85,23 @@ interface ApiService {
// Version // Version
@GET("version") @GET("version")
suspend fun getVersion(): Response<VersionInfo> suspend fun getVersion(): Response<VersionInfo>
// Exercise Checkin (运动打卡)
@POST("exercise/checkin")
suspend fun createExerciseCheckin(@Body request: CreateExerciseCheckinRequest): Response<MessageResponse>
@GET("exercise/checkins")
suspend fun getExerciseCheckins(
@Query("year") year: Int? = null,
@Query("month") month: Int? = null
): Response<List<ExerciseCheckin>>
@GET("exercise/heatmap")
suspend fun getExerciseHeatmap(@Query("year") year: String): Response<List<ExerciseHeatmapData>>
@GET("exercise/stats")
suspend fun getExerciseStats(): Response<ExerciseStats>
@DELETE("exercise/checkin/{id}")
suspend fun deleteExerciseCheckin(@Path("id") id: Long): Response<MessageResponse>
} }

View File

@@ -211,3 +211,37 @@ data class WeightStats(
@SerialName("target_weight") val targetWeight: Double? = null, @SerialName("target_weight") val targetWeight: Double? = null,
@SerialName("start_date") val startDate: String? = 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
)

View File

@@ -28,6 +28,7 @@ import com.healthflow.app.ui.viewmodel.EpochViewModel
sealed class Tab(val route: String, val label: String) { sealed class Tab(val route: String, val label: String) {
data object Epoch : Tab("tab_epoch", "纪元") data object Epoch : Tab("tab_epoch", "纪元")
data object Plan : Tab("tab_plan", "计划") data object Plan : Tab("tab_plan", "计划")
data object Exercise : Tab("tab_exercise", "运动")
data object Profile : Tab("tab_profile", "我的") data object Profile : Tab("tab_profile", "我的")
} }
@@ -41,7 +42,7 @@ object Routes {
fun weekPlanDetail(epochId: Long, planId: Long) = "week_plan_detail/$epochId/$planId" 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 @Composable
fun MainNavigation( 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) { composable(Routes.CREATE_EPOCH) {
CreateEpochScreen( CreateEpochScreen(
isLoading = isLoading, isLoading = isLoading,
@@ -233,6 +246,7 @@ fun MainNavigation(
val selectedTab = when { val selectedTab = when {
currentRoute == Tab.Epoch.route -> Tab.Epoch currentRoute == Tab.Epoch.route -> Tab.Epoch
currentRoute == Tab.Plan.route -> Tab.Plan currentRoute == Tab.Plan.route -> Tab.Plan
currentRoute == Tab.Exercise.route -> Tab.Exercise
currentRoute == Tab.Profile.route -> Tab.Profile currentRoute == Tab.Profile.route -> Tab.Profile
currentRoute?.startsWith("epoch_detail") == true -> Tab.Epoch currentRoute?.startsWith("epoch_detail") == true -> Tab.Epoch
currentRoute?.startsWith("week_plan_detail") == true -> Tab.Plan currentRoute?.startsWith("week_plan_detail") == true -> Tab.Plan
@@ -262,6 +276,7 @@ fun MainNavigation(
icon = when (tab) { icon = when (tab) {
Tab.Epoch -> TabIcon.Epoch Tab.Epoch -> TabIcon.Epoch
Tab.Plan -> TabIcon.Plan Tab.Plan -> TabIcon.Plan
Tab.Exercise -> TabIcon.Exercise
Tab.Profile -> TabIcon.Profile Tab.Profile -> TabIcon.Profile
}, },
onClick = { onClick = {
@@ -283,7 +298,7 @@ fun MainNavigation(
} }
} }
enum class TabIcon { Epoch, Plan, Profile } enum class TabIcon { Epoch, Plan, Exercise, Profile }
@Composable @Composable
private fun NavBarItem( 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 -> { TabIcon.Profile -> {
// 用户图标 - 代表个人中心 // 用户图标 - 代表个人中心

View File

@@ -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<ExerciseCheckin?>(null) }
var selectedDate by remember { mutableStateOf<String?>(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<String, Int>,
checkins: List<ExerciseCheckin>,
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<List<LocalDate?>>()
var currentWeekStart = adjustedStart
while (currentWeekStart <= quarterEndDate.plusDays(6)) {
val week = mutableListOf<LocalDate?>()
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<Uri?>(null) }
var uploadedImageUrl by remember { mutableStateOf("") }
var note by remember { mutableStateOf("") }
var isUploading by remember { mutableStateOf(false) }
var uploadError by remember { mutableStateOf<String?>(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)
}
}
}
}
}

View File

@@ -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)) Spacer(modifier = Modifier.height(40.dp))
// 年度进度卡片 // 年度进度卡片

View File

@@ -13,7 +13,8 @@ data class ProfileStats(
val yearLoss: Double = 0.0, val yearLoss: Double = 0.0,
val totalLoss: Double = 0.0, val totalLoss: Double = 0.0,
val daysRemaining: Int = 0, val daysRemaining: Int = 0,
val persistDays: Int = 0 val persistDays: Int = 0,
val exerciseDays: Int = 0 // 今年运动天数
) )
class EpochViewModel : ViewModel() { class EpochViewModel : ViewModel() {
@@ -43,6 +44,8 @@ class EpochViewModel : ViewModel() {
private val _profileStats = MutableStateFlow(ProfileStats()) private val _profileStats = MutableStateFlow(ProfileStats())
val profileStats: StateFlow<ProfileStats> = _profileStats val profileStats: StateFlow<ProfileStats> = _profileStats
private val _exerciseDays = MutableStateFlow(0)
private val _isLoading = MutableStateFlow(false) private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading val isLoading: StateFlow<Boolean> = _isLoading
@@ -150,10 +153,16 @@ class EpochViewModel : ViewModel() {
yearLoss = detail?.yearTotalLoss ?: 0.0, yearLoss = detail?.yearTotalLoss ?: 0.0,
totalLoss = detail?.allTimeTotalLoss ?: 0.0, totalLoss = detail?.allTimeTotalLoss ?: 0.0,
daysRemaining = daysRemaining.toInt(), daysRemaining = daysRemaining.toInt(),
persistDays = persistDays persistDays = persistDays,
exerciseDays = _exerciseDays.value
) )
} }
fun updateExerciseDays(days: Int) {
_exerciseDays.value = days
calculateProfileStats()
}
fun setActiveEpoch(epoch: WeightEpoch) { fun setActiveEpoch(epoch: WeightEpoch) {
_activeEpoch.value = epoch _activeEpoch.value = epoch
viewModelScope.launch { viewModelScope.launch {

View File

@@ -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<Map<String, Int>>(emptyMap())
val heatmapData: StateFlow<Map<String, Int>> = _heatmapData
private val _selectedYear = MutableStateFlow(LocalDate.now().year)
val selectedYear: StateFlow<Int> = _selectedYear
private val _selectedQuarter = MutableStateFlow((LocalDate.now().monthValue - 1) / 3 + 1)
val selectedQuarter: StateFlow<Int> = _selectedQuarter
private val _recentCheckins = MutableStateFlow<List<ExerciseCheckin>>(emptyList())
val recentCheckins: StateFlow<List<ExerciseCheckin>> = _recentCheckins
private val _stats = MutableStateFlow(ExerciseStats())
val stats: StateFlow<ExerciseStats> = _stats
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _isCheckinLoading = MutableStateFlow(false)
val isCheckinLoading: StateFlow<Boolean> = _isCheckinLoading
private val _todayCheckedIn = MutableStateFlow(false)
val todayCheckedIn: StateFlow<Boolean> = _todayCheckedIn
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _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
}
}

View File

@@ -642,9 +642,119 @@
</div> </div>
</div> </div>
<!-- 页面 7: 运动打卡 -->
<div id="exercise" class="screen">
<header>
<h1>运动</h1>
<p class="subtitle">记录你的运动轨迹</p>
</header>
<!-- 统计卡片 -->
<div style="display: flex; gap: 12px; margin-bottom: 24px;">
<div class="card" style="flex:1; margin-bottom:0; padding:16px; text-align:center;">
<span style="font-size:24px;">🏋️</span>
<span class="stat-value" style="display:block; margin-top:8px;">42</span>
<span class="stat-label">总打卡</span>
</div>
<div class="card" style="flex:1; margin-bottom:0; padding:16px; text-align:center;">
<span style="font-size:24px;">🔥</span>
<span class="stat-value" style="display:block; margin-top:8px; color:#f59e0b;">7</span>
<span class="stat-label">连续天数</span>
</div>
<div class="card" style="flex:1; margin-bottom:0; padding:16px; text-align:center;">
<span style="font-size:24px;">📅</span>
<span class="stat-value" style="display:block; margin-top:8px; color:var(--accent-green);">12</span>
<span class="stat-label">本月</span>
</div>
</div>
<!-- 季度选择器 -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<span style="font-size:20px; cursor:pointer;"></span>
<div style="text-align:center;">
<div style="font-size:12px; color:var(--text-secondary); font-weight:600;">2026年</div>
<div style="font-size:20px; font-weight:700;">1月 - 3月</div>
</div>
<span style="font-size:20px; cursor:pointer; color:var(--text-secondary);"></span>
</div>
<!-- GitHub 风格热力图 -->
<div class="card" style="padding:20px;">
<div style="display:flex; justify-content:space-between; margin-bottom:12px;">
<span style="font-size:12px; color:var(--text-secondary);">1月</span>
<span style="font-size:12px; color:var(--text-secondary);">2月</span>
<span style="font-size:12px; color:var(--text-secondary);">3月</span>
</div>
<div style="display:flex; gap:3px; justify-content:center; flex-wrap:wrap;">
<!-- 热力图格子 - 模拟数据 -->
<script>
// 生成热力图格子
const colors = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'];
for(let i = 0; i < 91; i++) {
const level = Math.random() > 0.6 ? Math.floor(Math.random() * 5) : 0;
document.write(`<div style="width:14px;height:14px;background:${colors[level]};border-radius:3px;"></div>`);
}
</script>
</div>
<div style="display:flex; justify-content:center; align-items:center; gap:8px; margin-top:16px;">
<span style="font-size:10px; color:var(--text-secondary);"></span>
<div style="width:10px;height:10px;background:#ebedf0;border-radius:2px;"></div>
<div style="width:10px;height:10px;background:#9be9a8;border-radius:2px;"></div>
<div style="width:10px;height:10px;background:#40c463;border-radius:2px;"></div>
<div style="width:10px;height:10px;background:#30a14e;border-radius:2px;"></div>
<div style="width:10px;height:10px;background:#216e39;border-radius:2px;"></div>
<span style="font-size:10px; color:var(--text-secondary);"></span>
</div>
</div>
<!-- 最近打卡记录 -->
<div style="margin-top:24px;">
<div style="display:flex; justify-content:space-between; margin-bottom:16px;">
<span style="font-weight:600;">最近打卡</span>
<span style="font-size:12px; color:var(--text-secondary);">共12条</span>
</div>
<div class="week-item">
<div style="display:flex; align-items:center; gap:12px;">
<div style="text-align:center; width:40px;">
<div style="font-size:12px; color:var(--text-secondary);">周五</div>
<div style="font-size:18px; font-weight:700;">2</div>
</div>
<div style="flex:1; background:#f8fafc; padding:12px; border-radius:12px;">
<div style="font-size:14px;">晨跑 5 公里 🏃</div>
<div style="font-size:12px; color:var(--text-secondary);">1月2日</div>
</div>
<div style="width:24px;height:24px;background:var(--accent-green);border-radius:50%;display:flex;align-items:center;justify-content:center;">
<span style="color:white;font-size:12px;"></span>
</div>
</div>
</div>
<div class="week-item">
<div style="display:flex; align-items:center; gap:12px;">
<div style="text-align:center; width:40px;">
<div style="font-size:12px; color:var(--text-secondary);">周四</div>
<div style="font-size:18px; font-weight:700;">1</div>
</div>
<div style="flex:1; background:#f8fafc; padding:12px; border-radius:12px;">
<div style="font-size:14px;">健身房力量训练 💪</div>
<div style="font-size:12px; color:var(--text-secondary);">1月1日</div>
</div>
<div style="width:24px;height:24px;background:var(--accent-green);border-radius:50%;display:flex;align-items:center;justify-content:center;">
<span style="color:white;font-size:12px;"></span>
</div>
</div>
</div>
</div>
<!-- 打卡按钮 -->
<div class="fab" style="background:var(--accent-green);" onclick="alert('打卡成功!')">+</div>
</div>
<nav class="bottom-nav"> <nav class="bottom-nav">
<div class="nav-item active" onclick="showScreen('home')"><span class="nav-icon"></span>纪元</div> <div class="nav-item active" onclick="showScreen('home')"><span class="nav-icon"></span>纪元</div>
<div class="nav-item" onclick="showScreen('plans')"><span class="nav-icon"></span>计划</div> <div class="nav-item" onclick="showScreen('plans')"><span class="nav-icon"></span>计划</div>
<div class="nav-item" onclick="showScreen('exercise')"><span class="nav-icon"></span>运动</div>
<div class="nav-item" onclick="showScreen('profile')"><span class="nav-icon"></span>我的</div> <div class="nav-item" onclick="showScreen('profile')"><span class="nav-icon"></span>我的</div>
</nav> </nav>
</div> </div>
@@ -659,8 +769,10 @@
document.querySelectorAll('.nav-item')[0].classList.add('active'); document.querySelectorAll('.nav-item')[0].classList.add('active');
} else if (screenId === 'plans' || screenId === 'week-detail' || screenId === 'epoch-detail') { } else if (screenId === 'plans' || screenId === 'week-detail' || screenId === 'epoch-detail') {
document.querySelectorAll('.nav-item')[1].classList.add('active'); document.querySelectorAll('.nav-item')[1].classList.add('active');
} else if (screenId === 'profile') { } else if (screenId === 'exercise') {
document.querySelectorAll('.nav-item')[2].classList.add('active'); document.querySelectorAll('.nav-item')[2].classList.add('active');
} else if (screenId === 'profile') {
document.querySelectorAll('.nav-item')[3].classList.add('active');
} }
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }

View File

@@ -257,7 +257,7 @@ if [ "$BUILD_APK" = true ]; then
# 获取更新日志 # 获取更新日志
echo "" echo ""
read -p "📝 请输入更新日志: " update_log read -p "📝 请输入更新日志 (回车使用默认): " update_log
if [ -z "$update_log" ]; then if [ -z "$update_log" ]; then
update_log="Bug 修复和性能优化" update_log="Bug 修复和性能优化"
@@ -267,54 +267,44 @@ if [ "$BUILD_APK" = true ]; then
echo "📋 发布信息:" echo "📋 发布信息:"
echo " 版本: v${new_version} (code: ${new_code})" echo " 版本: v${new_version} (code: ${new_code})"
echo " 更新日志: ${update_log}" echo " 更新日志: ${update_log}"
# 更新版本号
echo "" echo ""
read -p "确认发布 APK? (y/n) " confirm echo "📝 更新版本号..."
if [ "$confirm" != "y" ]; then update_version_in_gradle $new_version $new_code
echo "❌ APK 发布已取消"
BUILD_APK=false # 编译
else 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 ""
echo "📝 更新版本号..." echo "🎉 APK 发布完成!"
update_version_in_gradle $new_version $new_code echo " 版本: v${new_version}"
echo " 下载: ${download_url}"
# 编译 else
build_apk echo ""
echo "❌ 服务器版本更新失败APK 未上传"
# 先更新服务器版本信息,成功后再上传 APK exit 1
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
fi fi
fi fi
# 后端部署流程 # 后端部署流程
if [ "$DEPLOY_BACKEND" = true ]; then if [ "$DEPLOY_BACKEND" = true ]; then
echo "" echo ""
read -p "确认部署后端? (y/n) " deploy_confirm echo "🐳 开始部署后端..."
if [ "$deploy_confirm" = "y" ]; then build_docker
build_docker deploy_docker
deploy_docker echo ""
echo "" echo "🎉 后端部署完成!"
echo "🎉 后端部署完成!"
else
echo "❌ 后端部署已取消"
fi
fi fi
echo "" echo ""

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"os" "os"
"path/filepath" "path/filepath"
"strings"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -117,16 +118,102 @@ func migrate(db *sql.DB) error {
FOREIGN KEY (user_id) REFERENCES users(id) 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_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_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_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_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_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'); INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_register', 'true');
` `
_, err := db.Exec(schema) _, 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()
} }

View File

@@ -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": "删除成功"})
}

View File

@@ -66,7 +66,7 @@ func (h *UploadHandler) Upload(c *gin.Context) {
// 上传到 R2 // 上传到 R2
if h.s3Client != nil { if h.s3Client != nil {
key := "uploads/" + filename key := "memory/healthFlow/pic/" + filename
_, err := h.s3Client.PutObject(context.TODO(), &s3.PutObjectInput{ _, err := h.s3Client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(h.cfg.R2BucketName), Bucket: aws.String(h.cfg.R2BucketName),

View File

@@ -175,3 +175,34 @@ type RecordWeightRequest struct {
Week int `json:"week" binding:"required,min=1,max=53"` Week int `json:"week" binding:"required,min=1,max=53"`
Note string `json:"note"` 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"`
}

View File

@@ -39,6 +39,7 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
versionHandler := handler.NewVersionHandler(db, cfg) versionHandler := handler.NewVersionHandler(db, cfg)
weightHandler := handler.NewWeightHandler(db, cfg) weightHandler := handler.NewWeightHandler(db, cfg)
epochHandler := handler.NewEpochHandler(db, cfg) epochHandler := handler.NewEpochHandler(db, cfg)
exerciseHandler := handler.NewExerciseHandler(db, cfg)
// R2 文件代理 (公开访问) // R2 文件代理 (公开访问)
r.GET("/files/*filepath", uploadHandler.GetFile) 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.GET("/epoch/:id/plans", epochHandler.GetWeeklyPlans)
auth.PUT("/epoch/:id/plan/:planId", epochHandler.UpdateWeeklyPlan) 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) auth.POST("/upload", uploadHandler.Upload)