增加运动打卡功能
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -85,4 +85,23 @@ interface ApiService {
|
||||
// Version
|
||||
@GET("version")
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
@@ -414,6 +429,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 -> {
|
||||
// 用户图标 - 代表个人中心
|
||||
val w = this.size.width
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
// 年度进度卡片
|
||||
|
||||
@@ -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> = _profileStats
|
||||
|
||||
private val _exerciseDays = MutableStateFlow(0)
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -642,9 +642,119 @@
|
||||
</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">
|
||||
<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('exercise')"><span class="nav-icon">◎</span>运动</div>
|
||||
<div class="nav-item" onclick="showScreen('profile')"><span class="nav-icon">△</span>我的</div>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
72
release.sh
72
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 ""
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
239
server/internal/handler/exercise.go
Normal file
239
server/internal/handler/exercise.go
Normal 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": "删除成功"})
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user