Etapp 3: GPS-livespårning av aktiviteter
All checks were successful
release / build-release (push) Successful in 10m8s
All checks were successful
release / build-release (push) Successful in 10m8s
- TrackingService: foreground-service (location) med GPS-rutt, distans (brusfiltrerad), höjdmeter (glidande medel + tröskel), tid och live-kcal (ActivityKcal — Kotlin-port av serverns beräkning med tempokurvor) - Spårningsskärm: osmdroid/OSM-livekarta med rutt-polyline för distansaktiviteter; stor timer för tidsbaserade (rörelsespårning av, RPE-slider i sammanfattningen); statistikrad tid/distans/fart/höjd/kcal/GPS - Sammanfattning vid stopp: spara som source=tracked med encoded polyline, aktivitetsgissning på tempo+höjd - Starta aktivitet-knapp (GPS/timer per typ) + pågår-kort på hemskärmen - osmdroid 6.1.20; permissions för plats/foreground-service/notiser; v0.7.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import eu.brassepc.fitnessdroid.MainActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Läget för en pågående (eller nyss avslutad) aktivitetsspårning. */
|
||||
data class TrackingState(
|
||||
val isActive: Boolean = false,
|
||||
/** Aktivitetstypen som spåras */
|
||||
val typeId: Int = 0,
|
||||
val typeKey: String = "",
|
||||
val typeName: String = "",
|
||||
val category: String = "OTHER",
|
||||
val met: Double = 4.0,
|
||||
val isDistanceBased: Boolean = false,
|
||||
val startedAtEpochMs: Long = 0,
|
||||
val elapsedSeconds: Int = 0,
|
||||
val distanceMeters: Double = 0.0,
|
||||
val elevationGainMeters: Double = 0.0,
|
||||
/** Senaste GPS-position (lat, lon) + hela spåret */
|
||||
val points: List<Pair<Double, Double>> = emptyList(),
|
||||
val currentSpeedKmh: Double? = null,
|
||||
val kcal: Double? = null,
|
||||
val gpsFix: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class TrackingService : Service(), LocationListener {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private var locationManager: LocationManager? = null
|
||||
|
||||
private var weightKg: Double? = null
|
||||
private var lastLocation: Location? = null
|
||||
// Glidande medel av höjd för att filtrera GPS-brus innan höjdmeter summeras
|
||||
private val altitudeWindow = ArrayDeque<Double>()
|
||||
private var smoothedAltitude: Double? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
stopTracking()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
else -> {
|
||||
if (intent == null) return START_NOT_STICKY
|
||||
startTracking(intent)
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun startTracking(intent: Intent) {
|
||||
val distanceBased = intent.getBooleanExtra(EXTRA_DISTANCE_BASED, false)
|
||||
weightKg = intent.getDoubleExtra(EXTRA_WEIGHT_KG, -1.0).takeIf { it > 0 }
|
||||
|
||||
_state.value = TrackingState(
|
||||
isActive = true,
|
||||
typeId = intent.getIntExtra(EXTRA_TYPE_ID, 0),
|
||||
typeKey = intent.getStringExtra(EXTRA_TYPE_KEY) ?: "",
|
||||
typeName = intent.getStringExtra(EXTRA_TYPE_NAME) ?: "Aktivitet",
|
||||
category = intent.getStringExtra(EXTRA_CATEGORY) ?: "OTHER",
|
||||
met = intent.getDoubleExtra(EXTRA_MET, 4.0),
|
||||
isDistanceBased = distanceBased,
|
||||
startedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
if (distanceBased) startGps()
|
||||
|
||||
// Tick: tid + kcal + notis
|
||||
scope.launch {
|
||||
val startedElapsed = SystemClock.elapsedRealtime()
|
||||
while (_state.value.isActive) {
|
||||
val s = _state.value
|
||||
val elapsed = ((SystemClock.elapsedRealtime() - startedElapsed) / 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())
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startGps() {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) return
|
||||
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
runCatching {
|
||||
locationManager?.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
2000L, // ms
|
||||
3f, // meter
|
||||
this,
|
||||
Looper.getMainLooper(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLocationChanged(location: Location) {
|
||||
val s = _state.value
|
||||
if (!s.isActive || !s.isDistanceBased) return
|
||||
// Släng riktigt dåliga fixar
|
||||
if (location.hasAccuracy() && location.accuracy > 30f) return
|
||||
|
||||
var distance = s.distanceMeters
|
||||
var elevGain = s.elevationGainMeters
|
||||
|
||||
lastLocation?.let { prev ->
|
||||
val d = prev.distanceTo(location).toDouble()
|
||||
// Ignorera mikrobrus när man står stilla
|
||||
if (d >= 2.0) distance += d
|
||||
}
|
||||
|
||||
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
|
||||
_state.value = s.copy(
|
||||
distanceMeters = distance,
|
||||
elevationGainMeters = elevGain,
|
||||
points = s.points + (location.latitude to location.longitude),
|
||||
currentSpeedKmh = if (location.hasSpeed()) location.speed * 3.6 else null,
|
||||
gpsFix = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun stopTracking() {
|
||||
locationManager?.removeUpdates(this)
|
||||
_state.value = _state.value.copy(isActive = false)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
locationManager?.removeUpdates(this)
|
||||
if (_state.value.isActive) _state.value = _state.value.copy(isActive = false)
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
private fun createChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID, "Aktivitetsspårning", NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { description = "Pågående aktivitet (GPS/timer)" }
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val s = _state.value
|
||||
val open = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
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") }
|
||||
}
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||
.setContentTitle(s.typeName)
|
||||
.setContentText(text)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(open)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun formatElapsed(sec: Int): String {
|
||||
val h = sec / 3600
|
||||
val m = (sec % 3600) / 60
|
||||
val s = sec % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "activity_tracking"
|
||||
private const val NOTIFICATION_ID = 44
|
||||
|
||||
const val ACTION_STOP = "eu.brassepc.fitnessdroid.TRACKING_STOP"
|
||||
const val EXTRA_TYPE_ID = "typeId"
|
||||
const val EXTRA_TYPE_KEY = "typeKey"
|
||||
const val EXTRA_TYPE_NAME = "typeName"
|
||||
const val EXTRA_CATEGORY = "category"
|
||||
const val EXTRA_MET = "met"
|
||||
const val EXTRA_DISTANCE_BASED = "distanceBased"
|
||||
const val EXTRA_WEIGHT_KG = "weightKg"
|
||||
|
||||
private val _state = MutableStateFlow(TrackingState())
|
||||
/** Läses av UI:t — servicen äger sanningen. */
|
||||
val state: StateFlow<TrackingState> = _state.asStateFlow()
|
||||
|
||||
fun start(context: Context, type: ActivityType, weightKg: Double?) {
|
||||
val intent = Intent(context, TrackingService::class.java).apply {
|
||||
putExtra(EXTRA_TYPE_ID, type.id)
|
||||
putExtra(EXTRA_TYPE_KEY, type.key)
|
||||
putExtra(EXTRA_TYPE_NAME, type.nameSv)
|
||||
putExtra(EXTRA_CATEGORY, type.category)
|
||||
putExtra(EXTRA_MET, type.met)
|
||||
putExtra(EXTRA_DISTANCE_BASED, type.isDistanceBased)
|
||||
weightKg?.let { putExtra(EXTRA_WEIGHT_KG, it) }
|
||||
}
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.startService(
|
||||
Intent(context, TrackingService::class.java).apply { action = ACTION_STOP }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user