diff --git a/README.md b/README.md index 819348c..462fb9f 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,17 @@ vid varje push till `main` och lägger den på den rullande `latest`-releasen. Eftersom Googles aapt2 bara finns för x86_64 använder bygget en statiskt byggd arm64-aapt2 via `android.aapt2FromMavenOverride`. +## Aktiviteter, kcal-mätare & mål + +Utöver gympass loggar appen **aktiviteter** (promenad, löpning, vandring, +fäktning/HEMA m.fl. — ~64 typer ur MET-kompendiet) med GPS-livespårning +(osmdroid/OSM-karta, fartgrind mot GPS-hopp, accelerometer-rörelsevakt), +RPE-slider för tidsbaserade aktiviteter och offline-kö. Hemskärmen visar +**dagens kalorier** (aktivt loggat / telefonens steg via Health Connect / +passiv BMR) och **mål** med progress (kcal, pass, steg, aktiviteter, km — +per dag/vecka/månad). Statistiken har en konditionssektion ❤️. +Plan & detaljer: [`doc/activities-plan.md`](doc/activities-plan.md). + ## Bluetooth-våg Appen kan hämta vägningar (vikt + muskel/fett/vatten-%) direkt från en diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 53551b1..aae2c91 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.10.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") + versionName = "0.11.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") } signingConfigs { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt index fc48556..c2c5e56 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt @@ -1,6 +1,8 @@ package eu.brassepc.fitnessdroid.ui.stats import androidx.compose.foundation.background +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -57,14 +59,43 @@ sealed interface StatsUiState { data class Ready(val stats: StatsBundle) : StatsUiState } -class StatsViewModel(private val repo: GymRepository) : ViewModel() { +class StatsViewModel( + private val repo: GymRepository, + private val gymApi: eu.brassepc.fitnessdroid.data.GymApi, +) : ViewModel() { val period = MutableStateFlow("month") val uiState = MutableStateFlow(StatsUiState.Loading) val bodyMeasurements = MutableStateFlow>(emptyList()) + val activities = MutableStateFlow>(emptyList()) + + private fun periodStart(): java.time.Instant? { + val now = java.time.Instant.now() + return when (period.value) { + "week" -> now.minus(java.time.Duration.ofDays(7)) + "month" -> now.minus(java.time.Duration.ofDays(30)) + "halfyear" -> now.minus(java.time.Duration.ofDays(182)) + "year" -> now.minus(java.time.Duration.ofDays(365)) + else -> null + } + } + + private fun refreshActivities() { + viewModelScope.launch { + runCatching { + val all = gymApi.activities(limit = 500) + val start = periodStart() + activities.value = if (start == null) all else all.filter { + runCatching { java.time.OffsetDateTime.parse(it.startedAt).toInstant() >= start } + .getOrDefault(false) + } + } + } + } init { load() refreshMeasurements() + refreshActivities() } fun refreshMeasurements() { @@ -77,6 +108,7 @@ class StatsViewModel(private val repo: GymRepository) : ViewModel() { if (period.value == id) return period.value = id load() + refreshActivities() } fun load() { @@ -92,7 +124,10 @@ class StatsViewModel(private val repo: GymRepository) : ViewModel() { companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { - initializer { StatsViewModel(appContainer().gymRepository) } + initializer { + val c = appContainer() + StatsViewModel(c.gymRepository, c.gymApi) + } } } } @@ -168,6 +203,11 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F } } + item { + val periodActivities by viewModel.activities.collectAsStateWithLifecycle() + CardioCard(periodActivities) + } + if (stats.trend.size > 1) { item { Card(modifier = Modifier.fillMaxWidth()) { @@ -511,3 +551,132 @@ private fun TrendBars(stats: StatsBundle) { } } } + + +/** Konditionssektionen ❤️ — aktiviteter i vald period: KPI, vecko-km och per typ. */ +@Composable +private fun CardioCard(activities: List) { + if (activities.isEmpty()) return + val totalKm = activities.sumOf { it.distanceMeters ?: 0.0 } / 1000.0 + val totalMin = activities.sumOf { it.durationSeconds } / 60 + val totalKcal = activities.sumOf { it.estimatedKcal ?: 0.0 } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.Icon( + Icons.Filled.Favorite, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + "Kondition & aktiviteter", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 8.dp), + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + CardioKpi("Aktiviteter", "${activities.size}", Modifier.weight(1f)) + CardioKpi("Distans", "%.1f km".format(totalKm), Modifier.weight(1f)) + CardioKpi("Tid", "${totalMin / 60}h ${totalMin % 60}m", Modifier.weight(1f)) + CardioKpi("Kcal", "${totalKcal.toInt()}", Modifier.weight(1f)) + } + + // Km per vecka (senaste 8 med data) + val weekly = activities + .mapNotNull { a -> + runCatching { + val d = java.time.OffsetDateTime.parse(a.startedAt) + .atZoneSameInstant(java.time.ZoneId.systemDefault()).toLocalDate() + val week = d.with(java.time.DayOfWeek.MONDAY) + week to (a.distanceMeters ?: 0.0) + }.getOrNull() + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, v) -> v.sum() / 1000.0 } + .toSortedMap() + .entries.toList().takeLast(8) + val maxKm = weekly.maxOfOrNull { it.value } ?: 0.0 + if (weekly.size > 1 && maxKm > 0) { + Text( + "Km per vecka", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + weekly.forEach { (week, km) -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "v${week.get(java.time.temporal.WeekFields.ISO.weekOfWeekBasedYear())}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(32.dp), + ) + androidx.compose.foundation.layout.Box( + modifier = Modifier + .weight(1f) + .height(14.dp), + ) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxWidth(fraction = (km / maxKm).toFloat().coerceIn(0.02f, 1f)) + .height(14.dp) + .background( + MaterialTheme.colorScheme.primary, + MaterialTheme.shapes.small, + ), + ) + } + Text( + "%.1f".format(km), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + + // Per typ + val byType = activities.groupBy { it.activityType.nameSv } + .entries.sortedByDescending { it.value.size } + Text( + "Per aktivitet", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + byType.take(8).forEach { (name, list) -> + Row(modifier = Modifier.fillMaxWidth()) { + Text( + name, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + val km = list.sumOf { it.distanceMeters ?: 0.0 } / 1000.0 + Text( + buildString { + append("${list.size} st") + if (km > 0.05) append(" · %.1f km".format(km)) + append(" · ${list.sumOf { it.durationSeconds } / 60} min") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun CardioKpi(label: String, value: String, modifier: Modifier = Modifier) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(value, style = MaterialTheme.typography.titleSmall) + } +} diff --git a/doc/activities-plan.md b/doc/activities-plan.md index f2d7f74..0ef5fc7 100644 --- a/doc/activities-plan.md +++ b/doc/activities-plan.md @@ -104,6 +104,6 @@ frontend av `src/**` m.fl., FitnessDroid av varje push till main.) - [x] Etapp 3 — Live-spårning (GPS) *(klar & fälttestad 2026-08-26: GPS-väntfas, providerlyssning gps/fused/network, fartgrind (Doppler), rörelsevakt (accelerometer), offline-kö, teknisk logg — GPS-filter justerbara i inställningarna)* - [x] Etapp 4 — Mål & mätare *(kod klar 2026-08-26: Goal-modell + goalsWithProgress i API:t, målsektion i webbens aktivitetsflik, målkort med progress-bars på appens hemskärm + målskärm; väntar på användartest)* - [x] Etapp 5 — Steg & telefondata *(kod klar 2026-08-26: DailySteps + upsert i API:t med telefon-kcal (0.00038 kcal/steg/kg, dubbelräkningsskydd mot gång/löp/vandringsaktiviteter), Health Connect-läsning i appen (connect-client 1.1.0-alpha07 — nyare kräver AGP 8.9/SDK 36), aktivering under Inställningar → Stegsynk; webben behövde ingen ändring. Väntar på användartest)* -- [ ] Etapp 6 — Statistik & polish +- [x] Etapp 6 — Statistik & polish *(kod klar 2026-08-26: konditionssektion ❤️ i både appens och webbens statistik (KPI, km/vecka, per typ), README uppdaterad. Google Fit-importens grund verifierad: source=imported, externalId-dedup, googleFitType-mappning i biblioteket och Health Connect-läsning på plats)* -Google Fit-importen byggs efter etapp 6 som separat arbete. +Google Fit-importen byggs som separat arbete — all grund finns: source=imported, externalId-dedup i addActivity, googleFitType-mappning per aktivitetstyp och Health Connect-integrationen i appen.