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 {