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
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:
@@ -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 {
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
|
||||
<!-- Stegsynk via Health Connect (etapp 5) -->
|
||||
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
||||
<!-- Telefonens stegsensor under spårade aktiviteter -->
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
|
||||
<application
|
||||
android:name=".FitnessDroidApplication"
|
||||
|
||||
@@ -108,3 +108,72 @@ private fun encodeDiff(diff: Long, sb: StringBuilder) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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] }
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Pair<Double, Double>> = emptyList(),
|
||||
/** Senast kända position oavsett kvalitet — för kartcentrering/markör */
|
||||
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,
|
||||
* 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<Double>()
|
||||
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<Double>()
|
||||
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() {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user