diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d206ad4..2b6e968 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,7 +29,7 @@ android { minSdk = 31 targetSdk = 35 versionCode = ciRunNumber ?: 1 - versionName = "0.7.2" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") + versionName = "0.8.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") } signingConfigs { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt index 279a48f..b457b7b 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt @@ -162,6 +162,20 @@ class GymRepository( suspend fun bodyWeightKg(): Double? = store.bodyWeightKg() 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 ---------- */ suspend fun fetchBodyMeasurements(): List { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt index 3449d99..2c9adbe 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt @@ -24,6 +24,8 @@ object OpKind { /** targetLocalId bär server-id:t — den lokala raden är redan borta */ const val REMOVE_SET = "remove_set" 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 } @@ -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 -> { val session = dao.session(op.targetLocalId) ?: return val sessionServerId = session.serverId diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackLog.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackLog.kt new file mode 100644 index 0000000..c811f71 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackLog.kt @@ -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>(emptyList()) + val lines: StateFlow> = _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") +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt index 5a6f4a4..208dd5b 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt @@ -28,9 +28,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow 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. */ data class TrackingState( val isActive: Boolean = false, + val phase: TrackingPhase = TrackingPhase.IDLE, /** Aktivitetstypen som spåras */ val typeId: Int = 0, val typeKey: String = "", @@ -61,6 +65,7 @@ class TrackingService : Service(), LocationListener { private var locationManager: LocationManager? = null private var weightKg: Double? = null + private var activeSinceElapsed = 0L private var lastLocation: Location? = null // Glidande medel av höjd för att filtrera GPS-brus innan höjdmeter summeras private val altitudeWindow = ArrayDeque() @@ -77,6 +82,10 @@ class TrackingService : Service(), LocationListener { stopTracking() return START_NOT_STICKY } + ACTION_START_NOW -> { + activate("starta ändå") + return START_STICKY + } else -> { if (intent == null) return START_NOT_STICKY startTracking(intent) @@ -91,6 +100,7 @@ class TrackingService : Service(), LocationListener { _state.value = TrackingState( isActive = true, + phase = if (distanceBased) TrackingPhase.SEARCHING_GPS else TrackingPhase.ACTIVE, typeId = intent.getIntExtra(EXTRA_TYPE_ID, 0), typeKey = intent.getStringExtra(EXTRA_TYPE_KEY) ?: "", typeName = intent.getStringExtra(EXTRA_TYPE_NAME) ?: "Aktivitet", @@ -99,6 +109,11 @@ class TrackingService : Service(), LocationListener { isDistanceBased = distanceBased, 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) { ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION @@ -110,12 +125,16 @@ class TrackingService : Service(), LocationListener { if (distanceBased) startGps() - // Tick: tid + kcal + notis + // Tick: tid + kcal + notis. Klockan räknar från att fasen blev ACTIVE. scope.launch { - val startedElapsed = SystemClock.elapsedRealtime() while (_state.value.isActive) { 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( elapsedSeconds = elapsed, kcal = ActivityKcal.estimate( @@ -141,8 +160,12 @@ class TrackingService : Service(), LocationListener { private fun startGps() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED - ) return + ) { + TrackLog.log("FEL: platsbehörighet saknas — ingen GPS-lyssning") + return + } locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager + TrackLog.log("GPS-lyssning begärd (GPS_PROVIDER, 2 s / 3 m)") runCatching { locationManager?.requestLocationUpdates( 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) { val s = _state.value if (!s.isActive || !s.isDistanceBased) return + val acc = if (location.hasAccuracy()) location.accuracy else -1f // 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 elevGain = s.elevationGainMeters @@ -192,6 +235,11 @@ class TrackingService : Service(), LocationListener { } 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) _state.value = _state.value.copy(isActive = false) stopForeground(STOP_FOREGROUND_REMOVE) @@ -248,6 +296,7 @@ class TrackingService : Service(), LocationListener { private const val NOTIFICATION_ID = 44 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_KEY = "typeKey" const val EXTRA_TYPE_NAME = "typeName" @@ -278,5 +327,12 @@ class TrackingService : Service(), LocationListener { 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 } + ) + } } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt index 160947f..fecf7eb 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt @@ -18,8 +18,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase CachedMuscle::class, CachedStartCard::class, CachedPb::class, + PendingActivity::class, ], - version = 4, + version = 5, exportSchema = false, ) 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 = 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() .build() } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt index 76c31ac..cc34a75 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt @@ -80,6 +80,20 @@ interface OpDao { @Query("SELECT COUNT(*) FROM pending_op") 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> } @Dao diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt index bd82402..5b99448 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt @@ -70,6 +70,24 @@ data class PendingOp( 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) ---------- */ @Entity(tableName = "cached_exercise_type") diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt index 3f673c7..86f3420 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt @@ -80,11 +80,17 @@ val RPE_LABELS = listOf( "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>(emptyList()) val activities = MutableStateFlow>(emptyList()) val message = MutableStateFlow(null) + /** Offline-loggade aktiviteter som väntar på synk. */ + val pending = repo.pendingActivities + init { load() } fun load() { @@ -137,7 +143,30 @@ class ActivitiesViewModel(private val gymApi: GymApi) : ViewModel() { load() onDone() } 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 { 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 activities by viewModel.activities.collectAsStateWithLifecycle() + val pending by viewModel.pending.collectAsStateWithLifecycle(emptyList()) val message by viewModel.message.collectAsStateWithLifecycle() var showLog by remember { mutableStateOf(false) } var showStartPicker by remember { mutableStateOf(false) } @@ -266,6 +299,29 @@ fun ActivitiesScreen( ) } 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 -> ActivityRow(a, onClick = { editTarget = a }) } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt index cd9f45c..dd4f5a5 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt @@ -11,6 +11,7 @@ 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.size import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState 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.GymApi 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.TrackingState import eu.brassepc.fitnessdroid.data.encodePolyline @@ -115,23 +118,48 @@ class TrackViewModel( fun save(rpe: Double?, overrideTypeId: Int?) { val s = summary.value ?: return 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 { gymApi.addActivity( activityTypeId = overrideTypeId ?: s.typeId, - startedAtIso = Instant.ofEpochMilli(s.startedAtEpochMs).toString(), + startedAtIso = startedIso, durationSeconds = s.elapsedSeconds.coerceAtLeast(1), - distanceMeters = s.distanceMeters.takeIf { s.isDistanceBased && it > 10 }, - elevationGainMeters = s.elevationGainMeters.takeIf { s.isDistanceBased && it > 1 }, + distanceMeters = distance, + elevationGainMeters = elevation, 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) }, + routePolyline = polyline, ) saved.value = true message.value = null + TrackLog.log("sparad på servern") } 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( tracking = tracking, onStop = { viewModel.stop(context) }, + onStartAnyway = { TrackingService.startNow(context) }, ) } } @@ -301,7 +330,11 @@ fun TrackScreen( } @Composable -private fun LiveContent(tracking: TrackingState, onStop: () -> Unit) { +private fun LiveContent( + tracking: TrackingState, + onStop: () -> Unit, + onStartAnyway: () -> Unit, +) { Column(modifier = Modifier.fillMaxSize()) { if (tracking.isDistanceBased) { TrackMap( @@ -336,6 +369,23 @@ private fun LiveContent(tracking: TrackingState, onStop: () -> Unit) { modifier = Modifier.padding(14.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()) { StatBox("Tid", formatElapsed(tracking.elapsedSeconds), Modifier.weight(1f)) if (tracking.isDistanceBased) { @@ -373,11 +423,54 @@ private fun LiveContent(tracking: TrackingState, onStop: () -> Unit) { Icon(Icons.Default.Stop, contentDescription = null) 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 private fun StatBox(label: String, value: String, modifier: Modifier = Modifier) { Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { @@ -517,6 +610,8 @@ private fun SummaryContent( message?.let { Text(it, color = MaterialTheme.colorScheme.error) } + TechLogSection() + if (saved) { Text( "Sparad ✓", diff --git a/doc/activities-plan.md b/doc/activities-plan.md index a8af157..d35697c 100644 --- a/doc/activities-plan.md +++ b/doc/activities-plan.md @@ -101,7 +101,7 @@ frontend av `src/**` m.fl., FitnessDroid av varje push till main.) - [x] Etapp 1 — Profil & kalorigrund *(kod klar 2026-08-23: backend a1cd4d3, webb 560894c, app — väntar på användartest + omdeploy av gym-api/frontend-containrarna)* - [x] Etapp 2 — Aktiviteter: bibliotek + manuell logg *(kod klar 2026-08-23: backend 9a81cf6, webb — aktivitetsflik, app 0.6.0; väntar på användartest)* -- [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 5 — Steg & telefondata - [ ] Etapp 6 — Statistik & polish