Etapp 3-feedback: GPS-väntfas, offline-kö för aktiviteter, teknisk logg
All checks were successful
release / build-release (push) Successful in 4m10s
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:
@@ -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 {
|
||||
|
||||
@@ -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<BodyMeasurement> {
|
||||
|
||||
@@ -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
|
||||
|
||||
32
app/src/main/java/eu/brassepc/fitnessdroid/data/TrackLog.kt
Normal file
32
app/src/main/java/eu/brassepc/fitnessdroid/data/TrackLog.kt
Normal 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")
|
||||
}
|
||||
@@ -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<Double>()
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<List<PendingActivity>>
|
||||
}
|
||||
|
||||
@Dao
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<List<ActivityType>>(emptyList())
|
||||
val activities = MutableStateFlow<List<Activity>>(emptyList())
|
||||
val message = MutableStateFlow<String?>(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 })
|
||||
}
|
||||
|
||||
@@ -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 ✓",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user