All checks were successful
release / build-release (push) Successful in 4m30s
- StepsSync: läser dagens + gårdagens stegsumma (StepsRecord.COUNT_TOTAL) och upsertar till gym-API:t; synkas automatiskt (max var 10:e min) när hemskärmen visas - Inställningar → Stegsynk: status, aktivera-flöde (HC-permission), Play-länk om Health Connect saknas, manuell synk - Kcal-mätarens telefonrad visar nu riktiga steg + kcal (serverns beräkning med dubbelräkningsskydd); stegmål får riktig data - connect-client 1.1.0-alpha07 (nyare kräver AGP 8.9/compileSdk 36 — toolchain-bump tas separat); HC-manifest (READ_STEPS, rationale-intents) - Version 0.10.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg
254 lines
9.5 KiB
Kotlin
254 lines
9.5 KiB
Kotlin
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 GoalProgress(
|
|
val id: Int,
|
|
val metric: String,
|
|
val period: String,
|
|
val targetValue: Double,
|
|
val currentValue: Double,
|
|
)
|
|
|
|
@Serializable
|
|
data class Profile(
|
|
val username: String,
|
|
val displayName: String? = null,
|
|
val email: String? = null,
|
|
val bodyWeightKg: Double? = null,
|
|
val heightCm: Double? = null,
|
|
val birthYear: Int? = null,
|
|
/** "male"/"female" — serverns konvention */
|
|
val sex: String? = null,
|
|
)
|
|
|
|
/** Autentiserade anrop mot gym-API:t. Query-strängarna speglar gymTrackerApi.js. */
|
|
class GymApi(
|
|
private val client: GraphQlClient,
|
|
private val auth: AuthRepository,
|
|
) {
|
|
suspend fun myProfile(): Profile {
|
|
// Fallback till gamla fältuppsättningen mot en äldre server.
|
|
val data = try {
|
|
client.execute(MY_PROFILE, token = auth.bearerToken())
|
|
} catch (e: GraphQlException) {
|
|
client.execute(MY_PROFILE_LEGACY, token = auth.bearerToken())
|
|
}
|
|
val profile = data["myProfile"]?.jsonObject
|
|
?: throw GraphQlException(listOf("Kunde inte hämta profilen"))
|
|
return client.json.decodeFromJsonElement(profile)
|
|
}
|
|
|
|
/** Kroppsdata till servern. null = rör ej, -1/"" = rensa (serverns konvention). */
|
|
suspend fun updateProfile(heightCm: Double?, birthYear: Int?, sex: String?): Profile {
|
|
val data = client.execute(
|
|
"mutation(\$h: Float,\$y: Int,\$s: String){updateProfile(heightCm:\$h,birthYear:\$y,sex:\$s){username displayName email bodyWeightKg heightCm birthYear sex}}",
|
|
buildJsonObject {
|
|
heightCm?.let { put("h", it) }
|
|
birthYear?.let { put("y", it) }
|
|
sex?.let { put("s", it) }
|
|
},
|
|
auth.bearerToken(),
|
|
)
|
|
val profile = data["updateProfile"]?.jsonObject
|
|
?: throw GraphQlException(listOf("Kunde inte spara kroppsdata"))
|
|
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"]!!)
|
|
}
|
|
|
|
/* ---------- Mål ---------- */
|
|
|
|
/** Mål med progress; klienten skickar sina lokala periodstarter som instants. */
|
|
suspend fun goalsWithProgress(
|
|
dayStartIso: String,
|
|
weekStartIso: String,
|
|
monthStartIso: String,
|
|
): List<GoalProgress> {
|
|
val data = client.execute(
|
|
"query(\$d:DateTime!,\$w:DateTime!,\$m:DateTime!){goalsWithProgress(dayStart:\$d,weekStart:\$w,monthStart:\$m){id metric period targetValue currentValue}}",
|
|
buildJsonObject {
|
|
put("d", dayStartIso); put("w", weekStartIso); put("m", monthStartIso)
|
|
},
|
|
auth.bearerToken(),
|
|
)
|
|
return data["goalsWithProgress"]!!.jsonArray.map { client.json.decodeFromJsonElement<GoalProgress>(it) }
|
|
}
|
|
|
|
/** Synka upp dagens/gårdagens steg; [dayStartIso] = lokalt dygn 00:00 som instant. */
|
|
suspend fun upsertDailySteps(dayStartIso: String, steps: Int) {
|
|
client.execute(
|
|
"mutation(\$d:DateTime!,\$s:Int!){upsertDailySteps(date:\$d,steps:\$s)}",
|
|
buildJsonObject { put("d", dayStartIso); put("s", steps) },
|
|
auth.bearerToken(),
|
|
)
|
|
}
|
|
|
|
/** targetValue <= 0 tar bort målet. */
|
|
suspend fun setGoal(metric: String, period: String, targetValue: Double) {
|
|
client.execute(
|
|
"mutation(\$m:String!,\$p:String!,\$t:Float!){setGoal(metric:\$m,period:\$p,targetValue:\$t)}",
|
|
buildJsonObject { put("m", metric); put("p", period); put("t", targetValue) },
|
|
auth.bearerToken(),
|
|
)
|
|
}
|
|
|
|
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 =
|
|
"query{myProfile{username displayName email bodyWeightKg}}"
|
|
}
|
|
}
|