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:
@@ -29,7 +29,7 @@ android {
|
|||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = ciRunNumber ?: 1
|
versionCode = ciRunNumber ?: 1
|
||||||
versionName = "0.6.1" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
versionName = "0.7.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -82,6 +82,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.blessed.kotlin)
|
implementation(libs.blessed.kotlin)
|
||||||
|
implementation(libs.osmdroid)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
|
|||||||
@@ -11,6 +11,14 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||||
|
|
||||||
|
<!-- GPS-spårning av aktiviteter (foreground service med notis) -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".FitnessDroidApplication"
|
android:name=".FitnessDroidApplication"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -28,6 +36,11 @@
|
|||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.TrackingService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="location" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
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.profile.ProfileScreen
|
||||||
import eu.brassepc.fitnessdroid.ui.scale.ScalePairScreen
|
import eu.brassepc.fitnessdroid.ui.scale.ScalePairScreen
|
||||||
import eu.brassepc.fitnessdroid.ui.scale.WeighScreen
|
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.session.SessionScreen
|
||||||
import eu.brassepc.fitnessdroid.ui.settings.SettingsScreen
|
import eu.brassepc.fitnessdroid.ui.settings.SettingsScreen
|
||||||
import eu.brassepc.fitnessdroid.ui.stats.StatsScreen
|
import eu.brassepc.fitnessdroid.ui.stats.StatsScreen
|
||||||
@@ -62,6 +63,9 @@ object Routes {
|
|||||||
const val WEIGH = "weigh"
|
const val WEIGH = "weigh"
|
||||||
const val ABOUT = "about"
|
const val ABOUT = "about"
|
||||||
const val ACTIVITIES = "activities"
|
const val ACTIVITIES = "activities"
|
||||||
|
const val TRACK = "track/{typeId}"
|
||||||
|
|
||||||
|
fun track(typeId: Int) = "track/$typeId"
|
||||||
|
|
||||||
fun historyDetail(id: Int) = "history/$id"
|
fun historyDetail(id: Int) = "history/$id"
|
||||||
}
|
}
|
||||||
@@ -114,10 +118,23 @@ fun AppRoot(rootViewModel: RootViewModel = viewModel(factory = RootViewModel.Fac
|
|||||||
onOpenSession = { nav.navigate(Routes.SESSION) },
|
onOpenSession = { nav.navigate(Routes.SESSION) },
|
||||||
onOpenHistory = { nav.navigate(Routes.HISTORY) },
|
onOpenHistory = { nav.navigate(Routes.HISTORY) },
|
||||||
onOpenActivities = { nav.navigate(Routes.ACTIVITIES) },
|
onOpenActivities = { nav.navigate(Routes.ACTIVITIES) },
|
||||||
|
onOpenTrack = { typeId -> nav.navigate(Routes.track(typeId)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.ACTIVITIES) {
|
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) {
|
composable(Routes.HISTORY) {
|
||||||
HistoryScreen(onOpenSession = { id -> nav.navigate(Routes.historyDetail(id)) })
|
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.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -165,14 +166,31 @@ class ActivitiesViewModel(private val gymApi: GymApi) : ViewModel() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun ActivitiesScreen(
|
fun ActivitiesScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onStartTracking: (Int) -> Unit = {},
|
||||||
viewModel: ActivitiesViewModel = viewModel(factory = ActivitiesViewModel.Factory),
|
viewModel: ActivitiesViewModel = viewModel(factory = ActivitiesViewModel.Factory),
|
||||||
) {
|
) {
|
||||||
val types by viewModel.types.collectAsStateWithLifecycle()
|
val types by viewModel.types.collectAsStateWithLifecycle()
|
||||||
val activities by viewModel.activities.collectAsStateWithLifecycle()
|
val activities by viewModel.activities.collectAsStateWithLifecycle()
|
||||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||||
var showLog by remember { mutableStateOf(false) }
|
var showLog by remember { mutableStateOf(false) }
|
||||||
|
var showStartPicker by remember { mutableStateOf(false) }
|
||||||
var editTarget by remember { mutableStateOf<Activity?>(null) }
|
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) {
|
if (showLog || editTarget != null) {
|
||||||
LogActivityDialog(
|
LogActivityDialog(
|
||||||
types = types,
|
types = types,
|
||||||
@@ -210,7 +228,7 @@ fun ActivitiesScreen(
|
|||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
ExtendedFloatingActionButton(onClick = { showLog = true }) {
|
ExtendedFloatingActionButton(onClick = { showLog = true }) {
|
||||||
Icon(Icons.Default.Add, contentDescription = null)
|
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 ->
|
) { padding ->
|
||||||
@@ -221,6 +239,16 @@ fun ActivitiesScreen(
|
|||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.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 {
|
message?.let {
|
||||||
Text(
|
Text(
|
||||||
it,
|
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
|
@Composable
|
||||||
private fun ActivityRow(a: Activity, onClick: () -> Unit) {
|
private fun ActivityRow(a: Activity, onClick: () -> Unit) {
|
||||||
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ fun HomeScreen(
|
|||||||
onOpenSession: () -> Unit,
|
onOpenSession: () -> Unit,
|
||||||
onOpenHistory: () -> Unit,
|
onOpenHistory: () -> Unit,
|
||||||
onOpenActivities: () -> Unit = {},
|
onOpenActivities: () -> Unit = {},
|
||||||
|
onOpenTrack: (Int) -> Unit = {},
|
||||||
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
|
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@@ -79,6 +80,43 @@ fun HomeScreen(
|
|||||||
|
|
||||||
KcalTodayCard(kcalToday)
|
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 ->
|
updateState?.let { update ->
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
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)
|
||||||
|
}
|
||||||
@@ -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 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)*
|
- [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 4 — Mål & mätare
|
||||||
- [ ] Etapp 5 — Steg & telefondata
|
- [ ] Etapp 5 — Steg & telefondata
|
||||||
- [ ] Etapp 6 — Statistik & polish
|
- [ ] Etapp 6 — Statistik & polish
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ datastore = "1.1.1"
|
|||||||
room = "2.7.2"
|
room = "2.7.2"
|
||||||
ksp = "2.2.0-2.0.2"
|
ksp = "2.2.0-2.0.2"
|
||||||
blessedKotlin = "3.0.12"
|
blessedKotlin = "3.0.12"
|
||||||
|
osmdroid = "6.1.20"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", 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" }
|
blessed-kotlin = { group = "com.github.weliem", name = "blessed-kotlin", version.ref = "blessedKotlin" }
|
||||||
|
osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user