Spårning 2.0: spikfilter, pausad start med play/nedräkning, paus, steg, historikkarta
All checks were successful
release / build-release (push) Successful in 4m26s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg
This commit is contained in:
2026-08-27 07:14:23 +02:00
parent 71a1ef4bb1
commit d2a37f21de
9 changed files with 550 additions and 202 deletions

View File

@@ -29,7 +29,7 @@ android {
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = ciRunNumber ?: 1 versionCode = ciRunNumber ?: 1
versionName = "0.11.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") versionName = "0.12.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
} }
signingConfigs { signingConfigs {

View File

@@ -33,6 +33,8 @@
<!-- Stegsynk via Health Connect (etapp 5) --> <!-- Stegsynk via Health Connect (etapp 5) -->
<uses-permission android:name="android.permission.health.READ_STEPS" /> <uses-permission android:name="android.permission.health.READ_STEPS" />
<!-- Telefonens stegsensor under spårade aktiviteter -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<application <application
android:name=".FitnessDroidApplication" android:name=".FitnessDroidApplication"

View File

@@ -108,3 +108,72 @@ private fun encodeDiff(diff: Long, sb: StringBuilder) {
} }
sb.append((v + 63).toInt().toChar()) sb.append((v + 63).toInt().toChar())
} }
/** Avkoda Google encoded polyline (precision 1e5) → [(lat, lon)]. */
fun decodePolyline(encoded: String): List<Pair<Double, Double>> {
val points = mutableListOf<Pair<Double, Double>>()
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
}
/**
* DouglasPeucker-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<Pair<Double, Double>>, epsilonMeters: Double = 4.0): List<Pair<Double, Double>> {
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<Double, Double>) = (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<Pair<Int, Int>>()
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] }
}

View File

@@ -31,6 +31,8 @@ data class Activity(
val rpe: Double? = null, val rpe: Double? = null,
val source: String = "manual", val source: String = "manual",
val notes: String? = null, val notes: String? = null,
val steps: Int? = null,
val routePolyline: String? = null,
val activityType: ActivityType, val activityType: ActivityType,
) )
@@ -135,6 +137,7 @@ class GymApi(
source: String? = null, source: String? = null,
routePolyline: String? = null, routePolyline: String? = null,
notes: String? = null, notes: String? = null,
steps: Int? = null,
): Activity { ): Activity {
val data = client.execute( val data = client.execute(
"mutation(\$input: AddActivityInput!){addActivity(input:\$input){$ACTIVITY_FIELDS}}", "mutation(\$input: AddActivityInput!){addActivity(input:\$input){$ACTIVITY_FIELDS}}",
@@ -146,6 +149,7 @@ class GymApi(
distanceMeters?.let { put("distanceMeters", it) } distanceMeters?.let { put("distanceMeters", it) }
elevationGainMeters?.let { put("elevationGainMeters", it) } elevationGainMeters?.let { put("elevationGainMeters", it) }
rpe?.let { put("rpe", it) } rpe?.let { put("rpe", it) }
steps?.let { put("steps", it) }
estimatedKcal?.let { put("estimatedKcal", it) } estimatedKcal?.let { put("estimatedKcal", it) }
source?.let { put("source", it) } source?.let { put("source", it) }
routePolyline?.let { put("routePolyline", it) } routePolyline?.let { put("routePolyline", it) }
@@ -243,7 +247,7 @@ class GymApi(
companion object { companion object {
private const val ACTIVITY_FIELDS = 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}" "activityType{id key nameSv nameEn met category isDistanceBased isCardio iconKey}"
private const val MY_PROFILE = private const val MY_PROFILE =
"query{myProfile{username displayName email bodyWeightKg heightCm birthYear sex}}" "query{myProfile{username displayName email bodyWeightKg heightCm birthYear sex}}"

View File

@@ -44,6 +44,8 @@ data class AppSettings(
val motionGuardEnabled: Boolean = true, val motionGuardEnabled: Boolean = true,
/** Rörelsevaktens känslighet (m/s² avvikelse från vila; lägre = känsligare) */ /** Rörelsevaktens känslighet (m/s² avvikelse från vila; lägre = känsligare) */
val motionThreshold: Double = 0.35, 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) { class SettingsStore(private val context: Context) {
@@ -76,9 +78,14 @@ class SettingsStore(private val context: Context) {
gpsSpeedFloorKmh = prefs[KEY_GPS_FLOOR] ?: 12, gpsSpeedFloorKmh = prefs[KEY_GPS_FLOOR] ?: 12,
motionGuardEnabled = prefs[KEY_MOTION_GUARD]?.toBooleanStrictOrNull() ?: true, motionGuardEnabled = prefs[KEY_MOTION_GUARD]?.toBooleanStrictOrNull() ?: true,
motionThreshold = prefs[KEY_MOTION_THRESHOLD]?.toDoubleOrNull() ?: 0.35, 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) { suspend fun setMotionGuardEnabled(value: Boolean) {
context.settingsDataStore.edit { it[KEY_MOTION_GUARD] = value.toString() } 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_GPS_FLOOR = intPreferencesKey("gps_speed_floor_kmh")
private val KEY_MOTION_GUARD = stringPreferencesKey("motion_guard_enabled") private val KEY_MOTION_GUARD = stringPreferencesKey("motion_guard_enabled")
private val KEY_MOTION_THRESHOLD = stringPreferencesKey("motion_threshold") private val KEY_MOTION_THRESHOLD = stringPreferencesKey("motion_threshold")
private val KEY_COUNTDOWN = intPreferencesKey("countdown_seconds")
} }
} }

View File

@@ -32,13 +32,18 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch 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. */ /** Läget för en pågående (eller nyss avslutad) aktivitetsspårning. */
data class TrackingState( data class TrackingState(
val isActive: Boolean = false, val isActive: Boolean = false,
val phase: TrackingPhase = TrackingPhase.IDLE, val phase: TrackingPhase = TrackingPhase.IDLE,
/** Sekunder kvar av nedräkningen (fas COUNTDOWN) */
val countdownLeft: Int = 0,
/** Aktivitetstypen som spåras */ /** Aktivitetstypen som spåras */
val typeId: Int = 0, val typeId: Int = 0,
val typeKey: String = "", val typeKey: String = "",
@@ -50,7 +55,9 @@ data class TrackingState(
val elapsedSeconds: Int = 0, val elapsedSeconds: Int = 0,
val distanceMeters: Double = 0.0, val distanceMeters: Double = 0.0,
val elevationGainMeters: 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<Pair<Double, Double>> = emptyList(), val points: List<Pair<Double, Double>> = emptyList(),
/** Senast kända position oavsett kvalitet — för kartcentrering/markör */ /** Senast kända position oavsett kvalitet — för kartcentrering/markör */
val currentPosition: Pair<Double, Double>? = null, val currentPosition: Pair<Double, Double>? = null,
@@ -63,36 +70,48 @@ data class TrackingState(
/** /**
* Foreground-service som spårar en aktivitet: GPS-rutt, distans, höjdmeter, * 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 * steg, tid och live-kcal. Positionspipeline: fartgrind (Doppler) →
* timern — ingen GPS. UI:t läser [state]; servicen äger sanningen så * rörelsevakt (accelerometer) → lag-buffrat spikfilter (en punkt committas
* spårningen överlever att appen swipas bort från recents. * först när nästa setts; "ut-och-tillbaka"-hopp slängs) → stillastående-filter.
*/ */
class TrackingService : Service(), LocationListener, SensorEventListener { class TrackingService : Service(), LocationListener, SensorEventListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var locationManager: LocationManager? = null private var locationManager: LocationManager? = null
private var sensorManager: SensorManager? = null
private var weightKg: Double? = null private var weightKg: Double? = null
private var activeSinceElapsed = 0L // GPS-filter (justerbara i inställningarna)
private var lastLocation: Location? = null
// GPS-filter (justerbara i inställningarna, skickas med vid start)
private var accuracyLimitM = 35f private var accuracyLimitM = 35f
private var speedFactor = 2.0 private var speedFactor = 2.0
private var speedFloorMs = 12.0 / 3.6 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 motionGuard = true
private var motionThreshold = 0.35 private var motionThreshold = 0.35
private val motionWindow = ArrayDeque<Double>() private var countdownSeconds = 3
private var isMoving = true
private var lastMotionLogged = true // Tidräkning: ackumulerad aktiv tid + när nuvarande aktiva stint började
// Glidande medel av höjd för att filtrera GPS-brus innan höjdmeter summeras 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<Double>() private val altitudeWindow = ArrayDeque<Double>()
private var smoothedAltitude: Double? = null private var smoothedAltitude: Double? = null
// Rörelsevakt
private val motionWindow = ArrayDeque<Double>()
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() { override fun onCreate() {
super.onCreate() super.onCreate()
createChannel() createChannel()
@@ -104,8 +123,12 @@ class TrackingService : Service(), LocationListener, SensorEventListener {
stopTracking() stopTracking()
return START_NOT_STICKY return START_NOT_STICKY
} }
ACTION_START_NOW -> { ACTION_PLAY -> {
activate("starta ändå") play()
return START_STICKY
}
ACTION_PAUSE -> {
pause()
return START_STICKY return START_STICKY
} }
else -> { else -> {
@@ -124,15 +147,24 @@ class TrackingService : Service(), LocationListener, SensorEventListener {
speedFloorMs = intent.getIntExtra(EXTRA_GPS_SPEED_FLOOR, 12) / 3.6 speedFloorMs = intent.getIntExtra(EXTRA_GPS_SPEED_FLOOR, 12) / 3.6
motionGuard = intent.getBooleanExtra(EXTRA_MOTION_GUARD, true) motionGuard = intent.getBooleanExtra(EXTRA_MOTION_GUARD, true)
motionThreshold = intent.getDoubleExtra(EXTRA_MOTION_THRESHOLD, 0.35) 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 dopplerEmaMs = 0.0
rejectStreak = 0 rejectStreak = 0
altitudeWindow.clear()
smoothedAltitude = null
motionWindow.clear() motionWindow.clear()
isMoving = true isMoving = true
lastMotionLogged = true stepCounterLast = -1
stepsDuringActivity = 0
_state.value = TrackingState( _state.value = TrackingState(
isActive = true, isActive = true,
phase = if (distanceBased) TrackingPhase.SEARCHING_GPS else TrackingPhase.ACTIVE, phase = TrackingPhase.READY,
typeId = intent.getIntExtra(EXTRA_TYPE_ID, 0), typeId = intent.getIntExtra(EXTRA_TYPE_ID, 0),
typeKey = intent.getStringExtra(EXTRA_TYPE_KEY) ?: "", typeKey = intent.getStringExtra(EXTRA_TYPE_KEY) ?: "",
typeName = intent.getStringExtra(EXTRA_TYPE_NAME) ?: "Aktivitet", typeName = intent.getStringExtra(EXTRA_TYPE_NAME) ?: "Aktivitet",
@@ -142,62 +174,111 @@ class TrackingService : Service(), LocationListener, SensorEventListener {
startedAtEpochMs = System.currentTimeMillis(), startedAtEpochMs = System.currentTimeMillis(),
) )
TrackLog.clear() TrackLog.clear()
TrackLog.log( TrackLog.log("start: ${_state.value.typeName} (distans=$distanceBased, vikt=${weightKg ?: "?"} kg) — READY, tryck play")
"start: ${_state.value.typeName} (distans=$distanceBased, vikt=${weightKg ?: "?"} kg)" +
if (distanceBased) " — väntar på GPS-fix" else ""
)
if (distanceBased) { if (distanceBased) {
TrackLog.log( TrackLog.log(
"filter: acc≤${accuracyLimitM.toInt()} m, fartgrind ${speedFactor}× Doppler " + "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) { startForeground(
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION NOTIFICATION_ID, buildNotification(),
} else { ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
// Timer-läge behöver ingen plats, men servicetypen är deklarerad som location )
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
}
startForeground(NOTIFICATION_ID, buildNotification(), type)
if (distanceBased) { if (distanceBased) {
startGps() 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 { scope.launch {
while (_state.value.isActive) { while (_state.value.isActive) {
val s = _state.value val s = _state.value
if (s.phase != TrackingPhase.ACTIVE) { when (s.phase) {
delay(500) TrackingPhase.COUNTDOWN -> {
continue val left = s.countdownLeft - 1
} if (left <= 0) {
if (activeSinceElapsed == 0L) activeSinceElapsed = SystemClock.elapsedRealtime() goActive()
val elapsed = ((SystemClock.elapsedRealtime() - activeSinceElapsed) / 1000).toInt() } else {
_state.value = s.copy( _state.value = _state.value.copy(countdownLeft = left)
elapsedSeconds = elapsed, }
kcal = ActivityKcal.estimate( }
met = s.met, TrackingPhase.ACTIVE -> {
category = s.category, val elapsed = currentElapsedSeconds()
isDistanceBased = s.isDistanceBased, _state.value = _state.value.copy(
weightKg = weightKg, elapsedSeconds = elapsed,
durationSeconds = elapsed, steps = if (stepSensorAvailable) stepsDuringActivity.toInt() else _state.value.steps,
distanceMeters = s.distanceMeters.takeIf { it > 0 }, kcal = ActivityKcal.estimate(
elevationGainMeters = s.elevationGainMeters.takeIf { it > 0 }, met = s.met,
rpe = null, category = s.category,
), isDistanceBased = s.isDistanceBased,
) weightKg = weightKg,
if (elapsed % 10 == 0) { durationSeconds = elapsed,
getSystemService(NotificationManager::class.java) distanceMeters = s.distanceMeters.takeIf { it > 0 },
.notify(NOTIFICATION_ID, buildNotification()) elevationGainMeters = s.elevationGainMeters.takeIf { it > 0 },
rpe = null,
),
)
if (elapsed % 10 == 0) {
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, buildNotification())
}
}
else -> Unit
} }
delay(1000) 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() { private fun startGps() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED != PackageManager.PERMISSION_GRANTED
@@ -208,8 +289,6 @@ class TrackingService : Service(), LocationListener, SensorEventListener {
val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager
locationManager = lm 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( val providers = listOf(
LocationManager.GPS_PROVIDER, LocationManager.GPS_PROVIDER,
LocationManager.FUSED_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"}") TrackLog.log("provider $provider: ${if (!exists) "saknas" else if (enabled) "på" else "AVSTÄNGD"}")
if (!enabled) continue if (!enabled) continue
runCatching { runCatching {
lm.requestLocationUpdates(provider, 2000L, 0f, this, Looper.getMainLooper()) // 1 s / 0 m — tät sampling ger spikfiltret mer att jobba med
TrackLog.log("lyssnar på $provider (2 s)") 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}") } }.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 -> providers.firstNotNullOfOrNull { p ->
runCatching { lm.getLastKnownLocation(p) }.getOrNull() runCatching { lm.getLastKnownLocation(p) }.getOrNull()
}?.let { last -> }?.let { last ->
TrackLog.log("lastKnown: ${last.provider} acc=${if (last.hasAccuracy()) "%.0f".format(last.accuracy) else "?"} m") TrackLog.log("lastKnown: ${last.provider} acc=${if (last.hasAccuracy()) "%.0f".format(last.accuracy) else "?"} m")
_state.value = _state.value.copy( _state.value = _state.value.copy(currentPosition = last.latitude to last.longitude)
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) { override fun onLocationChanged(location: Location) {
val s = _state.value val s = _state.value
if (!s.isActive || !s.isDistanceBased) return if (!s.isActive || !s.isDistanceBased) return
val acc = if (location.hasAccuracy()) location.accuracy else -1f val acc = if (location.hasAccuracy()) location.accuracy else -1f
// Position för kartan uppdateras alltid, oavsett kvalitet // Position för kartan uppdateras alltid, oavsett kvalitet och fas
_state.value = _state.value.copy(currentPosition = location.latitude to location.longitude) _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) { if (location.hasAccuracy() && location.accuracy > accuracyLimitM) {
TrackLog.log("${location.provider}: förkastad, acc=${"%.0f".format(acc)} m (>${accuracyLimitM.toInt()})") TrackLog.log("${location.provider}: förkastad, acc=${"%.0f".format(acc)} m (>${accuracyLimitM.toInt()})")
return return
} }
// Uppdatera glidande Doppler-fart (GPS:ens fartmätning via frekvensskift) // Doppler-fart (glidande medel)
if (location.hasSpeed()) { if (location.hasSpeed()) {
dopplerEmaMs = if (dopplerEmaMs == 0.0) location.speed.toDouble() dopplerEmaMs = if (dopplerEmaMs == 0.0) location.speed.toDouble()
else 0.7 * dopplerEmaMs + 0.3 * location.speed else 0.7 * dopplerEmaMs + 0.3 * location.speed
} }
// Grind 2 (fartgrinden): en ny position som skulle kräva högre fart än // Grind 2: fartgrinden mot senast committade punkt
// speedFactor × Doppler-farten är orimlig — GPS-hopp, inte förflyttning. lastCommitted?.let { prev ->
lastLocation?.let { prev ->
val dtSec = (location.elapsedRealtimeNanos - prev.elapsedRealtimeNanos) / 1e9 val dtSec = (location.elapsedRealtimeNanos - prev.elapsedRealtimeNanos) / 1e9
if (dtSec > 0.3) { if (dtSec > 0.3) {
val jump = prev.distanceTo(location).toDouble() val jump = prev.distanceTo(location).toDouble()
@@ -317,14 +351,13 @@ class TrackingService : Service(), LocationListener, SensorEventListener {
if (impliedMs > allowedMs) { if (impliedMs > allowedMs) {
rejectStreak++ rejectStreak++
TrackLog.log( TrackLog.log(
"${location.provider}: fartgrind — hopp ${"%.0f".format(jump)} m${"%.1f".format(dtSec)} s " + "${location.provider}: fartgrind — ${"%.0f".format(jump)} m/${"%.1f".format(dtSec)} s " +
"= ${"%.1f".format(impliedMs * 3.6)} km/h (tillåtet ${"%.1f".format(allowedMs * 3.6)}), förkastad ($rejectStreak)" "= ${"%.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) { if (rejectStreak >= 4) {
TrackLog.log("fartgrind: ny baslinje efter $rejectStreak förkastade") TrackLog.log("fartgrind: ny baslinje efter $rejectStreak förkastade")
lastLocation = location lastCommitted = location
candidate = null
rejectStreak = 0 rejectStreak = 0
} }
return return
@@ -333,68 +366,151 @@ class TrackingService : Service(), LocationListener, SensorEventListener {
} }
rejectStreak = 0 rejectStreak = 0
// Rörelsevakten: stilla ⇒ uppdatera bara kartpositionen, frys resten. // Grind 3: rörelsevakten stilla ⇒ frys allt utom kartposition
// GPS-drift när man står still blir annars fejkdistans/-spår/-höjdmeter.
if (motionGuard && !isMoving) { if (motionGuard && !isMoving) {
lastLocation = location lastCommitted = location
candidate = null
return return
} }
TrackLog.log( // Grind 4: lag-buffrat spikfilter — committa kandidaten först när vi
"${location.provider}: fix acc=${"%.0f".format(acc)} m" + // sett nästa punkt; "ut-och-direkt-tillbaka" slängs (triangel-kollaps).
" alt=${if (location.hasAltitude()) "%.0f".format(location.altitude) else "?"}" + val prev = lastCommitted
" fart=${if (location.hasSpeed()) "%.1f".format(location.speed * 3.6) else "?"} km/h" val cand = candidate
) if (prev == null) {
if (s.phase == TrackingPhase.SEARCHING_GPS) activate("fix från ${location.provider}") lastCommitted = location
commitPoint(location, addDistance = 0.0)
var distance = s.distanceMeters return
var elevGain = s.elevationGainMeters }
if (cand == null) {
var acceptedAsMovement = true candidate = location
lastLocation?.let { prev -> return
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
} }
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()) { if (location.hasAltitude()) {
altitudeWindow.addLast(location.altitude) altitudeWindow.addLast(location.altitude)
if (altitudeWindow.size > 5) altitudeWindow.removeFirst() if (altitudeWindow.size > 5) altitudeWindow.removeFirst()
val avg = altitudeWindow.average() val avg = altitudeWindow.average()
smoothedAltitude?.let { prevAlt -> smoothedAltitude?.let { prevAlt ->
val delta = avg - prevAlt val delta = avg - prevAlt
// Bara tydliga stigningar räknas som höjdmeter
if (delta > 1.0) elevGain += delta if (delta > 1.0) elevGain += delta
} }
smoothedAltitude = avg 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 val cur = _state.value
_state.value = cur.copy( _state.value = cur.copy(
distanceMeters = distance, distanceMeters = cur.distanceMeters + addDistance,
elevationGainMeters = elevGain, elevationGainMeters = elevGain,
points = if (acceptedAsMovement || cur.points.isEmpty()) { points = if (addDistance > 0 || cur.points.isEmpty()) {
cur.points + (location.latitude to location.longitude) cur.points + (location.latitude to location.longitude)
} else cur.points, } 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() { 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 val s = _state.value
TrackLog.log( TrackLog.log(
"stopp: ${s.elapsedSeconds}s, ${"%.0f".format(s.distanceMeters)} m, " + "stopp: ${(accumulatedActiveMs / 1000)}s aktiv tid, ${"%.0f".format(s.distanceMeters)} m, " +
"+${"%.0f".format(s.elevationGainMeters)} hm, ${s.points.size} punkter, kcal=${s.kcal?.toInt() ?: "?"}" "+${"%.0f".format(s.elevationGainMeters)} hm, ${s.points.size} punkter, " +
"steg=${s.steps ?: "?"}, kcal=${s.kcal?.toInt() ?: "?"}"
) )
sensorManager?.unregisterListener(this)
locationManager?.removeUpdates(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) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
@@ -409,6 +525,8 @@ class TrackingService : Service(), LocationListener, SensorEventListener {
override fun onBind(intent: Intent?) = null override fun onBind(intent: Intent?) = null
/* ---------- Notis ---------- */
private fun createChannel() { private fun createChannel() {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, "Aktivitetsspårning", NotificationManager.IMPORTANCE_LOW, CHANNEL_ID, "Aktivitetsspårning", NotificationManager.IMPORTANCE_LOW,
@@ -423,10 +541,14 @@ class TrackingService : Service(), LocationListener, SensorEventListener {
Intent(this, MainActivity::class.java), Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_IMMUTABLE,
) )
val text = buildString { val text = when (s.phase) {
append(formatElapsed(s.elapsedSeconds)) TrackingPhase.READY -> "Redo — tryck play i appen"
if (s.isDistanceBased) append(" · ${"%.2f".format(s.distanceMeters / 1000)} km") TrackingPhase.PAUSED -> "Pausad · ${formatElapsed(s.elapsedSeconds)}"
s.kcal?.let { append(" · ${it.toInt()} kcal") } 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) return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_menu_mylocation) .setSmallIcon(android.R.drawable.ic_menu_mylocation)
@@ -450,7 +572,8 @@ class TrackingService : Service(), LocationListener, SensorEventListener {
private const val NOTIFICATION_ID = 44 private const val NOTIFICATION_ID = 44
const val ACTION_STOP = "eu.brassepc.fitnessdroid.TRACKING_STOP" 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_ID = "typeId"
const val EXTRA_TYPE_KEY = "typeKey" const val EXTRA_TYPE_KEY = "typeKey"
const val EXTRA_TYPE_NAME = "typeName" 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_GPS_SPEED_FLOOR = "gpsSpeedFloor"
const val EXTRA_MOTION_GUARD = "motionGuard" const val EXTRA_MOTION_GUARD = "motionGuard"
const val EXTRA_MOTION_THRESHOLD = "motionThreshold" const val EXTRA_MOTION_THRESHOLD = "motionThreshold"
const val EXTRA_COUNTDOWN = "countdownSeconds"
private val _state = MutableStateFlow(TrackingState()) private val _state = MutableStateFlow(TrackingState())
/** Läses av UI:t — servicen äger sanningen. */ /** Läses av UI:t — servicen äger sanningen. */
@@ -477,6 +601,7 @@ class TrackingService : Service(), LocationListener, SensorEventListener {
gpsSpeedFloorKmh: Int = 12, gpsSpeedFloorKmh: Int = 12,
motionGuardEnabled: Boolean = true, motionGuardEnabled: Boolean = true,
motionThreshold: Double = 0.35, motionThreshold: Double = 0.35,
countdownSeconds: Int = 3,
) { ) {
val intent = Intent(context, TrackingService::class.java).apply { val intent = Intent(context, TrackingService::class.java).apply {
putExtra(EXTRA_TYPE_ID, type.id) putExtra(EXTRA_TYPE_ID, type.id)
@@ -491,21 +616,27 @@ class TrackingService : Service(), LocationListener, SensorEventListener {
putExtra(EXTRA_GPS_SPEED_FLOOR, gpsSpeedFloorKmh) putExtra(EXTRA_GPS_SPEED_FLOOR, gpsSpeedFloorKmh)
putExtra(EXTRA_MOTION_GUARD, motionGuardEnabled) putExtra(EXTRA_MOTION_GUARD, motionGuardEnabled)
putExtra(EXTRA_MOTION_THRESHOLD, motionThreshold) putExtra(EXTRA_MOTION_THRESHOLD, motionThreshold)
putExtra(EXTRA_COUNTDOWN, countdownSeconds)
} }
context.startForegroundService(intent) 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) { fun stop(context: Context) {
context.startService( context.startService(
Intent(context, TrackingService::class.java).apply { action = ACTION_STOP } 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 }
)
}
} }
} }

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -38,6 +40,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -208,6 +211,11 @@ fun ActivitiesScreen(
var showLog by remember { mutableStateOf(false) } var showLog by remember { mutableStateOf(false) }
var showStartPicker by remember { mutableStateOf(false) } var showStartPicker by remember { mutableStateOf(false) }
var editTarget by remember { mutableStateOf<Activity?>(null) } var editTarget by remember { mutableStateOf<Activity?>(null) }
var mapTarget by remember { mutableStateOf<Activity?>(null) }
mapTarget?.let { a ->
RouteMapDialog(activity = a, onDismiss = { mapTarget = null })
}
// Uppdatera listan när man kommer tillbaka (t.ex. efter avslutad spårning) // Uppdatera listan när man kommer tillbaka (t.ex. efter avslutad spårning)
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() } androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() }
@@ -323,7 +331,13 @@ fun ActivitiesScreen(
} }
} }
items(activities, key = { it.id }) { a -> 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 @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)) { Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Row( Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
@@ -436,10 +450,68 @@ private fun ActivityRow(a: Activity, onClick: () -> Unit) {
fontWeight = FontWeight.Bold, 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 { private fun formatDuration(sec: Int): String {
val h = sec / 3600 val h = sec / 3600
val m = (sec % 3600) / 60 val m = (sec % 3600) / 60

View File

@@ -106,6 +106,9 @@ class SettingsViewModel(
store.setGpsSpeedFloor(settings.value.gpsSpeedFloorKmh + delta) store.setGpsSpeedFloor(settings.value.gpsSpeedFloorKmh + delta)
} }
fun setMotionGuard(value: Boolean) = viewModelScope.launch { store.setMotionGuardEnabled(value) } 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 { fun adjustMotionThreshold(delta: Double) = viewModelScope.launch {
store.setMotionThreshold(settings.value.motionThreshold + delta) store.setMotionThreshold(settings.value.motionThreshold + delta)
} }
@@ -274,6 +277,12 @@ fun SettingsScreen(
onMinus = { viewModel.adjustGpsSpeedFloor(-2) }, onMinus = { viewModel.adjustGpsSpeedFloor(-2) },
onPlus = { 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@@ -18,6 +18,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Favorite 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.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -119,6 +121,7 @@ class TrackViewModel(
gpsSpeedFloorKmh = cfg.gpsSpeedFloorKmh, gpsSpeedFloorKmh = cfg.gpsSpeedFloorKmh,
motionGuardEnabled = cfg.motionGuardEnabled, motionGuardEnabled = cfg.motionGuardEnabled,
motionThreshold = cfg.motionThreshold, motionThreshold = cfg.motionThreshold,
countdownSeconds = cfg.countdownSeconds,
) )
} }
} }
@@ -134,7 +137,11 @@ class TrackViewModel(
val startedIso = Instant.ofEpochMilli(s.startedAtEpochMs).toString() val startedIso = Instant.ofEpochMilli(s.startedAtEpochMs).toString()
val distance = s.distanceMeters.takeIf { s.isDistanceBased && it > 10 } val distance = s.distanceMeters.takeIf { s.isDistanceBased && it > 10 }
val elevation = s.elevationGainMeters.takeIf { s.isDistanceBased && it > 1 } 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 { try {
gymApi.addActivity( gymApi.addActivity(
activityTypeId = overrideTypeId ?: s.typeId, activityTypeId = overrideTypeId ?: s.typeId,
@@ -145,6 +152,7 @@ class TrackViewModel(
rpe = rpe, rpe = rpe,
source = "tracked", source = "tracked",
routePolyline = polyline, routePolyline = polyline,
steps = s.steps?.takeIf { it > 0 },
) )
saved.value = true saved.value = true
message.value = null message.value = null
@@ -228,6 +236,8 @@ fun TrackScreen(
if (type.isDistanceBased) { if (type.isDistanceBased) {
add(Manifest.permission.ACCESS_FINE_LOCATION) add(Manifest.permission.ACCESS_FINE_LOCATION)
add(Manifest.permission.ACCESS_COARSE_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) // POST_NOTIFICATIONS finns först i Android 13 (API 33)
if (android.os.Build.VERSION.SDK_INT >= 33) { if (android.os.Build.VERSION.SDK_INT >= 33) {
@@ -337,7 +347,8 @@ fun TrackScreen(
else -> LiveContent( else -> LiveContent(
tracking = tracking, tracking = tracking,
onStop = { viewModel.stop(context) }, onStop = { viewModel.stop(context) },
onStartAnyway = { TrackingService.startNow(context) }, onPlay = { TrackingService.play(context) },
onPause = { TrackingService.pause(context) },
showLog = logEnabled, showLog = logEnabled,
) )
} }
@@ -349,7 +360,8 @@ fun TrackScreen(
private fun LiveContent( private fun LiveContent(
tracking: TrackingState, tracking: TrackingState,
onStop: () -> Unit, onStop: () -> Unit,
onStartAnyway: () -> Unit, onPlay: () -> Unit,
onPause: () -> Unit,
showLog: Boolean, showLog: Boolean,
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@@ -387,32 +399,41 @@ private fun LiveContent(
modifier = Modifier.padding(14.dp), modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
if (tracking.phase == TrackingPhase.SEARCHING_GPS) { when (tracking.phase) {
Row(verticalAlignment = Alignment.CenterVertically) { TrackingPhase.READY -> Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.padding(end = 10.dp).size(18.dp),
strokeWidth = 2.dp,
)
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text("Söker GPS…", style = MaterialTheme.typography.titleSmall) Text("Redo att starta", style = MaterialTheme.typography.titleSmall)
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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()) { Row(modifier = Modifier.fillMaxWidth()) {
StatBox("Tid", formatElapsed(tracking.elapsedSeconds), Modifier.weight(1f)) StatBox("Tid", formatElapsed(tracking.elapsedSeconds), Modifier.weight(1f))
if (tracking.isDistanceBased) { if (tracking.isDistanceBased) {
StatBox("Distans", "${(tracking.distanceMeters / 1000).compact()} km", Modifier.weight(1f)) StatBox("Distans", "${(tracking.distanceMeters / 1000).compact()} km", Modifier.weight(1f))
StatBox( StatBox("Steg", tracking.steps?.toString() ?: "", Modifier.weight(1f))
"Fart",
tracking.currentSpeedKmh?.let { "${it.compact()} km/h" } ?: "",
Modifier.weight(1f),
)
} }
StatBox("Kcal", tracking.kcal?.toInt()?.toString() ?: "", Modifier.weight(1f)) StatBox("Kcal", tracking.kcal?.toInt()?.toString() ?: "", Modifier.weight(1f))
} }
@@ -427,6 +448,11 @@ private fun LiveContent(
} else "", } else "",
Modifier.weight(1f), Modifier.weight(1f),
) )
StatBox(
"Fart",
tracking.currentSpeedKmh?.let { "${it.compact()} km/h" } ?: "",
Modifier.weight(1f),
)
StatBox( StatBox(
"GPS", "GPS",
when { when {
@@ -438,16 +464,42 @@ private fun LiveContent(
) )
} }
} }
Button( Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
onClick = onStop, when (tracking.phase) {
modifier = Modifier.fillMaxWidth(), TrackingPhase.READY, TrackingPhase.PAUSED -> Button(
colors = androidx.compose.material3.ButtonDefaults.buttonColors( onClick = onPlay,
containerColor = MaterialTheme.colorScheme.error, modifier = Modifier.weight(1f),
contentColor = MaterialTheme.colorScheme.onError, ) {
), Icon(Icons.Default.PlayArrow, contentDescription = null)
) { Text(
Icon(Icons.Default.Stop, contentDescription = null) if (tracking.phase == TrackingPhase.PAUSED) "Fortsätt" else "Starta",
Text("Avsluta aktiviteten", modifier = Modifier.padding(start = 6.dp)) 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() if (showLog) TechLogSection()
} }
@@ -623,6 +675,7 @@ private fun SummaryContent(
SummaryRow("Snittfart", "${kmh.compact()} km/h") SummaryRow("Snittfart", "${kmh.compact()} km/h")
} }
} }
summary.steps?.takeIf { it > 0 }?.let { SummaryRow("Steg", "$it") }
summary.kcal?.let { SummaryRow("Kcal (preliminärt)", "${it.toInt()}") } summary.kcal?.let { SummaryRow("Kcal (preliminärt)", "${it.toInt()}") }
guessKey?.let { guessKey?.let {