Etapp 1: kroppsdata synkas mot servern + BMR + dagens kcal-mätare på Hem
All checks were successful
release / build-release (push) Successful in 5m54s
All checks were successful
release / build-release (push) Successful in 5m54s
- Profile/myProfile med heightCm/birthYear/sex (fallback mot äldre server), updateProfile-mutation i GymApi - Kroppsdata sparas lokalt och synkas: serverns värden vinner, lokala pushas upp om servern är tom (migrerar vågens lokala data) - Mifflin–St Jeor-BMR (data/Bmr.kt) - Hemskärm: Dagens kalorier-kort — aktivt loggat (dagens pass), telefonen (platshållare för etapp 5) och passivt (BMR-andel av dygnet, tickar per minut). Uppmaning om kroppsdata saknas. - Version 0.5.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg
This commit is contained in:
20
app/src/main/java/eu/brassepc/fitnessdroid/data/Bmr.kt
Normal file
20
app/src/main/java/eu/brassepc/fitnessdroid/data/Bmr.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Basalmetabolism (kcal/dygn) enligt Mifflin–St Jeor:
|
||||
* 10·vikt + 6.25·längd − 5·ålder + 5 (man) / −161 (kvinna).
|
||||
* null om något av underlagen saknas.
|
||||
*/
|
||||
fun bmrKcalPerDay(
|
||||
weightKg: Double?,
|
||||
heightCm: Double?,
|
||||
birthYear: Int?,
|
||||
isFemale: Boolean?,
|
||||
): Double? {
|
||||
if (weightKg == null || heightCm == null || birthYear == null || isFemale == null) return null
|
||||
val age = (LocalDate.now().year - birthYear).coerceIn(0, 120)
|
||||
val k = if (isFemale) -161.0 else 5.0
|
||||
return 10.0 * weightKg + 6.25 * heightCm - 5.0 * age + k
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
@Serializable
|
||||
data class Profile(
|
||||
@@ -10,6 +12,10 @@ data class Profile(
|
||||
val displayName: String? = null,
|
||||
val email: String? = null,
|
||||
val bodyWeightKg: Double? = null,
|
||||
val heightCm: Double? = null,
|
||||
val birthYear: Int? = null,
|
||||
/** "male"/"female" — serverns konvention */
|
||||
val sex: String? = null,
|
||||
)
|
||||
|
||||
/** Autentiserade anrop mot gym-API:t. Query-strängarna speglar gymTrackerApi.js. */
|
||||
@@ -18,14 +24,37 @@ class GymApi(
|
||||
private val auth: AuthRepository,
|
||||
) {
|
||||
suspend fun myProfile(): Profile {
|
||||
val data = client.execute(MY_PROFILE, token = auth.bearerToken())
|
||||
// Fallback till gamla fältuppsättningen mot en äldre server.
|
||||
val data = try {
|
||||
client.execute(MY_PROFILE, token = auth.bearerToken())
|
||||
} catch (e: GraphQlException) {
|
||||
client.execute(MY_PROFILE_LEGACY, token = auth.bearerToken())
|
||||
}
|
||||
val profile = data["myProfile"]?.jsonObject
|
||||
?: throw GraphQlException(listOf("Kunde inte hämta profilen"))
|
||||
return client.json.decodeFromJsonElement(profile)
|
||||
}
|
||||
|
||||
/** Kroppsdata till servern. null = rör ej, -1/"" = rensa (serverns konvention). */
|
||||
suspend fun updateProfile(heightCm: Double?, birthYear: Int?, sex: String?): Profile {
|
||||
val data = client.execute(
|
||||
"mutation(\$h: Float,\$y: Int,\$s: String){updateProfile(heightCm:\$h,birthYear:\$y,sex:\$s){username displayName email bodyWeightKg heightCm birthYear sex}}",
|
||||
buildJsonObject {
|
||||
heightCm?.let { put("h", it) }
|
||||
birthYear?.let { put("y", it) }
|
||||
sex?.let { put("s", it) }
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val profile = data["updateProfile"]?.jsonObject
|
||||
?: throw GraphQlException(listOf("Kunde inte spara kroppsdata"))
|
||||
return client.json.decodeFromJsonElement(profile)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MY_PROFILE =
|
||||
"query{myProfile{username displayName email bodyWeightKg heightCm birthYear sex}}"
|
||||
private const val MY_PROFILE_LEGACY =
|
||||
"query{myProfile{username displayName email bodyWeightKg}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material.icons.filled.CloudDone
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.LocalFireDepartment
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.SystemUpdate
|
||||
@@ -43,6 +47,7 @@ fun HomeScreen(
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
|
||||
val displayName by viewModel.displayName.collectAsStateWithLifecycle()
|
||||
val kcalToday by viewModel.kcalToday.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -66,6 +71,8 @@ fun HomeScreen(
|
||||
SyncChip(status = uiState.syncStatus, pending = uiState.pendingCount)
|
||||
}
|
||||
|
||||
KcalTodayCard(kcalToday)
|
||||
|
||||
updateState?.let { update ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
@@ -197,6 +204,92 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Dagens kaloriförbränning — aktivt loggat / telefon / passivt (BMR). */
|
||||
@Composable
|
||||
private fun KcalTodayCard(kcal: KcalToday) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.LocalFireDepartment,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"Dagens kalorier",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f).padding(start = 8.dp),
|
||||
)
|
||||
Text(
|
||||
"${kcal.totalKcal.toInt()} kcal",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
KcalRow(
|
||||
icon = Icons.Default.FitnessCenter,
|
||||
label = "Aktivt loggat",
|
||||
value = "${kcal.activeKcal.toInt()} kcal",
|
||||
)
|
||||
KcalRow(
|
||||
icon = Icons.Default.PhoneAndroid,
|
||||
label = "Telefonen (steg m.m.)",
|
||||
value = "kommer snart",
|
||||
dimValue = true,
|
||||
)
|
||||
KcalRow(
|
||||
icon = Icons.Default.Bedtime,
|
||||
label = "Passivt (BMR)",
|
||||
value = kcal.passiveKcalSoFar?.let {
|
||||
"${it.toInt()} kcal · ${kcal.bmrPerDay?.toInt()} vid midnatt"
|
||||
} ?: "—",
|
||||
)
|
||||
|
||||
if (!kcal.hasBodyData) {
|
||||
Text(
|
||||
"Fyll i vikt + kroppsdata (längd/födelseår/kön) i profilen " +
|
||||
"så kan den passiva förbränningen räknas ut.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KcalRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
dimValue: Boolean = false,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (dimValue) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncChip(status: SyncStatus, pending: Int) {
|
||||
val (icon, text) = when {
|
||||
|
||||
@@ -30,10 +30,30 @@ sealed interface UpdateState {
|
||||
data class Failed(val message: String) : UpdateState
|
||||
}
|
||||
|
||||
/**
|
||||
* Dagens kaloriförbränning, tredelad. Telefondelen (steg/auto-aktiviteter via
|
||||
* Health Connect) kommer i en senare etapp och är null tills dess.
|
||||
*/
|
||||
data class KcalToday(
|
||||
/** Aktivt loggat: gympass (och framöver aktiviteter) */
|
||||
val activeKcal: Double = 0.0,
|
||||
/** Passivt: BMR-andel av dygnet som gått */
|
||||
val passiveKcalSoFar: Double? = null,
|
||||
/** BMR för hela dygnet (för "i mål vid midnatt"-visning) */
|
||||
val bmrPerDay: Double? = null,
|
||||
/** Telefonens auto-loggade (steg) — etapp 5 */
|
||||
val phoneKcal: Double? = null,
|
||||
/** false = längd/födelseår/kön saknas, mätaren kan inte räkna passivt */
|
||||
val hasBodyData: Boolean = false,
|
||||
) {
|
||||
val totalKcal: Double get() = activeKcal + (passiveKcalSoFar ?: 0.0) + (phoneKcal ?: 0.0)
|
||||
}
|
||||
|
||||
class HomeViewModel(
|
||||
private val repo: GymRepository,
|
||||
auth: AuthRepository,
|
||||
private val updateChecker: eu.brassepc.fitnessdroid.data.UpdateChecker,
|
||||
private val gymApi: eu.brassepc.fitnessdroid.data.GymApi,
|
||||
) : ViewModel() {
|
||||
|
||||
val updateState = kotlinx.coroutines.flow.MutableStateFlow<UpdateState?>(null)
|
||||
@@ -41,11 +61,61 @@ class HomeViewModel(
|
||||
/** Helnamn från profilen — trevligare hälsning än användarnamnet. */
|
||||
val displayName = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
||||
|
||||
val kcalToday = kotlinx.coroutines.flow.MutableStateFlow(KcalToday())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
displayName.value = repo.displayName()
|
||||
updateChecker.check()?.let { updateState.value = UpdateState.Available(it) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
refreshKcal()
|
||||
// Passivdelen tickar med tiden — räkna om lokalt varje minut
|
||||
// (nätverket rörs bara vid laddning).
|
||||
while (true) {
|
||||
kotlinx.coroutines.delay(60_000)
|
||||
tickPassive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun secondsIntoToday(): Long = java.time.Duration.between(
|
||||
java.time.LocalDate.now().atStartOfDay(), java.time.LocalDateTime.now()
|
||||
).seconds.coerceAtLeast(0)
|
||||
|
||||
private fun tickPassive() {
|
||||
val current = kcalToday.value
|
||||
kcalToday.value = current.copy(
|
||||
passiveKcalSoFar = current.bmrPerDay?.let { it * secondsIntoToday() / 86_400.0 },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun refreshKcal() {
|
||||
val today = java.time.LocalDate.now()
|
||||
val active = runCatching {
|
||||
repo.fetchHistory(limit = 15)
|
||||
.filter { it.date.take(10) == today.toString() }
|
||||
.sumOf { it.calories ?: 0.0 }
|
||||
}.getOrElse { kcalToday.value.activeKcal }
|
||||
|
||||
val (bmr, hasBodyData) = runCatching {
|
||||
val p = gymApi.myProfile()
|
||||
val bmr = eu.brassepc.fitnessdroid.data.bmrKcalPerDay(
|
||||
weightKg = p.bodyWeightKg,
|
||||
heightCm = p.heightCm,
|
||||
birthYear = p.birthYear,
|
||||
isFemale = p.sex?.let { it == "female" },
|
||||
)
|
||||
bmr to (bmr != null)
|
||||
}.getOrElse { kcalToday.value.bmrPerDay to kcalToday.value.hasBodyData }
|
||||
|
||||
kcalToday.value = KcalToday(
|
||||
activeKcal = active,
|
||||
passiveKcalSoFar = bmr?.let { it * secondsIntoToday() / 86_400.0 },
|
||||
bmrPerDay = bmr,
|
||||
phoneKcal = null,
|
||||
hasBodyData = hasBodyData,
|
||||
)
|
||||
}
|
||||
|
||||
fun startUpdate() {
|
||||
@@ -99,7 +169,7 @@ class HomeViewModel(
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
HomeViewModel(c.gymRepository, c.authRepository, c.updateChecker)
|
||||
HomeViewModel(c.gymRepository, c.authRepository, c.updateChecker, c.gymApi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import eu.brassepc.fitnessdroid.data.Profile
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.compact
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ProfileViewModel(
|
||||
@@ -60,15 +61,57 @@ class ProfileViewModel(
|
||||
val message = MutableStateFlow<String?>(null)
|
||||
val settings = settingsStore.settings
|
||||
|
||||
/** Kroppsdata sparas lokalt (för vågen/offline) och synkas till servern. */
|
||||
fun saveBodyData(heightCm: Double?, birthYear: Int?, isFemale: Boolean?) {
|
||||
viewModelScope.launch { settingsStore.setBodyData(heightCm, birthYear, isFemale) }
|
||||
viewModelScope.launch {
|
||||
settingsStore.setBodyData(heightCm, birthYear, isFemale)
|
||||
try {
|
||||
// Tomt fält = rensa på servern (-1/"" är serverns konvention)
|
||||
gymApi.updateProfile(
|
||||
heightCm = heightCm ?: -1.0,
|
||||
birthYear = birthYear ?: -1,
|
||||
sex = when (isFemale) {
|
||||
true -> "female"
|
||||
false -> "male"
|
||||
null -> ""
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
message.value = "Kroppsdata sparad lokalt — kunde inte nå servern"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Håll lokala kroppsdatan i synk med servern: serverns värden vinner,
|
||||
* men finns de bara lokalt (satta innan servern fick fälten) pushas de upp.
|
||||
*/
|
||||
private suspend fun syncBodyData(p: Profile) {
|
||||
val local = settingsStore.settings.first()
|
||||
val serverHas = p.heightCm != null || p.birthYear != null || p.sex != null
|
||||
val localHas = local.heightCm != null || local.birthYear != null || local.isFemale != null
|
||||
if (serverHas) {
|
||||
settingsStore.setBodyData(p.heightCm, p.birthYear, p.sex?.let { it == "female" })
|
||||
} else if (localHas) {
|
||||
runCatching {
|
||||
gymApi.updateProfile(
|
||||
heightCm = local.heightCm,
|
||||
birthYear = local.birthYear,
|
||||
sex = local.isFemale?.let { if (it) "female" else "male" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
runCatching { profile.value = gymApi.myProfile() }
|
||||
runCatching {
|
||||
val p = gymApi.myProfile()
|
||||
profile.value = p
|
||||
syncBodyData(p)
|
||||
}
|
||||
runCatching { measurements.value = repo.fetchBodyMeasurements().reversed() }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user