Etapp 1: kroppsdata synkas mot servern + BMR + dagens kcal-mätare på Hem
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:
2026-08-23 17:20:39 +02:00
parent 9784b749aa
commit 06b2f0ec86
7 changed files with 261 additions and 6 deletions

View File

@@ -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 {

View File

@@ -0,0 +1,20 @@
package eu.brassepc.fitnessdroid.data
import java.time.LocalDate
/**
* Basalmetabolism (kcal/dygn) enligt MifflinSt 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
}

View File

@@ -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}}"
}
}

View File

@@ -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 {

View File

@@ -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)
}
}
}

View File

@@ -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() }
}
}