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:
110
app/src/main/java/eu/brassepc/fitnessdroid/data/ActivityKcal.kt
Normal file
110
app/src/main/java/eu/brassepc/fitnessdroid/data/ActivityKcal.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Kcal-uppskattning för aktiviteter — Kotlin-port av serverns
|
||||
* ActivityCalorieService så att live-siffran under spårning stämmer med det
|
||||
* som sparas. Netto-kcal (MET−1) eftersom BMR räknas separat i dagsmätaren.
|
||||
*/
|
||||
object ActivityKcal {
|
||||
|
||||
fun estimate(
|
||||
met: Double,
|
||||
category: String,
|
||||
isDistanceBased: Boolean,
|
||||
weightKg: Double?,
|
||||
durationSeconds: Int,
|
||||
distanceMeters: Double?,
|
||||
elevationGainMeters: Double?,
|
||||
rpe: Double?,
|
||||
): Double? {
|
||||
if (weightKg == null || weightKg <= 0 || durationSeconds <= 0) return null
|
||||
|
||||
val hours = durationSeconds / 3600.0
|
||||
var effectiveMet = met
|
||||
val speedKmh = if (distanceMeters != null && distanceMeters > 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<Pair<Double, Double>>): 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())
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) })
|
||||
|
||||
@@ -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<Activity?>(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<ActivityType>,
|
||||
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)) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String?>(null)
|
||||
val saved = MutableStateFlow(false)
|
||||
/** Sammanfattningen som visas efter stopp (null = spårning pågår/ej startad) */
|
||||
val summary = MutableStateFlow<TrackingState?>(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<Pair<Double, Double>>, 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)
|
||||
}
|
||||
Reference in New Issue
Block a user