diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f09ad7a..cac4cd8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,7 +29,7 @@ android { minSdk = 31 targetSdk = 35 versionCode = ciRunNumber ?: 1 - versionName = "0.4.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") + versionName = "0.5.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") } signingConfigs { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/Bmr.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/Bmr.kt new file mode 100644 index 0000000..68e8fc0 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/Bmr.kt @@ -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 +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt index 31316b0..da0abad 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt @@ -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}}" } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt index 1674013..fc2967a 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt @@ -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 { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt index aa38662..13d9b12 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt @@ -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(null) @@ -41,11 +61,61 @@ class HomeViewModel( /** Helnamn från profilen — trevligare hälsning än användarnamnet. */ val displayName = kotlinx.coroutines.flow.MutableStateFlow(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) } } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt index b5f05dc..21d2a31 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt @@ -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(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() } } } diff --git a/doc/activities-plan.md b/doc/activities-plan.md index 48659aa..928c2be 100644 --- a/doc/activities-plan.md +++ b/doc/activities-plan.md @@ -99,7 +99,7 @@ frontend av `src/**` m.fl., FitnessDroid av varje push till main.) ## Status -- [ ] Etapp 1 — Profil & kalorigrund +- [x] Etapp 1 — Profil & kalorigrund *(kod klar 2026-08-23: backend a1cd4d3, webb 560894c, app — väntar på användartest + omdeploy av gym-api/frontend-containrarna)* - [ ] Etapp 2 — Aktiviteter: bibliotek + manuell logg - [ ] Etapp 3 — Live-spårning (GPS) - [ ] Etapp 4 — Mål & mätare