Etapp 2: aktiviteter — bibliotek, manuell loggning, kcal via dailySummary
Some checks failed
release / build-release (push) Failing after 1h2m7s

- GymApi: activityTypes/activities/add/update/delete + dailySummary
- Aktivitetsskärm (Hem → Aktiviteter ❤️): logga med typväljare grupperad per
  kategori, datum/tid-pickers, varaktighet, distans+höjdmeter för
  distansbaserade, RPE-slider (talk test) för tidsbaserade; historik med
  tempo och kcal; rätta/ta bort
- Kcal-mätaren använder serverns dailySummary (gym + aktiviteter + BMR) med
  fallback till lokal beräkning mot äldre server
- Version 0.6.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-23 19:29:43 +02:00
parent 06b2f0ec86
commit b8bdeddf4c
7 changed files with 747 additions and 2 deletions

View File

@@ -3,9 +3,50 @@ package eu.brassepc.fitnessdroid.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
@Serializable
data class ActivityType(
val id: Int,
val key: String,
val nameSv: String,
val nameEn: String = "",
val met: Double = 4.0,
val category: String = "OTHER",
val isDistanceBased: Boolean = false,
val isCardio: Boolean = false,
val iconKey: String? = null,
)
@Serializable
data class Activity(
val id: String,
val startedAt: String,
val durationSeconds: Int,
val distanceMeters: Double? = null,
val elevationGainMeters: Double? = null,
val estimatedKcal: Double? = null,
val rpe: Double? = null,
val source: String = "manual",
val notes: String? = null,
val activityType: ActivityType,
)
@Serializable
data class DailySummary(
val gymKcal: Double = 0.0,
val activityKcal: Double = 0.0,
val activeKcal: Double = 0.0,
val gymSessionCount: Int = 0,
val activityCount: Int = 0,
val distanceMeters: Double = 0.0,
val bmrKcalPerDay: Double? = null,
val phoneKcal: Double? = null,
val steps: Int? = null,
)
@Serializable
data class Profile(
val username: String,
@@ -51,7 +92,114 @@ class GymApi(
return client.json.decodeFromJsonElement(profile)
}
/* ---------- Aktiviteter ---------- */
suspend fun activityTypes(): List<ActivityType> {
val data = client.execute(
"query{activityTypes{id key nameSv nameEn met category isDistanceBased isCardio iconKey}}",
token = auth.bearerToken(),
)
return data["activityTypes"]!!.jsonArray.map { client.json.decodeFromJsonElement<ActivityType>(it) }
}
suspend fun activities(limit: Int = 50): List<Activity> {
val data = client.execute(
"query(\$l:Int){activities(limit:\$l){$ACTIVITY_FIELDS}}",
buildJsonObject { put("l", limit) },
auth.bearerToken(),
)
return data["activities"]!!.jsonArray.map { client.json.decodeFromJsonElement<Activity>(it) }
}
/**
* Logga aktivitet. Servern beräknar kcal (MET/tempo/RPE) om estimatedKcal
* utelämnas. null = utelämna fält.
*/
suspend fun addActivity(
activityTypeId: Int,
startedAtIso: String,
durationSeconds: Int,
distanceMeters: Double? = null,
elevationGainMeters: Double? = null,
rpe: Double? = null,
estimatedKcal: Double? = null,
source: String? = null,
routePolyline: String? = null,
notes: String? = null,
): Activity {
val data = client.execute(
"mutation(\$input: AddActivityInput!){addActivity(input:\$input){$ACTIVITY_FIELDS}}",
buildJsonObject {
put("input", buildJsonObject {
put("activityTypeId", activityTypeId)
put("startedAt", startedAtIso)
put("durationSeconds", durationSeconds)
distanceMeters?.let { put("distanceMeters", it) }
elevationGainMeters?.let { put("elevationGainMeters", it) }
rpe?.let { put("rpe", it) }
estimatedKcal?.let { put("estimatedKcal", it) }
source?.let { put("source", it) }
routePolyline?.let { put("routePolyline", it) }
notes?.let { put("notes", it) }
})
},
auth.bearerToken(),
)
return client.json.decodeFromJsonElement(data["addActivity"]!!)
}
/** null = rör ej, -1/"" = rensa (serverns konvention). */
suspend fun updateActivity(
id: String,
activityTypeId: Int? = null,
startedAtIso: String? = null,
durationSeconds: Int? = null,
distanceMeters: Double? = null,
elevationGainMeters: Double? = null,
rpe: Double? = null,
notes: String? = null,
): Activity {
val data = client.execute(
"mutation(\$input: UpdateActivityInput!){updateActivity(input:\$input){$ACTIVITY_FIELDS}}",
buildJsonObject {
put("input", buildJsonObject {
put("id", id)
activityTypeId?.let { put("activityTypeId", it) }
startedAtIso?.let { put("startedAt", it) }
durationSeconds?.let { put("durationSeconds", it) }
distanceMeters?.let { put("distanceMeters", it) }
elevationGainMeters?.let { put("elevationGainMeters", it) }
rpe?.let { put("rpe", it) }
notes?.let { put("notes", it) }
})
},
auth.bearerToken(),
)
return client.json.decodeFromJsonElement(data["updateActivity"]!!)
}
suspend fun deleteActivity(id: String) {
client.execute(
"mutation(\$id: UUID!){deleteActivity(id:\$id)}",
buildJsonObject { put("id", id) },
auth.bearerToken(),
)
}
/** Dagens sammanställning; [dayStartIso] = lokala midnatt som instant. */
suspend fun dailySummary(dayStartIso: String): DailySummary {
val data = client.execute(
"query(\$d:DateTime){dailySummary(date:\$d){gymKcal activityKcal activeKcal gymSessionCount activityCount distanceMeters bmrKcalPerDay phoneKcal steps}}",
buildJsonObject { put("d", dayStartIso) },
auth.bearerToken(),
)
return client.json.decodeFromJsonElement(data["dailySummary"]!!)
}
companion object {
private const val ACTIVITY_FIELDS =
"id startedAt durationSeconds distanceMeters elevationGainMeters estimatedKcal rpe source notes " +
"activityType{id key nameSv nameEn met category isDistanceBased isCardio iconKey}"
private const val MY_PROFILE =
"query{myProfile{username displayName email bodyWeightKg heightCm birthYear sex}}"
private const val MY_PROFILE_LEGACY =