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
|
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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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}}"
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 på ${"%.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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user