Files
FitnessDroid/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt
claude 5f1d1eae4f
All checks were successful
release / build-release (push) Successful in 4m30s
Etapp 5: stegsynk via Health Connect
- 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
2026-08-26 12:10:30 +02:00

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}}"
}
}