Etapp 3-feedback: GPS-väntfas, offline-kö för aktiviteter, teknisk logg
All checks were successful
release / build-release (push) Successful in 4m10s

1. Start väntar på GPS-fix: klockan startar vid första fixen eller via
   'Starta ändå'-knappen (fasen SEARCHING_GPS → ACTIVE); tid utan fix
   räknas inte längre
2. Offline-sparning: aktiviteter (spårade + manuellt loggade) köas i Room
   (pending_activity, migration v5) och synkas av samma FIFO-kö som passen
   (OpKind.ADD_ACTIVITY); 'väntar på synk'-sektion i aktivitetslistan
3. Teknisk logg (TrackLog): ringbuffert med GPS-fixar (accepterade/förkastade
   + noggrannhet), fasbyten och sparningar — expanderbar på spårskärmen med
   'Dela loggen'-knapp för enkel fältfelsökning

Version 0.8.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg
This commit is contained in:
2026-08-25 08:55:34 +02:00
parent 80a758a097
commit 2f6c55e967
11 changed files with 345 additions and 20 deletions

View File

@@ -29,7 +29,7 @@ android {
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = ciRunNumber ?: 1 versionCode = ciRunNumber ?: 1
versionName = "0.7.2" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") versionName = "0.8.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
} }
signingConfigs { signingConfigs {

View File

@@ -162,6 +162,20 @@ class GymRepository(
suspend fun bodyWeightKg(): Double? = store.bodyWeightKg() suspend fun bodyWeightKg(): Double? = store.bodyWeightKg()
suspend fun displayName(): String? = store.displayName() suspend fun displayName(): String? = store.displayName()
/* ---------- Aktiviteter (offline-kö) ---------- */
/** Aktiviteter som väntar på synk — visas överst i aktivitetslistan. */
val pendingActivities = db.opDao().pendingActivities()
/**
* Köa en aktivitet lokalt och låt synk-kön skicka den när nätet är
* tillbaka (samma mönster som passen). Servern räknar ut kcal vid synk.
*/
suspend fun queueActivity(activity: eu.brassepc.fitnessdroid.data.local.PendingActivity) {
val id = db.opDao().enqueueActivity(activity)
enqueue(OpKind.ADD_ACTIVITY, id)
}
/* ---------- Kroppsmätningar ---------- */ /* ---------- Kroppsmätningar ---------- */
suspend fun fetchBodyMeasurements(): List<BodyMeasurement> { suspend fun fetchBodyMeasurements(): List<BodyMeasurement> {

View File

@@ -24,6 +24,8 @@ object OpKind {
/** targetLocalId bär server-id:t — den lokala raden är redan borta */ /** targetLocalId bär server-id:t — den lokala raden är redan borta */
const val REMOVE_SET = "remove_set" const val REMOVE_SET = "remove_set"
const val COMPLETE_SESSION = "complete_session" const val COMPLETE_SESSION = "complete_session"
/** targetLocalId pekar på pending_activity-raden */
const val ADD_ACTIVITY = "add_activity"
} }
enum class SyncStatus { SYNCED, PENDING, OFFLINE, ERROR } enum class SyncStatus { SYNCED, PENDING, OFFLINE, ERROR }
@@ -179,6 +181,28 @@ class SyncEngine(
) )
} }
OpKind.ADD_ACTIVITY -> {
val a = db.opDao().pendingActivity(op.targetLocalId) ?: return
client.execute(
"mutation(\$input: AddActivityInput!){addActivity(input:\$input){id}}",
buildJsonObject {
put("input", buildJsonObject {
put("activityTypeId", a.activityTypeId)
put("startedAt", a.startedAtIso)
put("durationSeconds", a.durationSeconds)
a.distanceMeters?.let { put("distanceMeters", it) }
a.elevationGainMeters?.let { put("elevationGainMeters", it) }
a.rpe?.let { put("rpe", it) }
put("source", a.source)
a.routePolyline?.let { put("routePolyline", it) }
a.notes?.let { put("notes", it) }
})
},
auth.bearerToken(),
)
db.opDao().activityDone(op.targetLocalId)
}
OpKind.COMPLETE_SESSION -> { OpKind.COMPLETE_SESSION -> {
val session = dao.session(op.targetLocalId) ?: return val session = dao.session(op.targetLocalId) ?: return
val sessionServerId = session.serverId val sessionServerId = session.serverId

View File

@@ -0,0 +1,32 @@
package eu.brassepc.fitnessdroid.data
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.time.LocalTime
import java.time.format.DateTimeFormatter
/**
* Teknisk logg för aktivitetsspårningen — ringbuffert som visas i UI:t
* ("Teknisk logg" på spårskärmen) och kan delas, så fältfel går att felsöka
* utan adb. Speglas till logcat (tag TrackLog).
*/
object TrackLog {
private const val MAX_LINES = 400
private val fmt = DateTimeFormatter.ofPattern("HH:mm:ss")
private val _lines = MutableStateFlow<List<String>>(emptyList())
val lines: StateFlow<List<String>> = _lines
fun log(msg: String) {
val line = "${LocalTime.now().format(fmt)} $msg"
Log.d("TrackLog", msg)
_lines.value = (_lines.value + line).takeLast(MAX_LINES)
}
fun clear() {
_lines.value = emptyList()
}
fun asText(): String = _lines.value.joinToString("\n")
}

View File

@@ -28,9 +28,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** Spårningens fas: söker GPS-fix innan klockan startar, sen aktiv. */
enum class TrackingPhase { IDLE, SEARCHING_GPS, ACTIVE }
/** Läget för en pågående (eller nyss avslutad) aktivitetsspårning. */ /** Läget för en pågående (eller nyss avslutad) aktivitetsspårning. */
data class TrackingState( data class TrackingState(
val isActive: Boolean = false, val isActive: Boolean = false,
val phase: TrackingPhase = TrackingPhase.IDLE,
/** Aktivitetstypen som spåras */ /** Aktivitetstypen som spåras */
val typeId: Int = 0, val typeId: Int = 0,
val typeKey: String = "", val typeKey: String = "",
@@ -61,6 +65,7 @@ class TrackingService : Service(), LocationListener {
private var locationManager: LocationManager? = null private var locationManager: LocationManager? = null
private var weightKg: Double? = null private var weightKg: Double? = null
private var activeSinceElapsed = 0L
private var lastLocation: Location? = null private var lastLocation: Location? = null
// Glidande medel av höjd för att filtrera GPS-brus innan höjdmeter summeras // Glidande medel av höjd för att filtrera GPS-brus innan höjdmeter summeras
private val altitudeWindow = ArrayDeque<Double>() private val altitudeWindow = ArrayDeque<Double>()
@@ -77,6 +82,10 @@ class TrackingService : Service(), LocationListener {
stopTracking() stopTracking()
return START_NOT_STICKY return START_NOT_STICKY
} }
ACTION_START_NOW -> {
activate("starta ändå")
return START_STICKY
}
else -> { else -> {
if (intent == null) return START_NOT_STICKY if (intent == null) return START_NOT_STICKY
startTracking(intent) startTracking(intent)
@@ -91,6 +100,7 @@ class TrackingService : Service(), LocationListener {
_state.value = TrackingState( _state.value = TrackingState(
isActive = true, isActive = true,
phase = if (distanceBased) TrackingPhase.SEARCHING_GPS else TrackingPhase.ACTIVE,
typeId = intent.getIntExtra(EXTRA_TYPE_ID, 0), typeId = intent.getIntExtra(EXTRA_TYPE_ID, 0),
typeKey = intent.getStringExtra(EXTRA_TYPE_KEY) ?: "", typeKey = intent.getStringExtra(EXTRA_TYPE_KEY) ?: "",
typeName = intent.getStringExtra(EXTRA_TYPE_NAME) ?: "Aktivitet", typeName = intent.getStringExtra(EXTRA_TYPE_NAME) ?: "Aktivitet",
@@ -99,6 +109,11 @@ class TrackingService : Service(), LocationListener {
isDistanceBased = distanceBased, isDistanceBased = distanceBased,
startedAtEpochMs = System.currentTimeMillis(), startedAtEpochMs = System.currentTimeMillis(),
) )
TrackLog.clear()
TrackLog.log(
"start: ${_state.value.typeName} (distans=$distanceBased, vikt=${weightKg ?: "?"} kg)" +
if (distanceBased) " — väntar på GPS-fix" else ""
)
val type = if (distanceBased) { val type = if (distanceBased) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
@@ -110,12 +125,16 @@ class TrackingService : Service(), LocationListener {
if (distanceBased) startGps() if (distanceBased) startGps()
// Tick: tid + kcal + notis // Tick: tid + kcal + notis. Klockan räknar från att fasen blev ACTIVE.
scope.launch { scope.launch {
val startedElapsed = SystemClock.elapsedRealtime()
while (_state.value.isActive) { while (_state.value.isActive) {
val s = _state.value val s = _state.value
val elapsed = ((SystemClock.elapsedRealtime() - startedElapsed) / 1000).toInt() if (s.phase != TrackingPhase.ACTIVE) {
delay(500)
continue
}
if (activeSinceElapsed == 0L) activeSinceElapsed = SystemClock.elapsedRealtime()
val elapsed = ((SystemClock.elapsedRealtime() - activeSinceElapsed) / 1000).toInt()
_state.value = s.copy( _state.value = s.copy(
elapsedSeconds = elapsed, elapsedSeconds = elapsed,
kcal = ActivityKcal.estimate( kcal = ActivityKcal.estimate(
@@ -141,8 +160,12 @@ class TrackingService : Service(), LocationListener {
private fun startGps() { private fun startGps() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED != PackageManager.PERMISSION_GRANTED
) return ) {
TrackLog.log("FEL: platsbehörighet saknas — ingen GPS-lyssning")
return
}
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
TrackLog.log("GPS-lyssning begärd (GPS_PROVIDER, 2 s / 3 m)")
runCatching { runCatching {
locationManager?.requestLocationUpdates( locationManager?.requestLocationUpdates(
LocationManager.GPS_PROVIDER, LocationManager.GPS_PROVIDER,
@@ -154,11 +177,31 @@ class TrackingService : Service(), LocationListener {
} }
} }
/** Gå från GPS-sökning till aktiv (klockan börjar ticka). */
private fun activate(reason: String) {
val s = _state.value
if (!s.isActive || s.phase == TrackingPhase.ACTIVE) return
TrackLog.log("aktiverad ($reason)")
_state.value = s.copy(
phase = TrackingPhase.ACTIVE,
startedAtEpochMs = System.currentTimeMillis(),
)
}
override fun onLocationChanged(location: Location) { override fun onLocationChanged(location: Location) {
val s = _state.value val s = _state.value
if (!s.isActive || !s.isDistanceBased) return if (!s.isActive || !s.isDistanceBased) return
val acc = if (location.hasAccuracy()) location.accuracy else -1f
// Släng riktigt dåliga fixar // Släng riktigt dåliga fixar
if (location.hasAccuracy() && location.accuracy > 30f) return if (location.hasAccuracy() && location.accuracy > 30f) {
TrackLog.log("fix förkastad: noggrannhet ${"%.0f".format(acc)} m (>30)")
return
}
TrackLog.log(
"fix: acc=${"%.0f".format(acc)} m alt=${if (location.hasAltitude()) "%.0f".format(location.altitude) else "?"}" +
" fart=${if (location.hasSpeed()) "%.1f".format(location.speed * 3.6) else "?"} km/h"
)
if (s.phase == TrackingPhase.SEARCHING_GPS) activate("GPS-fix")
var distance = s.distanceMeters var distance = s.distanceMeters
var elevGain = s.elevationGainMeters var elevGain = s.elevationGainMeters
@@ -192,6 +235,11 @@ class TrackingService : Service(), LocationListener {
} }
private fun stopTracking() { private fun stopTracking() {
val s = _state.value
TrackLog.log(
"stopp: ${s.elapsedSeconds}s, ${"%.0f".format(s.distanceMeters)} m, " +
"+${"%.0f".format(s.elevationGainMeters)} hm, ${s.points.size} punkter, kcal=${s.kcal?.toInt() ?: "?"}"
)
locationManager?.removeUpdates(this) locationManager?.removeUpdates(this)
_state.value = _state.value.copy(isActive = false) _state.value = _state.value.copy(isActive = false)
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
@@ -248,6 +296,7 @@ class TrackingService : Service(), LocationListener {
private const val NOTIFICATION_ID = 44 private const val NOTIFICATION_ID = 44
const val ACTION_STOP = "eu.brassepc.fitnessdroid.TRACKING_STOP" const val ACTION_STOP = "eu.brassepc.fitnessdroid.TRACKING_STOP"
const val ACTION_START_NOW = "eu.brassepc.fitnessdroid.TRACKING_START_NOW"
const val EXTRA_TYPE_ID = "typeId" const val EXTRA_TYPE_ID = "typeId"
const val EXTRA_TYPE_KEY = "typeKey" const val EXTRA_TYPE_KEY = "typeKey"
const val EXTRA_TYPE_NAME = "typeName" const val EXTRA_TYPE_NAME = "typeName"
@@ -278,5 +327,12 @@ class TrackingService : Service(), LocationListener {
Intent(context, TrackingService::class.java).apply { action = ACTION_STOP } Intent(context, TrackingService::class.java).apply { action = ACTION_STOP }
) )
} }
/** "Starta ändå" — börja räkna tid utan att vänta på GPS-fixen. */
fun startNow(context: Context) {
context.startService(
Intent(context, TrackingService::class.java).apply { action = ACTION_START_NOW }
)
}
} }
} }

View File

@@ -18,8 +18,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
CachedMuscle::class, CachedMuscle::class,
CachedStartCard::class, CachedStartCard::class,
CachedPb::class, CachedPb::class,
PendingActivity::class,
], ],
version = 4, version = 5,
exportSchema = false, exportSchema = false,
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -51,9 +52,24 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `pending_activity` (" +
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`activityTypeId` INTEGER NOT NULL, `typeName` TEXT NOT NULL, " +
"`isCardio` INTEGER NOT NULL DEFAULT 0, " +
"`startedAtIso` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, " +
"`distanceMeters` REAL, `elevationGainMeters` REAL, `rpe` REAL, " +
"`source` TEXT NOT NULL DEFAULT 'manual', " +
"`routePolyline` TEXT, `notes` TEXT)"
)
}
}
fun build(context: Context): AppDatabase = fun build(context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "fitnessdroid.db") Room.databaseBuilder(context, AppDatabase::class.java, "fitnessdroid.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build() .build()
} }

View File

@@ -80,6 +80,20 @@ interface OpDao {
@Query("SELECT COUNT(*) FROM pending_op") @Query("SELECT COUNT(*) FROM pending_op")
suspend fun pendingCountNow(): Int suspend fun pendingCountNow(): Int
/* ---- Aktiviteter loggade offline ---- */
@Insert
suspend fun enqueueActivity(activity: PendingActivity): Long
@Query("SELECT * FROM pending_activity WHERE id = :id")
suspend fun pendingActivity(id: Long): PendingActivity?
@Query("DELETE FROM pending_activity WHERE id = :id")
suspend fun activityDone(id: Long)
@Query("SELECT * FROM pending_activity ORDER BY id DESC")
fun pendingActivities(): Flow<List<PendingActivity>>
} }
@Dao @Dao

View File

@@ -70,6 +70,24 @@ data class PendingOp(
val lastError: String? = null, val lastError: String? = null,
) )
/** Aktivitet som väntar på att synkas till servern (loggad offline). */
@Entity(tableName = "pending_activity")
data class PendingActivity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val activityTypeId: Int,
/** För visning i listan innan synk */
val typeName: String,
val isCardio: Boolean = false,
val startedAtIso: String,
val durationSeconds: Int,
val distanceMeters: Double? = null,
val elevationGainMeters: Double? = null,
val rpe: Double? = null,
val source: String = "manual",
val routePolyline: String? = null,
val notes: String? = null,
)
/* ---------- Referensdata-cache (funkar offline) ---------- */ /* ---------- Referensdata-cache (funkar offline) ---------- */
@Entity(tableName = "cached_exercise_type") @Entity(tableName = "cached_exercise_type")

View File

@@ -80,11 +80,17 @@ val RPE_LABELS = listOf(
"Mycket ansträngande", "Nästan max", "Kan inte prata mer än ett par ord", "Mycket ansträngande", "Nästan max", "Kan inte prata mer än ett par ord",
) )
class ActivitiesViewModel(private val gymApi: GymApi) : ViewModel() { class ActivitiesViewModel(
private val gymApi: GymApi,
private val repo: eu.brassepc.fitnessdroid.data.GymRepository,
) : ViewModel() {
val types = MutableStateFlow<List<ActivityType>>(emptyList()) val types = MutableStateFlow<List<ActivityType>>(emptyList())
val activities = MutableStateFlow<List<Activity>>(emptyList()) val activities = MutableStateFlow<List<Activity>>(emptyList())
val message = MutableStateFlow<String?>(null) val message = MutableStateFlow<String?>(null)
/** Offline-loggade aktiviteter som väntar på synk. */
val pending = repo.pendingActivities
init { load() } init { load() }
fun load() { fun load() {
@@ -137,7 +143,30 @@ class ActivitiesViewModel(private val gymApi: GymApi) : ViewModel() {
load() load()
onDone() onDone()
} catch (e: Exception) { } catch (e: Exception) {
message.value = "Kunde inte spara — offline?" if (editingId == null) {
// Ny aktivitet offline → köa lokalt (synkas som passen)
runCatching {
repo.queueActivity(
eu.brassepc.fitnessdroid.data.local.PendingActivity(
activityTypeId = type.id,
typeName = type.nameSv,
isCardio = type.isCardio,
startedAtIso = startedAt
.atZone(ZoneId.systemDefault()).toInstant().toString(),
durationSeconds = durationSeconds,
distanceMeters = distanceKm?.times(1000),
elevationGainMeters = elevation,
rpe = rpe,
source = "manual",
notes = notes,
)
)
message.value = "Ingen kontakt med servern — sparad lokalt, synkas automatiskt."
onDone()
}.onFailure { message.value = "Kunde inte spara — försök igen." }
} else {
message.value = "Kunde inte uppdatera — offline?"
}
} }
} }
} }
@@ -157,7 +186,10 @@ class ActivitiesViewModel(private val gymApi: GymApi) : ViewModel() {
companion object { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { ActivitiesViewModel(appContainer().gymApi) } initializer {
val c = appContainer()
ActivitiesViewModel(c.gymApi, c.gymRepository)
}
} }
} }
} }
@@ -171,6 +203,7 @@ fun ActivitiesScreen(
) { ) {
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 pending by viewModel.pending.collectAsStateWithLifecycle(emptyList())
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 showStartPicker by remember { mutableStateOf(false) }
@@ -266,6 +299,29 @@ fun ActivitiesScreen(
) )
} }
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(pending, key = { "p${'$'}{it.id}" }) { p ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(p.typeName, style = MaterialTheme.typography.titleSmall)
Text(
"${'$'}{p.startedAtIso.take(10)} · ${'$'}{formatDuration(p.durationSeconds)}" +
(p.distanceMeters?.let { " · ${'$'}{(it / 1000).compact()} km" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"väntar på synk",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
}
items(activities, key = { it.id }) { a -> items(activities, key = { it.id }) { a ->
ActivityRow(a, onClick = { editTarget = a }) ActivityRow(a, onClick = { editTarget = a })
} }

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -56,6 +57,8 @@ import eu.brassepc.fitnessdroid.data.ActivityKcal
import eu.brassepc.fitnessdroid.data.ActivityType import eu.brassepc.fitnessdroid.data.ActivityType
import eu.brassepc.fitnessdroid.data.GymApi import eu.brassepc.fitnessdroid.data.GymApi
import eu.brassepc.fitnessdroid.data.GymRepository import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.TrackLog
import eu.brassepc.fitnessdroid.data.TrackingPhase
import eu.brassepc.fitnessdroid.data.TrackingService import eu.brassepc.fitnessdroid.data.TrackingService
import eu.brassepc.fitnessdroid.data.TrackingState import eu.brassepc.fitnessdroid.data.TrackingState
import eu.brassepc.fitnessdroid.data.encodePolyline import eu.brassepc.fitnessdroid.data.encodePolyline
@@ -115,23 +118,48 @@ class TrackViewModel(
fun save(rpe: Double?, overrideTypeId: Int?) { fun save(rpe: Double?, overrideTypeId: Int?) {
val s = summary.value ?: return val s = summary.value ?: return
viewModelScope.launch { viewModelScope.launch {
val startedIso = Instant.ofEpochMilli(s.startedAtEpochMs).toString()
val distance = s.distanceMeters.takeIf { s.isDistanceBased && it > 10 }
val elevation = s.elevationGainMeters.takeIf { s.isDistanceBased && it > 1 }
val polyline = s.points.takeIf { it.size >= 2 }?.let { encodePolyline(it) }
try { try {
gymApi.addActivity( gymApi.addActivity(
activityTypeId = overrideTypeId ?: s.typeId, activityTypeId = overrideTypeId ?: s.typeId,
startedAtIso = Instant.ofEpochMilli(s.startedAtEpochMs).toString(), startedAtIso = startedIso,
durationSeconds = s.elapsedSeconds.coerceAtLeast(1), durationSeconds = s.elapsedSeconds.coerceAtLeast(1),
distanceMeters = s.distanceMeters.takeIf { s.isDistanceBased && it > 10 }, distanceMeters = distance,
elevationGainMeters = s.elevationGainMeters.takeIf { s.isDistanceBased && it > 1 }, elevationGainMeters = elevation,
rpe = rpe, 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", source = "tracked",
routePolyline = s.points.takeIf { it.size >= 2 }?.let { encodePolyline(it) }, routePolyline = polyline,
) )
saved.value = true saved.value = true
message.value = null message.value = null
TrackLog.log("sparad på servern")
} catch (e: Exception) { } catch (e: Exception) {
message.value = "Kunde inte spara — offline? Försök igen." // Offline → köa lokalt, synk-kön skickar när nätet är tillbaka
runCatching {
repo.queueActivity(
eu.brassepc.fitnessdroid.data.local.PendingActivity(
activityTypeId = overrideTypeId ?: s.typeId,
typeName = s.typeName,
isCardio = true,
startedAtIso = startedIso,
durationSeconds = s.elapsedSeconds.coerceAtLeast(1),
distanceMeters = distance,
elevationGainMeters = elevation,
rpe = rpe,
source = "tracked",
routePolyline = polyline,
)
)
saved.value = true
message.value = "Ingen kontakt med servern — sparad lokalt, synkas automatiskt."
TrackLog.log("offline: köad lokalt för synk")
}.onFailure {
message.value = "Kunde inte spara — försök igen."
TrackLog.log("FEL vid sparning: ${it.message}")
}
} }
} }
} }
@@ -294,6 +322,7 @@ fun TrackScreen(
else -> LiveContent( else -> LiveContent(
tracking = tracking, tracking = tracking,
onStop = { viewModel.stop(context) }, onStop = { viewModel.stop(context) },
onStartAnyway = { TrackingService.startNow(context) },
) )
} }
} }
@@ -301,7 +330,11 @@ fun TrackScreen(
} }
@Composable @Composable
private fun LiveContent(tracking: TrackingState, onStop: () -> Unit) { private fun LiveContent(
tracking: TrackingState,
onStop: () -> Unit,
onStartAnyway: () -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
if (tracking.isDistanceBased) { if (tracking.isDistanceBased) {
TrackMap( TrackMap(
@@ -336,6 +369,23 @@ private fun LiveContent(tracking: TrackingState, onStop: () -> Unit) {
modifier = Modifier.padding(14.dp), modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
if (tracking.phase == TrackingPhase.SEARCHING_GPS) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.padding(end = 10.dp).size(18.dp),
strokeWidth = 2.dp,
)
Column(modifier = Modifier.weight(1f)) {
Text("Söker GPS…", style = MaterialTheme.typography.titleSmall)
Text(
"Klockan startar vid första fixen — gå gärna ut i det fria.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onStartAnyway) { Text("Starta ändå") }
}
}
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
StatBox("Tid", formatElapsed(tracking.elapsedSeconds), Modifier.weight(1f)) StatBox("Tid", formatElapsed(tracking.elapsedSeconds), Modifier.weight(1f))
if (tracking.isDistanceBased) { if (tracking.isDistanceBased) {
@@ -373,11 +423,54 @@ private fun LiveContent(tracking: TrackingState, onStop: () -> Unit) {
Icon(Icons.Default.Stop, contentDescription = null) Icon(Icons.Default.Stop, contentDescription = null)
Text("Avsluta aktiviteten", modifier = Modifier.padding(start = 6.dp)) Text("Avsluta aktiviteten", modifier = Modifier.padding(start = 6.dp))
} }
TechLogSection()
} }
} }
} }
} }
/** Expanderbar teknisk logg (GPS-fixar m.m.) med delningsknapp — för felsökning. */
@Composable
fun TechLogSection() {
val context = LocalContext.current
var expanded by remember { mutableStateOf(false) }
val lines by TrackLog.lines.collectAsStateWithLifecycle()
Column {
TextButton(onClick = { expanded = !expanded }) {
Text(if (expanded) "Dölj teknisk logg" else "Teknisk logg (${lines.size})")
}
if (expanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.verticalScroll(rememberScrollState()),
) {
lines.takeLast(100).forEach {
Text(
it,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
fontSize = androidx.compose.ui.unit.TextUnit(10f, androidx.compose.ui.unit.TextUnitType.Sp),
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = {
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(android.content.Intent.EXTRA_TEXT, TrackLog.asText())
}
context.startActivity(
android.content.Intent.createChooser(intent, "Dela teknisk logg")
)
}) { Text("Dela loggen") }
}
}
}
@Composable @Composable
private fun StatBox(label: String, value: String, modifier: Modifier = Modifier) { private fun StatBox(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
@@ -517,6 +610,8 @@ private fun SummaryContent(
message?.let { Text(it, color = MaterialTheme.colorScheme.error) } message?.let { Text(it, color = MaterialTheme.colorScheme.error) }
TechLogSection()
if (saved) { if (saved) {
Text( Text(
"Sparad ✓", "Sparad ✓",

View File

@@ -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)*
- [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)* - [x] Etapp 3 — Live-spårning (GPS) *(kod klar; efter fälttest 2026-08-24/25: GPS-väntfas med starta ändå, offline-kö för aktiviteter, teknisk logg med delning, manifest-fix för platsbehörighet på Android 12+)*
- [ ] 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