增加运动打卡功能
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 -> {
|
||||||
// 用户图标 - 代表个人中心
|
// 用户图标 - 代表个人中心
|
||||||
|
|||||||
@@ -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))
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
|
||||||
// 年度进度卡片
|
// 年度进度卡片
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
</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);
|
||||||
}
|
}
|
||||||
|
|||||||
72
release.sh
72
release.sh
@@ -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 ""
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
// 上传到 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),
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user