diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0324367..71b57e8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e81daab..3197298 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + 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")) diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt new file mode 100644 index 0000000..af49e28 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt @@ -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 = 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() + 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 { + 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 diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/RestTimer.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/RestTimer.kt new file mode 100644 index 0000000..0e59139 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/RestTimer.kt @@ -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(null) + val state: StateFlow = _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 + } + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt new file mode 100644 index 0000000..80cf1ce --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt @@ -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 = 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") + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt new file mode 100644 index 0000000..74b4872 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt @@ -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 = _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(), + ) + } + } + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt new file mode 100644 index 0000000..8a17b6b --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt @@ -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() + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt new file mode 100644 index 0000000..633e813 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt @@ -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 + + @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> + + @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> + + @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 + + @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> + + @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> + + @Query("SELECT * FROM cached_muscle ORDER BY name") + fun muscles(): Flow> + + @Query("SELECT * FROM cached_start_card ORDER BY sortOrder") + fun startCards(): Flow> + + @Transaction + suspend fun replaceExerciseTypes(rows: List) { + // 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) + + @androidx.room.Upsert + suspend fun upsertMuscles(rows: List) + + @Query("DELETE FROM cached_start_card") + suspend fun clearStartCards() + + @androidx.room.Upsert + suspend fun upsertStartCards(rows: List) + + @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) +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt new file mode 100644 index 0000000..b10cb58 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt @@ -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-" eller "favsession-" */ + @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, +) diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt new file mode 100644 index 0000000..8e278d2 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/FitnessDroidApp.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/FitnessDroidApp.kt index 28fb59d..54f16c8 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/FitnessDroidApp.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/FitnessDroidApp.kt @@ -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() } } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/RootViewModel.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/RootViewModel.kt new file mode 100644 index 0000000..cc5f9ac --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/RootViewModel.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/MuscleIcons.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/MuscleIcons.kt new file mode 100644 index 0000000..f1c8506 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/MuscleIcons.kt @@ -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) +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryScreen.kt new file mode 100644 index 0000000..73e6bf8 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryScreen.kt @@ -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) : HistoryUiState +} + +class HistoryViewModel(private val repo: GymRepository) : ViewModel() { + val uiState = MutableStateFlow(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, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt index 88d6c71..97ea4f6 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt @@ -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) } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt index fe668f4..fdc0ebf 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt @@ -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 = 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.Loading) - val uiState: StateFlow = _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) } } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/picker/ExercisePickerScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/picker/ExercisePickerScreen.kt new file mode 100644 index 0000000..2a3c24a --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/picker/ExercisePickerScreen.kt @@ -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), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/picker/PickerViewModel.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/picker/PickerViewModel.kt new file mode 100644 index 0000000..fb278f5 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/picker/PickerViewModel.kt @@ -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, + val favorite: Boolean, + val icon: ImageVector, +) + +data class PickerUiState( + val query: String = "", + val groups: List = emptyList(), + val musclesInGroup: List = emptyList(), + val selectedGroupId: Int? = null, + val selectedMuscleId: Int? = null, + val exercises: List = emptyList(), +) + +class PickerViewModel(private val repo: GymRepository) : ViewModel() { + + private val query = MutableStateFlow("") + private val selectedGroup = MutableStateFlow(null) + private val selectedMuscle = MutableStateFlow(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) diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..b803702 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt @@ -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(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) + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt new file mode 100644 index 0000000..e99dcea --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt @@ -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") } + } + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionViewModel.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionViewModel.kt new file mode 100644 index 0000000..8780630 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionViewModel.kt @@ -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, + val draft: SetDraft, + val lastPerformanceText: String?, +) + +data class SessionUiState( + val sessionId: Long? = null, + val sessionName: String, + val startedAtEpochMs: Long = 0, + val pages: List = 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>(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): 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) diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..9310531 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt new file mode 100644 index 0000000..9b9b86a --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt @@ -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), + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 132ad8d..614b661 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ff486a..a00639d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }