M2: passläge, offline-först med synk-kö, vilotimer med inställningar
Some checks failed
release / build-release (push) Has been cancelled

- Room-cache: övningar/muskelgrupper/favoritkort + lokal-först-pass
- SyncEngine: FIFO-op-kö mot API:t, retry med backoff, synk-status per set
  (moln-ikon) + global räknare; nätfel loggar inte ut användaren
- Passläge: en övning i taget (svep), ±steppers, RPE, förifyllda värden
  från förra passet, logga set med ett tryck
- Vilotimer: helskärm eller banner (inställning), larm/vibration/visuell/
  inget vid utgång, ±15s, per-övnings vilotid via API:ts defaultRestSeconds
- Övningsväljare: sök + muskelgrupp/muskel-chips med ikoner (API:ts iconKey
  med namnheuristik som fallback), favoriter först, KG/REPS/TID/M-brickor
- Navigation: bottenrad + start-FAB + minispelare, hemskärm med favoritpass
- Inställningsskärm under Profil; historik-lista; version 0.2.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
2026-07-24 13:29:17 +02:00
parent 53d17d467f
commit 984e2374f8
28 changed files with 3000 additions and 112 deletions

View File

@@ -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)
}

View File

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name=".FitnessDroidApplication"

View File

@@ -5,14 +5,28 @@ import android.content.Context
import eu.brassepc.fitnessdroid.data.AuthRepository
import eu.brassepc.fitnessdroid.data.GraphQlClient
import eu.brassepc.fitnessdroid.data.GymApi
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.RestTimerController
import eu.brassepc.fitnessdroid.data.SettingsStore
import eu.brassepc.fitnessdroid.data.SyncEngine
import eu.brassepc.fitnessdroid.data.TokenStore
import eu.brassepc.fitnessdroid.data.local.AppDatabase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/** Enkel manuell DI-container — appen är för liten för Hilt. */
class AppContainer(context: Context) {
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val tokenStore = TokenStore(context)
val settingsStore = SettingsStore(context)
val graphQlClient = GraphQlClient { tokenStore.apiUrl() }
val authRepository = AuthRepository(tokenStore, graphQlClient)
val gymApi = GymApi(graphQlClient, authRepository)
val database = AppDatabase.build(context)
val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope)
val gymRepository = GymRepository(database, graphQlClient, authRepository, syncEngine, appScope)
val restTimer = RestTimerController(context, settingsStore, appScope)
}
class FitnessDroidApplication : Application() {

View File

@@ -133,6 +133,9 @@ class AuthRepository(
store.updateTokens(newToken, newRefresh, parseExpiration(newExpiration))
newToken
} catch (e: GraphQlException) {
// Servern avvisade refresh-tokenen — riktig utloggning.
// Nätfel (IOException) bubblar istället uppåt så att synkmotorn
// kan försöka igen utan att kasta ut användaren.
forceLogout()
throw GraphQlException(listOf("Sessionen har gått ut, logga in igen"))
}

View File

@@ -47,6 +47,11 @@ class GraphQlClient(private val urlProvider: suspend () -> String) {
http.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
// 5xx = servern/proxyn nere — behandla som nätfel (retry) och inte
// som ett logiskt API-fel (som t.ex. loggar ut användaren).
if (response.code >= 500) {
throw java.io.IOException("HTTP ${response.code} från servern")
}
val root = runCatching { json.parseToJsonElement(text).jsonObject }.getOrNull()
?: throw GraphQlException(listOf("HTTP ${response.code}: oväntat svar från servern"))

View File

@@ -0,0 +1,373 @@
package eu.brassepc.fitnessdroid.data
import eu.brassepc.fitnessdroid.data.local.AppDatabase
import eu.brassepc.fitnessdroid.data.local.CachedExerciseType
import eu.brassepc.fitnessdroid.data.local.CachedMuscle
import eu.brassepc.fitnessdroid.data.local.CachedMuscleGroup
import eu.brassepc.fitnessdroid.data.local.CachedStartCard
import eu.brassepc.fitnessdroid.data.local.LocalExercise
import eu.brassepc.fitnessdroid.data.local.LocalSession
import eu.brassepc.fitnessdroid.data.local.LocalSet
import eu.brassepc.fitnessdroid.data.local.PendingOp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
/**
* Lokal-först-repository. Allt UI läser Room-flows; alla skrivningar sker
* lokalt först och läggs på op-kön som [SyncEngine] driver mot servern.
*/
class GymRepository(
private val db: AppDatabase,
private val client: GraphQlClient,
private val auth: AuthRepository,
private val sync: SyncEngine,
private val scope: CoroutineScope,
) {
val activeSession = db.sessionDao().activeSession()
val exerciseTypes = db.cacheDao().exerciseTypes()
val muscleGroups = db.cacheDao().muscleGroups()
val muscles = db.cacheDao().muscles()
val startCards = db.cacheDao().startCards()
val pendingCount = db.opDao().pendingCount()
val syncStatus = sync.status
fun exercises(sessionId: Long) = db.sessionDao().exercises(sessionId)
fun setsForSession(sessionId: Long) = db.sessionDao().setsForSession(sessionId)
private suspend fun enqueue(kind: String, targetLocalId: Long) {
db.opDao().enqueue(
PendingOp(kind = kind, targetLocalId = targetLocalId, createdAtEpochMs = System.currentTimeMillis())
)
sync.kick()
}
/* ---------- Passhantering ---------- */
suspend fun startFreeSession(name: String? = null): Long {
val id = db.sessionDao().insertSession(
LocalSession(name = name, startedAtEpochMs = System.currentTimeMillis())
)
enqueue(OpKind.START_SESSION, id)
return id
}
/** Starta från favoritpass/mall-kort: skapar passet + alla övningar lokalt. */
suspend fun startFromCard(card: CachedStartCard): Long {
val sessionId = db.sessionDao().insertSession(
LocalSession(name = card.title, startedAtEpochMs = System.currentTimeMillis())
)
enqueue(OpKind.START_SESSION, sessionId)
card.exerciseTypeIds.split(",").mapNotNull { it.trim().toIntOrNull() }
.forEachIndexed { index, typeId ->
val exId = db.sessionDao().insertExercise(
LocalExercise(sessionId = sessionId, exerciseTypeId = typeId, order = index + 1)
)
enqueue(OpKind.ADD_EXERCISE, exId)
}
return sessionId
}
suspend fun addExercise(sessionId: Long, exerciseTypeId: Int): Long {
val order = db.sessionDao().exerciseCount(sessionId) + 1
val id = db.sessionDao().insertExercise(
LocalExercise(sessionId = sessionId, exerciseTypeId = exerciseTypeId, order = order)
)
enqueue(OpKind.ADD_EXERCISE, id)
refreshLastPerformance(exerciseTypeId)
return id
}
suspend fun logSet(
exerciseId: Long,
reps: Int?,
weight: Double?,
distanceMeters: Double?,
durationSeconds: Int?,
rpe: Double?,
isWarmup: Boolean,
): Long {
val order = db.sessionDao().setCount(exerciseId) + 1
val id = db.sessionDao().insertSet(
LocalSet(
exerciseId = exerciseId,
order = order,
reps = reps,
weight = weight,
distanceMeters = distanceMeters,
durationSeconds = durationSeconds,
rpe = rpe,
isWarmup = isWarmup,
loggedAtEpochMs = System.currentTimeMillis(),
)
)
enqueue(OpKind.ADD_SET, id)
// Uppdatera cachens "senaste prestation" för förifyllnad nästa gång.
db.sessionDao().exercise(exerciseId)?.let { ex ->
db.cacheDao().updateLastPerformance(ex.exerciseTypeId, weight, reps, durationSeconds, distanceMeters)
}
return id
}
suspend fun updateSet(set: LocalSet) {
db.sessionDao().updateSet(set)
// Inte synkad än? Då bär den väntande ADD_SET-opsen de nya värdena
// automatiskt (den läser raden när den körs). Annars: egen update-op.
if (set.serverId != null) enqueue(OpKind.UPDATE_SET, set.id)
}
suspend fun completeSession(sessionId: Long) {
val session = db.sessionDao().session(sessionId) ?: return
db.sessionDao().updateSession(session.copy(status = "completed"))
enqueue(OpKind.COMPLETE_SESSION, sessionId)
}
/* ---------- Referensdata / cache ---------- */
/** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */
fun refreshAllAsync() {
scope.launch {
runCatching { refreshReferenceData() }
runCatching { adoptServerActiveSession() }
sync.kick()
}
}
private suspend fun refreshReferenceData() {
val token = auth.bearerToken()
val groups = client.execute("query{muscleGroups{id name iconKey}}", token = token)
db.cacheDao().upsertMuscleGroups(
groups["muscleGroups"]!!.jsonArray.map {
val o = it.jsonObject
CachedMuscleGroup(
o["id"]!!.jsonPrimitive.int,
o["name"]!!.jsonPrimitive.content,
o["iconKey"]?.jsonPrimitive?.contentOrNull(),
)
}
)
val muscles = client.execute("query{muscles{id name muscleGroupId}}", token = token)
db.cacheDao().upsertMuscles(
muscles["muscles"]!!.jsonArray.map {
val o = it.jsonObject
CachedMuscle(
o["id"]!!.jsonPrimitive.int,
o["name"]!!.jsonPrimitive.content,
o["muscleGroupId"]?.jsonPrimitive?.intOrNull,
)
}
)
val favIds: Set<Int> = runCatching {
client.execute("query{favoriteExercises{id}}", token = token)["favoriteExercises"]!!
.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.int }.toSet()
}.getOrDefault(emptySet())
val types = client.execute(
"query{exerciseTypes{id name description metValue tracksWeight tracksReps tracksDistance tracksDuration isBodyweight defaultRestSeconds exerciseTypeMuscles{muscle{id}}}}",
token = token,
)
db.cacheDao().replaceExerciseTypes(
types["exerciseTypes"]!!.jsonArray.map {
val o = it.jsonObject
CachedExerciseType(
id = o["id"]!!.jsonPrimitive.int,
name = o["name"]!!.jsonPrimitive.content,
description = o["description"]?.jsonPrimitive?.contentOrNull(),
metValue = o["metValue"]?.jsonPrimitive?.doubleOrNull ?: 5.0,
tracksWeight = o["tracksWeight"]?.jsonPrimitive?.booleanOrNull ?: false,
tracksReps = o["tracksReps"]?.jsonPrimitive?.booleanOrNull ?: false,
tracksDistance = o["tracksDistance"]?.jsonPrimitive?.booleanOrNull ?: false,
tracksDuration = o["tracksDuration"]?.jsonPrimitive?.booleanOrNull ?: false,
isBodyweight = o["isBodyweight"]?.jsonPrimitive?.booleanOrNull ?: false,
defaultRestSeconds = o["defaultRestSeconds"]?.jsonPrimitive?.intOrNull,
muscleIds = o["exerciseTypeMuscles"]?.jsonArray
?.mapNotNull { m -> m.jsonObject["muscle"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
?.joinToString(",") ?: "",
isFavorite = o["id"]!!.jsonPrimitive.int in favIds,
)
}
)
// Startkort: mallar + favoritpass
val cards = mutableListOf<CachedStartCard>()
runCatching {
val templates = client.execute(
"query{workoutTemplates{id name exercises{order exerciseType{id}}}}",
token = token,
)
templates["workoutTemplates"]!!.jsonArray.forEachIndexed { i, t ->
val o = t.jsonObject
val ids = o["exercises"]!!.jsonArray
.sortedBy { e -> e.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
.mapNotNull { e -> e.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
cards += CachedStartCard(
key = "template-${o["id"]!!.jsonPrimitive.int}",
title = o["name"]!!.jsonPrimitive.content,
subtitle = "Mall · ${ids.size} övningar",
exerciseTypeIds = ids.joinToString(","),
templateId = o["id"]!!.jsonPrimitive.int,
sortOrder = i,
)
}
}
runCatching {
val favs = client.execute(
"query{favoriteGymSessions{id name gymSessionExercises{order exerciseType{id}}}}",
token = token,
)
favs["favoriteGymSessions"]!!.jsonArray.forEachIndexed { i, s ->
val o = s.jsonObject
val ids = o["gymSessionExercises"]!!.jsonArray
.sortedBy { e -> e.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
.mapNotNull { e -> e.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
cards += CachedStartCard(
key = "favsession-${o["id"]!!.jsonPrimitive.int}",
title = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Favoritpass",
subtitle = "Favorit · ${ids.size} övningar",
exerciseTypeIds = ids.joinToString(","),
sortOrder = 100 + i,
)
}
}
db.cacheDao().clearStartCards()
db.cacheDao().upsertStartCards(cards)
}
/** Om servern har ett aktivt pass men appen saknar ett: ta över det lokalt. */
private suspend fun adoptServerActiveSession() {
if (db.sessionDao().activeSessionIdNow() != null) return
val token = auth.bearerToken()
val data = client.execute(
"query{activeGymSession{id name notes startTime gymSessionExercises{id order notes exerciseType{id} sets{id order reps weight distanceMeters durationSeconds rpe isWarmup isCompleted}}}}",
token = token,
)
val s = data["activeGymSession"] ?: return
if (s is kotlinx.serialization.json.JsonNull) return
val o = s.jsonObject
val sessionId = db.sessionDao().insertSession(
LocalSession(
serverId = o["id"]!!.jsonPrimitive.int,
name = o["name"]?.jsonPrimitive?.contentOrNull(),
notes = o["notes"]?.jsonPrimitive?.contentOrNull(),
startedAtEpochMs = System.currentTimeMillis(),
)
)
o["gymSessionExercises"]?.jsonArray?.forEach { e ->
val eo = e.jsonObject
val exId = db.sessionDao().insertExercise(
LocalExercise(
sessionId = sessionId,
serverId = eo["id"]!!.jsonPrimitive.int,
exerciseTypeId = eo["exerciseType"]!!.jsonObject["id"]!!.jsonPrimitive.int,
order = eo["order"]?.jsonPrimitive?.intOrNull ?: 0,
notes = eo["notes"]?.jsonPrimitive?.contentOrNull(),
)
)
eo["sets"]?.jsonArray?.forEach { st ->
val so = st.jsonObject
db.sessionDao().insertSet(
LocalSet(
exerciseId = exId,
serverId = so["id"]!!.jsonPrimitive.int,
order = so["order"]?.jsonPrimitive?.intOrNull ?: 0,
reps = so["reps"]?.jsonPrimitive?.intOrNull,
weight = so["weight"]?.jsonPrimitive?.doubleOrNull,
distanceMeters = so["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
isCompleted = so["isCompleted"]?.jsonPrimitive?.booleanOrNull ?: true,
loggedAtEpochMs = System.currentTimeMillis(),
)
)
}
}
}
private fun refreshLastPerformance(exerciseTypeId: Int) {
scope.launch {
runCatching {
val data = client.execute(
"query(\$id:Int!){lastExercisePerformance(exerciseTypeId:\$id){sets{reps weight distanceMeters durationSeconds}}}",
buildJsonObject { put("id", exerciseTypeId) },
auth.bearerToken(),
)
val perf = data["lastExercisePerformance"] ?: return@launch
if (perf is kotlinx.serialization.json.JsonNull) return@launch
val best = perf.jsonObject["sets"]?.jsonArray?.lastOrNull()?.jsonObject ?: return@launch
db.cacheDao().updateLastPerformance(
exerciseTypeId,
best["weight"]?.jsonPrimitive?.doubleOrNull,
best["reps"]?.jsonPrimitive?.intOrNull,
best["durationSeconds"]?.jsonPrimitive?.intOrNull,
best["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
)
}
}
}
fun toggleFavoriteExercise(exerciseTypeId: Int, favorite: Boolean) {
scope.launch {
db.cacheDao().setExerciseFavorite(exerciseTypeId, favorite)
runCatching {
val mutation = if (favorite) {
"mutation(\$id:Int!){addFavoriteExercise(exerciseTypeId:\$id){id}}"
} else {
"mutation(\$id:Int!){removeFavoriteExercise(exerciseTypeId:\$id)}"
}
client.execute(mutation, buildJsonObject { put("id", exerciseTypeId) }, auth.bearerToken())
}
}
}
/** Historik hämtas direkt (ingen offline-garanti i v1). */
suspend fun fetchHistory(limit: Int = 30): List<HistoryItem> {
val data = client.execute(
"query(\$s:String,\$l:Int){gymSessions(status:\$s,limit:\$l){id name date startTime durationSecondsOverride estimatedCalories gymSessionExercises{id sets{id weight reps}}}}",
buildJsonObject { put("s", "completed"); put("l", limit) },
auth.bearerToken(),
)
return data["gymSessions"]!!.jsonArray.map { s ->
val o = s.jsonObject
val sets = o["gymSessionExercises"]!!.jsonArray.flatMap { it.jsonObject["sets"]!!.jsonArray }
val volume = sets.sumOf { st ->
val so = st.jsonObject
(so["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0) * (so["reps"]?.jsonPrimitive?.intOrNull ?: 0)
}
HistoryItem(
id = o["id"]!!.jsonPrimitive.int,
name = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
date = o["date"]?.jsonPrimitive?.contentOrNull()
?: o["startTime"]?.jsonPrimitive?.contentOrNull() ?: "",
exerciseCount = o["gymSessionExercises"]!!.jsonArray.size,
setCount = sets.size,
volumeKg = volume,
calories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
)
}
}
}
data class HistoryItem(
val id: Int,
val name: String,
val date: String,
val exerciseCount: Int,
val setCount: Int,
val volumeKg: Double,
val calories: Double?,
)
private fun kotlinx.serialization.json.JsonPrimitive.contentOrNull(): String? =
if (this is kotlinx.serialization.json.JsonNull) null else content

View File

@@ -0,0 +1,102 @@
package eu.brassepc.fitnessdroid.data
import android.content.Context
import android.media.RingtoneManager
import android.os.VibrationEffect
import android.os.VibratorManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
data class RestState(
val totalSeconds: Int,
val endsAtEpochMs: Long,
val secondsLeft: Int,
val finished: Boolean = false,
)
/**
* Vilotimern. Räknar mot en absolut sluttid (överlever att appen pausas)
* och larmar enligt inställningarna när tiden är ute.
*/
class RestTimerController(
private val context: Context,
private val settingsStore: SettingsStore,
private val scope: CoroutineScope,
) {
private val _state = MutableStateFlow<RestState?>(null)
val state: StateFlow<RestState?> = _state
private var ticker: Job? = null
fun start(seconds: Int? = null) {
scope.launch {
val settings = settingsStore.settings.first()
val total = seconds ?: settings.defaultRestSeconds
_state.value = RestState(
totalSeconds = total,
endsAtEpochMs = System.currentTimeMillis() + total * 1000L,
secondsLeft = total,
)
startTicker()
}
}
fun adjust(deltaSeconds: Int) {
val current = _state.value ?: return
if (current.finished) return
_state.value = current.copy(
endsAtEpochMs = (current.endsAtEpochMs + deltaSeconds * 1000L)
.coerceAtLeast(System.currentTimeMillis()),
totalSeconds = (current.totalSeconds + deltaSeconds).coerceAtLeast(5),
)
}
fun dismiss() {
ticker?.cancel()
_state.value = null
}
private fun startTicker() {
ticker?.cancel()
ticker = scope.launch {
while (true) {
val current = _state.value ?: return@launch
val left = (((current.endsAtEpochMs - System.currentTimeMillis()) + 999) / 1000)
.coerceAtLeast(0).toInt()
if (left != current.secondsLeft) {
_state.value = current.copy(secondsLeft = left)
}
if (left <= 0) {
_state.value = _state.value?.copy(secondsLeft = 0, finished = true)
alert()
// Låt "klart"-läget synas en stund, försvinn sedan självmant.
delay(8_000)
if (_state.value?.finished == true) _state.value = null
return@launch
}
delay(250)
}
}
}
private suspend fun alert() {
when (settingsStore.settings.first().restAlert) {
RestAlert.SOUND -> runCatching {
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
RingtoneManager.getRingtone(context, uri)?.play()
}
RestAlert.VIBRATE -> runCatching {
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vm.defaultVibrator.vibrate(
VibrationEffect.createWaveform(longArrayOf(0, 350, 150, 350, 150, 500), -1)
)
}
RestAlert.VISUAL, RestAlert.NONE -> Unit
}
}
}

View File

@@ -0,0 +1,53 @@
package eu.brassepc.fitnessdroid.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.settingsDataStore by preferencesDataStore(name = "settings")
enum class RestTimerStyle { FULLSCREEN, BANNER }
enum class RestAlert { SOUND, VIBRATE, VISUAL, NONE }
data class AppSettings(
val restTimerStyle: RestTimerStyle = RestTimerStyle.FULLSCREEN,
val restAlert: RestAlert = RestAlert.VIBRATE,
val defaultRestSeconds: Int = 90,
)
class SettingsStore(private val context: Context) {
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { prefs ->
AppSettings(
restTimerStyle = prefs[KEY_TIMER_STYLE]?.let {
runCatching { RestTimerStyle.valueOf(it) }.getOrNull()
} ?: RestTimerStyle.FULLSCREEN,
restAlert = prefs[KEY_REST_ALERT]?.let {
runCatching { RestAlert.valueOf(it) }.getOrNull()
} ?: RestAlert.VIBRATE,
defaultRestSeconds = prefs[KEY_REST_SECONDS] ?: 90,
)
}
suspend fun setRestTimerStyle(value: RestTimerStyle) {
context.settingsDataStore.edit { it[KEY_TIMER_STYLE] = value.name }
}
suspend fun setRestAlert(value: RestAlert) {
context.settingsDataStore.edit { it[KEY_REST_ALERT] = value.name }
}
suspend fun setDefaultRestSeconds(value: Int) {
context.settingsDataStore.edit { it[KEY_REST_SECONDS] = value.coerceIn(10, 600) }
}
companion object {
private val KEY_TIMER_STYLE = stringPreferencesKey("rest_timer_style")
private val KEY_REST_ALERT = stringPreferencesKey("rest_alert")
private val KEY_REST_SECONDS = intPreferencesKey("default_rest_seconds")
}
}

View File

@@ -0,0 +1,188 @@
package eu.brassepc.fitnessdroid.data
import eu.brassepc.fitnessdroid.data.local.AppDatabase
import eu.brassepc.fitnessdroid.data.local.PendingOp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.io.IOException
object OpKind {
const val START_SESSION = "start_session"
const val ADD_EXERCISE = "add_exercise"
const val ADD_SET = "add_set"
const val UPDATE_SET = "update_set"
const val COMPLETE_SESSION = "complete_session"
}
enum class SyncStatus { SYNCED, PENDING, OFFLINE, ERROR }
/**
* Kör op-kön mot servern i strikt ordning (FIFO). Varje op pekar på en lokal
* rad; server-id:n som kommer tillbaka skrivs in på raden så att efterföljande
* ops kan referera dem. Nätfel → backoff och nytt försök; kön ligger kvar i
* Room så inget tappas om appen dödas.
*/
class SyncEngine(
private val db: AppDatabase,
private val client: GraphQlClient,
private val auth: AuthRepository,
private val scope: CoroutineScope,
) {
private val mutex = Mutex()
private val _status = MutableStateFlow(SyncStatus.SYNCED)
val status: StateFlow<SyncStatus> = _status
val pendingCount = db.opDao().pendingCount()
/** Puffa igång kön; ofarlig att kalla ofta. */
fun kick() {
scope.launch { drain() }
}
suspend fun drain() {
mutex.withLock {
while (true) {
val op = db.opDao().next() ?: break
try {
execute(op)
db.opDao().done(op.id)
_status.value = if (db.opDao().pendingCountNow() == 0) {
SyncStatus.SYNCED
} else {
SyncStatus.PENDING
}
} catch (e: IOException) {
// Inget nät — försök igen senare, rör inte kön.
_status.value = SyncStatus.OFFLINE
scheduleRetry(op)
return
} catch (e: GraphQlException) {
// Servern sa nej. Behåll op:en för felsökning men fortsätt inte —
// efterföljande ops kan bero på den här.
db.opDao().update(
op.copy(attempts = op.attempts + 1, lastError = e.message)
)
_status.value = SyncStatus.ERROR
scheduleRetry(op, longDelay = true)
return
}
}
}
}
private var retryScheduled = false
private fun scheduleRetry(op: PendingOp, longDelay: Boolean = false) {
if (retryScheduled) return
retryScheduled = true
scope.launch {
val backoff = if (longDelay) 30_000L else 5_000L
delay(backoff * (op.attempts + 1).coerceAtMost(6))
retryScheduled = false
drain()
}
}
private suspend fun execute(op: PendingOp) {
val dao = db.sessionDao()
when (op.kind) {
OpKind.START_SESSION -> {
val session = dao.session(op.targetLocalId) ?: return
if (session.serverId != null) return
val input = buildJsonObject {
session.name?.let { put("name", it) }
session.notes?.let { put("notes", it) }
}
val data = client.execute(
"mutation(\$input: StartGymSessionInput!){startGymSession(input:\$input){id}}",
buildJsonObject { put("input", input) },
auth.bearerToken(),
)
val id = data["startGymSession"]!!.jsonObject["id"]!!.jsonPrimitive.int
dao.updateSession(session.copy(serverId = id))
}
OpKind.ADD_EXERCISE -> {
val exercise = dao.exercise(op.targetLocalId) ?: return
if (exercise.serverId != null) return
val session = dao.session(exercise.sessionId) ?: return
val sessionServerId = session.serverId
?: throw GraphQlException(listOf("Passet är inte synkat än"))
val input = buildJsonObject {
put("gymSessionId", sessionServerId)
put("exerciseTypeId", exercise.exerciseTypeId)
put("order", exercise.order)
exercise.notes?.let { put("notes", it) }
}
val data = client.execute(
"mutation(\$input: AddSessionExerciseInput!){addExerciseToSession(input:\$input){id}}",
buildJsonObject { put("input", input) },
auth.bearerToken(),
)
val id = data["addExerciseToSession"]!!.jsonObject["id"]!!.jsonPrimitive.int
dao.updateExercise(exercise.copy(serverId = id))
}
OpKind.ADD_SET, OpKind.UPDATE_SET -> {
val set = dao.set(op.targetLocalId) ?: return
if (op.kind == OpKind.ADD_SET && set.serverId != null) return
val exercise = dao.exercise(set.exerciseId) ?: return
val exerciseServerId = exercise.serverId
?: throw GraphQlException(listOf("Övningen är inte synkad än"))
val fields = buildJsonObject {
if (op.kind == OpKind.ADD_SET) {
put("sessionExerciseId", exerciseServerId)
} else {
put("id", set.serverId ?: throw GraphQlException(listOf("Setet är inte synkat än")))
}
set.reps?.let { put("reps", it) }
set.weight?.let { put("weight", it) }
set.distanceMeters?.let { put("distanceMeters", it) }
set.durationSeconds?.let { put("durationSeconds", it) }
set.rpe?.let { put("rpe", it) }
put("isWarmup", set.isWarmup)
put("isCompleted", set.isCompleted)
put("order", set.order)
}
if (op.kind == OpKind.ADD_SET) {
val data = client.execute(
"mutation(\$input: AddSessionSetInput!){addSessionSet(input:\$input){id}}",
buildJsonObject { put("input", fields) },
auth.bearerToken(),
)
val id = data["addSessionSet"]!!.jsonObject["id"]!!.jsonPrimitive.int
dao.updateSet(set.copy(serverId = id))
} else {
client.execute(
"mutation(\$input: UpdateSessionSetInput!){updateSessionSet(input:\$input){id}}",
buildJsonObject { put("input", fields) },
auth.bearerToken(),
)
}
}
OpKind.COMPLETE_SESSION -> {
val session = dao.session(op.targetLocalId) ?: return
val sessionServerId = session.serverId
?: throw GraphQlException(listOf("Passet är inte synkat än"))
val input = buildJsonObject {
put("id", sessionServerId)
session.name?.let { put("name", it) }
}
client.execute(
"mutation(\$input: CompleteGymSessionInput!){completeGymSession(input:\$input){id status}}",
buildJsonObject { put("input", input) },
auth.bearerToken(),
)
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,138 @@
package eu.brassepc.fitnessdroid.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface SessionDao {
@Insert
suspend fun insertSession(session: LocalSession): Long
@Insert
suspend fun insertExercise(exercise: LocalExercise): Long
@Insert
suspend fun insertSet(set: LocalSet): Long
@Update
suspend fun updateSession(session: LocalSession)
@Update
suspend fun updateExercise(exercise: LocalExercise)
@Update
suspend fun updateSet(set: LocalSet)
@Query("SELECT * FROM local_session WHERE status = 'active' ORDER BY id DESC LIMIT 1")
fun activeSession(): Flow<LocalSession?>
@Query("SELECT * FROM local_session WHERE id = :id")
suspend fun session(id: Long): LocalSession?
@Query("SELECT id FROM local_session WHERE status = 'active' ORDER BY id DESC LIMIT 1")
suspend fun activeSessionIdNow(): Long?
@Query("SELECT * FROM local_exercise WHERE sessionId = :sessionId ORDER BY `order`")
fun exercises(sessionId: Long): Flow<List<LocalExercise>>
@Query("SELECT * FROM local_exercise WHERE id = :id")
suspend fun exercise(id: Long): LocalExercise?
@Query("SELECT * FROM local_set WHERE exerciseId IN (SELECT id FROM local_exercise WHERE sessionId = :sessionId) ORDER BY `order`")
fun setsForSession(sessionId: Long): Flow<List<LocalSet>>
@Query("SELECT * FROM local_set WHERE id = :id")
suspend fun set(id: Long): LocalSet?
@Query("SELECT COUNT(*) FROM local_exercise WHERE sessionId = :sessionId")
suspend fun exerciseCount(sessionId: Long): Int
@Query("SELECT COUNT(*) FROM local_set WHERE exerciseId = :exerciseId")
suspend fun setCount(exerciseId: Long): Int
@Query("DELETE FROM local_exercise WHERE id = :id")
suspend fun deleteExercise(id: Long)
@Query("DELETE FROM local_set WHERE id = :id")
suspend fun deleteSet(id: Long)
}
@Dao
interface OpDao {
@Insert
suspend fun enqueue(op: PendingOp): Long
@Update
suspend fun update(op: PendingOp)
@Query("SELECT * FROM pending_op ORDER BY id LIMIT 1")
suspend fun next(): PendingOp?
@Query("DELETE FROM pending_op WHERE id = :id")
suspend fun done(id: Long)
@Query("SELECT COUNT(*) FROM pending_op")
fun pendingCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM pending_op")
suspend fun pendingCountNow(): Int
}
@Dao
interface CacheDao {
@Query("SELECT * FROM cached_exercise_type ORDER BY isFavorite DESC, name")
fun exerciseTypes(): Flow<List<CachedExerciseType>>
@Query("SELECT * FROM cached_exercise_type WHERE id = :id")
suspend fun exerciseType(id: Int): CachedExerciseType?
@Query("SELECT * FROM cached_muscle_group ORDER BY name")
fun muscleGroups(): Flow<List<CachedMuscleGroup>>
@Query("SELECT * FROM cached_muscle ORDER BY name")
fun muscles(): Flow<List<CachedMuscle>>
@Query("SELECT * FROM cached_start_card ORDER BY sortOrder")
fun startCards(): Flow<List<CachedStartCard>>
@Transaction
suspend fun replaceExerciseTypes(rows: List<CachedExerciseType>) {
// Behåll lastWeight/lastReps-fälten vid refresh — de fylls på separat.
for (row in rows) {
val old = exerciseType(row.id)
upsertExerciseType(
if (old != null) row.copy(
lastWeight = old.lastWeight,
lastReps = old.lastReps,
lastDurationSeconds = old.lastDurationSeconds,
lastDistanceMeters = old.lastDistanceMeters,
) else row
)
}
}
@androidx.room.Upsert
suspend fun upsertExerciseType(row: CachedExerciseType)
@androidx.room.Upsert
suspend fun upsertMuscleGroups(rows: List<CachedMuscleGroup>)
@androidx.room.Upsert
suspend fun upsertMuscles(rows: List<CachedMuscle>)
@Query("DELETE FROM cached_start_card")
suspend fun clearStartCards()
@androidx.room.Upsert
suspend fun upsertStartCards(rows: List<CachedStartCard>)
@Query("UPDATE cached_exercise_type SET lastWeight = :weight, lastReps = :reps, lastDurationSeconds = :duration, lastDistanceMeters = :distance WHERE id = :id")
suspend fun updateLastPerformance(id: Int, weight: Double?, reps: Int?, duration: Int?, distance: Double?)
@Query("UPDATE cached_exercise_type SET isFavorite = :favorite WHERE id = :id")
suspend fun setExerciseFavorite(id: Int, favorite: Boolean)
}

View File

@@ -0,0 +1,120 @@
package eu.brassepc.fitnessdroid.data.local
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Lokal-först-modell: passet lever i Room medan du tränar och synkas till
* servern via op-kön ([PendingOp]) i bakgrunden. serverId är null tills
* motsvarande mutation gått igenom — det driver synk-indikatorerna i UI:t.
*/
@Entity(tableName = "local_session")
data class LocalSession(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val serverId: Int? = null,
val name: String? = null,
val notes: String? = null,
val startedAtEpochMs: Long,
/** "active" eller "completed" (lokalt avslutad, ev. ej synkad än) */
val status: String = "active",
)
@Entity(
tableName = "local_exercise",
indices = [Index("sessionId")],
)
data class LocalExercise(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long,
val serverId: Int? = null,
val exerciseTypeId: Int,
val order: Int,
val notes: String? = null,
)
@Entity(
tableName = "local_set",
indices = [Index("exerciseId")],
)
data class LocalSet(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val exerciseId: Long,
val serverId: Int? = null,
val order: Int,
val reps: Int? = null,
val weight: Double? = null,
val distanceMeters: Double? = null,
val durationSeconds: Int? = null,
val rpe: Double? = null,
val isWarmup: Boolean = false,
val isCompleted: Boolean = true,
val loggedAtEpochMs: Long,
)
/** Kön av mutationer som väntar på att nå servern. Körs strikt i id-ordning. */
@Entity(tableName = "pending_op")
data class PendingOp(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
/** se [eu.brassepc.fitnessdroid.data.OpKind] */
val kind: String,
/** Lokalt id på raden operationen gäller (session/övning/set beroende på kind) */
val targetLocalId: Long,
val createdAtEpochMs: Long,
val attempts: Int = 0,
val lastError: String? = null,
)
/* ---------- Referensdata-cache (funkar offline) ---------- */
@Entity(tableName = "cached_exercise_type")
data class CachedExerciseType(
@PrimaryKey val id: Int,
val name: String,
val description: String? = null,
val metValue: Double = 5.0,
val tracksWeight: Boolean = false,
val tracksReps: Boolean = false,
val tracksDistance: Boolean = false,
val tracksDuration: Boolean = false,
val isBodyweight: Boolean = false,
/** kommaseparerade muskel-id:n */
val muscleIds: String = "",
val isFavorite: Boolean = false,
/** från API-tillägget ExerciseType.defaultRestSeconds; null → appens standardvila */
val defaultRestSeconds: Int? = null,
/** Senast kända prestation (för förifyllnad offline) */
val lastWeight: Double? = null,
val lastReps: Int? = null,
val lastDurationSeconds: Int? = null,
val lastDistanceMeters: Double? = null,
)
@Entity(tableName = "cached_muscle_group")
data class CachedMuscleGroup(
@PrimaryKey val id: Int,
val name: String,
/** från API-tillägget MuscleGroup.iconKey; null → namnheuristik */
val iconKey: String? = null,
)
@Entity(tableName = "cached_muscle")
data class CachedMuscle(
@PrimaryKey val id: Int,
val name: String,
val muscleGroupId: Int? = null,
)
/** Favoritpass/mallar som visas på hemskärmen, cachade för offline-start. */
@Entity(tableName = "cached_start_card")
data class CachedStartCard(
/** "template-<id>" eller "favsession-<id>" */
@PrimaryKey val key: String,
val title: String,
val subtitle: String,
/** kommaseparerade exerciseTypeId:n i ordning — används för att starta passet */
val exerciseTypeIds: String,
val templateId: Int? = null,
val sortOrder: Int = 0,
)

View File

@@ -0,0 +1,208 @@
package eu.brassepc.fitnessdroid.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import eu.brassepc.fitnessdroid.ui.history.HistoryScreen
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
import eu.brassepc.fitnessdroid.ui.picker.ExercisePickerScreen
import eu.brassepc.fitnessdroid.ui.profile.ProfileScreen
import eu.brassepc.fitnessdroid.ui.session.SessionScreen
import eu.brassepc.fitnessdroid.ui.settings.SettingsScreen
import eu.brassepc.fitnessdroid.ui.stats.StatsScreen
object Routes {
const val HOME = "home"
const val HISTORY = "history"
const val STATS = "stats"
const val PROFILE = "profile"
const val SETTINGS = "settings"
const val SESSION = "session"
const val PICKER = "picker"
}
private data class TabItem(val route: String, val label: String, val icon: ImageVector)
private val tabs = listOf(
TabItem(Routes.HOME, "Hem", Icons.Default.Home),
TabItem(Routes.HISTORY, "Pass", Icons.AutoMirrored.Filled.List),
TabItem(Routes.STATS, "Statistik", Icons.Default.BarChart),
TabItem(Routes.PROFILE, "Profil", Icons.Default.Person),
)
@Composable
fun AppRoot(rootViewModel: RootViewModel = viewModel(factory = RootViewModel.Factory)) {
val nav = rememberNavController()
val backStack by nav.currentBackStackEntryAsState()
val route = backStack?.destination?.route
val hasActiveSession by rootViewModel.hasActiveSession.collectAsStateWithLifecycle(false)
val restState by rootViewModel.restState.collectAsStateWithLifecycle()
val pendingCount by rootViewModel.pendingCount.collectAsStateWithLifecycle(0)
val showChrome = route in tabs.map { it.route }
Scaffold(
bottomBar = {
if (showChrome) {
Column {
if (hasActiveSession) {
MiniPlayer(
restSecondsLeft = restState?.secondsLeft,
pendingCount = pendingCount,
onOpen = { nav.navigate(Routes.SESSION) },
)
}
BottomBar(nav = nav, currentRoute = route, onStart = {
rootViewModel.startOrResume { nav.navigate(Routes.SESSION) }
})
}
}
},
) { padding ->
NavHost(
navController = nav,
startDestination = Routes.HOME,
modifier = Modifier.padding(padding),
) {
composable(Routes.HOME) {
HomeScreen(
onOpenSession = { nav.navigate(Routes.SESSION) },
onOpenHistory = { nav.navigate(Routes.HISTORY) },
)
}
composable(Routes.HISTORY) { HistoryScreen() }
composable(Routes.STATS) { StatsScreen() }
composable(Routes.PROFILE) {
ProfileScreen(onOpenSettings = { nav.navigate(Routes.SETTINGS) })
}
composable(Routes.SETTINGS) { SettingsScreen(onBack = { nav.popBackStack() }) }
composable(Routes.SESSION) {
SessionScreen(
onBack = { nav.popBackStack() },
onAddExercise = { nav.navigate(Routes.PICKER) },
onSessionEnded = {
nav.popBackStack(Routes.HOME, inclusive = false)
},
)
}
composable(Routes.PICKER) {
ExercisePickerScreen(onDone = { nav.popBackStack() })
}
}
}
}
@Composable
private fun BottomBar(nav: NavHostController, currentRoute: String?, onStart: () -> Unit) {
Box {
NavigationBar {
tabs.forEachIndexed { index, tab ->
if (index == 2) {
// plats för mittknappen
NavigationBarItem(
selected = false,
onClick = {},
enabled = false,
icon = {},
label = { Text("") },
)
}
NavigationBarItem(
selected = currentRoute == tab.route,
onClick = {
nav.navigate(tab.route) {
popUpTo(Routes.HOME) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(tab.icon, contentDescription = tab.label) },
label = { Text(tab.label) },
)
}
}
FloatingActionButton(
onClick = onStart,
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Icon(Icons.Default.PlayArrow, contentDescription = "Starta pass")
}
}
}
@Composable
private fun MiniPlayer(restSecondsLeft: Int?, pendingCount: Int, onOpen: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
Text("Pass pågår", style = MaterialTheme.typography.labelLarge)
val sub = buildString {
if (restSecondsLeft != null && restSecondsLeft > 0) {
append("Vila ${restSecondsLeft / 60}:${"%02d".format(restSecondsLeft % 60)} kvar")
} else {
append("Tryck för att fortsätta")
}
if (pendingCount > 0) append(" · $pendingCount osynkat")
}
Text(
sub,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"ÖPPNA",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,134 @@
package eu.brassepc.fitnessdroid.ui.history
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.HistoryItem
import eu.brassepc.fitnessdroid.ui.appContainer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
sealed interface HistoryUiState {
data object Loading : HistoryUiState
data class Error(val message: String) : HistoryUiState
data class Ready(val items: List<HistoryItem>) : HistoryUiState
}
class HistoryViewModel(private val repo: GymRepository) : ViewModel() {
val uiState = MutableStateFlow<HistoryUiState>(HistoryUiState.Loading)
init { load() }
fun load() {
uiState.value = HistoryUiState.Loading
viewModelScope.launch {
uiState.value = try {
HistoryUiState.Ready(repo.fetchHistory())
} catch (e: Exception) {
HistoryUiState.Error("Kunde inte hämta historiken — offline?")
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { HistoryViewModel(appContainer().gymRepository) }
}
}
}
@Composable
fun HistoryScreen(viewModel: HistoryViewModel = viewModel(factory = HistoryViewModel.Factory)) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is HistoryUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is HistoryUiState.Error -> {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(state.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 12.dp)) {
Text("Försök igen")
}
}
}
is HistoryUiState.Ready -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Text("Passhistorik", style = MaterialTheme.typography.headlineSmall)
}
items(state.items, key = { it.id }) { item ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Row {
Text(
item.name,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Text(
item.date.take(10),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
buildString {
append("${item.exerciseCount} övningar · ${item.setCount} set")
if (item.volumeKg > 0) append(" · ${item.volumeKg.toInt()} kg volym")
item.calories?.let { append(" · ${it.toInt()} kcal") }
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
if (state.items.isEmpty()) {
item {
Text(
"Inga avslutade pass än — dags att ändra på det!",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -6,54 +6,64 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.AuthRepository
import eu.brassepc.fitnessdroid.data.GraphQlException
import eu.brassepc.fitnessdroid.data.GymApi
import eu.brassepc.fitnessdroid.data.Profile
import eu.brassepc.fitnessdroid.data.AuthState
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.SyncStatus
import eu.brassepc.fitnessdroid.data.local.CachedStartCard
import eu.brassepc.fitnessdroid.ui.appContainer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
sealed interface HomeUiState {
data object Loading : HomeUiState
data class Error(val message: String) : HomeUiState
data class Ready(val profile: Profile) : HomeUiState
}
data class HomeUiState(
val username: String = "",
val hasActiveSession: Boolean = false,
val startCards: List<CachedStartCard> = emptyList(),
val syncStatus: SyncStatus = SyncStatus.SYNCED,
val pendingCount: Int = 0,
)
class HomeViewModel(
private val gymApi: GymApi,
private val auth: AuthRepository,
private val repo: GymRepository,
auth: AuthRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState
val uiState = combine(
repo.activeSession,
repo.startCards,
repo.syncStatus,
repo.pendingCount,
auth.state,
) { session, cards, sync, pending, authState ->
HomeUiState(
username = (authState as? AuthState.LoggedIn)?.username ?: "",
hasActiveSession = session != null,
startCards = cards,
syncStatus = sync,
pendingCount = pending,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState())
init {
load()
}
fun load() {
_uiState.value = HomeUiState.Loading
fun startFree(onReady: () -> Unit) {
viewModelScope.launch {
try {
_uiState.value = HomeUiState.Ready(gymApi.myProfile())
} catch (e: GraphQlException) {
_uiState.value = HomeUiState.Error(e.message ?: "Något gick fel")
} catch (e: Exception) {
_uiState.value = HomeUiState.Error("Kunde inte nå servern")
}
repo.startFreeSession()
onReady()
}
}
fun logout() {
viewModelScope.launch { auth.logout() }
fun startFromCard(card: CachedStartCard, onReady: () -> Unit) {
viewModelScope.launch {
repo.startFromCard(card)
onReady()
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val container = appContainer()
HomeViewModel(container.gymApi, container.authRepository)
val c = appContainer()
HomeViewModel(c.gymRepository, c.authRepository)
}
}
}

View File

@@ -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),
)
}
}
}
}
}
}

View File

@@ -0,0 +1,132 @@
package eu.brassepc.fitnessdroid.ui.picker
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.local.CachedMuscle
import eu.brassepc.fitnessdroid.data.local.CachedMuscleGroup
import eu.brassepc.fitnessdroid.ui.appContainer
import eu.brassepc.fitnessdroid.ui.common.MuscleIcons
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class ExerciseRow(
val id: Int,
val name: String,
val subtitle: String,
val badges: List<String>,
val favorite: Boolean,
val icon: ImageVector,
)
data class PickerUiState(
val query: String = "",
val groups: List<CachedMuscleGroup> = emptyList(),
val musclesInGroup: List<CachedMuscle> = emptyList(),
val selectedGroupId: Int? = null,
val selectedMuscleId: Int? = null,
val exercises: List<ExerciseRow> = emptyList(),
)
class PickerViewModel(private val repo: GymRepository) : ViewModel() {
private val query = MutableStateFlow("")
private val selectedGroup = MutableStateFlow<Int?>(null)
private val selectedMuscle = MutableStateFlow<Int?>(null)
private data class Filters(val query: String, val groupId: Int?, val muscleId: Int?)
private val filters = combine(query, selectedGroup, selectedMuscle) { q, g, m -> Filters(q, g, m) }
val uiState = combine(
repo.exerciseTypes,
repo.muscleGroups,
repo.muscles,
filters,
) { types, groups, muscles, f ->
val musclesByGroup = muscles.groupBy { it.muscleGroupId }
val groupByMuscleId = muscles.associate { it.id to it.muscleGroupId }
val groupNameById = groups.associate { it.id to it.name }
val groupIconKeyById = groups.associate { it.id to it.iconKey }
val filtered = types.filter { t ->
val muscleIds = t.muscleIds.split(",").mapNotNull { it.trim().toIntOrNull() }
val inGroup = f.groupId == null ||
muscleIds.any { groupByMuscleId[it] == f.groupId }
val inMuscle = f.muscleId == null || f.muscleId in muscleIds
val matches = f.query.isBlank() || t.name.contains(f.query.trim(), ignoreCase = true)
inGroup && inMuscle && matches
}
PickerUiState(
query = f.query,
groups = groups,
musclesInGroup = f.groupId?.let { musclesByGroup[it] }.orEmpty(),
selectedGroupId = f.groupId,
selectedMuscleId = f.muscleId,
exercises = filtered.map { t ->
val muscleIds = t.muscleIds.split(",").mapNotNull { it.trim().toIntOrNull() }
val primaryGroupId = muscleIds.firstNotNullOfOrNull { groupByMuscleId[it] }
val groupNames = muscleIds.mapNotNull { groupByMuscleId[it] }
.distinct().mapNotNull { groupNameById[it] }
ExerciseRow(
id = t.id,
name = t.name,
subtitle = buildString {
append(groupNames.joinToString(" · ").ifEmpty { "Övrigt" })
t.lastWeight?.let { append(" · senast ${it.compact()} kg") }
},
badges = buildList {
if (t.tracksWeight) add("KG")
if (t.tracksReps) add("REPS")
if (t.tracksDuration) add("TID")
if (t.tracksDistance) add("M")
},
favorite = t.isFavorite,
icon = MuscleIcons.resolve(
primaryGroupId?.let { groupIconKeyById[it] },
primaryGroupId?.let { groupNameById[it] },
),
)
},
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PickerUiState())
fun onQueryChange(value: String) { query.value = value }
fun selectGroup(id: Int?) {
selectedGroup.value = id
selectedMuscle.value = null
}
fun selectMuscle(id: Int?) { selectedMuscle.value = id }
fun toggleFavorite(exerciseTypeId: Int, favorite: Boolean) =
repo.toggleFavoriteExercise(exerciseTypeId, favorite)
fun pick(exerciseTypeId: Int, onDone: () -> Unit) {
viewModelScope.launch {
val sessionId = repo.activeSession.first()?.id ?: return@launch
repo.addExercise(sessionId, exerciseTypeId)
onDone()
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { PickerViewModel(appContainer().gymRepository) }
}
}
}
private fun Double.compact(): String =
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)

View File

@@ -0,0 +1,131 @@
package eu.brassepc.fitnessdroid.ui.profile
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.AuthRepository
import eu.brassepc.fitnessdroid.data.GymApi
import eu.brassepc.fitnessdroid.data.Profile
import eu.brassepc.fitnessdroid.ui.appContainer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
class ProfileViewModel(
private val gymApi: GymApi,
private val auth: AuthRepository,
) : ViewModel() {
val profile = MutableStateFlow<Profile?>(null)
init {
viewModelScope.launch {
runCatching { profile.value = gymApi.myProfile() }
}
}
fun logout() {
viewModelScope.launch { auth.logout() }
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val c = appContainer()
ProfileViewModel(c.gymApi, c.authRepository)
}
}
}
}
@Composable
fun ProfileScreen(
onOpenSettings: () -> Unit,
viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory),
) {
val profile by viewModel.profile.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text("Profil", style = MaterialTheme.typography.headlineSmall)
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ProfileRow("Användarnamn", profile?.username ?: "")
profile?.displayName?.let { ProfileRow("Namn", it) }
profile?.email?.let { ProfileRow("E-post", it) }
profile?.bodyWeightKg?.let { ProfileRow("Kroppsvikt", "$it kg") }
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpenSettings),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Settings, contentDescription = null)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text("Inställningar", style = MaterialTheme.typography.titleSmall)
Text(
"Vilotimer, larm, standardvila",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
TextButton(onClick = viewModel::logout) {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
Text("Logga ut", modifier = Modifier.padding(start = 6.dp))
}
}
}
@Composable
private fun ProfileRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
label,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(value)
}
}

View File

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

View File

@@ -0,0 +1,177 @@
package eu.brassepc.fitnessdroid.ui.session
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.AppSettings
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.RestState
import eu.brassepc.fitnessdroid.data.RestTimerController
import eu.brassepc.fitnessdroid.data.SettingsStore
import eu.brassepc.fitnessdroid.data.local.CachedExerciseType
import eu.brassepc.fitnessdroid.data.local.LocalSet
import eu.brassepc.fitnessdroid.ui.appContainer
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/** Utkast till nästa set för en övning (styrs av steppers i UI:t). */
data class SetDraft(
val weight: Double? = null,
val reps: Int? = null,
val durationSeconds: Int? = null,
val distanceMeters: Double? = null,
val rpe: Double? = null,
)
data class ExercisePage(
val localId: Long,
val type: CachedExerciseType?,
val name: String,
val doneSets: List<LocalSet>,
val draft: SetDraft,
val lastPerformanceText: String?,
)
data class SessionUiState(
val sessionId: Long? = null,
val sessionName: String,
val startedAtEpochMs: Long = 0,
val pages: List<ExercisePage> = emptyList(),
val pendingCount: Int = 0,
val rest: RestState? = null,
val settings: AppSettings = AppSettings(),
val completedLocally: Boolean = false,
) {
companion object { val EMPTY = SessionUiState(sessionName = "Pass") }
}
@OptIn(ExperimentalCoroutinesApi::class)
class SessionViewModel(
private val repo: GymRepository,
private val restTimer: RestTimerController,
private val settingsStore: SettingsStore,
) : ViewModel() {
/** exerciseLocalId → utkast som användaren pillat på */
private val drafts = MutableStateFlow<Map<Long, SetDraft>>(emptyMap())
private val sessionFlow = repo.activeSession
private val bundleFlow = sessionFlow.flatMapLatest { session ->
if (session == null) {
flowOf(SessionUiState.EMPTY)
} else {
combine(
repo.exercises(session.id),
repo.setsForSession(session.id),
repo.exerciseTypes,
drafts,
repo.pendingCount,
) { exercises, sets, types, draftMap, pending ->
val typeById = types.associateBy { it.id }
val setsByExercise = sets.groupBy { it.exerciseId }
SessionUiState(
sessionId = session.id,
sessionName = session.name ?: "Fritt pass",
startedAtEpochMs = session.startedAtEpochMs,
pendingCount = pending,
pages = exercises.map { ex ->
val type = typeById[ex.exerciseTypeId]
val done = setsByExercise[ex.id].orEmpty()
ExercisePage(
localId = ex.id,
type = type,
name = type?.name ?: "Övning ${ex.order}",
doneSets = done,
draft = draftMap[ex.id] ?: initialDraft(type, done),
lastPerformanceText = type?.lastWeight?.let { w ->
"Senast: ${w.compact()} kg" +
(type.lastReps?.let { " × $it" } ?: "")
},
)
},
)
}
}
}
val uiState = combine(
bundleFlow,
restTimer.state,
settingsStore.settings,
) { bundle, rest, settings ->
bundle.copy(rest = rest, settings = settings)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SessionUiState.EMPTY)
/** Förifyll utkastet: senaste setet i passet, annars cachens senaste prestation. */
private fun initialDraft(type: CachedExerciseType?, done: List<LocalSet>): SetDraft {
val last = done.lastOrNull()
return SetDraft(
weight = last?.weight ?: type?.lastWeight
?: if (type?.tracksWeight == true) 20.0 else null,
reps = last?.reps ?: type?.lastReps
?: if (type?.tracksReps == true) 8 else null,
durationSeconds = last?.durationSeconds ?: type?.lastDurationSeconds
?: if (type?.tracksDuration == true) 30 else null,
distanceMeters = last?.distanceMeters ?: type?.lastDistanceMeters
?: if (type?.tracksDistance == true) 1000.0 else null,
rpe = last?.rpe,
)
}
fun updateDraft(exerciseId: Long, transform: (SetDraft) -> SetDraft) {
val page = uiState.value.pages.firstOrNull { it.localId == exerciseId } ?: return
drafts.value = drafts.value + (exerciseId to transform(page.draft))
}
fun logSet(exerciseId: Long) {
val page = uiState.value.pages.firstOrNull { it.localId == exerciseId } ?: return
val d = page.draft
viewModelScope.launch {
repo.logSet(
exerciseId = exerciseId,
reps = if (page.type?.tracksReps != false) d.reps else null,
weight = if (page.type?.tracksWeight != false) d.weight else null,
distanceMeters = if (page.type?.tracksDistance == true) d.distanceMeters else null,
durationSeconds = if (page.type?.tracksDuration == true) d.durationSeconds else null,
rpe = d.rpe,
isWarmup = false,
)
// API-tillägget defaultRestSeconds per övning; annars appens standard.
restTimer.start(page.type?.defaultRestSeconds)
}
}
fun adjustRest(delta: Int) = restTimer.adjust(delta)
fun skipRest() = restTimer.dismiss()
fun completeSession(onDone: () -> Unit) {
viewModelScope.launch {
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
restTimer.dismiss()
repo.completeSession(id)
onDone()
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val c = appContainer()
SessionViewModel(c.gymRepository, c.restTimer, c.settingsStore)
}
}
}
}
internal fun Double.compact(): String =
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)

View File

@@ -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,
)
}
}
}

View File

@@ -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),
)
}
}

View File

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

View File

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