From 444028c91ed7ba9766d5f7845fff10883bbdccde Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 23 Aug 2026 23:18:21 +0200 Subject: [PATCH] =?UTF-8?q?Etapp=203:=20GPS-livesp=C3=A5rning=20av=20aktiv?= =?UTF-8?q?iteter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg --- app/build.gradle.kts | 3 +- app/src/main/AndroidManifest.xml | 13 + .../fitnessdroid/data/ActivityKcal.kt | 110 ++++ .../fitnessdroid/data/TrackingService.kt | 282 ++++++++++ .../eu/brassepc/fitnessdroid/ui/AppRoot.kt | 19 +- .../ui/activities/ActivitiesScreen.kt | 97 +++- .../fitnessdroid/ui/home/HomeScreen.kt | 38 ++ .../fitnessdroid/ui/track/TrackScreen.kt | 513 ++++++++++++++++++ doc/activities-plan.md | 2 +- gradle/libs.versions.toml | 2 + 10 files changed, 1075 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/eu/brassepc/fitnessdroid/data/ActivityKcal.kt create mode 100644 app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt create mode 100644 app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt 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" }