Etapp 5: stegsynk via Health Connect
All checks were successful
release / build-release (push) Successful in 4m30s
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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
80
app/src/main/java/eu/brassepc/fitnessdroid/data/StepsSync.kt
Normal file
80
app/src/main/java/eu/brassepc/fitnessdroid/data/StepsSync.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
Reference in New Issue
Block a user