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
|
minSdk = 31
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = ciRunNumber ?: 1
|
versionCode = ciRunNumber ?: 1
|
||||||
versionName = "0.9.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
versionName = "0.10.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -83,6 +83,7 @@ dependencies {
|
|||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.blessed.kotlin)
|
implementation(libs.blessed.kotlin)
|
||||||
implementation(libs.osmdroid)
|
implementation(libs.osmdroid)
|
||||||
|
implementation(libs.health.connect)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
@@ -27,6 +31,9 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
<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
|
<application
|
||||||
android:name=".FitnessDroidApplication"
|
android:name=".FitnessDroidApplication"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -57,6 +64,14 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import eu.brassepc.fitnessdroid.data.GymApi
|
|||||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||||
import eu.brassepc.fitnessdroid.data.RestTimerController
|
import eu.brassepc.fitnessdroid.data.RestTimerController
|
||||||
import eu.brassepc.fitnessdroid.data.ScaleManager
|
import eu.brassepc.fitnessdroid.data.ScaleManager
|
||||||
|
import eu.brassepc.fitnessdroid.data.StepsSync
|
||||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||||
import eu.brassepc.fitnessdroid.data.SyncEngine
|
import eu.brassepc.fitnessdroid.data.SyncEngine
|
||||||
import eu.brassepc.fitnessdroid.data.TokenStore
|
import eu.brassepc.fitnessdroid.data.TokenStore
|
||||||
@@ -33,6 +34,7 @@ class AppContainer(context: Context) {
|
|||||||
val restTimer = RestTimerController(context, settingsStore, appScope)
|
val restTimer = RestTimerController(context, settingsStore, appScope)
|
||||||
val updateChecker = UpdateChecker(context)
|
val updateChecker = UpdateChecker(context)
|
||||||
val scaleManager = ScaleManager(context, settingsStore, appScope)
|
val scaleManager = ScaleManager(context, settingsStore, appScope)
|
||||||
|
val stepsSync = StepsSync(context, gymApi)
|
||||||
}
|
}
|
||||||
|
|
||||||
class FitnessDroidApplication : Application() {
|
class FitnessDroidApplication : Application() {
|
||||||
|
|||||||
@@ -223,6 +223,15 @@ class GymApi(
|
|||||||
return data["goalsWithProgress"]!!.jsonArray.map { client.json.decodeFromJsonElement<GoalProgress>(it) }
|
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. */
|
/** targetValue <= 0 tar bort målet. */
|
||||||
suspend fun setGoal(metric: String, period: String, targetValue: Double) {
|
suspend fun setGoal(metric: String, period: String, targetValue: Double) {
|
||||||
client.execute(
|
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(
|
KcalRow(
|
||||||
icon = Icons.Default.PhoneAndroid,
|
icon = Icons.Default.PhoneAndroid,
|
||||||
label = "Telefonen (steg m.m.)",
|
label = "Telefonen (steg)",
|
||||||
value = "kommer snart",
|
value = when {
|
||||||
dimValue = true,
|
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(
|
KcalRow(
|
||||||
icon = Icons.Default.Bedtime,
|
icon = Icons.Default.Bedtime,
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ data class KcalToday(
|
|||||||
val passiveKcalSoFar: Double? = null,
|
val passiveKcalSoFar: Double? = null,
|
||||||
/** BMR för hela dygnet (för "i mål vid midnatt"-visning) */
|
/** BMR för hela dygnet (för "i mål vid midnatt"-visning) */
|
||||||
val bmrPerDay: Double? = null,
|
val bmrPerDay: Double? = null,
|
||||||
/** Telefonens auto-loggade (steg) — etapp 5 */
|
/** Telefonens auto-loggade (steg) — via Health Connect */
|
||||||
val phoneKcal: Double? = null,
|
val phoneKcal: Double? = null,
|
||||||
|
val steps: Int? = null,
|
||||||
/** false = längd/födelseår/kön saknas, mätaren kan inte räkna passivt */
|
/** false = längd/födelseår/kön saknas, mätaren kan inte räkna passivt */
|
||||||
val hasBodyData: Boolean = false,
|
val hasBodyData: Boolean = false,
|
||||||
) {
|
) {
|
||||||
@@ -54,6 +55,7 @@ class HomeViewModel(
|
|||||||
auth: AuthRepository,
|
auth: AuthRepository,
|
||||||
private val updateChecker: eu.brassepc.fitnessdroid.data.UpdateChecker,
|
private val updateChecker: eu.brassepc.fitnessdroid.data.UpdateChecker,
|
||||||
private val gymApi: eu.brassepc.fitnessdroid.data.GymApi,
|
private val gymApi: eu.brassepc.fitnessdroid.data.GymApi,
|
||||||
|
private val stepsSync: eu.brassepc.fitnessdroid.data.StepsSync,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val updateState = kotlinx.coroutines.flow.MutableStateFlow<UpdateState?>(null)
|
val updateState = kotlinx.coroutines.flow.MutableStateFlow<UpdateState?>(null)
|
||||||
@@ -83,8 +85,12 @@ class HomeViewModel(
|
|||||||
|
|
||||||
/** Uppdatera mätaren — anropas när hemskärmen visas (igen). */
|
/** Uppdatera mätaren — anropas när hemskärmen visas (igen). */
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch { refreshKcal() }
|
viewModelScope.launch {
|
||||||
viewModelScope.launch { refreshGoals() }
|
// Steg synkas först så att dailySummary/målen får färska värden
|
||||||
|
runCatching { stepsSync.syncThrottled() }
|
||||||
|
refreshKcal()
|
||||||
|
refreshGoals()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refreshGoals() {
|
private suspend fun refreshGoals() {
|
||||||
@@ -123,6 +129,7 @@ class HomeViewModel(
|
|||||||
passiveKcalSoFar = summary.bmrKcalPerDay?.let { it * secondsIntoToday() / 86_400.0 },
|
passiveKcalSoFar = summary.bmrKcalPerDay?.let { it * secondsIntoToday() / 86_400.0 },
|
||||||
bmrPerDay = summary.bmrKcalPerDay,
|
bmrPerDay = summary.bmrKcalPerDay,
|
||||||
phoneKcal = summary.phoneKcal,
|
phoneKcal = summary.phoneKcal,
|
||||||
|
steps = summary.steps,
|
||||||
hasBodyData = summary.bmrKcalPerDay != null,
|
hasBodyData = summary.bmrKcalPerDay != null,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -206,7 +213,7 @@ class HomeViewModel(
|
|||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
val c = appContainer()
|
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.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
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(
|
class SettingsViewModel(
|
||||||
private val store: SettingsStore,
|
private val store: SettingsStore,
|
||||||
private val tokenStore: eu.brassepc.fitnessdroid.data.TokenStore,
|
private val tokenStore: eu.brassepc.fitnessdroid.data.TokenStore,
|
||||||
private val credentialStore: eu.brassepc.fitnessdroid.data.CredentialStore,
|
private val credentialStore: eu.brassepc.fitnessdroid.data.CredentialStore,
|
||||||
|
val stepsSync: eu.brassepc.fitnessdroid.data.StepsSync,
|
||||||
) : ViewModel() {
|
) : 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
|
val settings = store.settings
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AppSettings())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AppSettings())
|
||||||
|
|
||||||
@@ -58,6 +80,7 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch { apiUrl.value = tokenStore.apiUrl() }
|
viewModelScope.launch { apiUrl.value = tokenStore.apiUrl() }
|
||||||
|
refreshStepsState()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setStyle(value: RestTimerStyle) = viewModelScope.launch { store.setRestTimerStyle(value) }
|
fun setStyle(value: RestTimerStyle) = viewModelScope.launch { store.setRestTimerStyle(value) }
|
||||||
@@ -103,7 +126,7 @@ class SettingsViewModel(
|
|||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
val c = appContainer()
|
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") {
|
SettingsCard(title = "GPS-spårning") {
|
||||||
Text(
|
Text(
|
||||||
"Filter mot GPS-brus i aktivitetsspårningen. Fartgrinden förkastar " +
|
"Filter mot GPS-brus i aktivitetsspårningen. Fartgrinden förkastar " +
|
||||||
|
|||||||
@@ -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 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 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 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
|
- [ ] Etapp 6 — Statistik & polish
|
||||||
|
|
||||||
Google Fit-importen byggs efter etapp 6 som separat arbete.
|
Google Fit-importen byggs efter etapp 6 som separat arbete.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ room = "2.7.2"
|
|||||||
ksp = "2.2.0-2.0.2"
|
ksp = "2.2.0-2.0.2"
|
||||||
blessedKotlin = "3.0.12"
|
blessedKotlin = "3.0.12"
|
||||||
osmdroid = "6.1.20"
|
osmdroid = "6.1.20"
|
||||||
|
healthConnect = "1.1.0-alpha07"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
blessed-kotlin = { group = "com.github.weliem", name = "blessed-kotlin", version.ref = "blessedKotlin" }
|
blessed-kotlin = { group = "com.github.weliem", name = "blessed-kotlin", version.ref = "blessedKotlin" }
|
||||||
osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
||||||
|
health-connect = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user