diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8f3dd23..53551b1 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.9.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") + versionName = "0.10.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") } signingConfigs { @@ -83,6 +83,7 @@ dependencies { implementation(libs.okhttp) implementation(libs.blessed.kotlin) implementation(libs.osmdroid) + implementation(libs.health.connect) implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 063a85d..e851a4d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + @@ -27,6 +31,9 @@ + + + + + + + + + + + diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt b/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt index ce7d489..a62893d 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt @@ -9,6 +9,7 @@ import eu.brassepc.fitnessdroid.data.GymApi import eu.brassepc.fitnessdroid.data.GymRepository import eu.brassepc.fitnessdroid.data.RestTimerController import eu.brassepc.fitnessdroid.data.ScaleManager +import eu.brassepc.fitnessdroid.data.StepsSync import eu.brassepc.fitnessdroid.data.SettingsStore import eu.brassepc.fitnessdroid.data.SyncEngine import eu.brassepc.fitnessdroid.data.TokenStore @@ -33,6 +34,7 @@ class AppContainer(context: Context) { val restTimer = RestTimerController(context, settingsStore, appScope) val updateChecker = UpdateChecker(context) val scaleManager = ScaleManager(context, settingsStore, appScope) + val stepsSync = StepsSync(context, gymApi) } class FitnessDroidApplication : Application() { 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 e738ed4..e4febd8 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt @@ -223,6 +223,15 @@ class GymApi( return data["goalsWithProgress"]!!.jsonArray.map { client.json.decodeFromJsonElement(it) } } + /** Synka upp dagens/gårdagens steg; [dayStartIso] = lokalt dygn 00:00 som instant. */ + suspend fun upsertDailySteps(dayStartIso: String, steps: Int) { + client.execute( + "mutation(\$d:DateTime!,\$s:Int!){upsertDailySteps(date:\$d,steps:\$s)}", + buildJsonObject { put("d", dayStartIso); put("s", steps) }, + auth.bearerToken(), + ) + } + /** targetValue <= 0 tar bort målet. */ suspend fun setGoal(metric: String, period: String, targetValue: Double) { client.execute( diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/StepsSync.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/StepsSync.kt new file mode 100644 index 0000000..8f5b2ee --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/StepsSync.kt @@ -0,0 +1,80 @@ +package eu.brassepc.fitnessdroid.data + +import android.content.Context +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.request.AggregateRequest +import androidx.health.connect.client.time.TimeRangeFilter +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Steg via Health Connect: läser dagens + gårdagens stegsumma (som andra + * appar/telefonen loggat) och synkar upp till gym-API:t (upsertDailySteps). + * Servern räknar sedan in stegen i kcal-mätarens telefondel och stegmålen. + */ +class StepsSync( + private val context: Context, + private val gymApi: GymApi, +) { + val stepsPermission: String = HealthPermission.getReadPermission(StepsRecord::class) + + private var lastSyncMs = 0L + + /** SDK_UNAVAILABLE / SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED / SDK_AVAILABLE */ + fun sdkStatus(): Int = HealthConnectClient.getSdkStatus(context) + + fun isAvailable(): Boolean = sdkStatus() == HealthConnectClient.SDK_AVAILABLE + + suspend fun hasPermission(): Boolean { + if (!isAvailable()) return false + return runCatching { + HealthConnectClient.getOrCreate(context) + .permissionController.getGrantedPermissions() + .contains(stepsPermission) + }.getOrDefault(false) + } + + /** + * Läs och synka. Returnerar dagens steg, eller null om HC saknas / + * behörighet inte getts. Fel sväljs (nästa synk försöker igen). + */ + suspend fun syncNow(): Int? { + if (!hasPermission()) return null + val client = HealthConnectClient.getOrCreate(context) + val zone = ZoneId.systemDefault() + val today = LocalDate.now() + var todaySteps: Int? = null + + for (day in listOf(today.minusDays(1), today)) { + val startInstant = day.atStartOfDay(zone).toInstant() + val endInstant = if (day == today) Instant.now() + else day.plusDays(1).atStartOfDay(zone).toInstant() + + val steps = runCatching { + val result = client.aggregate( + AggregateRequest( + metrics = setOf(StepsRecord.COUNT_TOTAL), + timeRangeFilter = TimeRangeFilter.between(startInstant, endInstant), + ) + ) + (result[StepsRecord.COUNT_TOTAL] ?: 0L).toInt() + }.getOrNull() ?: continue + + if (day == today) todaySteps = steps + runCatching { + gymApi.upsertDailySteps(startInstant.toString(), steps) + } + } + lastSyncMs = System.currentTimeMillis() + return todaySteps + } + + /** Synka högst var 10:e minut — anropas t.ex. när hemskärmen visas. */ + suspend fun syncThrottled() { + if (System.currentTimeMillis() - lastSyncMs < 10 * 60_000) return + syncNow() + } +} 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 cdfc405..1481416 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 @@ -346,9 +346,15 @@ private fun KcalTodayCard(kcal: KcalToday) { ) KcalRow( icon = Icons.Default.PhoneAndroid, - label = "Telefonen (steg m.m.)", - value = "kommer snart", - dimValue = true, + label = "Telefonen (steg)", + value = when { + kcal.phoneKcal != null -> + "${kcal.phoneKcal.toInt()} kcal" + + (kcal.steps?.let { " · $it steg" } ?: "") + kcal.steps != null -> "${kcal.steps} steg" + else -> "aktivera i inställningarna" + }, + dimValue = kcal.phoneKcal == null && kcal.steps == null, ) KcalRow( icon = Icons.Default.Bedtime, 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 2f267dd..3ca2191 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 @@ -41,8 +41,9 @@ data class KcalToday( 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 */ + /** Telefonens auto-loggade (steg) — via Health Connect */ val phoneKcal: Double? = null, + val steps: Int? = null, /** false = längd/födelseår/kön saknas, mätaren kan inte räkna passivt */ val hasBodyData: Boolean = false, ) { @@ -54,6 +55,7 @@ class HomeViewModel( auth: AuthRepository, private val updateChecker: eu.brassepc.fitnessdroid.data.UpdateChecker, private val gymApi: eu.brassepc.fitnessdroid.data.GymApi, + private val stepsSync: eu.brassepc.fitnessdroid.data.StepsSync, ) : ViewModel() { val updateState = kotlinx.coroutines.flow.MutableStateFlow(null) @@ -83,8 +85,12 @@ class HomeViewModel( /** Uppdatera mätaren — anropas när hemskärmen visas (igen). */ fun refresh() { - viewModelScope.launch { refreshKcal() } - viewModelScope.launch { refreshGoals() } + viewModelScope.launch { + // Steg synkas först så att dailySummary/målen får färska värden + runCatching { stepsSync.syncThrottled() } + refreshKcal() + refreshGoals() + } } private suspend fun refreshGoals() { @@ -123,6 +129,7 @@ class HomeViewModel( passiveKcalSoFar = summary.bmrKcalPerDay?.let { it * secondsIntoToday() / 86_400.0 }, bmrPerDay = summary.bmrKcalPerDay, phoneKcal = summary.phoneKcal, + steps = summary.steps, hasBodyData = summary.bmrKcalPerDay != null, ) return @@ -206,7 +213,7 @@ class HomeViewModel( val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val c = appContainer() - HomeViewModel(c.gymRepository, c.authRepository, c.updateChecker, c.gymApi) + HomeViewModel(c.gymRepository, c.authRepository, c.updateChecker, c.gymApi, c.stepsSync) } } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt index 5789d6d..009a26e 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt @@ -46,11 +46,33 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +data class StepsSyncState( + val available: Boolean = false, + val granted: Boolean = false, + val todaySteps: Int? = null, + val syncing: Boolean = false, +) + class SettingsViewModel( private val store: SettingsStore, private val tokenStore: eu.brassepc.fitnessdroid.data.TokenStore, private val credentialStore: eu.brassepc.fitnessdroid.data.CredentialStore, + val stepsSync: eu.brassepc.fitnessdroid.data.StepsSync, ) : ViewModel() { + val stepsState = kotlinx.coroutines.flow.MutableStateFlow(StepsSyncState()) + + fun refreshStepsState() = viewModelScope.launch { + stepsState.value = stepsState.value.copy( + available = stepsSync.isAvailable(), + granted = stepsSync.hasPermission(), + ) + } + + fun syncStepsNow() = viewModelScope.launch { + stepsState.value = stepsState.value.copy(syncing = true) + val steps = runCatching { stepsSync.syncNow() }.getOrNull() + stepsState.value = stepsState.value.copy(syncing = false, todaySteps = steps) + } val settings = store.settings .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AppSettings()) @@ -58,6 +80,7 @@ class SettingsViewModel( init { viewModelScope.launch { apiUrl.value = tokenStore.apiUrl() } + refreshStepsState() } fun setStyle(value: RestTimerStyle) = viewModelScope.launch { store.setRestTimerStyle(value) } @@ -103,7 +126,7 @@ class SettingsViewModel( val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val c = appContainer() - SettingsViewModel(c.settingsStore, c.tokenStore, c.credentialStore) + SettingsViewModel(c.settingsStore, c.tokenStore, c.credentialStore, c.stepsSync) } } } @@ -161,6 +184,69 @@ fun SettingsScreen( } } + SettingsCard(title = "Stegsynk (Health Connect)") { + val stepsState by viewModel.stepsState.collectAsStateWithLifecycle() + val context = androidx.compose.ui.platform.LocalContext.current + val hcLauncher = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.health.connect.client.PermissionController + .createRequestPermissionResultContract() + ) { _ -> viewModel.refreshStepsState() } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Läser dagens steg från telefonen (Health Connect) och räknar in " + + "dem i kalorimätaren och stegmålen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + when { + !stepsState.available -> { + Text( + "Health Connect saknas på telefonen.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + FilledTonalButton( + onClick = { + runCatching { + context.startActivity( + android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse("market://details?id=com.google.android.apps.healthdata"), + ) + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Installera Health Connect") } + } + !stepsState.granted -> { + FilledTonalButton( + onClick = { hcLauncher.launch(setOf(viewModel.stepsSync.stepsPermission)) }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Aktivera stegläsning") } + } + else -> { + Text( + "Aktiv ✓" + (stepsState.todaySteps?.let { " · idag: $it steg" } ?: ""), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + ) + FilledTonalButton( + onClick = viewModel::syncStepsNow, + enabled = !stepsState.syncing, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (stepsState.syncing) "Synkar…" else "Synka nu") } + } + } + } + } + SettingsCard(title = "GPS-spårning") { Text( "Filter mot GPS-brus i aktivitetsspårningen. Fartgrinden förkastar " + diff --git a/doc/activities-plan.md b/doc/activities-plan.md index 3521f38..f2d7f74 100644 --- a/doc/activities-plan.md +++ b/doc/activities-plan.md @@ -103,7 +103,7 @@ frontend av `src/**` m.fl., FitnessDroid av varje push till main.) - [x] Etapp 2 — Aktiviteter: bibliotek + manuell logg *(kod klar 2026-08-23: backend 9a81cf6, webb — aktivitetsflik, app 0.6.0; väntar på användartest)* - [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)* -- [ ] Etapp 5 — Steg & telefondata +- [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 Google Fit-importen byggs efter etapp 6 som separat arbete. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c9a272..189e5a5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ room = "2.7.2" ksp = "2.2.0-2.0.2" blessedKotlin = "3.0.12" osmdroid = "6.1.20" +healthConnect = "1.1.0-alpha07" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -35,6 +36,7 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } blessed-kotlin = { group = "com.github.weliem", name = "blessed-kotlin", version.ref = "blessedKotlin" } osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } +health-connect = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }