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

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