M2: passläge, offline-först med synk-kö, vilotimer med inställningar
Some checks failed
release / build-release (push) Has been cancelled
Some checks failed
release / build-release (push) Has been cancelled
- Room-cache: övningar/muskelgrupper/favoritkort + lokal-först-pass - SyncEngine: FIFO-op-kö mot API:t, retry med backoff, synk-status per set (moln-ikon) + global räknare; nätfel loggar inte ut användaren - Passläge: en övning i taget (svep), ±steppers, RPE, förifyllda värden från förra passet, logga set med ett tryck - Vilotimer: helskärm eller banner (inställning), larm/vibration/visuell/ inget vid utgång, ±15s, per-övnings vilotid via API:ts defaultRestSeconds - Övningsväljare: sök + muskelgrupp/muskel-chips med ikoner (API:ts iconKey med namnheuristik som fallback), favoriter först, KG/REPS/TID/M-brickor - Navigation: bottenrad + start-FAB + minispelare, hemskärm med favoritpass - Inställningsskärm under Profil; historik-lista; version 0.2.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
@@ -5,6 +5,7 @@ plugins {
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
// Signeringsnyckeln är inte incheckad. Lokalt ligger den i signing/ (gitignorerad),
|
||||
@@ -28,7 +29,7 @@ android {
|
||||
minSdk = 31
|
||||
targetSdk = 35
|
||||
versionCode = ciRunNumber ?: 1
|
||||
versionName = "0.1.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
||||
versionName = "0.2.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -80,5 +81,8 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
android:name=".FitnessDroidApplication"
|
||||
|
||||
@@ -5,14 +5,28 @@ import android.content.Context
|
||||
import eu.brassepc.fitnessdroid.data.AuthRepository
|
||||
import eu.brassepc.fitnessdroid.data.GraphQlClient
|
||||
import eu.brassepc.fitnessdroid.data.GymApi
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerController
|
||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||
import eu.brassepc.fitnessdroid.data.SyncEngine
|
||||
import eu.brassepc.fitnessdroid.data.TokenStore
|
||||
import eu.brassepc.fitnessdroid.data.local.AppDatabase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
/** Enkel manuell DI-container — appen är för liten för Hilt. */
|
||||
class AppContainer(context: Context) {
|
||||
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val tokenStore = TokenStore(context)
|
||||
val settingsStore = SettingsStore(context)
|
||||
val graphQlClient = GraphQlClient { tokenStore.apiUrl() }
|
||||
val authRepository = AuthRepository(tokenStore, graphQlClient)
|
||||
val gymApi = GymApi(graphQlClient, authRepository)
|
||||
val database = AppDatabase.build(context)
|
||||
val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope)
|
||||
val gymRepository = GymRepository(database, graphQlClient, authRepository, syncEngine, appScope)
|
||||
val restTimer = RestTimerController(context, settingsStore, appScope)
|
||||
}
|
||||
|
||||
class FitnessDroidApplication : Application() {
|
||||
|
||||
@@ -133,6 +133,9 @@ class AuthRepository(
|
||||
store.updateTokens(newToken, newRefresh, parseExpiration(newExpiration))
|
||||
newToken
|
||||
} catch (e: GraphQlException) {
|
||||
// Servern avvisade refresh-tokenen — riktig utloggning.
|
||||
// Nätfel (IOException) bubblar istället uppåt så att synkmotorn
|
||||
// kan försöka igen utan att kasta ut användaren.
|
||||
forceLogout()
|
||||
throw GraphQlException(listOf("Sessionen har gått ut, logga in igen"))
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ class GraphQlClient(private val urlProvider: suspend () -> String) {
|
||||
|
||||
http.newCall(request).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
// 5xx = servern/proxyn nere — behandla som nätfel (retry) och inte
|
||||
// som ett logiskt API-fel (som t.ex. loggar ut användaren).
|
||||
if (response.code >= 500) {
|
||||
throw java.io.IOException("HTTP ${response.code} från servern")
|
||||
}
|
||||
val root = runCatching { json.parseToJsonElement(text).jsonObject }.getOrNull()
|
||||
?: throw GraphQlException(listOf("HTTP ${response.code}: oväntat svar från servern"))
|
||||
|
||||
|
||||
373
app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt
Normal file
373
app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt
Normal file
@@ -0,0 +1,373 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import eu.brassepc.fitnessdroid.data.local.AppDatabase
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedExerciseType
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscle
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscleGroup
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedStartCard
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalExercise
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalSession
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalSet
|
||||
import eu.brassepc.fitnessdroid.data.local.PendingOp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Lokal-först-repository. Allt UI läser Room-flows; alla skrivningar sker
|
||||
* lokalt först och läggs på op-kön som [SyncEngine] driver mot servern.
|
||||
*/
|
||||
class GymRepository(
|
||||
private val db: AppDatabase,
|
||||
private val client: GraphQlClient,
|
||||
private val auth: AuthRepository,
|
||||
private val sync: SyncEngine,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
val activeSession = db.sessionDao().activeSession()
|
||||
val exerciseTypes = db.cacheDao().exerciseTypes()
|
||||
val muscleGroups = db.cacheDao().muscleGroups()
|
||||
val muscles = db.cacheDao().muscles()
|
||||
val startCards = db.cacheDao().startCards()
|
||||
val pendingCount = db.opDao().pendingCount()
|
||||
val syncStatus = sync.status
|
||||
|
||||
fun exercises(sessionId: Long) = db.sessionDao().exercises(sessionId)
|
||||
fun setsForSession(sessionId: Long) = db.sessionDao().setsForSession(sessionId)
|
||||
|
||||
private suspend fun enqueue(kind: String, targetLocalId: Long) {
|
||||
db.opDao().enqueue(
|
||||
PendingOp(kind = kind, targetLocalId = targetLocalId, createdAtEpochMs = System.currentTimeMillis())
|
||||
)
|
||||
sync.kick()
|
||||
}
|
||||
|
||||
/* ---------- Passhantering ---------- */
|
||||
|
||||
suspend fun startFreeSession(name: String? = null): Long {
|
||||
val id = db.sessionDao().insertSession(
|
||||
LocalSession(name = name, startedAtEpochMs = System.currentTimeMillis())
|
||||
)
|
||||
enqueue(OpKind.START_SESSION, id)
|
||||
return id
|
||||
}
|
||||
|
||||
/** Starta från favoritpass/mall-kort: skapar passet + alla övningar lokalt. */
|
||||
suspend fun startFromCard(card: CachedStartCard): Long {
|
||||
val sessionId = db.sessionDao().insertSession(
|
||||
LocalSession(name = card.title, startedAtEpochMs = System.currentTimeMillis())
|
||||
)
|
||||
enqueue(OpKind.START_SESSION, sessionId)
|
||||
card.exerciseTypeIds.split(",").mapNotNull { it.trim().toIntOrNull() }
|
||||
.forEachIndexed { index, typeId ->
|
||||
val exId = db.sessionDao().insertExercise(
|
||||
LocalExercise(sessionId = sessionId, exerciseTypeId = typeId, order = index + 1)
|
||||
)
|
||||
enqueue(OpKind.ADD_EXERCISE, exId)
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
suspend fun addExercise(sessionId: Long, exerciseTypeId: Int): Long {
|
||||
val order = db.sessionDao().exerciseCount(sessionId) + 1
|
||||
val id = db.sessionDao().insertExercise(
|
||||
LocalExercise(sessionId = sessionId, exerciseTypeId = exerciseTypeId, order = order)
|
||||
)
|
||||
enqueue(OpKind.ADD_EXERCISE, id)
|
||||
refreshLastPerformance(exerciseTypeId)
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun logSet(
|
||||
exerciseId: Long,
|
||||
reps: Int?,
|
||||
weight: Double?,
|
||||
distanceMeters: Double?,
|
||||
durationSeconds: Int?,
|
||||
rpe: Double?,
|
||||
isWarmup: Boolean,
|
||||
): Long {
|
||||
val order = db.sessionDao().setCount(exerciseId) + 1
|
||||
val id = db.sessionDao().insertSet(
|
||||
LocalSet(
|
||||
exerciseId = exerciseId,
|
||||
order = order,
|
||||
reps = reps,
|
||||
weight = weight,
|
||||
distanceMeters = distanceMeters,
|
||||
durationSeconds = durationSeconds,
|
||||
rpe = rpe,
|
||||
isWarmup = isWarmup,
|
||||
loggedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
enqueue(OpKind.ADD_SET, id)
|
||||
// Uppdatera cachens "senaste prestation" för förifyllnad nästa gång.
|
||||
db.sessionDao().exercise(exerciseId)?.let { ex ->
|
||||
db.cacheDao().updateLastPerformance(ex.exerciseTypeId, weight, reps, durationSeconds, distanceMeters)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun updateSet(set: LocalSet) {
|
||||
db.sessionDao().updateSet(set)
|
||||
// Inte synkad än? Då bär den väntande ADD_SET-opsen de nya värdena
|
||||
// automatiskt (den läser raden när den körs). Annars: egen update-op.
|
||||
if (set.serverId != null) enqueue(OpKind.UPDATE_SET, set.id)
|
||||
}
|
||||
|
||||
suspend fun completeSession(sessionId: Long) {
|
||||
val session = db.sessionDao().session(sessionId) ?: return
|
||||
db.sessionDao().updateSession(session.copy(status = "completed"))
|
||||
enqueue(OpKind.COMPLETE_SESSION, sessionId)
|
||||
}
|
||||
|
||||
/* ---------- Referensdata / cache ---------- */
|
||||
|
||||
/** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */
|
||||
fun refreshAllAsync() {
|
||||
scope.launch {
|
||||
runCatching { refreshReferenceData() }
|
||||
runCatching { adoptServerActiveSession() }
|
||||
sync.kick()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshReferenceData() {
|
||||
val token = auth.bearerToken()
|
||||
|
||||
val groups = client.execute("query{muscleGroups{id name iconKey}}", token = token)
|
||||
db.cacheDao().upsertMuscleGroups(
|
||||
groups["muscleGroups"]!!.jsonArray.map {
|
||||
val o = it.jsonObject
|
||||
CachedMuscleGroup(
|
||||
o["id"]!!.jsonPrimitive.int,
|
||||
o["name"]!!.jsonPrimitive.content,
|
||||
o["iconKey"]?.jsonPrimitive?.contentOrNull(),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
val muscles = client.execute("query{muscles{id name muscleGroupId}}", token = token)
|
||||
db.cacheDao().upsertMuscles(
|
||||
muscles["muscles"]!!.jsonArray.map {
|
||||
val o = it.jsonObject
|
||||
CachedMuscle(
|
||||
o["id"]!!.jsonPrimitive.int,
|
||||
o["name"]!!.jsonPrimitive.content,
|
||||
o["muscleGroupId"]?.jsonPrimitive?.intOrNull,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
val favIds: Set<Int> = runCatching {
|
||||
client.execute("query{favoriteExercises{id}}", token = token)["favoriteExercises"]!!
|
||||
.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.int }.toSet()
|
||||
}.getOrDefault(emptySet())
|
||||
|
||||
val types = client.execute(
|
||||
"query{exerciseTypes{id name description metValue tracksWeight tracksReps tracksDistance tracksDuration isBodyweight defaultRestSeconds exerciseTypeMuscles{muscle{id}}}}",
|
||||
token = token,
|
||||
)
|
||||
db.cacheDao().replaceExerciseTypes(
|
||||
types["exerciseTypes"]!!.jsonArray.map {
|
||||
val o = it.jsonObject
|
||||
CachedExerciseType(
|
||||
id = o["id"]!!.jsonPrimitive.int,
|
||||
name = o["name"]!!.jsonPrimitive.content,
|
||||
description = o["description"]?.jsonPrimitive?.contentOrNull(),
|
||||
metValue = o["metValue"]?.jsonPrimitive?.doubleOrNull ?: 5.0,
|
||||
tracksWeight = o["tracksWeight"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
tracksReps = o["tracksReps"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
tracksDistance = o["tracksDistance"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
tracksDuration = o["tracksDuration"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
isBodyweight = o["isBodyweight"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
defaultRestSeconds = o["defaultRestSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
muscleIds = o["exerciseTypeMuscles"]?.jsonArray
|
||||
?.mapNotNull { m -> m.jsonObject["muscle"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
|
||||
?.joinToString(",") ?: "",
|
||||
isFavorite = o["id"]!!.jsonPrimitive.int in favIds,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Startkort: mallar + favoritpass
|
||||
val cards = mutableListOf<CachedStartCard>()
|
||||
runCatching {
|
||||
val templates = client.execute(
|
||||
"query{workoutTemplates{id name exercises{order exerciseType{id}}}}",
|
||||
token = token,
|
||||
)
|
||||
templates["workoutTemplates"]!!.jsonArray.forEachIndexed { i, t ->
|
||||
val o = t.jsonObject
|
||||
val ids = o["exercises"]!!.jsonArray
|
||||
.sortedBy { e -> e.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
|
||||
.mapNotNull { e -> e.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
|
||||
cards += CachedStartCard(
|
||||
key = "template-${o["id"]!!.jsonPrimitive.int}",
|
||||
title = o["name"]!!.jsonPrimitive.content,
|
||||
subtitle = "Mall · ${ids.size} övningar",
|
||||
exerciseTypeIds = ids.joinToString(","),
|
||||
templateId = o["id"]!!.jsonPrimitive.int,
|
||||
sortOrder = i,
|
||||
)
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
val favs = client.execute(
|
||||
"query{favoriteGymSessions{id name gymSessionExercises{order exerciseType{id}}}}",
|
||||
token = token,
|
||||
)
|
||||
favs["favoriteGymSessions"]!!.jsonArray.forEachIndexed { i, s ->
|
||||
val o = s.jsonObject
|
||||
val ids = o["gymSessionExercises"]!!.jsonArray
|
||||
.sortedBy { e -> e.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
|
||||
.mapNotNull { e -> e.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
|
||||
cards += CachedStartCard(
|
||||
key = "favsession-${o["id"]!!.jsonPrimitive.int}",
|
||||
title = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Favoritpass",
|
||||
subtitle = "Favorit · ${ids.size} övningar",
|
||||
exerciseTypeIds = ids.joinToString(","),
|
||||
sortOrder = 100 + i,
|
||||
)
|
||||
}
|
||||
}
|
||||
db.cacheDao().clearStartCards()
|
||||
db.cacheDao().upsertStartCards(cards)
|
||||
}
|
||||
|
||||
/** Om servern har ett aktivt pass men appen saknar ett: ta över det lokalt. */
|
||||
private suspend fun adoptServerActiveSession() {
|
||||
if (db.sessionDao().activeSessionIdNow() != null) return
|
||||
val token = auth.bearerToken()
|
||||
val data = client.execute(
|
||||
"query{activeGymSession{id name notes startTime gymSessionExercises{id order notes exerciseType{id} sets{id order reps weight distanceMeters durationSeconds rpe isWarmup isCompleted}}}}",
|
||||
token = token,
|
||||
)
|
||||
val s = data["activeGymSession"] ?: return
|
||||
if (s is kotlinx.serialization.json.JsonNull) return
|
||||
val o = s.jsonObject
|
||||
val sessionId = db.sessionDao().insertSession(
|
||||
LocalSession(
|
||||
serverId = o["id"]!!.jsonPrimitive.int,
|
||||
name = o["name"]?.jsonPrimitive?.contentOrNull(),
|
||||
notes = o["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||
startedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
o["gymSessionExercises"]?.jsonArray?.forEach { e ->
|
||||
val eo = e.jsonObject
|
||||
val exId = db.sessionDao().insertExercise(
|
||||
LocalExercise(
|
||||
sessionId = sessionId,
|
||||
serverId = eo["id"]!!.jsonPrimitive.int,
|
||||
exerciseTypeId = eo["exerciseType"]!!.jsonObject["id"]!!.jsonPrimitive.int,
|
||||
order = eo["order"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
notes = eo["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||
)
|
||||
)
|
||||
eo["sets"]?.jsonArray?.forEach { st ->
|
||||
val so = st.jsonObject
|
||||
db.sessionDao().insertSet(
|
||||
LocalSet(
|
||||
exerciseId = exId,
|
||||
serverId = so["id"]!!.jsonPrimitive.int,
|
||||
order = so["order"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
reps = so["reps"]?.jsonPrimitive?.intOrNull,
|
||||
weight = so["weight"]?.jsonPrimitive?.doubleOrNull,
|
||||
distanceMeters = so["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
|
||||
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
|
||||
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
isCompleted = so["isCompleted"]?.jsonPrimitive?.booleanOrNull ?: true,
|
||||
loggedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshLastPerformance(exerciseTypeId: Int) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val data = client.execute(
|
||||
"query(\$id:Int!){lastExercisePerformance(exerciseTypeId:\$id){sets{reps weight distanceMeters durationSeconds}}}",
|
||||
buildJsonObject { put("id", exerciseTypeId) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val perf = data["lastExercisePerformance"] ?: return@launch
|
||||
if (perf is kotlinx.serialization.json.JsonNull) return@launch
|
||||
val best = perf.jsonObject["sets"]?.jsonArray?.lastOrNull()?.jsonObject ?: return@launch
|
||||
db.cacheDao().updateLastPerformance(
|
||||
exerciseTypeId,
|
||||
best["weight"]?.jsonPrimitive?.doubleOrNull,
|
||||
best["reps"]?.jsonPrimitive?.intOrNull,
|
||||
best["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
best["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavoriteExercise(exerciseTypeId: Int, favorite: Boolean) {
|
||||
scope.launch {
|
||||
db.cacheDao().setExerciseFavorite(exerciseTypeId, favorite)
|
||||
runCatching {
|
||||
val mutation = if (favorite) {
|
||||
"mutation(\$id:Int!){addFavoriteExercise(exerciseTypeId:\$id){id}}"
|
||||
} else {
|
||||
"mutation(\$id:Int!){removeFavoriteExercise(exerciseTypeId:\$id)}"
|
||||
}
|
||||
client.execute(mutation, buildJsonObject { put("id", exerciseTypeId) }, auth.bearerToken())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Historik hämtas direkt (ingen offline-garanti i v1). */
|
||||
suspend fun fetchHistory(limit: Int = 30): List<HistoryItem> {
|
||||
val data = client.execute(
|
||||
"query(\$s:String,\$l:Int){gymSessions(status:\$s,limit:\$l){id name date startTime durationSecondsOverride estimatedCalories gymSessionExercises{id sets{id weight reps}}}}",
|
||||
buildJsonObject { put("s", "completed"); put("l", limit) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
return data["gymSessions"]!!.jsonArray.map { s ->
|
||||
val o = s.jsonObject
|
||||
val sets = o["gymSessionExercises"]!!.jsonArray.flatMap { it.jsonObject["sets"]!!.jsonArray }
|
||||
val volume = sets.sumOf { st ->
|
||||
val so = st.jsonObject
|
||||
(so["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0) * (so["reps"]?.jsonPrimitive?.intOrNull ?: 0)
|
||||
}
|
||||
HistoryItem(
|
||||
id = o["id"]!!.jsonPrimitive.int,
|
||||
name = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
|
||||
date = o["date"]?.jsonPrimitive?.contentOrNull()
|
||||
?: o["startTime"]?.jsonPrimitive?.contentOrNull() ?: "",
|
||||
exerciseCount = o["gymSessionExercises"]!!.jsonArray.size,
|
||||
setCount = sets.size,
|
||||
volumeKg = volume,
|
||||
calories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class HistoryItem(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val date: String,
|
||||
val exerciseCount: Int,
|
||||
val setCount: Int,
|
||||
val volumeKg: Double,
|
||||
val calories: Double?,
|
||||
)
|
||||
|
||||
private fun kotlinx.serialization.json.JsonPrimitive.contentOrNull(): String? =
|
||||
if (this is kotlinx.serialization.json.JsonNull) null else content
|
||||
102
app/src/main/java/eu/brassepc/fitnessdroid/data/RestTimer.kt
Normal file
102
app/src/main/java/eu/brassepc/fitnessdroid/data/RestTimer.kt
Normal file
@@ -0,0 +1,102 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import android.media.RingtoneManager
|
||||
import android.os.VibrationEffect
|
||||
import android.os.VibratorManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class RestState(
|
||||
val totalSeconds: Int,
|
||||
val endsAtEpochMs: Long,
|
||||
val secondsLeft: Int,
|
||||
val finished: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Vilotimern. Räknar mot en absolut sluttid (överlever att appen pausas)
|
||||
* och larmar enligt inställningarna när tiden är ute.
|
||||
*/
|
||||
class RestTimerController(
|
||||
private val context: Context,
|
||||
private val settingsStore: SettingsStore,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _state = MutableStateFlow<RestState?>(null)
|
||||
val state: StateFlow<RestState?> = _state
|
||||
|
||||
private var ticker: Job? = null
|
||||
|
||||
fun start(seconds: Int? = null) {
|
||||
scope.launch {
|
||||
val settings = settingsStore.settings.first()
|
||||
val total = seconds ?: settings.defaultRestSeconds
|
||||
_state.value = RestState(
|
||||
totalSeconds = total,
|
||||
endsAtEpochMs = System.currentTimeMillis() + total * 1000L,
|
||||
secondsLeft = total,
|
||||
)
|
||||
startTicker()
|
||||
}
|
||||
}
|
||||
|
||||
fun adjust(deltaSeconds: Int) {
|
||||
val current = _state.value ?: return
|
||||
if (current.finished) return
|
||||
_state.value = current.copy(
|
||||
endsAtEpochMs = (current.endsAtEpochMs + deltaSeconds * 1000L)
|
||||
.coerceAtLeast(System.currentTimeMillis()),
|
||||
totalSeconds = (current.totalSeconds + deltaSeconds).coerceAtLeast(5),
|
||||
)
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
ticker?.cancel()
|
||||
_state.value = null
|
||||
}
|
||||
|
||||
private fun startTicker() {
|
||||
ticker?.cancel()
|
||||
ticker = scope.launch {
|
||||
while (true) {
|
||||
val current = _state.value ?: return@launch
|
||||
val left = (((current.endsAtEpochMs - System.currentTimeMillis()) + 999) / 1000)
|
||||
.coerceAtLeast(0).toInt()
|
||||
if (left != current.secondsLeft) {
|
||||
_state.value = current.copy(secondsLeft = left)
|
||||
}
|
||||
if (left <= 0) {
|
||||
_state.value = _state.value?.copy(secondsLeft = 0, finished = true)
|
||||
alert()
|
||||
// Låt "klart"-läget synas en stund, försvinn sedan självmant.
|
||||
delay(8_000)
|
||||
if (_state.value?.finished == true) _state.value = null
|
||||
return@launch
|
||||
}
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun alert() {
|
||||
when (settingsStore.settings.first().restAlert) {
|
||||
RestAlert.SOUND -> runCatching {
|
||||
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
RingtoneManager.getRingtone(context, uri)?.play()
|
||||
}
|
||||
RestAlert.VIBRATE -> runCatching {
|
||||
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vm.defaultVibrator.vibrate(
|
||||
VibrationEffect.createWaveform(longArrayOf(0, 350, 150, 350, 150, 500), -1)
|
||||
)
|
||||
}
|
||||
RestAlert.VISUAL, RestAlert.NONE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.settingsDataStore by preferencesDataStore(name = "settings")
|
||||
|
||||
enum class RestTimerStyle { FULLSCREEN, BANNER }
|
||||
enum class RestAlert { SOUND, VIBRATE, VISUAL, NONE }
|
||||
|
||||
data class AppSettings(
|
||||
val restTimerStyle: RestTimerStyle = RestTimerStyle.FULLSCREEN,
|
||||
val restAlert: RestAlert = RestAlert.VIBRATE,
|
||||
val defaultRestSeconds: Int = 90,
|
||||
)
|
||||
|
||||
class SettingsStore(private val context: Context) {
|
||||
|
||||
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
restTimerStyle = prefs[KEY_TIMER_STYLE]?.let {
|
||||
runCatching { RestTimerStyle.valueOf(it) }.getOrNull()
|
||||
} ?: RestTimerStyle.FULLSCREEN,
|
||||
restAlert = prefs[KEY_REST_ALERT]?.let {
|
||||
runCatching { RestAlert.valueOf(it) }.getOrNull()
|
||||
} ?: RestAlert.VIBRATE,
|
||||
defaultRestSeconds = prefs[KEY_REST_SECONDS] ?: 90,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setRestTimerStyle(value: RestTimerStyle) {
|
||||
context.settingsDataStore.edit { it[KEY_TIMER_STYLE] = value.name }
|
||||
}
|
||||
|
||||
suspend fun setRestAlert(value: RestAlert) {
|
||||
context.settingsDataStore.edit { it[KEY_REST_ALERT] = value.name }
|
||||
}
|
||||
|
||||
suspend fun setDefaultRestSeconds(value: Int) {
|
||||
context.settingsDataStore.edit { it[KEY_REST_SECONDS] = value.coerceIn(10, 600) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY_TIMER_STYLE = stringPreferencesKey("rest_timer_style")
|
||||
private val KEY_REST_ALERT = stringPreferencesKey("rest_alert")
|
||||
private val KEY_REST_SECONDS = intPreferencesKey("default_rest_seconds")
|
||||
}
|
||||
}
|
||||
188
app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt
Normal file
188
app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt
Normal file
@@ -0,0 +1,188 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import eu.brassepc.fitnessdroid.data.local.AppDatabase
|
||||
import eu.brassepc.fitnessdroid.data.local.PendingOp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import java.io.IOException
|
||||
|
||||
object OpKind {
|
||||
const val START_SESSION = "start_session"
|
||||
const val ADD_EXERCISE = "add_exercise"
|
||||
const val ADD_SET = "add_set"
|
||||
const val UPDATE_SET = "update_set"
|
||||
const val COMPLETE_SESSION = "complete_session"
|
||||
}
|
||||
|
||||
enum class SyncStatus { SYNCED, PENDING, OFFLINE, ERROR }
|
||||
|
||||
/**
|
||||
* Kör op-kön mot servern i strikt ordning (FIFO). Varje op pekar på en lokal
|
||||
* rad; server-id:n som kommer tillbaka skrivs in på raden så att efterföljande
|
||||
* ops kan referera dem. Nätfel → backoff och nytt försök; kön ligger kvar i
|
||||
* Room så inget tappas om appen dödas.
|
||||
*/
|
||||
class SyncEngine(
|
||||
private val db: AppDatabase,
|
||||
private val client: GraphQlClient,
|
||||
private val auth: AuthRepository,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private val _status = MutableStateFlow(SyncStatus.SYNCED)
|
||||
val status: StateFlow<SyncStatus> = _status
|
||||
val pendingCount = db.opDao().pendingCount()
|
||||
|
||||
/** Puffa igång kön; ofarlig att kalla ofta. */
|
||||
fun kick() {
|
||||
scope.launch { drain() }
|
||||
}
|
||||
|
||||
suspend fun drain() {
|
||||
mutex.withLock {
|
||||
while (true) {
|
||||
val op = db.opDao().next() ?: break
|
||||
try {
|
||||
execute(op)
|
||||
db.opDao().done(op.id)
|
||||
_status.value = if (db.opDao().pendingCountNow() == 0) {
|
||||
SyncStatus.SYNCED
|
||||
} else {
|
||||
SyncStatus.PENDING
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
// Inget nät — försök igen senare, rör inte kön.
|
||||
_status.value = SyncStatus.OFFLINE
|
||||
scheduleRetry(op)
|
||||
return
|
||||
} catch (e: GraphQlException) {
|
||||
// Servern sa nej. Behåll op:en för felsökning men fortsätt inte —
|
||||
// efterföljande ops kan bero på den här.
|
||||
db.opDao().update(
|
||||
op.copy(attempts = op.attempts + 1, lastError = e.message)
|
||||
)
|
||||
_status.value = SyncStatus.ERROR
|
||||
scheduleRetry(op, longDelay = true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var retryScheduled = false
|
||||
private fun scheduleRetry(op: PendingOp, longDelay: Boolean = false) {
|
||||
if (retryScheduled) return
|
||||
retryScheduled = true
|
||||
scope.launch {
|
||||
val backoff = if (longDelay) 30_000L else 5_000L
|
||||
delay(backoff * (op.attempts + 1).coerceAtMost(6))
|
||||
retryScheduled = false
|
||||
drain()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun execute(op: PendingOp) {
|
||||
val dao = db.sessionDao()
|
||||
when (op.kind) {
|
||||
OpKind.START_SESSION -> {
|
||||
val session = dao.session(op.targetLocalId) ?: return
|
||||
if (session.serverId != null) return
|
||||
val input = buildJsonObject {
|
||||
session.name?.let { put("name", it) }
|
||||
session.notes?.let { put("notes", it) }
|
||||
}
|
||||
val data = client.execute(
|
||||
"mutation(\$input: StartGymSessionInput!){startGymSession(input:\$input){id}}",
|
||||
buildJsonObject { put("input", input) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val id = data["startGymSession"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||
dao.updateSession(session.copy(serverId = id))
|
||||
}
|
||||
|
||||
OpKind.ADD_EXERCISE -> {
|
||||
val exercise = dao.exercise(op.targetLocalId) ?: return
|
||||
if (exercise.serverId != null) return
|
||||
val session = dao.session(exercise.sessionId) ?: return
|
||||
val sessionServerId = session.serverId
|
||||
?: throw GraphQlException(listOf("Passet är inte synkat än"))
|
||||
val input = buildJsonObject {
|
||||
put("gymSessionId", sessionServerId)
|
||||
put("exerciseTypeId", exercise.exerciseTypeId)
|
||||
put("order", exercise.order)
|
||||
exercise.notes?.let { put("notes", it) }
|
||||
}
|
||||
val data = client.execute(
|
||||
"mutation(\$input: AddSessionExerciseInput!){addExerciseToSession(input:\$input){id}}",
|
||||
buildJsonObject { put("input", input) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val id = data["addExerciseToSession"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||
dao.updateExercise(exercise.copy(serverId = id))
|
||||
}
|
||||
|
||||
OpKind.ADD_SET, OpKind.UPDATE_SET -> {
|
||||
val set = dao.set(op.targetLocalId) ?: return
|
||||
if (op.kind == OpKind.ADD_SET && set.serverId != null) return
|
||||
val exercise = dao.exercise(set.exerciseId) ?: return
|
||||
val exerciseServerId = exercise.serverId
|
||||
?: throw GraphQlException(listOf("Övningen är inte synkad än"))
|
||||
val fields = buildJsonObject {
|
||||
if (op.kind == OpKind.ADD_SET) {
|
||||
put("sessionExerciseId", exerciseServerId)
|
||||
} else {
|
||||
put("id", set.serverId ?: throw GraphQlException(listOf("Setet är inte synkat än")))
|
||||
}
|
||||
set.reps?.let { put("reps", it) }
|
||||
set.weight?.let { put("weight", it) }
|
||||
set.distanceMeters?.let { put("distanceMeters", it) }
|
||||
set.durationSeconds?.let { put("durationSeconds", it) }
|
||||
set.rpe?.let { put("rpe", it) }
|
||||
put("isWarmup", set.isWarmup)
|
||||
put("isCompleted", set.isCompleted)
|
||||
put("order", set.order)
|
||||
}
|
||||
if (op.kind == OpKind.ADD_SET) {
|
||||
val data = client.execute(
|
||||
"mutation(\$input: AddSessionSetInput!){addSessionSet(input:\$input){id}}",
|
||||
buildJsonObject { put("input", fields) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val id = data["addSessionSet"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||
dao.updateSet(set.copy(serverId = id))
|
||||
} else {
|
||||
client.execute(
|
||||
"mutation(\$input: UpdateSessionSetInput!){updateSessionSet(input:\$input){id}}",
|
||||
buildJsonObject { put("input", fields) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OpKind.COMPLETE_SESSION -> {
|
||||
val session = dao.session(op.targetLocalId) ?: return
|
||||
val sessionServerId = session.serverId
|
||||
?: throw GraphQlException(listOf("Passet är inte synkat än"))
|
||||
val input = buildJsonObject {
|
||||
put("id", sessionServerId)
|
||||
session.name?.let { put("name", it) }
|
||||
}
|
||||
client.execute(
|
||||
"mutation(\$input: CompleteGymSessionInput!){completeGymSession(input:\$input){id status}}",
|
||||
buildJsonObject { put("input", input) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package eu.brassepc.fitnessdroid.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
LocalSession::class,
|
||||
LocalExercise::class,
|
||||
LocalSet::class,
|
||||
PendingOp::class,
|
||||
CachedExerciseType::class,
|
||||
CachedMuscleGroup::class,
|
||||
CachedMuscle::class,
|
||||
CachedStartCard::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun sessionDao(): SessionDao
|
||||
abstract fun opDao(): OpDao
|
||||
abstract fun cacheDao(): CacheDao
|
||||
|
||||
companion object {
|
||||
fun build(context: Context): AppDatabase =
|
||||
Room.databaseBuilder(context, AppDatabase::class.java, "fitnessdroid.db")
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
138
app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt
Normal file
138
app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt
Normal file
@@ -0,0 +1,138 @@
|
||||
package eu.brassepc.fitnessdroid.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface SessionDao {
|
||||
@Insert
|
||||
suspend fun insertSession(session: LocalSession): Long
|
||||
|
||||
@Insert
|
||||
suspend fun insertExercise(exercise: LocalExercise): Long
|
||||
|
||||
@Insert
|
||||
suspend fun insertSet(set: LocalSet): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateSession(session: LocalSession)
|
||||
|
||||
@Update
|
||||
suspend fun updateExercise(exercise: LocalExercise)
|
||||
|
||||
@Update
|
||||
suspend fun updateSet(set: LocalSet)
|
||||
|
||||
@Query("SELECT * FROM local_session WHERE status = 'active' ORDER BY id DESC LIMIT 1")
|
||||
fun activeSession(): Flow<LocalSession?>
|
||||
|
||||
@Query("SELECT * FROM local_session WHERE id = :id")
|
||||
suspend fun session(id: Long): LocalSession?
|
||||
|
||||
@Query("SELECT id FROM local_session WHERE status = 'active' ORDER BY id DESC LIMIT 1")
|
||||
suspend fun activeSessionIdNow(): Long?
|
||||
|
||||
@Query("SELECT * FROM local_exercise WHERE sessionId = :sessionId ORDER BY `order`")
|
||||
fun exercises(sessionId: Long): Flow<List<LocalExercise>>
|
||||
|
||||
@Query("SELECT * FROM local_exercise WHERE id = :id")
|
||||
suspend fun exercise(id: Long): LocalExercise?
|
||||
|
||||
@Query("SELECT * FROM local_set WHERE exerciseId IN (SELECT id FROM local_exercise WHERE sessionId = :sessionId) ORDER BY `order`")
|
||||
fun setsForSession(sessionId: Long): Flow<List<LocalSet>>
|
||||
|
||||
@Query("SELECT * FROM local_set WHERE id = :id")
|
||||
suspend fun set(id: Long): LocalSet?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM local_exercise WHERE sessionId = :sessionId")
|
||||
suspend fun exerciseCount(sessionId: Long): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM local_set WHERE exerciseId = :exerciseId")
|
||||
suspend fun setCount(exerciseId: Long): Int
|
||||
|
||||
@Query("DELETE FROM local_exercise WHERE id = :id")
|
||||
suspend fun deleteExercise(id: Long)
|
||||
|
||||
@Query("DELETE FROM local_set WHERE id = :id")
|
||||
suspend fun deleteSet(id: Long)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface OpDao {
|
||||
@Insert
|
||||
suspend fun enqueue(op: PendingOp): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(op: PendingOp)
|
||||
|
||||
@Query("SELECT * FROM pending_op ORDER BY id LIMIT 1")
|
||||
suspend fun next(): PendingOp?
|
||||
|
||||
@Query("DELETE FROM pending_op WHERE id = :id")
|
||||
suspend fun done(id: Long)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM pending_op")
|
||||
fun pendingCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM pending_op")
|
||||
suspend fun pendingCountNow(): Int
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface CacheDao {
|
||||
@Query("SELECT * FROM cached_exercise_type ORDER BY isFavorite DESC, name")
|
||||
fun exerciseTypes(): Flow<List<CachedExerciseType>>
|
||||
|
||||
@Query("SELECT * FROM cached_exercise_type WHERE id = :id")
|
||||
suspend fun exerciseType(id: Int): CachedExerciseType?
|
||||
|
||||
@Query("SELECT * FROM cached_muscle_group ORDER BY name")
|
||||
fun muscleGroups(): Flow<List<CachedMuscleGroup>>
|
||||
|
||||
@Query("SELECT * FROM cached_muscle ORDER BY name")
|
||||
fun muscles(): Flow<List<CachedMuscle>>
|
||||
|
||||
@Query("SELECT * FROM cached_start_card ORDER BY sortOrder")
|
||||
fun startCards(): Flow<List<CachedStartCard>>
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceExerciseTypes(rows: List<CachedExerciseType>) {
|
||||
// Behåll lastWeight/lastReps-fälten vid refresh — de fylls på separat.
|
||||
for (row in rows) {
|
||||
val old = exerciseType(row.id)
|
||||
upsertExerciseType(
|
||||
if (old != null) row.copy(
|
||||
lastWeight = old.lastWeight,
|
||||
lastReps = old.lastReps,
|
||||
lastDurationSeconds = old.lastDurationSeconds,
|
||||
lastDistanceMeters = old.lastDistanceMeters,
|
||||
) else row
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertExerciseType(row: CachedExerciseType)
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertMuscleGroups(rows: List<CachedMuscleGroup>)
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertMuscles(rows: List<CachedMuscle>)
|
||||
|
||||
@Query("DELETE FROM cached_start_card")
|
||||
suspend fun clearStartCards()
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertStartCards(rows: List<CachedStartCard>)
|
||||
|
||||
@Query("UPDATE cached_exercise_type SET lastWeight = :weight, lastReps = :reps, lastDurationSeconds = :duration, lastDistanceMeters = :distance WHERE id = :id")
|
||||
suspend fun updateLastPerformance(id: Int, weight: Double?, reps: Int?, duration: Int?, distance: Double?)
|
||||
|
||||
@Query("UPDATE cached_exercise_type SET isFavorite = :favorite WHERE id = :id")
|
||||
suspend fun setExerciseFavorite(id: Int, favorite: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package eu.brassepc.fitnessdroid.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Lokal-först-modell: passet lever i Room medan du tränar och synkas till
|
||||
* servern via op-kön ([PendingOp]) i bakgrunden. serverId är null tills
|
||||
* motsvarande mutation gått igenom — det driver synk-indikatorerna i UI:t.
|
||||
*/
|
||||
|
||||
@Entity(tableName = "local_session")
|
||||
data class LocalSession(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val serverId: Int? = null,
|
||||
val name: String? = null,
|
||||
val notes: String? = null,
|
||||
val startedAtEpochMs: Long,
|
||||
/** "active" eller "completed" (lokalt avslutad, ev. ej synkad än) */
|
||||
val status: String = "active",
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "local_exercise",
|
||||
indices = [Index("sessionId")],
|
||||
)
|
||||
data class LocalExercise(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val sessionId: Long,
|
||||
val serverId: Int? = null,
|
||||
val exerciseTypeId: Int,
|
||||
val order: Int,
|
||||
val notes: String? = null,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "local_set",
|
||||
indices = [Index("exerciseId")],
|
||||
)
|
||||
data class LocalSet(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val exerciseId: Long,
|
||||
val serverId: Int? = null,
|
||||
val order: Int,
|
||||
val reps: Int? = null,
|
||||
val weight: Double? = null,
|
||||
val distanceMeters: Double? = null,
|
||||
val durationSeconds: Int? = null,
|
||||
val rpe: Double? = null,
|
||||
val isWarmup: Boolean = false,
|
||||
val isCompleted: Boolean = true,
|
||||
val loggedAtEpochMs: Long,
|
||||
)
|
||||
|
||||
/** Kön av mutationer som väntar på att nå servern. Körs strikt i id-ordning. */
|
||||
@Entity(tableName = "pending_op")
|
||||
data class PendingOp(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
/** se [eu.brassepc.fitnessdroid.data.OpKind] */
|
||||
val kind: String,
|
||||
/** Lokalt id på raden operationen gäller (session/övning/set beroende på kind) */
|
||||
val targetLocalId: Long,
|
||||
val createdAtEpochMs: Long,
|
||||
val attempts: Int = 0,
|
||||
val lastError: String? = null,
|
||||
)
|
||||
|
||||
/* ---------- Referensdata-cache (funkar offline) ---------- */
|
||||
|
||||
@Entity(tableName = "cached_exercise_type")
|
||||
data class CachedExerciseType(
|
||||
@PrimaryKey val id: Int,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val metValue: Double = 5.0,
|
||||
val tracksWeight: Boolean = false,
|
||||
val tracksReps: Boolean = false,
|
||||
val tracksDistance: Boolean = false,
|
||||
val tracksDuration: Boolean = false,
|
||||
val isBodyweight: Boolean = false,
|
||||
/** kommaseparerade muskel-id:n */
|
||||
val muscleIds: String = "",
|
||||
val isFavorite: Boolean = false,
|
||||
/** från API-tillägget ExerciseType.defaultRestSeconds; null → appens standardvila */
|
||||
val defaultRestSeconds: Int? = null,
|
||||
/** Senast kända prestation (för förifyllnad offline) */
|
||||
val lastWeight: Double? = null,
|
||||
val lastReps: Int? = null,
|
||||
val lastDurationSeconds: Int? = null,
|
||||
val lastDistanceMeters: Double? = null,
|
||||
)
|
||||
|
||||
@Entity(tableName = "cached_muscle_group")
|
||||
data class CachedMuscleGroup(
|
||||
@PrimaryKey val id: Int,
|
||||
val name: String,
|
||||
/** från API-tillägget MuscleGroup.iconKey; null → namnheuristik */
|
||||
val iconKey: String? = null,
|
||||
)
|
||||
|
||||
@Entity(tableName = "cached_muscle")
|
||||
data class CachedMuscle(
|
||||
@PrimaryKey val id: Int,
|
||||
val name: String,
|
||||
val muscleGroupId: Int? = null,
|
||||
)
|
||||
|
||||
/** Favoritpass/mallar som visas på hemskärmen, cachade för offline-start. */
|
||||
@Entity(tableName = "cached_start_card")
|
||||
data class CachedStartCard(
|
||||
/** "template-<id>" eller "favsession-<id>" */
|
||||
@PrimaryKey val key: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
/** kommaseparerade exerciseTypeId:n i ordning — används för att starta passet */
|
||||
val exerciseTypeIds: String,
|
||||
val templateId: Int? = null,
|
||||
val sortOrder: Int = 0,
|
||||
)
|
||||
208
app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt
Normal file
208
app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt
Normal file
@@ -0,0 +1,208 @@
|
||||
package eu.brassepc.fitnessdroid.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import eu.brassepc.fitnessdroid.ui.history.HistoryScreen
|
||||
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
|
||||
import eu.brassepc.fitnessdroid.ui.picker.ExercisePickerScreen
|
||||
import eu.brassepc.fitnessdroid.ui.profile.ProfileScreen
|
||||
import eu.brassepc.fitnessdroid.ui.session.SessionScreen
|
||||
import eu.brassepc.fitnessdroid.ui.settings.SettingsScreen
|
||||
import eu.brassepc.fitnessdroid.ui.stats.StatsScreen
|
||||
|
||||
object Routes {
|
||||
const val HOME = "home"
|
||||
const val HISTORY = "history"
|
||||
const val STATS = "stats"
|
||||
const val PROFILE = "profile"
|
||||
const val SETTINGS = "settings"
|
||||
const val SESSION = "session"
|
||||
const val PICKER = "picker"
|
||||
}
|
||||
|
||||
private data class TabItem(val route: String, val label: String, val icon: ImageVector)
|
||||
|
||||
private val tabs = listOf(
|
||||
TabItem(Routes.HOME, "Hem", Icons.Default.Home),
|
||||
TabItem(Routes.HISTORY, "Pass", Icons.AutoMirrored.Filled.List),
|
||||
TabItem(Routes.STATS, "Statistik", Icons.Default.BarChart),
|
||||
TabItem(Routes.PROFILE, "Profil", Icons.Default.Person),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppRoot(rootViewModel: RootViewModel = viewModel(factory = RootViewModel.Factory)) {
|
||||
val nav = rememberNavController()
|
||||
val backStack by nav.currentBackStackEntryAsState()
|
||||
val route = backStack?.destination?.route
|
||||
val hasActiveSession by rootViewModel.hasActiveSession.collectAsStateWithLifecycle(false)
|
||||
val restState by rootViewModel.restState.collectAsStateWithLifecycle()
|
||||
val pendingCount by rootViewModel.pendingCount.collectAsStateWithLifecycle(0)
|
||||
|
||||
val showChrome = route in tabs.map { it.route }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showChrome) {
|
||||
Column {
|
||||
if (hasActiveSession) {
|
||||
MiniPlayer(
|
||||
restSecondsLeft = restState?.secondsLeft,
|
||||
pendingCount = pendingCount,
|
||||
onOpen = { nav.navigate(Routes.SESSION) },
|
||||
)
|
||||
}
|
||||
BottomBar(nav = nav, currentRoute = route, onStart = {
|
||||
rootViewModel.startOrResume { nav.navigate(Routes.SESSION) }
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
NavHost(
|
||||
navController = nav,
|
||||
startDestination = Routes.HOME,
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
composable(Routes.HOME) {
|
||||
HomeScreen(
|
||||
onOpenSession = { nav.navigate(Routes.SESSION) },
|
||||
onOpenHistory = { nav.navigate(Routes.HISTORY) },
|
||||
)
|
||||
}
|
||||
composable(Routes.HISTORY) { HistoryScreen() }
|
||||
composable(Routes.STATS) { StatsScreen() }
|
||||
composable(Routes.PROFILE) {
|
||||
ProfileScreen(onOpenSettings = { nav.navigate(Routes.SETTINGS) })
|
||||
}
|
||||
composable(Routes.SETTINGS) { SettingsScreen(onBack = { nav.popBackStack() }) }
|
||||
composable(Routes.SESSION) {
|
||||
SessionScreen(
|
||||
onBack = { nav.popBackStack() },
|
||||
onAddExercise = { nav.navigate(Routes.PICKER) },
|
||||
onSessionEnded = {
|
||||
nav.popBackStack(Routes.HOME, inclusive = false)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.PICKER) {
|
||||
ExercisePickerScreen(onDone = { nav.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomBar(nav: NavHostController, currentRoute: String?, onStart: () -> Unit) {
|
||||
Box {
|
||||
NavigationBar {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
if (index == 2) {
|
||||
// plats för mittknappen
|
||||
NavigationBarItem(
|
||||
selected = false,
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
icon = {},
|
||||
label = { Text("") },
|
||||
)
|
||||
}
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == tab.route,
|
||||
onClick = {
|
||||
nav.navigate(tab.route) {
|
||||
popUpTo(Routes.HOME) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { Icon(tab.icon, contentDescription = tab.label) },
|
||||
label = { Text(tab.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
onClick = onStart,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = "Starta pass")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniPlayer(restSecondsLeft: Int?, pendingCount: Int, onOpen: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpen),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
|
||||
Text("Pass pågår", style = MaterialTheme.typography.labelLarge)
|
||||
val sub = buildString {
|
||||
if (restSecondsLeft != null && restSecondsLeft > 0) {
|
||||
append("Vila ${restSecondsLeft / 60}:${"%02d".format(restSecondsLeft % 60)} kvar")
|
||||
} else {
|
||||
append("Tryck för att fortsätta")
|
||||
}
|
||||
if (pendingCount > 0) append(" · $pendingCount osynkat")
|
||||
}
|
||||
Text(
|
||||
sub,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"ÖPPNA",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import eu.brassepc.fitnessdroid.data.AuthState
|
||||
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
|
||||
import eu.brassepc.fitnessdroid.ui.login.LoginScreen
|
||||
|
||||
@Composable
|
||||
@@ -35,7 +34,7 @@ fun FitnessDroidApp(appViewModel: AppViewModel = viewModel(factory = AppViewMode
|
||||
}
|
||||
}
|
||||
AuthState.LoggedOut -> LoginScreen()
|
||||
is AuthState.LoggedIn -> HomeScreen()
|
||||
is AuthState.LoggedIn -> AppRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package eu.brassepc.fitnessdroid.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerController
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RootViewModel(
|
||||
private val repo: GymRepository,
|
||||
restTimer: RestTimerController,
|
||||
) : ViewModel() {
|
||||
|
||||
val hasActiveSession = repo.activeSession.map { it != null }
|
||||
val restState = restTimer.state
|
||||
val pendingCount = repo.pendingCount
|
||||
|
||||
private val activeSessionState = repo.activeSession
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
init {
|
||||
repo.refreshAllAsync()
|
||||
}
|
||||
|
||||
/** Mittknappen: öppna pågående pass, eller starta ett fritt pass. */
|
||||
fun startOrResume(onReady: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
if (activeSessionState.value == null) {
|
||||
repo.startFreeSession()
|
||||
}
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
RootViewModel(c.gymRepository, c.restTimer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package eu.brassepc.fitnessdroid.ui.common
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
|
||||
import androidx.compose.material.icons.filled.Accessibility
|
||||
import androidx.compose.material.icons.filled.AirlineSeatLegroomExtra
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.FrontHand
|
||||
import androidx.compose.material.icons.filled.Grid4x4
|
||||
import androidx.compose.material.icons.filled.SportsGymnastics
|
||||
import androidx.compose.material.icons.filled.SportsMartialArts
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* Ikon per muskelgrupp. I första hand via serverns iconKey
|
||||
* (API-tillägget MuscleGroup.iconKey), i andra hand namnheuristik.
|
||||
*/
|
||||
object MuscleIcons {
|
||||
|
||||
fun forKey(iconKey: String?): ImageVector? = when (iconKey?.lowercase()) {
|
||||
"chest" -> Icons.Default.FitnessCenter
|
||||
"back" -> Icons.Default.SportsGymnastics
|
||||
"legs" -> Icons.Default.AirlineSeatLegroomExtra
|
||||
"shoulders" -> Icons.Default.SportsMartialArts
|
||||
"arms" -> Icons.Default.FrontHand
|
||||
"core" -> Icons.Default.Grid4x4
|
||||
"cardio" -> Icons.AutoMirrored.Filled.DirectionsRun
|
||||
"fullbody" -> Icons.Default.Accessibility
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun forGroupName(name: String?): ImageVector {
|
||||
val n = name?.lowercase().orEmpty()
|
||||
return when {
|
||||
"bröst" in n || "chest" in n -> Icons.Default.FitnessCenter
|
||||
"rygg" in n || "back" in n || "lat" in n -> Icons.Default.SportsGymnastics
|
||||
"ben" in n || "leg" in n || "quad" in n || "vad" in n -> Icons.Default.AirlineSeatLegroomExtra
|
||||
"axl" in n || "shoulder" in n || "delt" in n -> Icons.Default.SportsMartialArts
|
||||
"arm" in n || "bicep" in n || "tricep" in n -> Icons.Default.FrontHand
|
||||
"mage" in n || "core" in n || "abs" in n || "bål" in n -> Icons.Default.Grid4x4
|
||||
"kondition" in n || "cardio" in n || "löp" in n -> Icons.AutoMirrored.Filled.DirectionsRun
|
||||
"hel" in n || "full" in n -> Icons.Default.Accessibility
|
||||
else -> Icons.Default.FavoriteBorder
|
||||
}
|
||||
}
|
||||
|
||||
fun resolve(iconKey: String?, groupName: String?): ImageVector =
|
||||
forKey(iconKey) ?: forGroupName(groupName)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package eu.brassepc.fitnessdroid.ui.history
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.HistoryItem
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface HistoryUiState {
|
||||
data object Loading : HistoryUiState
|
||||
data class Error(val message: String) : HistoryUiState
|
||||
data class Ready(val items: List<HistoryItem>) : HistoryUiState
|
||||
}
|
||||
|
||||
class HistoryViewModel(private val repo: GymRepository) : ViewModel() {
|
||||
val uiState = MutableStateFlow<HistoryUiState>(HistoryUiState.Loading)
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
uiState.value = HistoryUiState.Loading
|
||||
viewModelScope.launch {
|
||||
uiState.value = try {
|
||||
HistoryUiState.Ready(repo.fetchHistory())
|
||||
} catch (e: Exception) {
|
||||
HistoryUiState.Error("Kunde inte hämta historiken — offline?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer { HistoryViewModel(appContainer().gymRepository) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HistoryScreen(viewModel: HistoryViewModel = viewModel(factory = HistoryViewModel.Factory)) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
when (val state = uiState) {
|
||||
is HistoryUiState.Loading -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is HistoryUiState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(state.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text("Försök igen")
|
||||
}
|
||||
}
|
||||
}
|
||||
is HistoryUiState.Ready -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
Text("Passhistorik", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
items(state.items, key = { it.id }) { item ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row {
|
||||
Text(
|
||||
item.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
item.date.take(10),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
buildString {
|
||||
append("${item.exerciseCount} övningar · ${item.setCount} set")
|
||||
if (item.volumeKg > 0) append(" · ${item.volumeKg.toInt()} kg volym")
|
||||
item.calories?.let { append(" · ${it.toInt()} kcal") }
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.items.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Inga avslutade pass än — dags att ändra på det!",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,28 @@
|
||||
package eu.brassepc.fitnessdroid.ui.home
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.CloudDone
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -26,95 +31,136 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import eu.brassepc.fitnessdroid.data.SyncStatus
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory)) {
|
||||
fun HomeScreen(
|
||||
onOpenSession: () -> Unit,
|
||||
onOpenHistory: () -> Unit,
|
||||
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("FitnessDroid") },
|
||||
actions = {
|
||||
IconButton(onClick = viewModel::logout) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = "Logga ut",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
when (val state = uiState) {
|
||||
is HomeUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Tjena ${uiState.username}", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Redo för ett pass?",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
is HomeUiState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
SyncChip(status = uiState.syncStatus, pending = uiState.pendingCount)
|
||||
}
|
||||
|
||||
if (uiState.hasActiveSession) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenSession),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(state.message, color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 16.dp)) {
|
||||
Text("Försök igen")
|
||||
}
|
||||
}
|
||||
}
|
||||
is HomeUiState.Ready -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
"Hej ${state.profile.displayName ?: state.profile.username}!",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Profil", style = MaterialTheme.typography.titleMedium)
|
||||
ProfileRow("Användarnamn", state.profile.username)
|
||||
state.profile.email?.let { ProfileRow("E-post", it) }
|
||||
state.profile.bodyWeightKg?.let { ProfileRow("Kroppsvikt", "$it kg") }
|
||||
Text(
|
||||
"Pass pågår — tryck för att fortsätta",
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.startFree(onOpenSession) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Text("Starta fritt pass", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.startCards.isNotEmpty()) {
|
||||
Text("Favoritpass & mallar", style = MaterialTheme.typography.titleMedium)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(uiState.startCards, key = { it.key }) { card ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.width(170.dp)
|
||||
.clickable(enabled = !uiState.hasActiveSession) {
|
||||
viewModel.startFromCard(card, onOpenSession)
|
||||
},
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
card.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
card.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Text(
|
||||
if (uiState.hasActiveSession) "Pass pågår" else "▶ Starta",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Pass, loggning, statistik och PB kommer i nästa steg.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AssistChip(
|
||||
onClick = onOpenHistory,
|
||||
label = { Text("Passhistorik") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
private fun SyncChip(status: SyncStatus, pending: Int) {
|
||||
val (icon, text) = when {
|
||||
status == SyncStatus.OFFLINE -> Icons.Default.CloudOff to "Offline · $pending väntar"
|
||||
status == SyncStatus.ERROR -> Icons.Default.CloudOff to "Synkfel"
|
||||
pending > 0 -> Icons.Default.CloudUpload to "$pending osynkat"
|
||||
else -> Icons.Default.CloudDone to "Synkat"
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = text,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.weight(1f),
|
||||
text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,54 +6,64 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.AuthRepository
|
||||
import eu.brassepc.fitnessdroid.data.GraphQlException
|
||||
import eu.brassepc.fitnessdroid.data.GymApi
|
||||
import eu.brassepc.fitnessdroid.data.Profile
|
||||
import eu.brassepc.fitnessdroid.data.AuthState
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.SyncStatus
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedStartCard
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface HomeUiState {
|
||||
data object Loading : HomeUiState
|
||||
data class Error(val message: String) : HomeUiState
|
||||
data class Ready(val profile: Profile) : HomeUiState
|
||||
}
|
||||
data class HomeUiState(
|
||||
val username: String = "",
|
||||
val hasActiveSession: Boolean = false,
|
||||
val startCards: List<CachedStartCard> = emptyList(),
|
||||
val syncStatus: SyncStatus = SyncStatus.SYNCED,
|
||||
val pendingCount: Int = 0,
|
||||
)
|
||||
|
||||
class HomeViewModel(
|
||||
private val gymApi: GymApi,
|
||||
private val auth: AuthRepository,
|
||||
private val repo: GymRepository,
|
||||
auth: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
|
||||
val uiState: StateFlow<HomeUiState> = _uiState
|
||||
val uiState = combine(
|
||||
repo.activeSession,
|
||||
repo.startCards,
|
||||
repo.syncStatus,
|
||||
repo.pendingCount,
|
||||
auth.state,
|
||||
) { session, cards, sync, pending, authState ->
|
||||
HomeUiState(
|
||||
username = (authState as? AuthState.LoggedIn)?.username ?: "",
|
||||
hasActiveSession = session != null,
|
||||
startCards = cards,
|
||||
syncStatus = sync,
|
||||
pendingCount = pending,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState())
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_uiState.value = HomeUiState.Loading
|
||||
fun startFree(onReady: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = HomeUiState.Ready(gymApi.myProfile())
|
||||
} catch (e: GraphQlException) {
|
||||
_uiState.value = HomeUiState.Error(e.message ?: "Något gick fel")
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = HomeUiState.Error("Kunde inte nå servern")
|
||||
}
|
||||
repo.startFreeSession()
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch { auth.logout() }
|
||||
fun startFromCard(card: CachedStartCard, onReady: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
repo.startFromCard(card)
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val container = appContainer()
|
||||
HomeViewModel(container.gymApi, container.authRepository)
|
||||
val c = appContainer()
|
||||
HomeViewModel(c.gymRepository, c.authRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package eu.brassepc.fitnessdroid.ui.picker
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import eu.brassepc.fitnessdroid.ui.common.MuscleIcons
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExercisePickerScreen(
|
||||
onDone: () -> Unit,
|
||||
viewModel: PickerViewModel = viewModel(factory = PickerViewModel.Factory),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Lägg till övning") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onDone) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = uiState.query,
|
||||
onValueChange = viewModel::onQueryChange,
|
||||
placeholder = { Text("Sök övning …") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
FilterChip(
|
||||
selected = uiState.selectedGroupId == null,
|
||||
onClick = { viewModel.selectGroup(null) },
|
||||
label = { Text("Alla") },
|
||||
)
|
||||
}
|
||||
items(uiState.groups, key = { it.id }) { group ->
|
||||
FilterChip(
|
||||
selected = uiState.selectedGroupId == group.id,
|
||||
onClick = { viewModel.selectGroup(group.id) },
|
||||
label = { Text(group.name) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
MuscleIcons.resolve(group.iconKey, group.name),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.musclesInGroup.isNotEmpty()) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
FilterChip(
|
||||
selected = uiState.selectedMuscleId == null,
|
||||
onClick = { viewModel.selectMuscle(null) },
|
||||
label = { Text("Alla i gruppen") },
|
||||
)
|
||||
}
|
||||
items(uiState.musclesInGroup, key = { it.id }) { muscle ->
|
||||
FilterChip(
|
||||
selected = uiState.selectedMuscleId == muscle.id,
|
||||
onClick = { viewModel.selectMuscle(muscle.id) },
|
||||
label = { Text(muscle.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(uiState.exercises, key = { it.id }) { row ->
|
||||
Surface(
|
||||
onClick = { viewModel.pick(row.id, onDone) },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(38.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
row.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 10.dp),
|
||||
) {
|
||||
Text(row.name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
row.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
row.badges.forEach { badge ->
|
||||
Text(
|
||||
badge,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { viewModel.toggleFavorite(row.id, !row.favorite) }) {
|
||||
Icon(
|
||||
if (row.favorite) Icons.Default.Star else Icons.Default.StarBorder,
|
||||
contentDescription = "Favorit",
|
||||
tint = if (row.favorite) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uiState.exercises.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Inga övningar matchar — testa ett annat filter.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package eu.brassepc.fitnessdroid.ui.picker
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscle
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscleGroup
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.MuscleIcons
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ExerciseRow(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val subtitle: String,
|
||||
val badges: List<String>,
|
||||
val favorite: Boolean,
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
data class PickerUiState(
|
||||
val query: String = "",
|
||||
val groups: List<CachedMuscleGroup> = emptyList(),
|
||||
val musclesInGroup: List<CachedMuscle> = emptyList(),
|
||||
val selectedGroupId: Int? = null,
|
||||
val selectedMuscleId: Int? = null,
|
||||
val exercises: List<ExerciseRow> = emptyList(),
|
||||
)
|
||||
|
||||
class PickerViewModel(private val repo: GymRepository) : ViewModel() {
|
||||
|
||||
private val query = MutableStateFlow("")
|
||||
private val selectedGroup = MutableStateFlow<Int?>(null)
|
||||
private val selectedMuscle = MutableStateFlow<Int?>(null)
|
||||
|
||||
private data class Filters(val query: String, val groupId: Int?, val muscleId: Int?)
|
||||
|
||||
private val filters = combine(query, selectedGroup, selectedMuscle) { q, g, m -> Filters(q, g, m) }
|
||||
|
||||
val uiState = combine(
|
||||
repo.exerciseTypes,
|
||||
repo.muscleGroups,
|
||||
repo.muscles,
|
||||
filters,
|
||||
) { types, groups, muscles, f ->
|
||||
val musclesByGroup = muscles.groupBy { it.muscleGroupId }
|
||||
val groupByMuscleId = muscles.associate { it.id to it.muscleGroupId }
|
||||
val groupNameById = groups.associate { it.id to it.name }
|
||||
val groupIconKeyById = groups.associate { it.id to it.iconKey }
|
||||
|
||||
val filtered = types.filter { t ->
|
||||
val muscleIds = t.muscleIds.split(",").mapNotNull { it.trim().toIntOrNull() }
|
||||
val inGroup = f.groupId == null ||
|
||||
muscleIds.any { groupByMuscleId[it] == f.groupId }
|
||||
val inMuscle = f.muscleId == null || f.muscleId in muscleIds
|
||||
val matches = f.query.isBlank() || t.name.contains(f.query.trim(), ignoreCase = true)
|
||||
inGroup && inMuscle && matches
|
||||
}
|
||||
|
||||
PickerUiState(
|
||||
query = f.query,
|
||||
groups = groups,
|
||||
musclesInGroup = f.groupId?.let { musclesByGroup[it] }.orEmpty(),
|
||||
selectedGroupId = f.groupId,
|
||||
selectedMuscleId = f.muscleId,
|
||||
exercises = filtered.map { t ->
|
||||
val muscleIds = t.muscleIds.split(",").mapNotNull { it.trim().toIntOrNull() }
|
||||
val primaryGroupId = muscleIds.firstNotNullOfOrNull { groupByMuscleId[it] }
|
||||
val groupNames = muscleIds.mapNotNull { groupByMuscleId[it] }
|
||||
.distinct().mapNotNull { groupNameById[it] }
|
||||
ExerciseRow(
|
||||
id = t.id,
|
||||
name = t.name,
|
||||
subtitle = buildString {
|
||||
append(groupNames.joinToString(" · ").ifEmpty { "Övrigt" })
|
||||
t.lastWeight?.let { append(" · senast ${it.compact()} kg") }
|
||||
},
|
||||
badges = buildList {
|
||||
if (t.tracksWeight) add("KG")
|
||||
if (t.tracksReps) add("REPS")
|
||||
if (t.tracksDuration) add("TID")
|
||||
if (t.tracksDistance) add("M")
|
||||
},
|
||||
favorite = t.isFavorite,
|
||||
icon = MuscleIcons.resolve(
|
||||
primaryGroupId?.let { groupIconKeyById[it] },
|
||||
primaryGroupId?.let { groupNameById[it] },
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PickerUiState())
|
||||
|
||||
fun onQueryChange(value: String) { query.value = value }
|
||||
|
||||
fun selectGroup(id: Int?) {
|
||||
selectedGroup.value = id
|
||||
selectedMuscle.value = null
|
||||
}
|
||||
|
||||
fun selectMuscle(id: Int?) { selectedMuscle.value = id }
|
||||
|
||||
fun toggleFavorite(exerciseTypeId: Int, favorite: Boolean) =
|
||||
repo.toggleFavoriteExercise(exerciseTypeId, favorite)
|
||||
|
||||
fun pick(exerciseTypeId: Int, onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val sessionId = repo.activeSession.first()?.id ?: return@launch
|
||||
repo.addExercise(sessionId, exerciseTypeId)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer { PickerViewModel(appContainer().gymRepository) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Double.compact(): String =
|
||||
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)
|
||||
@@ -0,0 +1,131 @@
|
||||
package eu.brassepc.fitnessdroid.ui.profile
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.AuthRepository
|
||||
import eu.brassepc.fitnessdroid.data.GymApi
|
||||
import eu.brassepc.fitnessdroid.data.Profile
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ProfileViewModel(
|
||||
private val gymApi: GymApi,
|
||||
private val auth: AuthRepository,
|
||||
) : ViewModel() {
|
||||
val profile = MutableStateFlow<Profile?>(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
runCatching { profile.value = gymApi.myProfile() }
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch { auth.logout() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
ProfileViewModel(c.gymApi, c.authRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory),
|
||||
) {
|
||||
val profile by viewModel.profile.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text("Profil", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ProfileRow("Användarnamn", profile?.username ?: "…")
|
||||
profile?.displayName?.let { ProfileRow("Namn", it) }
|
||||
profile?.email?.let { ProfileRow("E-post", it) }
|
||||
profile?.bodyWeightKg?.let { ProfileRow("Kroppsvikt", "$it kg") }
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenSettings),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Text("Inställningar", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Vilotimer, larm, standardvila",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = viewModel::logout) {
|
||||
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
|
||||
Text("Logga ut", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
package eu.brassepc.fitnessdroid.ui.session
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CloudDone
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.Flag
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import eu.brassepc.fitnessdroid.data.RestState
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerStyle
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalSet
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SessionScreen(
|
||||
onBack: () -> Unit,
|
||||
onAddExercise: () -> Unit,
|
||||
onSessionEnded: () -> Unit,
|
||||
viewModel: SessionViewModel = viewModel(factory = SessionViewModel.Factory),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var confirmComplete by remember { mutableStateOf(false) }
|
||||
|
||||
// Passtimern (uppe till höger) tickar en gång i sekunden.
|
||||
var nowMs by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
nowMs = System.currentTimeMillis()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
val fullscreenRest = uiState.rest != null &&
|
||||
uiState.settings.restTimerStyle == RestTimerStyle.FULLSCREEN
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(uiState.sessionName, style = MaterialTheme.typography.titleMedium)
|
||||
val elapsed = ((nowMs - uiState.startedAtEpochMs) / 1000).coerceAtLeast(0)
|
||||
Text(
|
||||
"${elapsed / 60}:${"%02d".format(elapsed % 60)}" +
|
||||
if (uiState.pendingCount > 0) " · ${uiState.pendingCount} osynkat" else "",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = { confirmComplete = true }) {
|
||||
Icon(Icons.Default.Flag, contentDescription = null)
|
||||
Text("Avsluta", modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (uiState.rest != null && uiState.settings.restTimerStyle == RestTimerStyle.BANNER) {
|
||||
RestBanner(
|
||||
rest = uiState.rest!!,
|
||||
onAdjust = viewModel::adjustRest,
|
||||
onSkip = viewModel::skipRest,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
if (uiState.pages.isEmpty()) {
|
||||
EmptySession(onAddExercise)
|
||||
} else {
|
||||
val pagerState = rememberPagerState(pageCount = { uiState.pages.size })
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
PagerDots(
|
||||
count = uiState.pages.size,
|
||||
current = pagerState.currentPage,
|
||||
doneUpTo = uiState.pages.count { it.doneSets.isNotEmpty() },
|
||||
)
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { pageIndex ->
|
||||
ExercisePageContent(
|
||||
page = uiState.pages[pageIndex],
|
||||
pageIndex = pageIndex,
|
||||
pageCount = uiState.pages.size,
|
||||
onDraft = viewModel::updateDraft,
|
||||
onLog = viewModel::logSet,
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = onAddExercise,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Text("Lägg till övning", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fullscreenRest) {
|
||||
RestFullscreen(
|
||||
rest = uiState.rest!!,
|
||||
onAdjust = viewModel::adjustRest,
|
||||
onSkip = viewModel::skipRest,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmComplete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmComplete = false },
|
||||
title = { Text("Avsluta passet?") },
|
||||
text = {
|
||||
Text(
|
||||
if (uiState.pendingCount > 0) {
|
||||
"${uiState.pendingCount} ändringar är inte synkade än — de synkas så fort nätet är tillbaka, avsluta lugnt."
|
||||
} else {
|
||||
"Allt är synkat till servern."
|
||||
}
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
confirmComplete = false
|
||||
viewModel.completeSession(onSessionEnded)
|
||||
}) { Text("Avsluta pass") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmComplete = false }) { Text("Fortsätt träna") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptySession(onAddExercise: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("Passet är igång!", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Lägg till din första övning så kör vi.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 20.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(onClick = onAddExercise) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Text("Lägg till övning", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PagerDots(count: Int, current: Int, doneUpTo: Int) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
repeat(count) { i ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(4.dp)
|
||||
.background(
|
||||
color = when {
|
||||
i == current -> MaterialTheme.colorScheme.primary
|
||||
i < doneUpTo -> MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExercisePageContent(
|
||||
page: ExercisePage,
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
onDraft: (Long, (SetDraft) -> SetDraft) -> Unit,
|
||||
onLog: (Long) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(page.name, style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
buildString {
|
||||
append("Övning ${pageIndex + 1} av $pageCount")
|
||||
page.lastPerformanceText?.let { append(" · $it") }
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
page.doneSets.forEach { set -> DoneSetRow(set) }
|
||||
|
||||
Card {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"SET ${page.doneSets.size + 1}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (page.type?.tracksWeight != false) {
|
||||
Stepper(
|
||||
label = "kg",
|
||||
value = page.draft.weight?.compact() ?: "—",
|
||||
onMinus = { onDraft(page.localId) { d -> d.copy(weight = ((d.weight ?: 0.0) - 2.5).coerceAtLeast(0.0)) } },
|
||||
onPlus = { onDraft(page.localId) { d -> d.copy(weight = (d.weight ?: 0.0) + 2.5) } },
|
||||
)
|
||||
}
|
||||
if (page.type?.tracksReps != false) {
|
||||
Stepper(
|
||||
label = "reps",
|
||||
value = page.draft.reps?.toString() ?: "—",
|
||||
onMinus = { onDraft(page.localId) { d -> d.copy(reps = ((d.reps ?: 1) - 1).coerceAtLeast(1)) } },
|
||||
onPlus = { onDraft(page.localId) { d -> d.copy(reps = (d.reps ?: 0) + 1) } },
|
||||
)
|
||||
}
|
||||
if (page.type?.tracksDuration == true) {
|
||||
Stepper(
|
||||
label = "sekunder",
|
||||
value = page.draft.durationSeconds?.toString() ?: "—",
|
||||
onMinus = { onDraft(page.localId) { d -> d.copy(durationSeconds = ((d.durationSeconds ?: 15) - 15).coerceAtLeast(5)) } },
|
||||
onPlus = { onDraft(page.localId) { d -> d.copy(durationSeconds = (d.durationSeconds ?: 0) + 15) } },
|
||||
)
|
||||
}
|
||||
if (page.type?.tracksDistance == true) {
|
||||
Stepper(
|
||||
label = "meter",
|
||||
value = page.draft.distanceMeters?.compact() ?: "—",
|
||||
onMinus = { onDraft(page.localId) { d -> d.copy(distanceMeters = ((d.distanceMeters ?: 100.0) - 100).coerceAtLeast(0.0)) } },
|
||||
onPlus = { onDraft(page.localId) { d -> d.copy(distanceMeters = (d.distanceMeters ?: 0.0) + 100) } },
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
"RPE",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
listOf(7.0, 8.0, 9.0, 10.0).forEach { rpe ->
|
||||
val selected = page.draft.rpe == rpe
|
||||
Surface(
|
||||
onClick = {
|
||||
onDraft(page.localId) { d ->
|
||||
d.copy(rpe = if (selected) null else rpe)
|
||||
}
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
rpe.compact(),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { onLog(page.localId) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
Text("Logga set — vilan startar", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DoneSetRow(set: LocalSet) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
buildString {
|
||||
append("Set ${set.order}")
|
||||
set.weight?.let { append(" · ${it.compact()} kg") }
|
||||
set.reps?.let { append(" × $it") }
|
||||
set.durationSeconds?.let { append(" · ${it}s") }
|
||||
set.distanceMeters?.let { append(" · ${it.compact()} m") }
|
||||
set.rpe?.let { append(" · RPE ${it.compact()}") }
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Icon(
|
||||
if (set.serverId != null) Icons.Default.CloudDone else Icons.Default.CloudUpload,
|
||||
contentDescription = if (set.serverId != null) "Synkad" else "Väntar på synk",
|
||||
tint = if (set.serverId != null) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(15.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Stepper(label: String, value: String, onMinus: () -> Unit, onPlus: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilledTonalIconButton(onClick = onMinus) { Text("−", style = MaterialTheme.typography.titleLarge) }
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(value, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
FilledTonalIconButton(onClick = onPlus) { Text("+", style = MaterialTheme.typography.titleLarge) }
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Vilotimer, två presentationer ---------- */
|
||||
|
||||
@Composable
|
||||
private fun RestFullscreen(rest: RestState, onAdjust: (Int) -> Unit, onSkip: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background.copy(alpha = 0.97f),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
) {
|
||||
Text(
|
||||
if (rest.finished) "Kör!" else "Vila",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(vertical = 24.dp)) {
|
||||
CircularProgressIndicator(
|
||||
progress = {
|
||||
if (rest.totalSeconds == 0) 0f
|
||||
else rest.secondsLeft / rest.totalSeconds.toFloat()
|
||||
},
|
||||
modifier = Modifier.size(200.dp),
|
||||
strokeWidth = 10.dp,
|
||||
)
|
||||
Text(
|
||||
"${rest.secondsLeft / 60}:${"%02d".format(rest.secondsLeft % 60)}",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
FilledTonalButton(onClick = { onAdjust(-15) }) { Text("−15 s") }
|
||||
FilledTonalButton(onClick = { onAdjust(15) }) { Text("+15 s") }
|
||||
Button(onClick = onSkip) { Text(if (rest.finished) "Fortsätt" else "Hoppa över") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestBanner(rest: RestState, onAdjust: (Int) -> Unit, onSkip: () -> Unit) {
|
||||
Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (rest.finished) {
|
||||
"Vilan klar — kör!"
|
||||
} else {
|
||||
"Vila ${rest.secondsLeft / 60}:${"%02d".format(rest.secondsLeft % 60)}"
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { onAdjust(15) }) { Text("+15 s") }
|
||||
TextButton(onClick = onSkip) { Text(if (rest.finished) "OK" else "Hoppa över") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package eu.brassepc.fitnessdroid.ui.session
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.AppSettings
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.RestState
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerController
|
||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedExerciseType
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalSet
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Utkast till nästa set för en övning (styrs av steppers i UI:t). */
|
||||
data class SetDraft(
|
||||
val weight: Double? = null,
|
||||
val reps: Int? = null,
|
||||
val durationSeconds: Int? = null,
|
||||
val distanceMeters: Double? = null,
|
||||
val rpe: Double? = null,
|
||||
)
|
||||
|
||||
data class ExercisePage(
|
||||
val localId: Long,
|
||||
val type: CachedExerciseType?,
|
||||
val name: String,
|
||||
val doneSets: List<LocalSet>,
|
||||
val draft: SetDraft,
|
||||
val lastPerformanceText: String?,
|
||||
)
|
||||
|
||||
data class SessionUiState(
|
||||
val sessionId: Long? = null,
|
||||
val sessionName: String,
|
||||
val startedAtEpochMs: Long = 0,
|
||||
val pages: List<ExercisePage> = emptyList(),
|
||||
val pendingCount: Int = 0,
|
||||
val rest: RestState? = null,
|
||||
val settings: AppSettings = AppSettings(),
|
||||
val completedLocally: Boolean = false,
|
||||
) {
|
||||
companion object { val EMPTY = SessionUiState(sessionName = "Pass") }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SessionViewModel(
|
||||
private val repo: GymRepository,
|
||||
private val restTimer: RestTimerController,
|
||||
private val settingsStore: SettingsStore,
|
||||
) : ViewModel() {
|
||||
|
||||
/** exerciseLocalId → utkast som användaren pillat på */
|
||||
private val drafts = MutableStateFlow<Map<Long, SetDraft>>(emptyMap())
|
||||
|
||||
private val sessionFlow = repo.activeSession
|
||||
|
||||
private val bundleFlow = sessionFlow.flatMapLatest { session ->
|
||||
if (session == null) {
|
||||
flowOf(SessionUiState.EMPTY)
|
||||
} else {
|
||||
combine(
|
||||
repo.exercises(session.id),
|
||||
repo.setsForSession(session.id),
|
||||
repo.exerciseTypes,
|
||||
drafts,
|
||||
repo.pendingCount,
|
||||
) { exercises, sets, types, draftMap, pending ->
|
||||
val typeById = types.associateBy { it.id }
|
||||
val setsByExercise = sets.groupBy { it.exerciseId }
|
||||
SessionUiState(
|
||||
sessionId = session.id,
|
||||
sessionName = session.name ?: "Fritt pass",
|
||||
startedAtEpochMs = session.startedAtEpochMs,
|
||||
pendingCount = pending,
|
||||
pages = exercises.map { ex ->
|
||||
val type = typeById[ex.exerciseTypeId]
|
||||
val done = setsByExercise[ex.id].orEmpty()
|
||||
ExercisePage(
|
||||
localId = ex.id,
|
||||
type = type,
|
||||
name = type?.name ?: "Övning ${ex.order}",
|
||||
doneSets = done,
|
||||
draft = draftMap[ex.id] ?: initialDraft(type, done),
|
||||
lastPerformanceText = type?.lastWeight?.let { w ->
|
||||
"Senast: ${w.compact()} kg" +
|
||||
(type.lastReps?.let { " × $it" } ?: "")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val uiState = combine(
|
||||
bundleFlow,
|
||||
restTimer.state,
|
||||
settingsStore.settings,
|
||||
) { bundle, rest, settings ->
|
||||
bundle.copy(rest = rest, settings = settings)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SessionUiState.EMPTY)
|
||||
|
||||
/** Förifyll utkastet: senaste setet i passet, annars cachens senaste prestation. */
|
||||
private fun initialDraft(type: CachedExerciseType?, done: List<LocalSet>): SetDraft {
|
||||
val last = done.lastOrNull()
|
||||
return SetDraft(
|
||||
weight = last?.weight ?: type?.lastWeight
|
||||
?: if (type?.tracksWeight == true) 20.0 else null,
|
||||
reps = last?.reps ?: type?.lastReps
|
||||
?: if (type?.tracksReps == true) 8 else null,
|
||||
durationSeconds = last?.durationSeconds ?: type?.lastDurationSeconds
|
||||
?: if (type?.tracksDuration == true) 30 else null,
|
||||
distanceMeters = last?.distanceMeters ?: type?.lastDistanceMeters
|
||||
?: if (type?.tracksDistance == true) 1000.0 else null,
|
||||
rpe = last?.rpe,
|
||||
)
|
||||
}
|
||||
|
||||
fun updateDraft(exerciseId: Long, transform: (SetDraft) -> SetDraft) {
|
||||
val page = uiState.value.pages.firstOrNull { it.localId == exerciseId } ?: return
|
||||
drafts.value = drafts.value + (exerciseId to transform(page.draft))
|
||||
}
|
||||
|
||||
fun logSet(exerciseId: Long) {
|
||||
val page = uiState.value.pages.firstOrNull { it.localId == exerciseId } ?: return
|
||||
val d = page.draft
|
||||
viewModelScope.launch {
|
||||
repo.logSet(
|
||||
exerciseId = exerciseId,
|
||||
reps = if (page.type?.tracksReps != false) d.reps else null,
|
||||
weight = if (page.type?.tracksWeight != false) d.weight else null,
|
||||
distanceMeters = if (page.type?.tracksDistance == true) d.distanceMeters else null,
|
||||
durationSeconds = if (page.type?.tracksDuration == true) d.durationSeconds else null,
|
||||
rpe = d.rpe,
|
||||
isWarmup = false,
|
||||
)
|
||||
// API-tillägget defaultRestSeconds per övning; annars appens standard.
|
||||
restTimer.start(page.type?.defaultRestSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun adjustRest(delta: Int) = restTimer.adjust(delta)
|
||||
fun skipRest() = restTimer.dismiss()
|
||||
|
||||
fun completeSession(onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
|
||||
restTimer.dismiss()
|
||||
repo.completeSession(id)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
SessionViewModel(c.gymRepository, c.restTimer, c.settingsStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Double.compact(): String =
|
||||
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)
|
||||
@@ -0,0 +1,195 @@
|
||||
package eu.brassepc.fitnessdroid.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.AppSettings
|
||||
import eu.brassepc.fitnessdroid.data.RestAlert
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerStyle
|
||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(private val store: SettingsStore) : ViewModel() {
|
||||
val settings = store.settings
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AppSettings())
|
||||
|
||||
fun setStyle(value: RestTimerStyle) = viewModelScope.launch { store.setRestTimerStyle(value) }
|
||||
fun setAlert(value: RestAlert) = viewModelScope.launch { store.setRestAlert(value) }
|
||||
fun adjustRestSeconds(delta: Int) = viewModelScope.launch {
|
||||
store.setDefaultRestSeconds(settings.value.defaultRestSeconds + delta)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer { SettingsViewModel(appContainer().settingsStore) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory),
|
||||
) {
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Inställningar") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SettingsCard(title = "Vilotimerns utseende") {
|
||||
RadioRow(
|
||||
label = "Helskärm",
|
||||
description = "Timern tar över skärmen mellan seten",
|
||||
selected = settings.restTimerStyle == RestTimerStyle.FULLSCREEN,
|
||||
onClick = { viewModel.setStyle(RestTimerStyle.FULLSCREEN) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Liten banner",
|
||||
description = "Diskret rad längst ner på pass-sidan",
|
||||
selected = settings.restTimerStyle == RestTimerStyle.BANNER,
|
||||
onClick = { viewModel.setStyle(RestTimerStyle.BANNER) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCard(title = "När vilan är klar") {
|
||||
RadioRow(
|
||||
label = "Larmljud",
|
||||
description = "Spelar notissignalen",
|
||||
selected = settings.restAlert == RestAlert.SOUND,
|
||||
onClick = { viewModel.setAlert(RestAlert.SOUND) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Vibration",
|
||||
description = "Telefonen vibrerar",
|
||||
selected = settings.restAlert == RestAlert.VIBRATE,
|
||||
onClick = { viewModel.setAlert(RestAlert.VIBRATE) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Bara visuellt",
|
||||
description = "Timern byter utseende, inget ljud eller surr",
|
||||
selected = settings.restAlert == RestAlert.VISUAL,
|
||||
onClick = { viewModel.setAlert(RestAlert.VISUAL) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Ingenting",
|
||||
description = "Timern försvinner tyst",
|
||||
selected = settings.restAlert == RestAlert.NONE,
|
||||
onClick = { viewModel.setAlert(RestAlert.NONE) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCard(title = "Standardvila mellan set") {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
FilledTonalButton(onClick = { viewModel.adjustRestSeconds(-15) }) { Text("−15 s") }
|
||||
Text(
|
||||
"${settings.defaultRestSeconds} s",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
FilledTonalButton(onClick = { viewModel.adjustRestSeconds(15) }) { Text("+15 s") }
|
||||
}
|
||||
Text(
|
||||
"Övningar kan få egen vilotid via API:t — då gäller den istället.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadioRow(label: String, description: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(selected = selected, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
Column(modifier = Modifier.padding(start = 8.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package eu.brassepc.fitnessdroid.ui.stats
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StatsScreen() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("Statistik", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Kroppskartan, trender och PB kommer i nästa milstolpe. " +
|
||||
"Din data samlas redan — allt du loggar syns här sen.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@ plugins {
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ navigationCompose = "2.8.5"
|
||||
okhttp = "4.12.0"
|
||||
kotlinxSerialization = "1.7.3"
|
||||
datastore = "1.1.1"
|
||||
room = "2.6.1"
|
||||
ksp = "2.0.21-1.0.28"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -26,9 +28,13 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
||||
Reference in New Issue
Block a user