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
|
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 {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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.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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ✓",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user