From d2a37f21de5dce4aff2537b01befd77c58186375 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 27 Aug 2026 07:14:23 +0200 Subject: [PATCH] =?UTF-8?q?Sp=C3=A5rning=202.0:=20spikfilter,=20pausad=20s?= =?UTF-8?q?tart=20med=20play/nedr=C3=A4kning,=20paus,=20steg,=20historikka?= =?UTF-8?q?rta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Baserat på analys av verklig GPS-data (25 av 180 segment var ut-och-tillbaka- spikar = 527 av 1090 m fejkdistans; filtret simulerat på datan: 9,1 → 5,4 km/h = realistisk gångfart): - 1 s-sampling + lag-buffrat spikfilter: en punkt committas först när nästa setts; triangel-kollapser (ut ≥4 m och direkt tillbaka) slängs - Douglas-Peucker-nedbantning (4 m) före sparning — 182 → 16 punkter i testet - Start i READY (pausad): play → inställbar nedräkning (0–15 s, standard 3) → aktiv; paus/fortsätt med bruten positionskedja så pausförflyttning inte räknas; tid ackumuleras bara i aktiv fas - Stegräkning via TYPE_STEP_COUNTER under aktiv tid (ACTIVITY_RECOGNITION- behörighet; steg sparas på aktiviteten och räknas i dagsmätaren/målen när Health Connect saknas) - Historikkarta: 🗺-knapp på spårade aktiviteter → OSM-karta med rutten - Version 0.12.0 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg --- app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 2 + .../fitnessdroid/data/ActivityKcal.kt | 69 +++ .../eu/brassepc/fitnessdroid/data/GymApi.kt | 6 +- .../fitnessdroid/data/SettingsStore.kt | 8 + .../fitnessdroid/data/TrackingService.kt | 473 +++++++++++------- .../ui/activities/ActivitiesScreen.kt | 76 ++- .../ui/settings/SettingsScreen.kt | 9 + .../fitnessdroid/ui/track/TrackScreen.kt | 107 +++- 9 files changed, 550 insertions(+), 202 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aae2c91..47d37ba 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.11.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") + versionName = "0.12.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") } signingConfigs { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e851a4d..f4f9920 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,8 @@ + + > { + val points = mutableListOf>() + var index = 0 + var lat = 0L + var lon = 0L + while (index < encoded.length) { + for (which in 0..1) { + var result = 0L + var shift = 0 + while (true) { + val b = (encoded[index].code - 63).toLong() + index++ + result = result or ((b and 0x1f) shl shift) + shift += 5 + if (b < 0x20) break + } + val delta = if ((result and 1L) != 0L) (result shr 1).inv() else (result shr 1) + if (which == 0) lat += delta else lon += delta + } + points.add(lat / 1e5 to lon / 1e5) + } + return points +} + +/** + * Douglas–Peucker-nedbantning av en rutt: tar bort punkter som ligger inom + * [epsilonMeters] från linjen mellan sina grannar. Kraftigt färre punkter i + * databasen utan att ruttens form ändras nämnvärt. + */ +fun simplifyRoute(points: List>, epsilonMeters: Double = 4.0): List> { + if (points.size < 3) return points + val lat0 = Math.toRadians(points[0].first) + // approximativ projektion till meter (räcker gott för korta rutter) + fun xy(p: Pair) = (p.second * 111_320 * Math.cos(lat0)) to (p.first * 110_540) + val proj = points.map { xy(it) } + val keep = BooleanArray(points.size) + keep[0] = true + keep[points.size - 1] = true + + val stack = ArrayDeque>() + stack.addLast(0 to points.size - 1) + while (stack.isNotEmpty()) { + val (a, b) = stack.removeLast() + val (ax, ay) = proj[a] + val (bx, by) = proj[b] + val dx = bx - ax + val dy = by - ay + val norm = Math.hypot(dx, dy).coerceAtLeast(1e-9) + var worst = -1 + var worstDist = 0.0 + for (i in a + 1 until b) { + val (px, py) = proj[i] + val d = Math.abs(dy * (px - ax) - dx * (py - ay)) / norm + if (d > worstDist) { + worstDist = d + worst = i + } + } + if (worstDist > epsilonMeters && worst > 0) { + keep[worst] = true + stack.addLast(a to worst) + stack.addLast(worst to b) + } + } + return points.filterIndexed { i, _ -> keep[i] } +} 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 e4febd8..5006b4b 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt @@ -31,6 +31,8 @@ data class Activity( val rpe: Double? = null, val source: String = "manual", val notes: String? = null, + val steps: Int? = null, + val routePolyline: String? = null, val activityType: ActivityType, ) @@ -135,6 +137,7 @@ class GymApi( source: String? = null, routePolyline: String? = null, notes: String? = null, + steps: Int? = null, ): Activity { val data = client.execute( "mutation(\$input: AddActivityInput!){addActivity(input:\$input){$ACTIVITY_FIELDS}}", @@ -146,6 +149,7 @@ class GymApi( distanceMeters?.let { put("distanceMeters", it) } elevationGainMeters?.let { put("elevationGainMeters", it) } rpe?.let { put("rpe", it) } + steps?.let { put("steps", it) } estimatedKcal?.let { put("estimatedKcal", it) } source?.let { put("source", it) } routePolyline?.let { put("routePolyline", it) } @@ -243,7 +247,7 @@ class GymApi( companion object { private const val ACTIVITY_FIELDS = - "id startedAt durationSeconds distanceMeters elevationGainMeters estimatedKcal rpe source notes " + + "id startedAt durationSeconds distanceMeters elevationGainMeters estimatedKcal rpe source notes steps routePolyline " + "activityType{id key nameSv nameEn met category isDistanceBased isCardio iconKey}" private const val MY_PROFILE = "query{myProfile{username displayName email bodyWeightKg heightCm birthYear sex}}" diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt index 530b8ab..ca42ef6 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt @@ -44,6 +44,8 @@ data class AppSettings( val motionGuardEnabled: Boolean = true, /** Rörelsevaktens känslighet (m/s² avvikelse från vila; lägre = känsligare) */ val motionThreshold: Double = 0.35, + /** Nedräkning (sekunder) när man trycker play på en aktivitet */ + val countdownSeconds: Int = 3, ) class SettingsStore(private val context: Context) { @@ -76,9 +78,14 @@ class SettingsStore(private val context: Context) { gpsSpeedFloorKmh = prefs[KEY_GPS_FLOOR] ?: 12, motionGuardEnabled = prefs[KEY_MOTION_GUARD]?.toBooleanStrictOrNull() ?: true, motionThreshold = prefs[KEY_MOTION_THRESHOLD]?.toDoubleOrNull() ?: 0.35, + countdownSeconds = prefs[KEY_COUNTDOWN] ?: 3, ) } + suspend fun setCountdownSeconds(value: Int) { + context.settingsDataStore.edit { it[KEY_COUNTDOWN] = value.coerceIn(0, 15) } + } + suspend fun setMotionGuardEnabled(value: Boolean) { context.settingsDataStore.edit { it[KEY_MOTION_GUARD] = value.toString() } } @@ -182,5 +189,6 @@ class SettingsStore(private val context: Context) { private val KEY_GPS_FLOOR = intPreferencesKey("gps_speed_floor_kmh") private val KEY_MOTION_GUARD = stringPreferencesKey("motion_guard_enabled") private val KEY_MOTION_THRESHOLD = stringPreferencesKey("motion_threshold") + private val KEY_COUNTDOWN = intPreferencesKey("countdown_seconds") } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt index c8f3c54..96ad82a 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt @@ -32,13 +32,18 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -/** Spårningens fas: söker GPS-fix innan klockan startar, sen aktiv. */ -enum class TrackingPhase { IDLE, SEARCHING_GPS, ACTIVE } +/** + * Spårningens faser: startar i READY (pausad — GPS värmer upp), play startar + * en nedräkning (inställbar) och sen ACTIVE. Kan pausas/återupptas. + */ +enum class TrackingPhase { IDLE, READY, COUNTDOWN, ACTIVE, PAUSED } /** Läget för en pågående (eller nyss avslutad) aktivitetsspårning. */ data class TrackingState( val isActive: Boolean = false, val phase: TrackingPhase = TrackingPhase.IDLE, + /** Sekunder kvar av nedräkningen (fas COUNTDOWN) */ + val countdownLeft: Int = 0, /** Aktivitetstypen som spåras */ val typeId: Int = 0, val typeKey: String = "", @@ -50,7 +55,9 @@ data class TrackingState( val elapsedSeconds: Int = 0, val distanceMeters: Double = 0.0, val elevationGainMeters: Double = 0.0, - /** Senaste GPS-position (lat, lon) + hela spåret */ + /** Steg räknade av telefonens sensor under aktiv tid (null = sensor saknas/nekad) */ + val steps: Int? = null, + /** Committade spårpunkter (spikfiltrerade) */ val points: List> = emptyList(), /** Senast kända position oavsett kvalitet — för kartcentrering/markör */ val currentPosition: Pair? = null, @@ -63,36 +70,48 @@ data class TrackingState( /** * Foreground-service som spårar en aktivitet: GPS-rutt, distans, höjdmeter, - * tid och live-kcal. För icke-distansaktiviteter (fäktning m.m.) körs bara - * timern — ingen GPS. UI:t läser [state]; servicen äger sanningen så - * spårningen överlever att appen swipas bort från recents. + * steg, tid och live-kcal. Positionspipeline: fartgrind (Doppler) → + * rörelsevakt (accelerometer) → lag-buffrat spikfilter (en punkt committas + * först när nästa setts; "ut-och-tillbaka"-hopp slängs) → stillastående-filter. */ class TrackingService : Service(), LocationListener, SensorEventListener { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var locationManager: LocationManager? = null + private var sensorManager: SensorManager? = null private var weightKg: Double? = null - private var activeSinceElapsed = 0L - private var lastLocation: Location? = null - // GPS-filter (justerbara i inställningarna, skickas med vid start) + // GPS-filter (justerbara i inställningarna) private var accuracyLimitM = 35f private var speedFactor = 2.0 private var speedFloorMs = 12.0 / 3.6 - // Glidande Doppler-fart (m/s) — GPS:ens egen fartmätning, stabilare än positionerna - private var dopplerEmaMs = 0.0 - private var rejectStreak = 0 - // Rörelsevakt (accelerometer): står man stilla fryses spår/distans/höjd - private var sensorManager: SensorManager? = null private var motionGuard = true private var motionThreshold = 0.35 - private val motionWindow = ArrayDeque() - private var isMoving = true - private var lastMotionLogged = true - // Glidande medel av höjd för att filtrera GPS-brus innan höjdmeter summeras + private var countdownSeconds = 3 + + // Tidräkning: ackumulerad aktiv tid + när nuvarande aktiva stint började + private var accumulatedActiveMs = 0L + private var activeSinceElapsed = 0L + + // Positionspipeline + private var lastCommitted: Location? = null + private var candidate: Location? = null + private var dopplerEmaMs = 0.0 + private var rejectStreak = 0 + + // Höjd (glidande medel mot GPS-brus) private val altitudeWindow = ArrayDeque() private var smoothedAltitude: Double? = null + // Rörelsevakt + private val motionWindow = ArrayDeque() + private var isMoving = true + + // Steg (TYPE_STEP_COUNTER är kumulativ sedan boot) + private var stepSensorAvailable = false + private var stepCounterLast: Long = -1 + private var stepsDuringActivity = 0L + override fun onCreate() { super.onCreate() createChannel() @@ -104,8 +123,12 @@ class TrackingService : Service(), LocationListener, SensorEventListener { stopTracking() return START_NOT_STICKY } - ACTION_START_NOW -> { - activate("starta ändå") + ACTION_PLAY -> { + play() + return START_STICKY + } + ACTION_PAUSE -> { + pause() return START_STICKY } else -> { @@ -124,15 +147,24 @@ class TrackingService : Service(), LocationListener, SensorEventListener { speedFloorMs = intent.getIntExtra(EXTRA_GPS_SPEED_FLOOR, 12) / 3.6 motionGuard = intent.getBooleanExtra(EXTRA_MOTION_GUARD, true) motionThreshold = intent.getDoubleExtra(EXTRA_MOTION_THRESHOLD, 0.35) + countdownSeconds = intent.getIntExtra(EXTRA_COUNTDOWN, 3) + + accumulatedActiveMs = 0L + activeSinceElapsed = 0L + lastCommitted = null + candidate = null dopplerEmaMs = 0.0 rejectStreak = 0 + altitudeWindow.clear() + smoothedAltitude = null motionWindow.clear() isMoving = true - lastMotionLogged = true + stepCounterLast = -1 + stepsDuringActivity = 0 _state.value = TrackingState( isActive = true, - phase = if (distanceBased) TrackingPhase.SEARCHING_GPS else TrackingPhase.ACTIVE, + phase = TrackingPhase.READY, typeId = intent.getIntExtra(EXTRA_TYPE_ID, 0), typeKey = intent.getStringExtra(EXTRA_TYPE_KEY) ?: "", typeName = intent.getStringExtra(EXTRA_TYPE_NAME) ?: "Aktivitet", @@ -142,62 +174,111 @@ class TrackingService : Service(), LocationListener, SensorEventListener { startedAtEpochMs = System.currentTimeMillis(), ) TrackLog.clear() - TrackLog.log( - "start: ${_state.value.typeName} (distans=$distanceBased, vikt=${weightKg ?: "?"} kg)" + - if (distanceBased) " — väntar på GPS-fix" else "" - ) + TrackLog.log("start: ${_state.value.typeName} (distans=$distanceBased, vikt=${weightKg ?: "?"} kg) — READY, tryck play") if (distanceBased) { TrackLog.log( "filter: acc≤${accuracyLimitM.toInt()} m, fartgrind ${speedFactor}× Doppler " + - "(golv ${"%.0f".format(speedFloorMs * 3.6)} km/h)" + "(golv ${"%.0f".format(speedFloorMs * 3.6)} km/h), spikfilter på, nedräkning ${countdownSeconds}s" ) } - val type = if (distanceBased) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION - } else { - // Timer-läge behöver ingen plats, men servicetypen är deklarerad som location - ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION - } - startForeground(NOTIFICATION_ID, buildNotification(), type) + startForeground( + NOTIFICATION_ID, buildNotification(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION, + ) if (distanceBased) { startGps() - if (motionGuard) startMotionGuard() + startSensors() } - // Tick: tid + kcal + notis. Klockan räknar från att fasen blev ACTIVE. + // Tick: nedräkning, aktiv tid, kcal, notis scope.launch { while (_state.value.isActive) { val s = _state.value - if (s.phase != TrackingPhase.ACTIVE) { - delay(500) - continue - } - if (activeSinceElapsed == 0L) activeSinceElapsed = SystemClock.elapsedRealtime() - val elapsed = ((SystemClock.elapsedRealtime() - activeSinceElapsed) / 1000).toInt() - _state.value = s.copy( - elapsedSeconds = elapsed, - kcal = ActivityKcal.estimate( - met = s.met, - category = s.category, - isDistanceBased = s.isDistanceBased, - weightKg = weightKg, - durationSeconds = elapsed, - distanceMeters = s.distanceMeters.takeIf { it > 0 }, - elevationGainMeters = s.elevationGainMeters.takeIf { it > 0 }, - rpe = null, - ), - ) - if (elapsed % 10 == 0) { - getSystemService(NotificationManager::class.java) - .notify(NOTIFICATION_ID, buildNotification()) + when (s.phase) { + TrackingPhase.COUNTDOWN -> { + val left = s.countdownLeft - 1 + if (left <= 0) { + goActive() + } else { + _state.value = _state.value.copy(countdownLeft = left) + } + } + TrackingPhase.ACTIVE -> { + val elapsed = currentElapsedSeconds() + _state.value = _state.value.copy( + elapsedSeconds = elapsed, + steps = if (stepSensorAvailable) stepsDuringActivity.toInt() else _state.value.steps, + kcal = ActivityKcal.estimate( + met = s.met, + category = s.category, + isDistanceBased = s.isDistanceBased, + weightKg = weightKg, + durationSeconds = elapsed, + distanceMeters = s.distanceMeters.takeIf { it > 0 }, + elevationGainMeters = s.elevationGainMeters.takeIf { it > 0 }, + rpe = null, + ), + ) + if (elapsed % 10 == 0) { + getSystemService(NotificationManager::class.java) + .notify(NOTIFICATION_ID, buildNotification()) + } + } + else -> Unit } delay(1000) } } } + private fun currentElapsedSeconds(): Int { + val extra = if (_state.value.phase == TrackingPhase.ACTIVE && activeSinceElapsed > 0) { + SystemClock.elapsedRealtime() - activeSinceElapsed + } else 0L + return ((accumulatedActiveMs + extra) / 1000).toInt() + } + + private fun play() { + val s = _state.value + if (!s.isActive || s.phase == TrackingPhase.ACTIVE || s.phase == TrackingPhase.COUNTDOWN) return + if (countdownSeconds > 0) { + TrackLog.log("play → nedräkning ${countdownSeconds}s") + _state.value = s.copy(phase = TrackingPhase.COUNTDOWN, countdownLeft = countdownSeconds) + } else { + goActive() + } + } + + private fun goActive() { + activeSinceElapsed = SystemClock.elapsedRealtime() + stepCounterLast = -1 // ny baslinje vid nästa sensoravläsning + TrackLog.log("AKTIV — klockan går") + _state.value = _state.value.copy( + phase = TrackingPhase.ACTIVE, + countdownLeft = 0, + startedAtEpochMs = if (accumulatedActiveMs == 0L) System.currentTimeMillis() + else _state.value.startedAtEpochMs, + ) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification()) + } + + private fun pause() { + val s = _state.value + if (!s.isActive || s.phase != TrackingPhase.ACTIVE) return + accumulatedActiveMs += SystemClock.elapsedRealtime() - activeSinceElapsed + activeSinceElapsed = 0L + // Positionskedjan bryts så pausvandring inte räknas vid återupptag + lastCommitted = null + candidate = null + TrackLog.log("pausad vid ${currentElapsedSeconds()}s, ${"%.0f".format(s.distanceMeters)} m") + _state.value = s.copy(phase = TrackingPhase.PAUSED, elapsedSeconds = (accumulatedActiveMs / 1000).toInt()) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification()) + } + + /* ---------- GPS ---------- */ + private fun startGps() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED @@ -208,8 +289,6 @@ class TrackingService : Service(), LocationListener, SensorEventListener { val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager locationManager = lm - // Billiga telefoner levererar ofta bara via fused/network — lyssna på - // allt som finns och filtrera på noggrannhet istället för provider. val providers = listOf( LocationManager.GPS_PROVIDER, LocationManager.FUSED_PROVIDER, @@ -221,94 +300,49 @@ class TrackingService : Service(), LocationListener, SensorEventListener { TrackLog.log("provider $provider: ${if (!exists) "saknas" else if (enabled) "på" else "AVSTÄNGD"}") if (!enabled) continue runCatching { - lm.requestLocationUpdates(provider, 2000L, 0f, this, Looper.getMainLooper()) - TrackLog.log("lyssnar på $provider (2 s)") + // 1 s / 0 m — tät sampling ger spikfiltret mer att jobba med + lm.requestLocationUpdates(provider, 1000L, 0f, this, Looper.getMainLooper()) + TrackLog.log("lyssnar på $provider (1 s)") }.onFailure { TrackLog.log("FEL: kunde inte lyssna på $provider: ${it.message}") } } - // Senast kända position → omedelbar kartcentrering (läggs inte i spåret) providers.firstNotNullOfOrNull { p -> runCatching { lm.getLastKnownLocation(p) }.getOrNull() }?.let { last -> TrackLog.log("lastKnown: ${last.provider} acc=${if (last.hasAccuracy()) "%.0f".format(last.accuracy) else "?"} m") - _state.value = _state.value.copy( - currentPosition = last.latitude to last.longitude, - ) + _state.value = _state.value.copy(currentPosition = last.latitude to last.longitude) } } - private fun startMotionGuard() { - val sm = getSystemService(Context.SENSOR_SERVICE) as SensorManager - sensorManager = sm - val accel = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) - if (accel == null) { - TrackLog.log("rörelsevakt: ingen accelerometer — avstängd") - motionGuard = false - return - } - sm.registerListener(this, accel, SensorManager.SENSOR_DELAY_UI) - TrackLog.log("rörelsevakt: på (tröskel ${motionThreshold} m/s²)") - } - - override fun onSensorChanged(event: SensorEvent) { - if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) return - val x = event.values[0].toDouble() - val y = event.values[1].toDouble() - val z = event.values[2].toDouble() - // Avvikelse från vilomagnituden (gravitationen) — steg ger tydliga utslag - val deviation = kotlin.math.abs(kotlin.math.sqrt(x * x + y * y + z * z) - 9.81) - motionWindow.addLast(deviation) - if (motionWindow.size > 20) motionWindow.removeFirst() // ~4 s vid UI-takt - if (motionWindow.size < 8) return - - val avg = motionWindow.average() - val moving = avg > motionThreshold - if (moving != isMoving) { - isMoving = moving - _state.value = _state.value.copy(isMoving = moving) - if (moving != lastMotionLogged) { - TrackLog.log("rörelsevakt: ${if (moving) "i rörelse" else "stilla"} (nivå ${"%.2f".format(avg)})") - lastMotionLogged = moving - } - } - } - - override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit - - /** Gå från GPS-sökning till aktiv (klockan börjar ticka). */ - private fun activate(reason: String) { - val s = _state.value - if (!s.isActive || s.phase == TrackingPhase.ACTIVE) return - TrackLog.log("aktiverad ($reason)") - _state.value = s.copy( - phase = TrackingPhase.ACTIVE, - startedAtEpochMs = System.currentTimeMillis(), - ) - } - override fun onLocationChanged(location: Location) { val s = _state.value if (!s.isActive || !s.isDistanceBased) return val acc = if (location.hasAccuracy()) location.accuracy else -1f - // Position för kartan uppdateras alltid, oavsett kvalitet - _state.value = _state.value.copy(currentPosition = location.latitude to location.longitude) + // Position för kartan uppdateras alltid, oavsett kvalitet och fas + _state.value = _state.value.copy( + currentPosition = location.latitude to location.longitude, + gpsFix = true, + currentSpeedKmh = if (location.hasSpeed()) location.speed * 3.6 else _state.value.currentSpeedKmh, + ) - // Grind 1: spårpunkter kräver hygglig noggrannhet + // Ackumulering sker bara i ACTIVE + if (s.phase != TrackingPhase.ACTIVE) return + + // Grind 1: noggrannhet if (location.hasAccuracy() && location.accuracy > accuracyLimitM) { TrackLog.log("${location.provider}: förkastad, acc=${"%.0f".format(acc)} m (>${accuracyLimitM.toInt()})") return } - // Uppdatera glidande Doppler-fart (GPS:ens fartmätning via frekvensskift) + // Doppler-fart (glidande medel) if (location.hasSpeed()) { dopplerEmaMs = if (dopplerEmaMs == 0.0) location.speed.toDouble() else 0.7 * dopplerEmaMs + 0.3 * location.speed } - // Grind 2 (fartgrinden): en ny position som skulle kräva högre fart än - // speedFactor × Doppler-farten är orimlig — GPS-hopp, inte förflyttning. - lastLocation?.let { prev -> + // Grind 2: fartgrinden mot senast committade punkt + lastCommitted?.let { prev -> val dtSec = (location.elapsedRealtimeNanos - prev.elapsedRealtimeNanos) / 1e9 if (dtSec > 0.3) { val jump = prev.distanceTo(location).toDouble() @@ -317,14 +351,13 @@ class TrackingService : Service(), LocationListener, SensorEventListener { if (impliedMs > allowedMs) { rejectStreak++ TrackLog.log( - "${location.provider}: fartgrind — hopp ${"%.0f".format(jump)} m på ${"%.1f".format(dtSec)} s " + - "= ${"%.1f".format(impliedMs * 3.6)} km/h (tillåtet ${"%.1f".format(allowedMs * 3.6)}), förkastad ($rejectStreak)" + "${location.provider}: fartgrind — ${"%.0f".format(jump)} m/${"%.1f".format(dtSec)} s " + + "= ${"%.0f".format(impliedMs * 3.6)} km/h (max ${"%.0f".format(allowedMs * 3.6)}), förkastad ($rejectStreak)" ) - // Många orimliga i rad = det är NYA positionen som är sann - // (t.ex. efter tunnel) — sätt ny baslinje utan att räkna distans. if (rejectStreak >= 4) { TrackLog.log("fartgrind: ny baslinje efter $rejectStreak förkastade") - lastLocation = location + lastCommitted = location + candidate = null rejectStreak = 0 } return @@ -333,68 +366,151 @@ class TrackingService : Service(), LocationListener, SensorEventListener { } rejectStreak = 0 - // Rörelsevakten: stilla ⇒ uppdatera bara kartpositionen, frys resten. - // GPS-drift när man står still blir annars fejkdistans/-spår/-höjdmeter. + // Grind 3: rörelsevakten — stilla ⇒ frys allt utom kartposition if (motionGuard && !isMoving) { - lastLocation = location + lastCommitted = location + candidate = null return } - TrackLog.log( - "${location.provider}: fix acc=${"%.0f".format(acc)} m" + - " alt=${if (location.hasAltitude()) "%.0f".format(location.altitude) else "?"}" + - " fart=${if (location.hasSpeed()) "%.1f".format(location.speed * 3.6) else "?"} km/h" - ) - if (s.phase == TrackingPhase.SEARCHING_GPS) activate("fix från ${location.provider}") - - var distance = s.distanceMeters - var elevGain = s.elevationGainMeters - - var acceptedAsMovement = true - lastLocation?.let { prev -> - val d = prev.distanceTo(location).toDouble() - // Grind 3 (stillastående): vandring mindre än halva noggrannheten - // är brus, inte förflyttning — räkna varken distans eller spårpunkt. - val minMove = maxOf(2.0, (if (location.hasAccuracy()) location.accuracy else 5f) * 0.5) - if (d >= minMove) distance += d else acceptedAsMovement = false + // Grind 4: lag-buffrat spikfilter — committa kandidaten först när vi + // sett nästa punkt; "ut-och-direkt-tillbaka" slängs (triangel-kollaps). + val prev = lastCommitted + val cand = candidate + if (prev == null) { + lastCommitted = location + commitPoint(location, addDistance = 0.0) + return + } + if (cand == null) { + candidate = location + return } + val d1 = prev.distanceTo(cand).toDouble() + val d2 = cand.distanceTo(location).toDouble() + val d02 = prev.distanceTo(location).toDouble() + val isSpike = d1 > 4.0 && (d1 + d2) > 0 && d02 < 0.6 * (d1 + d2) + if (isSpike) { + TrackLog.log("spikfilter: slängde punkt (ut ${"%.0f".format(d1)} m, tillbaka ${"%.0f".format(d2)} m, direkt ${"%.0f".format(d02)} m)") + candidate = location + return + } + + // Kandidaten är trovärdig — committa den + val minMove = maxOf(2.0, (if (cand.hasAccuracy()) cand.accuracy else 5f) * 0.5) + commitPoint(cand, addDistance = if (d1 >= minMove) d1 else 0.0) + lastCommitted = cand + candidate = location + } + + private fun commitPoint(location: Location, addDistance: Double) { + var elevGain = _state.value.elevationGainMeters if (location.hasAltitude()) { altitudeWindow.addLast(location.altitude) if (altitudeWindow.size > 5) altitudeWindow.removeFirst() val avg = altitudeWindow.average() smoothedAltitude?.let { prevAlt -> val delta = avg - prevAlt - // Bara tydliga stigningar räknas som höjdmeter if (delta > 1.0) elevGain += delta } smoothedAltitude = avg } - lastLocation = location - // Utgå från AKTUELLT state (inte snapshotet från funktionens start) — - // annars skrivs t.ex. fasbytet till ACTIVE över och klockan står still. val cur = _state.value _state.value = cur.copy( - distanceMeters = distance, + distanceMeters = cur.distanceMeters + addDistance, elevationGainMeters = elevGain, - points = if (acceptedAsMovement || cur.points.isEmpty()) { + points = if (addDistance > 0 || cur.points.isEmpty()) { cur.points + (location.latitude to location.longitude) } else cur.points, - currentSpeedKmh = if (location.hasSpeed()) location.speed * 3.6 else null, - gpsFix = true, ) } + /* ---------- Sensorer (rörelsevakt + steg) ---------- */ + + private fun startSensors() { + val sm = getSystemService(Context.SENSOR_SERVICE) as SensorManager + sensorManager = sm + if (motionGuard) { + val accel = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + if (accel != null) { + sm.registerListener(this, accel, SensorManager.SENSOR_DELAY_UI) + TrackLog.log("rörelsevakt: på (tröskel $motionThreshold m/s²)") + } else { + TrackLog.log("rörelsevakt: ingen accelerometer — avstängd") + motionGuard = false + } + } + // Stegsensorn kräver ACTIVITY_RECOGNITION-behörighet (Android 10+) + val hasActivityPermission = ContextCompat.checkSelfPermission( + this, Manifest.permission.ACTIVITY_RECOGNITION, + ) == PackageManager.PERMISSION_GRANTED + val stepSensor = sm.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) + if (stepSensor != null && hasActivityPermission) { + stepSensorAvailable = true + sm.registerListener(this, stepSensor, SensorManager.SENSOR_DELAY_UI) + TrackLog.log("stegsensor: på") + } else { + stepSensorAvailable = false + TrackLog.log("stegsensor: ${if (stepSensor == null) "saknas" else "behörighet saknas"}") + } + } + + override fun onSensorChanged(event: SensorEvent) { + when (event.sensor.type) { + Sensor.TYPE_ACCELEROMETER -> { + val x = event.values[0].toDouble() + val y = event.values[1].toDouble() + val z = event.values[2].toDouble() + val deviation = kotlin.math.abs(kotlin.math.sqrt(x * x + y * y + z * z) - 9.81) + motionWindow.addLast(deviation) + if (motionWindow.size > 20) motionWindow.removeFirst() + if (motionWindow.size < 8) return + val moving = motionWindow.average() > motionThreshold + if (moving != isMoving) { + isMoving = moving + _state.value = _state.value.copy(isMoving = moving) + TrackLog.log("rörelsevakt: ${if (moving) "i rörelse" else "stilla"} (nivå ${"%.2f".format(motionWindow.average())})") + } + } + Sensor.TYPE_STEP_COUNTER -> { + val counter = event.values[0].toLong() + if (_state.value.phase == TrackingPhase.ACTIVE) { + if (stepCounterLast >= 0 && counter > stepCounterLast) { + stepsDuringActivity += counter - stepCounterLast + } + stepCounterLast = counter + } else { + // följ med räknaren utanför ACTIVE utan att ackumulera + stepCounterLast = counter + } + } + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + + /* ---------- Stopp & städning ---------- */ + private fun stopTracking() { - sensorManager?.unregisterListener(this) + // Räkna in pågående aktiv stint + if (_state.value.phase == TrackingPhase.ACTIVE && activeSinceElapsed > 0) { + accumulatedActiveMs += SystemClock.elapsedRealtime() - activeSinceElapsed + activeSinceElapsed = 0L + } val s = _state.value TrackLog.log( - "stopp: ${s.elapsedSeconds}s, ${"%.0f".format(s.distanceMeters)} m, " + - "+${"%.0f".format(s.elevationGainMeters)} hm, ${s.points.size} punkter, kcal=${s.kcal?.toInt() ?: "?"}" + "stopp: ${(accumulatedActiveMs / 1000)}s aktiv tid, ${"%.0f".format(s.distanceMeters)} m, " + + "+${"%.0f".format(s.elevationGainMeters)} hm, ${s.points.size} punkter, " + + "steg=${s.steps ?: "?"}, kcal=${s.kcal?.toInt() ?: "?"}" ) + sensorManager?.unregisterListener(this) locationManager?.removeUpdates(this) - _state.value = _state.value.copy(isActive = false) + _state.value = _state.value.copy( + isActive = false, + elapsedSeconds = (accumulatedActiveMs / 1000).toInt(), + ) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } @@ -409,6 +525,8 @@ class TrackingService : Service(), LocationListener, SensorEventListener { override fun onBind(intent: Intent?) = null + /* ---------- Notis ---------- */ + private fun createChannel() { val channel = NotificationChannel( CHANNEL_ID, "Aktivitetsspårning", NotificationManager.IMPORTANCE_LOW, @@ -423,10 +541,14 @@ class TrackingService : Service(), LocationListener, SensorEventListener { Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE, ) - val text = buildString { - append(formatElapsed(s.elapsedSeconds)) - if (s.isDistanceBased) append(" · ${"%.2f".format(s.distanceMeters / 1000)} km") - s.kcal?.let { append(" · ${it.toInt()} kcal") } + val text = when (s.phase) { + TrackingPhase.READY -> "Redo — tryck play i appen" + TrackingPhase.PAUSED -> "Pausad · ${formatElapsed(s.elapsedSeconds)}" + else -> buildString { + append(formatElapsed(s.elapsedSeconds)) + if (s.isDistanceBased) append(" · ${"%.2f".format(s.distanceMeters / 1000)} km") + s.kcal?.let { append(" · ${it.toInt()} kcal") } + } } return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(android.R.drawable.ic_menu_mylocation) @@ -450,7 +572,8 @@ class TrackingService : Service(), LocationListener, SensorEventListener { private const val NOTIFICATION_ID = 44 const val ACTION_STOP = "eu.brassepc.fitnessdroid.TRACKING_STOP" - const val ACTION_START_NOW = "eu.brassepc.fitnessdroid.TRACKING_START_NOW" + const val ACTION_PLAY = "eu.brassepc.fitnessdroid.TRACKING_PLAY" + const val ACTION_PAUSE = "eu.brassepc.fitnessdroid.TRACKING_PAUSE" const val EXTRA_TYPE_ID = "typeId" const val EXTRA_TYPE_KEY = "typeKey" const val EXTRA_TYPE_NAME = "typeName" @@ -463,6 +586,7 @@ class TrackingService : Service(), LocationListener, SensorEventListener { const val EXTRA_GPS_SPEED_FLOOR = "gpsSpeedFloor" const val EXTRA_MOTION_GUARD = "motionGuard" const val EXTRA_MOTION_THRESHOLD = "motionThreshold" + const val EXTRA_COUNTDOWN = "countdownSeconds" private val _state = MutableStateFlow(TrackingState()) /** Läses av UI:t — servicen äger sanningen. */ @@ -477,6 +601,7 @@ class TrackingService : Service(), LocationListener, SensorEventListener { gpsSpeedFloorKmh: Int = 12, motionGuardEnabled: Boolean = true, motionThreshold: Double = 0.35, + countdownSeconds: Int = 3, ) { val intent = Intent(context, TrackingService::class.java).apply { putExtra(EXTRA_TYPE_ID, type.id) @@ -491,21 +616,27 @@ class TrackingService : Service(), LocationListener, SensorEventListener { putExtra(EXTRA_GPS_SPEED_FLOOR, gpsSpeedFloorKmh) putExtra(EXTRA_MOTION_GUARD, motionGuardEnabled) putExtra(EXTRA_MOTION_THRESHOLD, motionThreshold) + putExtra(EXTRA_COUNTDOWN, countdownSeconds) } context.startForegroundService(intent) } + fun play(context: Context) { + context.startService( + Intent(context, TrackingService::class.java).apply { action = ACTION_PLAY } + ) + } + + fun pause(context: Context) { + context.startService( + Intent(context, TrackingService::class.java).apply { action = ACTION_PAUSE } + ) + } + fun stop(context: Context) { context.startService( Intent(context, TrackingService::class.java).apply { action = ACTION_STOP } ) } - - /** "Starta ändå" — börja räkna tid utan att vänta på GPS-fixen. */ - fun startNow(context: Context) { - context.startService( - Intent(context, TrackingService::class.java).apply { action = ACTION_START_NOW } - ) - } } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt index 86f3420..bfc7073 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -16,6 +17,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -38,6 +40,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -208,6 +211,11 @@ fun ActivitiesScreen( var showLog by remember { mutableStateOf(false) } var showStartPicker by remember { mutableStateOf(false) } var editTarget by remember { mutableStateOf(null) } + var mapTarget by remember { mutableStateOf(null) } + + mapTarget?.let { a -> + RouteMapDialog(activity = a, onDismiss = { mapTarget = null }) + } // Uppdatera listan när man kommer tillbaka (t.ex. efter avslutad spårning) androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() } @@ -323,7 +331,13 @@ fun ActivitiesScreen( } } items(activities, key = { it.id }) { a -> - ActivityRow(a, onClick = { editTarget = a }) + ActivityRow( + a, + onClick = { editTarget = a }, + onShowMap = if (a.routePolyline != null) { + { mapTarget = a } + } else null, + ) } } } @@ -398,7 +412,7 @@ fun TypePickerDialog( } @Composable -private fun ActivityRow(a: Activity, onClick: () -> Unit) { +private fun ActivityRow(a: Activity, onClick: () -> Unit, onShowMap: (() -> Unit)? = null) { Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { Row( modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), @@ -436,10 +450,68 @@ private fun ActivityRow(a: Activity, onClick: () -> Unit) { fontWeight = FontWeight.Bold, ) } + onShowMap?.let { + androidx.compose.material3.IconButton(onClick = it) { + Icon( + Icons.Filled.Map, + contentDescription = "Visa rutt", + tint = MaterialTheme.colorScheme.primary, + ) + } + } } } } +/** Fullskärmsdialog med rutten på en OSM-karta (osmdroid). */ +@Composable +fun RouteMapDialog(activity: Activity, onDismiss: () -> Unit) { + val points = remember(activity.id) { + activity.routePolyline?.let { eu.brassepc.fitnessdroid.data.decodePolyline(it) } ?: emptyList() + } + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("${activity.activityType.nameSv} · ${activity.startedAt.take(10)}") + }, + text = { + if (points.size < 2) { + Text("Ingen rutt sparad för den här aktiviteten.") + } else { + val lineColor = MaterialTheme.colorScheme.primary.toArgb() + androidx.compose.ui.viewinterop.AndroidView( + factory = { ctx -> + org.osmdroid.config.Configuration.getInstance().apply { + userAgentValue = "FitnessDroid" + osmdroidBasePath = java.io.File(ctx.cacheDir, "osmdroid") + osmdroidTileCache = java.io.File(ctx.cacheDir, "osmdroid/tiles") + } + org.osmdroid.views.MapView(ctx).apply { + setTileSource(org.osmdroid.tileprovider.tilesource.TileSourceFactory.MAPNIK) + setMultiTouchControls(true) + val line = org.osmdroid.views.overlay.Polyline().apply { + outlinePaint.color = lineColor + outlinePaint.strokeWidth = 10f + setPoints(points.map { (lat, lon) -> org.osmdroid.util.GeoPoint(lat, lon) }) + } + overlays.add(line) + post { + zoomToBoundingBox(line.bounds.increaseByScale(1.3f), false) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .height(380.dp), + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Stäng") } + }, + ) +} + private fun formatDuration(sec: Int): String { val h = sec / 3600 val m = (sec % 3600) / 60 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 009a26e..c23a815 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 @@ -106,6 +106,9 @@ class SettingsViewModel( store.setGpsSpeedFloor(settings.value.gpsSpeedFloorKmh + delta) } fun setMotionGuard(value: Boolean) = viewModelScope.launch { store.setMotionGuardEnabled(value) } + fun adjustCountdown(delta: Int) = viewModelScope.launch { + store.setCountdownSeconds(settings.value.countdownSeconds + delta) + } fun adjustMotionThreshold(delta: Double) = viewModelScope.launch { store.setMotionThreshold(settings.value.motionThreshold + delta) } @@ -274,6 +277,12 @@ fun SettingsScreen( onMinus = { viewModel.adjustGpsSpeedFloor(-2) }, onPlus = { viewModel.adjustGpsSpeedFloor(2) }, ) + StepperRow( + label = "Nedräkning vid start", + value = "${settings.countdownSeconds} s", + onMinus = { viewModel.adjustCountdown(-1) }, + onPlus = { viewModel.adjustCountdown(1) }, + ) Row( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt index c7d402d..17d20f7 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -119,6 +121,7 @@ class TrackViewModel( gpsSpeedFloorKmh = cfg.gpsSpeedFloorKmh, motionGuardEnabled = cfg.motionGuardEnabled, motionThreshold = cfg.motionThreshold, + countdownSeconds = cfg.countdownSeconds, ) } } @@ -134,7 +137,11 @@ class TrackViewModel( val startedIso = Instant.ofEpochMilli(s.startedAtEpochMs).toString() val distance = s.distanceMeters.takeIf { s.isDistanceBased && it > 10 } val elevation = s.elevationGainMeters.takeIf { s.isDistanceBased && it > 1 } - val polyline = s.points.takeIf { it.size >= 2 }?.let { encodePolyline(it) } + // Banta rutten före sparning — Douglas-Peucker 4 m + tak 800 punkter + val polyline = s.points.takeIf { it.size >= 2 } + ?.let { eu.brassepc.fitnessdroid.data.simplifyRoute(it) } + ?.let { if (it.size > 800) it.filterIndexed { i, _ -> i % (it.size / 800 + 1) == 0 } else it } + ?.let { encodePolyline(it) } try { gymApi.addActivity( activityTypeId = overrideTypeId ?: s.typeId, @@ -145,6 +152,7 @@ class TrackViewModel( rpe = rpe, source = "tracked", routePolyline = polyline, + steps = s.steps?.takeIf { it > 0 }, ) saved.value = true message.value = null @@ -228,6 +236,8 @@ fun TrackScreen( if (type.isDistanceBased) { add(Manifest.permission.ACCESS_FINE_LOCATION) add(Manifest.permission.ACCESS_COARSE_LOCATION) + // Stegsensorn (valfri — nekas den blir stegen bara tomma) + add(Manifest.permission.ACTIVITY_RECOGNITION) } // POST_NOTIFICATIONS finns först i Android 13 (API 33) if (android.os.Build.VERSION.SDK_INT >= 33) { @@ -337,7 +347,8 @@ fun TrackScreen( else -> LiveContent( tracking = tracking, onStop = { viewModel.stop(context) }, - onStartAnyway = { TrackingService.startNow(context) }, + onPlay = { TrackingService.play(context) }, + onPause = { TrackingService.pause(context) }, showLog = logEnabled, ) } @@ -349,7 +360,8 @@ fun TrackScreen( private fun LiveContent( tracking: TrackingState, onStop: () -> Unit, - onStartAnyway: () -> Unit, + onPlay: () -> Unit, + onPause: () -> Unit, showLog: Boolean, ) { Column(modifier = Modifier.fillMaxSize()) { @@ -387,32 +399,41 @@ private fun LiveContent( modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - if (tracking.phase == TrackingPhase.SEARCHING_GPS) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator( - modifier = Modifier.padding(end = 10.dp).size(18.dp), - strokeWidth = 2.dp, - ) + when (tracking.phase) { + TrackingPhase.READY -> Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { - Text("Söker GPS…", style = MaterialTheme.typography.titleSmall) + Text("Redo att starta", style = MaterialTheme.typography.titleSmall) Text( - "Klockan startar vid första fixen — gå gärna ut i det fria.", + if (tracking.isDistanceBased && !tracking.gpsFix) + "GPS söker — vänta gärna på fix, eller kör igång direkt." + else "Tryck play när du är redo.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - TextButton(onClick = onStartAnyway) { Text("Starta ändå") } } + TrackingPhase.COUNTDOWN -> Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + "${tracking.countdownLeft}", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + TrackingPhase.PAUSED -> Text( + "Pausad — tryck play för att fortsätta", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> Unit } Row(modifier = Modifier.fillMaxWidth()) { StatBox("Tid", formatElapsed(tracking.elapsedSeconds), Modifier.weight(1f)) if (tracking.isDistanceBased) { StatBox("Distans", "${(tracking.distanceMeters / 1000).compact()} km", Modifier.weight(1f)) - StatBox( - "Fart", - tracking.currentSpeedKmh?.let { "${it.compact()} km/h" } ?: "—", - Modifier.weight(1f), - ) + StatBox("Steg", tracking.steps?.toString() ?: "—", Modifier.weight(1f)) } StatBox("Kcal", tracking.kcal?.toInt()?.toString() ?: "—", Modifier.weight(1f)) } @@ -427,6 +448,11 @@ private fun LiveContent( } else "—", Modifier.weight(1f), ) + StatBox( + "Fart", + tracking.currentSpeedKmh?.let { "${it.compact()} km/h" } ?: "—", + Modifier.weight(1f), + ) StatBox( "GPS", when { @@ -438,16 +464,42 @@ private fun LiveContent( ) } } - Button( - onClick = onStop, - modifier = Modifier.fillMaxWidth(), - colors = androidx.compose.material3.ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Icon(Icons.Default.Stop, contentDescription = null) - Text("Avsluta aktiviteten", modifier = Modifier.padding(start = 6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + when (tracking.phase) { + TrackingPhase.READY, TrackingPhase.PAUSED -> Button( + onClick = onPlay, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null) + Text( + if (tracking.phase == TrackingPhase.PAUSED) "Fortsätt" else "Starta", + modifier = Modifier.padding(start = 6.dp), + ) + } + TrackingPhase.ACTIVE -> Button( + onClick = onPause, + modifier = Modifier.weight(1f), + colors = androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + ), + ) { + Icon(Icons.Default.Pause, contentDescription = null) + Text("Pausa", modifier = Modifier.padding(start = 6.dp)) + } + else -> Unit + } + Button( + onClick = onStop, + modifier = Modifier.weight(1f), + colors = androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Icon(Icons.Default.Stop, contentDescription = null) + Text("Avsluta", modifier = Modifier.padding(start = 6.dp)) + } } if (showLog) TechLogSection() } @@ -623,6 +675,7 @@ private fun SummaryContent( SummaryRow("Snittfart", "${kmh.compact()} km/h") } } + summary.steps?.takeIf { it > 0 }?.let { SummaryRow("Steg", "$it") } summary.kcal?.let { SummaryRow("Kcal (preliminärt)", "${it.toInt()}") } guessKey?.let {