diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f599391..2bac046 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -29,7 +29,7 @@ android {
minSdk = 31
targetSdk = 35
versionCode = ciRunNumber ?: 1
- versionName = "0.6.1" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
+ versionName = "0.7.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
}
signingConfigs {
@@ -82,6 +82,7 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.okhttp)
implementation(libs.blessed.kotlin)
+ implementation(libs.osmdroid)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b959d0d..654cea9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,14 @@
+
+
+
+
+
+
+
+
+
+
0) {
+ (distanceMeters / 1000.0) / hours
+ } else null
+
+ if (isDistanceBased && speedKmh != null) {
+ effectiveMet = when (category) {
+ "WALK" -> walkingMet(speedKmh, hiking = false)
+ "HIKE" -> walkingMet(speedKmh, hiking = true)
+ "RUN" -> runningMet(speedKmh)
+ "CYCLE" -> cyclingMet(speedKmh)
+ else -> effectiveMet
+ }
+ } else if (!isDistanceBased && rpe != null) {
+ effectiveMet *= 0.5 + rpe.coerceIn(0.0, 10.0) / 10.0
+ }
+
+ var kcal = (max(effectiveMet, 1.0) - 1.0) * weightKg * hours
+ if (elevationGainMeters != null && elevationGainMeters > 0) {
+ kcal += 0.0067 * weightKg * elevationGainMeters
+ }
+ return kcal
+ }
+
+ private fun walkingMet(kmh: Double, hiking: Boolean): Double {
+ val met = when {
+ kmh < 3.2 -> 2.8
+ kmh < 4.0 -> 3.0
+ kmh < 4.8 -> 3.5
+ kmh < 5.6 -> 4.3
+ kmh < 6.4 -> 5.0
+ kmh < 7.2 -> 7.0
+ else -> 8.3
+ }
+ return if (hiking) max(met + 1.5, 5.3) else met
+ }
+
+ private fun runningMet(kmh: Double): Double = (kmh * 1.02).coerceIn(6.0, 19.0)
+
+ private fun cyclingMet(kmh: Double): Double = when {
+ kmh < 15.0 -> 4.0
+ kmh < 19.0 -> 5.8
+ kmh < 22.5 -> 8.0
+ kmh < 25.7 -> 10.0
+ kmh < 30.0 -> 12.0
+ else -> 15.8
+ }
+
+ /**
+ * Gissa aktivitetstyp (nyckel) för ett spårat distanspass — används för
+ * "Ser ut som X"-förslaget i sammanfattningen.
+ */
+ fun guessTypeKey(avgKmh: Double, elevationGainPerKm: Double): String = when {
+ avgKmh < 2.0 -> "walking_casual"
+ avgKmh < 6.5 -> if (elevationGainPerKm > 25) "hiking_forest" else "walking_city"
+ avgKmh < 9.0 -> "jogging"
+ avgKmh < 14.0 -> "running"
+ else -> "cycling"
+ }
+}
+
+/** Google encoded polyline (precision 1e5) — samma format som webben avkodar. */
+fun encodePolyline(points: List>): String {
+ val sb = StringBuilder()
+ var lastLat = 0L
+ var lastLon = 0L
+ for ((lat, lon) in points) {
+ val iLat = Math.round(lat * 1e5)
+ val iLon = Math.round(lon * 1e5)
+ encodeDiff(iLat - lastLat, sb)
+ encodeDiff(iLon - lastLon, sb)
+ lastLat = iLat
+ lastLon = iLon
+ }
+ return sb.toString()
+}
+
+private fun encodeDiff(diff: Long, sb: StringBuilder) {
+ var v = diff shl 1
+ if (diff < 0) v = v.inv()
+ while (v >= 0x20) {
+ sb.append((((v and 0x1f) or 0x20) + 63).toInt().toChar())
+ v = v shr 5
+ }
+ sb.append((v + 63).toInt().toChar())
+}
diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt
new file mode 100644
index 0000000..5a6f4a4
--- /dev/null
+++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt
@@ -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> = 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()
+ 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 = _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 }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt
index cc4f4f0..12b5945 100644
--- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt
+++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt
@@ -45,6 +45,7 @@ import eu.brassepc.fitnessdroid.ui.picker.ExercisePickerScreen
import eu.brassepc.fitnessdroid.ui.profile.ProfileScreen
import eu.brassepc.fitnessdroid.ui.scale.ScalePairScreen
import eu.brassepc.fitnessdroid.ui.scale.WeighScreen
+import eu.brassepc.fitnessdroid.ui.track.TrackScreen
import eu.brassepc.fitnessdroid.ui.session.SessionScreen
import eu.brassepc.fitnessdroid.ui.settings.SettingsScreen
import eu.brassepc.fitnessdroid.ui.stats.StatsScreen
@@ -62,6 +63,9 @@ object Routes {
const val WEIGH = "weigh"
const val ABOUT = "about"
const val ACTIVITIES = "activities"
+ const val TRACK = "track/{typeId}"
+
+ fun track(typeId: Int) = "track/$typeId"
fun historyDetail(id: Int) = "history/$id"
}
@@ -114,10 +118,23 @@ fun AppRoot(rootViewModel: RootViewModel = viewModel(factory = RootViewModel.Fac
onOpenSession = { nav.navigate(Routes.SESSION) },
onOpenHistory = { nav.navigate(Routes.HISTORY) },
onOpenActivities = { nav.navigate(Routes.ACTIVITIES) },
+ onOpenTrack = { typeId -> nav.navigate(Routes.track(typeId)) },
)
}
composable(Routes.ACTIVITIES) {
- ActivitiesScreen(onBack = { nav.popBackStack() })
+ ActivitiesScreen(
+ onBack = { nav.popBackStack() },
+ onStartTracking = { typeId -> nav.navigate(Routes.track(typeId)) },
+ )
+ }
+ composable(
+ Routes.TRACK,
+ arguments = listOf(navArgument("typeId") { type = NavType.IntType }),
+ ) { entry ->
+ TrackScreen(
+ typeId = entry.arguments?.getInt("typeId") ?: 0,
+ onBack = { nav.popBackStack() },
+ )
}
composable(Routes.HISTORY) {
HistoryScreen(onOpenSession = { id -> nav.navigate(Routes.historyDetail(id)) })
diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt
index c00f550..3f673c7 100644
--- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt
+++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt
@@ -16,6 +16,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.PlayArrow
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -165,14 +166,31 @@ class ActivitiesViewModel(private val gymApi: GymApi) : ViewModel() {
@Composable
fun ActivitiesScreen(
onBack: () -> Unit,
+ onStartTracking: (Int) -> Unit = {},
viewModel: ActivitiesViewModel = viewModel(factory = ActivitiesViewModel.Factory),
) {
val types by viewModel.types.collectAsStateWithLifecycle()
val activities by viewModel.activities.collectAsStateWithLifecycle()
val message by viewModel.message.collectAsStateWithLifecycle()
var showLog by remember { mutableStateOf(false) }
+ var showStartPicker by remember { mutableStateOf(false) }
var editTarget by remember { mutableStateOf(null) }
+ // Uppdatera listan när man kommer tillbaka (t.ex. efter avslutad spårning)
+ androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() }
+
+ if (showStartPicker) {
+ TypePickerDialog(
+ types = types,
+ title = "Starta aktivitet",
+ onPick = { t ->
+ showStartPicker = false
+ onStartTracking(t.id)
+ },
+ onDismiss = { showStartPicker = false },
+ )
+ }
+
if (showLog || editTarget != null) {
LogActivityDialog(
types = types,
@@ -210,7 +228,7 @@ fun ActivitiesScreen(
floatingActionButton = {
ExtendedFloatingActionButton(onClick = { showLog = true }) {
Icon(Icons.Default.Add, contentDescription = null)
- Text("Logga aktivitet", modifier = Modifier.padding(start = 6.dp))
+ Text("Logga manuellt", modifier = Modifier.padding(start = 6.dp))
}
},
) { padding ->
@@ -221,6 +239,16 @@ fun ActivitiesScreen(
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
+ Button(
+ onClick = { showStartPicker = true },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(Icons.Default.PlayArrow, contentDescription = null)
+ Text(
+ "Starta aktivitet — GPS eller timer",
+ modifier = Modifier.padding(start = 6.dp),
+ )
+ }
message?.let {
Text(
it,
@@ -246,6 +274,73 @@ fun ActivitiesScreen(
}
}
+/** Kategorigrupperad aktivitetsväljare — används av både start- och loggflödet. */
+@Composable
+fun TypePickerDialog(
+ types: List,
+ title: String,
+ onPick: (ActivityType) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ if (types.isEmpty()) {
+ Text(
+ "Aktivitetsbiblioteket kunde inte hämtas — offline?",
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ CATEGORY_LABELS.forEach { (cat, label) ->
+ val inCat = types.filter { it.category == cat }
+ if (inCat.isNotEmpty()) {
+ Text(
+ label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ inCat.forEach { t ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onPick(t) }
+ .padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (t.isCardio) {
+ Icon(
+ Icons.Default.Favorite,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(end = 8.dp),
+ )
+ }
+ Column {
+ Text(t.nameSv)
+ Text(
+ if (t.isDistanceBased) "GPS + distans" else "Timer (ingen rörelsespårning)",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text("Avbryt") }
+ },
+ )
+}
+
@Composable
private fun ActivityRow(a: Activity, onClick: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt
index 9af1885..ca6cabd 100644
--- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt
+++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt
@@ -44,6 +44,7 @@ fun HomeScreen(
onOpenSession: () -> Unit,
onOpenHistory: () -> Unit,
onOpenActivities: () -> Unit = {},
+ onOpenTrack: (Int) -> Unit = {},
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -79,6 +80,43 @@ fun HomeScreen(
KcalTodayCard(kcalToday)
+ val trackingState by eu.brassepc.fitnessdroid.data.TrackingService.state.collectAsStateWithLifecycle()
+ if (trackingState.isActive) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onOpenTrack(trackingState.typeId) },
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ Icons.Default.Favorite,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
+ Text(
+ "${trackingState.typeName} pågår",
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Text(
+ buildString {
+ val s = trackingState.elapsedSeconds
+ append("%d:%02d".format(s / 60, s % 60))
+ if (trackingState.isDistanceBased) {
+ append(" · ${"%.2f".format(trackingState.distanceMeters / 1000)} km")
+ }
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+
updateState?.let { update ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt
new file mode 100644
index 0000000..3cd4519
--- /dev/null
+++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt
@@ -0,0 +1,513 @@
+package eu.brassepc.fitnessdroid.ui.track
+
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.rememberScrollState
+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.Stop
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+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.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import eu.brassepc.fitnessdroid.data.ActivityKcal
+import eu.brassepc.fitnessdroid.data.ActivityType
+import eu.brassepc.fitnessdroid.data.GymApi
+import eu.brassepc.fitnessdroid.data.GymRepository
+import eu.brassepc.fitnessdroid.data.TrackingService
+import eu.brassepc.fitnessdroid.data.TrackingState
+import eu.brassepc.fitnessdroid.data.encodePolyline
+import eu.brassepc.fitnessdroid.ui.activities.RPE_LABELS
+import eu.brassepc.fitnessdroid.ui.appContainer
+import eu.brassepc.fitnessdroid.ui.common.compact
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import org.osmdroid.config.Configuration
+import org.osmdroid.tileprovider.tilesource.TileSourceFactory
+import org.osmdroid.util.GeoPoint
+import org.osmdroid.views.MapView
+import org.osmdroid.views.overlay.Polyline
+import java.io.File
+import java.time.Instant
+
+class TrackViewModel(
+ private val gymApi: GymApi,
+ private val repo: GymRepository,
+) : ViewModel() {
+ val tracking = TrackingService.state
+ val message = MutableStateFlow(null)
+ val saved = MutableStateFlow(false)
+ /** Sammanfattningen som visas efter stopp (null = spårning pågår/ej startad) */
+ val summary = MutableStateFlow(null)
+
+ var pendingType: ActivityType? = null
+ private set
+
+ fun loadType(typeId: Int, onMissing: () -> Unit) {
+ viewModelScope.launch {
+ if (TrackingService.state.value.isActive) return@launch
+ runCatching { gymApi.activityTypes().find { it.id == typeId } }
+ .getOrNull()
+ ?.let { pendingType = it }
+ ?: onMissing()
+ }
+ }
+
+ fun start(context: android.content.Context) {
+ val type = pendingType ?: return
+ viewModelScope.launch {
+ TrackingService.start(context, type, repo.bodyWeightKg())
+ }
+ }
+
+ fun stop(context: android.content.Context) {
+ summary.value = tracking.value
+ TrackingService.stop(context)
+ }
+
+ fun save(rpe: Double?, overrideTypeId: Int?) {
+ val s = summary.value ?: return
+ viewModelScope.launch {
+ try {
+ gymApi.addActivity(
+ activityTypeId = overrideTypeId ?: s.typeId,
+ startedAtIso = Instant.ofEpochMilli(s.startedAtEpochMs).toString(),
+ durationSeconds = s.elapsedSeconds.coerceAtLeast(1),
+ distanceMeters = s.distanceMeters.takeIf { s.isDistanceBased && it > 10 },
+ elevationGainMeters = s.elevationGainMeters.takeIf { s.isDistanceBased && it > 1 },
+ rpe = rpe,
+ // Servern räknar om kcal med RPE för tidsbaserade; för
+ // distans skickar vi inget heller — samma formel som live.
+ source = "tracked",
+ routePolyline = s.points.takeIf { it.size >= 2 }?.let { encodePolyline(it) },
+ )
+ saved.value = true
+ message.value = null
+ } catch (e: Exception) {
+ message.value = "Kunde inte spara — offline? Försök igen."
+ }
+ }
+ }
+
+ companion object {
+ val Factory: ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ val c = appContainer()
+ TrackViewModel(c.gymApi, c.gymRepository)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TrackScreen(
+ typeId: Int,
+ onBack: () -> Unit,
+ viewModel: TrackViewModel = viewModel(factory = TrackViewModel.Factory),
+) {
+ val context = LocalContext.current
+ val tracking by viewModel.tracking.collectAsStateWithLifecycle()
+ val summary by viewModel.summary.collectAsStateWithLifecycle()
+ val saved by viewModel.saved.collectAsStateWithLifecycle()
+ val message by viewModel.message.collectAsStateWithLifecycle()
+
+ var permissionDenied by remember { mutableStateOf(false) }
+ var started by remember { mutableStateOf(false) }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { granted ->
+ // Notis-permission är trevlig men inte nödvändig; GPS krävs för distans
+ val fineOk = granted[Manifest.permission.ACCESS_FINE_LOCATION] ?: false
+ val needsGps = viewModel.pendingType?.isDistanceBased == true
+ if (!needsGps || fineOk) {
+ viewModel.start(context)
+ started = true
+ } else {
+ permissionDenied = true
+ }
+ }
+
+ // Ladda typen och starta (om inte en spårning redan pågår)
+ LaunchedEffect(typeId) {
+ if (tracking.isActive) { started = true; return@LaunchedEffect }
+ viewModel.loadType(typeId, onMissing = onBack)
+ // Vänta in typen
+ while (viewModel.pendingType == null && !tracking.isActive) {
+ kotlinx.coroutines.delay(100)
+ }
+ val type = viewModel.pendingType ?: return@LaunchedEffect
+ val needed = buildList {
+ if (type.isDistanceBased) {
+ add(Manifest.permission.ACCESS_FINE_LOCATION)
+ add(Manifest.permission.ACCESS_COARSE_LOCATION)
+ }
+ add(Manifest.permission.POST_NOTIFICATIONS)
+ }.filter {
+ ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
+ }
+ if (needed.isEmpty()) {
+ viewModel.start(context)
+ started = true
+ } else {
+ permissionLauncher.launch(needed.toTypedArray())
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.Favorite,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ tracking.typeName.ifBlank { viewModel.pendingType?.nameSv ?: "Aktivitet" },
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ when {
+ summary != null -> SummaryContent(
+ summary = summary!!,
+ saved = saved,
+ message = message,
+ onSave = { rpe, overrideId -> viewModel.save(rpe, overrideId) },
+ onDone = onBack,
+ )
+ permissionDenied -> Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ "Platsbehörighet nekades — GPS-spårning kräver den. " +
+ "Ge appen platsåtkomst i systeminställningarna och försök igen.",
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ !tracking.isActive && !started -> Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) { CircularProgressIndicator() }
+ else -> LiveContent(
+ tracking = tracking,
+ onStop = { viewModel.stop(context) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun LiveContent(tracking: TrackingState, onStop: () -> Unit) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ if (tracking.isDistanceBased) {
+ TrackMap(
+ points = tracking.points,
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ formatElapsed(tracking.elapsedSeconds),
+ style = MaterialTheme.typography.displayLarge,
+ )
+ Text(
+ "Rörelsespårning avstängd — bara tiden loggas",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
+ Column(
+ modifier = Modifier.padding(14.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ 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("Kcal", tracking.kcal?.toInt()?.toString() ?: "—", Modifier.weight(1f))
+ }
+ if (tracking.isDistanceBased) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ StatBox("Höjdmeter", "+${tracking.elevationGainMeters.toInt()} m", Modifier.weight(1f))
+ StatBox(
+ "Snittfart",
+ if (tracking.elapsedSeconds > 30 && tracking.distanceMeters > 10) {
+ val kmh = (tracking.distanceMeters / 1000) / (tracking.elapsedSeconds / 3600.0)
+ "${kmh.compact()} km/h"
+ } else "—",
+ Modifier.weight(1f),
+ )
+ StatBox("GPS", if (tracking.gpsFix) "OK" else "söker…", Modifier.weight(1f))
+ }
+ }
+ 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))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatBox(label: String, value: String, modifier: Modifier = Modifier) {
+ Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(value, style = MaterialTheme.typography.titleMedium)
+ }
+}
+
+@Composable
+private fun TrackMap(points: List>, modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ val lineColor = MaterialTheme.colorScheme.primary.toArgb()
+
+ val mapView = remember {
+ Configuration.getInstance().apply {
+ userAgentValue = "FitnessDroid"
+ osmdroidBasePath = File(context.cacheDir, "osmdroid")
+ osmdroidTileCache = File(context.cacheDir, "osmdroid/tiles")
+ }
+ MapView(context).apply {
+ setTileSource(TileSourceFactory.MAPNIK)
+ setMultiTouchControls(true)
+ controller.setZoom(17.0)
+ }
+ }
+ val polyline = remember {
+ Polyline().apply {
+ outlinePaint.color = lineColor
+ outlinePaint.strokeWidth = 10f
+ }.also { mapView.overlays.add(it) }
+ }
+
+ DisposableEffect(Unit) {
+ mapView.onResume()
+ onDispose {
+ mapView.onPause()
+ mapView.onDetach()
+ }
+ }
+
+ AndroidView(
+ factory = { mapView },
+ modifier = modifier,
+ update = {
+ if (points.isNotEmpty()) {
+ polyline.setPoints(points.map { (lat, lon) -> GeoPoint(lat, lon) })
+ val last = points.last()
+ it.controller.animateTo(GeoPoint(last.first, last.second))
+ it.invalidate()
+ }
+ },
+ )
+}
+
+@Composable
+private fun SummaryContent(
+ summary: TrackingState,
+ saved: Boolean,
+ message: String?,
+ onSave: (Double?, Int?) -> Unit,
+ onDone: () -> Unit,
+) {
+ var rpe by remember { mutableStateOf(5f) }
+
+ // Aktivitetsgissning: föreslå bättre matchande typ utifrån tempo + höjd
+ val guessKey = remember(summary) {
+ if (summary.isDistanceBased && summary.distanceMeters > 200 && summary.elapsedSeconds > 60) {
+ val kmh = (summary.distanceMeters / 1000) / (summary.elapsedSeconds / 3600.0)
+ val elevPerKm = summary.elevationGainMeters / (summary.distanceMeters / 1000)
+ ActivityKcal.guessTypeKey(kmh, elevPerKm).takeIf { it != summary.typeKey }
+ } else null
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Text("Sammanfattning", style = MaterialTheme.typography.headlineSmall)
+
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ SummaryRow("Aktivitet", summary.typeName)
+ SummaryRow("Tid", formatElapsed(summary.elapsedSeconds))
+ if (summary.isDistanceBased) {
+ SummaryRow("Distans", "${(summary.distanceMeters / 1000).compact()} km")
+ SummaryRow("Höjdmeter", "+${summary.elevationGainMeters.toInt()} m")
+ if (summary.elapsedSeconds > 0 && summary.distanceMeters > 10) {
+ val kmh = (summary.distanceMeters / 1000) / (summary.elapsedSeconds / 3600.0)
+ SummaryRow("Snittfart", "${kmh.compact()} km/h")
+ }
+ }
+ summary.kcal?.let { SummaryRow("Kcal (preliminärt)", "${it.toInt()}") }
+
+ guessKey?.let {
+ Text(
+ "Tempot ser ut som \"$it\" — aktiviteten sparas som " +
+ "${summary.typeName}, rätta i efterhand om det blev fel.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ if (!summary.isDistanceBased) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text("Hur ansträngande var det?", style = MaterialTheme.typography.titleSmall)
+ Text(
+ RPE_LABELS[rpe.toInt().coerceIn(0, 10)],
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Slider(value = rpe, onValueChange = { rpe = it }, valueRange = 0f..10f, steps = 9)
+ Text(
+ "Används för kaloriberäkningen — från \"ingen ansträngning\" " +
+ "till \"kan inte prata mer än ett par ord\".",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ message?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+
+ if (saved) {
+ Text(
+ "Sparad ✓",
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Klar") }
+ } else {
+ Button(
+ onClick = {
+ onSave(if (!summary.isDistanceBased) rpe.toInt().toDouble() else null, null)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = summary.elapsedSeconds > 0,
+ ) { Text("Spara aktiviteten") }
+ TextButton(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
+ Text("Släng utan att spara", color = MaterialTheme.colorScheme.error)
+ }
+ }
+ }
+}
+
+@Composable
+private fun SummaryRow(label: String, value: String) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ label,
+ modifier = Modifier.weight(1f),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(value)
+ }
+}
+
+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 "%02d:%02d".format(m, s)
+}
diff --git a/doc/activities-plan.md b/doc/activities-plan.md
index 46f7958..a8af157 100644
--- a/doc/activities-plan.md
+++ b/doc/activities-plan.md
@@ -101,7 +101,7 @@ frontend av `src/**` m.fl., FitnessDroid av varje push till main.)
- [x] Etapp 1 — Profil & kalorigrund *(kod klar 2026-08-23: backend a1cd4d3, webb 560894c, app — väntar på användartest + omdeploy av gym-api/frontend-containrarna)*
- [x] Etapp 2 — Aktiviteter: bibliotek + manuell logg *(kod klar 2026-08-23: backend 9a81cf6, webb — aktivitetsflik, app 0.6.0; väntar på användartest)*
-- [ ] Etapp 3 — Live-spårning (GPS)
+- [x] Etapp 3 — Live-spårning (GPS) *(kod klar 2026-08-23: ingen backend-ändring krävdes; webb Leaflet-rutt i historiken; app 0.7.0 — osmdroid-livekarta, foreground-service, timer/RPE-läge, aktivitetsgissning, polyline; väntar på användartest)*
- [ ] Etapp 4 — Mål & mätare
- [ ] Etapp 5 — Steg & telefondata
- [ ] Etapp 6 — Statistik & polish
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ed05d4c..1c9a272 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -12,6 +12,7 @@ datastore = "1.1.1"
room = "2.7.2"
ksp = "2.2.0-2.0.2"
blessedKotlin = "3.0.12"
+osmdroid = "6.1.20"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -33,6 +34,7 @@ androidx-room-runtime = { group = "androidx.room", name = "room-runtime", versio
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
blessed-kotlin = { group = "com.github.weliem", name = "blessed-kotlin", version.ref = "blessedKotlin" }
+osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }