Etapp 5: stegsynk via Health Connect
All checks were successful
release / build-release (push) Successful in 4m30s

- StepsSync: läser dagens + gårdagens stegsumma (StepsRecord.COUNT_TOTAL)
  och upsertar till gym-API:t; synkas automatiskt (max var 10:e min) när
  hemskärmen visas
- Inställningar → Stegsynk: status, aktivera-flöde (HC-permission),
  Play-länk om Health Connect saknas, manuell synk
- Kcal-mätarens telefonrad visar nu riktiga steg + kcal (serverns
  beräkning med dubbelräkningsskydd); stegmål får riktig data
- connect-client 1.1.0-alpha07 (nyare kräver AGP 8.9/compileSdk 36 —
  toolchain-bump tas separat); HC-manifest (READ_STEPS, rationale-intents)
- Version 0.10.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-26 12:10:30 +02:00
parent 49da9190b6
commit 5f1d1eae4f
10 changed files with 218 additions and 10 deletions

View File

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

View File

@@ -2,6 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@@ -27,6 +31,9 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<!-- Stegsynk via Health Connect (etapp 5) -->
<uses-permission android:name="android.permission.health.READ_STEPS" />
<application
android:name=".FitnessDroidApplication"
android:label="@string/app_name"
@@ -57,6 +64,14 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Health Connect: visa varför appen vill läsa steg -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity>
</application>
</manifest>

View File

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

View File

@@ -223,6 +223,15 @@ class GymApi(
return data["goalsWithProgress"]!!.jsonArray.map { client.json.decodeFromJsonElement<GoalProgress>(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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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