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