diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a74745d1..ffb4b42a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -1,6 +1,6 @@ # Attribution -Ambio ships thirteen audio files. All are CC0 / public domain. This file records +Ambio ships fourteen audio files. All are CC0 / public domain. This file records what is known about each, including where the record is incomplete. ## Traceable to a source (5) @@ -19,11 +19,12 @@ hash a silent substitution upstream would flow into a shipped asset. | `birds_loop` | 234315 | nick121087 | CC0 1.0 | | `cafe_loop` | 625112 | sonically_sound | CC0 1.0 | -## Generated, not recorded (2) +## Generated, not recorded (3) `white_noise_loop` and `brown_noise_loop` are produced by -`tools/synth-noise.sh` from fixed seeds. They are public domain by construction: -there is no recording and no third party involved. +`tools/synth-noise.sh` from fixed seeds, and `success_chime` by +`tools/synth-success-chime.sh` from three fixed sine notes. They are public +domain by construction: there is no recording and no third party involved. ## Sourced as CC0, upstream provenance not retained (6) diff --git a/CLAUDE.md b/CLAUDE.md index 89ff6021..abb382a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ Clean and build: - Build: `./gradlew assembleDebug` - Lint: `./gradlew lint` - Tests: `./gradlew test` +- Translations: tools/check-translations.sh ## Operational Notes diff --git a/README.md b/README.md index b88ed364..d1337eda 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ the mix, not just one of them. - **Dynamic Theming** — The palette is mixed from every sound in the mix, not just one - **Ambient Visuals** — A particle field behind the timer that answers the whole mix, and can be switched off - **Pomodoro Timer** — 25-min and 50-min presets, or your own focus and break lengths -- **Ambient Mode** — Continuous playback without a timer, for relaxation or sleep +- **Ambient Mode** — Continuous playback for relaxation or sleep, with a sleep timer that stops it for you - **Session History** — Statistics for the hours you actually put in - **Adaptive Tablet Layout** — Two panes, the mixer permanently beside the timer past 840dp - **47 Languages** — The app itself, with per-app language selection on Android 13+ diff --git a/audio-src/README.md b/audio-src/README.md index 441fadda..1cf4a273 100644 --- a/audio-src/README.md +++ b/audio-src/README.md @@ -24,7 +24,8 @@ window to cut from it. Storing ~26MB of FLAC in git bought nothing that the manifest does not, since these files are still on Freesound. **Generated, not stored at all.** `white_noise` and `brown_noise`, produced by -`tools/synth-noise.sh` from fixed seeds. +`tools/synth-noise.sh` from fixed seeds, and `success_chime`, produced by +`tools/synth-success-chime.sh` from three fixed sine notes. ## Rebuilding the non-committed sources @@ -32,12 +33,17 @@ manifest does not, since these files are still on Freesound. export FREESOUND_TOKEN=... # see below tools/fetch-sources.sh # downloads, verifies sha256, cuts the windows tools/synth-noise.sh # generates the two noise beds +tools/synth-success-chime.sh # generates the end-of-plan chime tools/process-audio.sh # -> build/processed-audio cp build/processed-audio/*.ogg core/data/src/main/res/raw/ ``` Both scripts write to `build/audio-src-fetched/`, which is gitignored. +To regenerate just the success chime without the Freesound sources on disk, run +`ONLY=success_chime tools/process-audio.sh` — it restricts the pipeline to that +one file. + ### The credential requirement `fetch-sources.sh` needs a Freesound **OAuth2 access token**. An API key alone diff --git a/core/data/src/main/java/com/jbgsoft/ambio/core/data/datastore/PreferencesDataStore.kt b/core/data/src/main/java/com/jbgsoft/ambio/core/data/datastore/PreferencesDataStore.kt index 6632abf4..ac8b3f00 100644 --- a/core/data/src/main/java/com/jbgsoft/ambio/core/data/datastore/PreferencesDataStore.kt +++ b/core/data/src/main/java/com/jbgsoft/ambio/core/data/datastore/PreferencesDataStore.kt @@ -8,6 +8,8 @@ import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.jbgsoft.ambio.core.domain.model.AppMode +import com.jbgsoft.ambio.core.domain.model.PlanCodec +import com.jbgsoft.ambio.core.domain.model.SessionPlan import com.jbgsoft.ambio.core.domain.model.UserPreferences import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -29,20 +31,29 @@ class PreferencesDataStore @Inject constructor( val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled") val CHIME_ENABLED = booleanPreferencesKey("chime_enabled") val EFFECTS_ENABLED = booleanPreferencesKey("effects_enabled") + val BREAK_MIX = stringPreferencesKey("break_mix") + val SESSION_PLAN = stringPreferencesKey("session_plan") + val BREAK_SOUND_ENABLED = booleanPreferencesKey("break_sound_enabled") + val SLEEP_MINUTES = intPreferencesKey("sleep_minutes") } val preferences: Flow = dataStore.data.map { prefs -> UserPreferences( lastMix = prefs[PreferencesKeys.LAST_SOUND_ID] ?: "rain", + breakMix = prefs[PreferencesKeys.BREAK_MIX], volume = prefs[PreferencesKeys.VOLUME] ?: 0.7f, lastTimerMinutes = prefs[PreferencesKeys.LAST_TIMER_MINUTES] ?: 25, breakMinutes = prefs[PreferencesKeys.BREAK_MINUTES] ?: 5, + sessionPlan = prefs[PreferencesKeys.SESSION_PLAN]?.let(PlanCodec::decode) + ?: SessionPlan.DEFAULT, lastMode = prefs[PreferencesKeys.LAST_MODE]?.let { AppMode.valueOf(it) } ?: AppMode.TIMER, + sleepMinutes = prefs[PreferencesKeys.SLEEP_MINUTES] ?: 0, hapticsEnabled = prefs[PreferencesKeys.HAPTICS_ENABLED] ?: true, chimeEnabled = prefs[PreferencesKeys.CHIME_ENABLED] ?: true, - effectsEnabled = prefs[PreferencesKeys.EFFECTS_ENABLED] ?: true + effectsEnabled = prefs[PreferencesKeys.EFFECTS_ENABLED] ?: true, + breakSoundEnabled = prefs[PreferencesKeys.BREAK_SOUND_ENABLED] ?: false ) } @@ -93,4 +104,20 @@ class PreferencesDataStore @Inject constructor( prefs[PreferencesKeys.EFFECTS_ENABLED] = enabled } } + + suspend fun setBreakMix(encoded: String) { + dataStore.edit { prefs -> prefs[PreferencesKeys.BREAK_MIX] = encoded } + } + + suspend fun setSessionPlan(plan: SessionPlan) { + dataStore.edit { prefs -> prefs[PreferencesKeys.SESSION_PLAN] = PlanCodec.encode(plan) } + } + + suspend fun setBreakSoundEnabled(enabled: Boolean) { + dataStore.edit { prefs -> prefs[PreferencesKeys.BREAK_SOUND_ENABLED] = enabled } + } + + suspend fun setSleepMinutes(minutes: Int) { + dataStore.edit { prefs -> prefs[PreferencesKeys.SLEEP_MINUTES] = minutes } + } } diff --git a/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/ChimeRepositoryImpl.kt b/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/ChimeRepositoryImpl.kt index c44ea6a8..cfa984b5 100644 --- a/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/ChimeRepositoryImpl.kt +++ b/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/ChimeRepositoryImpl.kt @@ -11,4 +11,7 @@ class ChimeRepositoryImpl @Inject constructor() : ChimeRepository { @RawRes override fun getTimerChimeResource(): Int = R.raw.timer_chime + + @RawRes + override fun getSuccessChimeResource(): Int = R.raw.success_chime } diff --git a/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/PreferencesRepositoryImpl.kt b/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/PreferencesRepositoryImpl.kt index 4099ede6..78211785 100644 --- a/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/PreferencesRepositoryImpl.kt +++ b/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/PreferencesRepositoryImpl.kt @@ -2,6 +2,7 @@ package com.jbgsoft.ambio.core.data.repository import com.jbgsoft.ambio.core.data.datastore.PreferencesDataStore import com.jbgsoft.ambio.core.domain.model.AppMode +import com.jbgsoft.ambio.core.domain.model.SessionPlan import com.jbgsoft.ambio.core.domain.model.UserPreferences import com.jbgsoft.ambio.core.domain.repository.PreferencesRepository import kotlinx.coroutines.flow.Flow @@ -29,10 +30,17 @@ class PreferencesRepositoryImpl @Inject constructor( preferencesDataStore.setLastMode(mode) } + override suspend fun setSleepMinutes(minutes: Int) = preferencesDataStore.setSleepMinutes(minutes) + override suspend fun setBreakMinutes(minutes: Int) { preferencesDataStore.setBreakMinutes(minutes) } + override suspend fun setBreakMix(encoded: String) = preferencesDataStore.setBreakMix(encoded) + override suspend fun setSessionPlan(plan: SessionPlan) = preferencesDataStore.setSessionPlan(plan) + override suspend fun setBreakSoundEnabled(enabled: Boolean) = + preferencesDataStore.setBreakSoundEnabled(enabled) + override suspend fun setHapticsEnabled(enabled: Boolean) = preferencesDataStore.setHapticsEnabled(enabled) diff --git a/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/SoundRepositoryImpl.kt b/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/SoundRepositoryImpl.kt index 485108c4..08ccb588 100644 --- a/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/SoundRepositoryImpl.kt +++ b/core/data/src/main/java/com/jbgsoft/ambio/core/data/repository/SoundRepositoryImpl.kt @@ -16,9 +16,11 @@ import androidx.compose.material.icons.filled.WaterDrop import com.jbgsoft.ambio.core.data.datastore.PreferencesDataStore import com.jbgsoft.ambio.core.domain.model.ActiveSound import com.jbgsoft.ambio.core.domain.model.MixCodec +import com.jbgsoft.ambio.core.domain.model.MixSlot import com.jbgsoft.ambio.core.domain.model.Sound import com.jbgsoft.ambio.core.domain.model.SoundGlow import com.jbgsoft.ambio.core.domain.model.SoundTheme +import com.jbgsoft.ambio.core.domain.model.UserPreferences import com.jbgsoft.ambio.core.domain.repository.SoundRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -151,28 +153,31 @@ class SoundRepositoryImpl @Inject constructor( private val sounds = SOUND_CATALOGUE - private val mixOverride = MutableStateFlow(null) + // One optimistic override per slot, see persist(). + private val overrides: Map> = + MixSlot.entries.associateWith { MutableStateFlow(null) } // Serializes the read-modify-write in setSoundActive/setSoundLevel so two // overlapping calls (rapid taps, each its own viewModelScope.launch) can't // both read the same snapshot and have the second silently drop the first. + // One mutex for both slots: the picker edits one at a time anyway. private val mutex = Mutex() override fun getAllSounds(): List = sounds override fun getSoundById(id: String): Sound? = sounds.find { it.id == id } - override fun getActiveMix(): Flow> = combine( - mixOverride, + override fun getActiveMix(slot: MixSlot): Flow> = combine( + overrides.getValue(slot), preferencesDataStore.preferences ) { override, prefs -> - MixCodec.decode(override ?: prefs.lastMix, sounds) + MixCodec.decode(override ?: stored(prefs, slot), sounds) } - override suspend fun setSoundActive(soundId: String, active: Boolean) { + override suspend fun setSoundActive(soundId: String, active: Boolean, slot: MixSlot) { if (getSoundById(soundId) == null) return mutex.withLock { - val current = currentMix() + val current = currentMix(slot) val updated = when { active && current.any { it.sound.id == soundId } -> current // The mix never holds more than MAX_ACTIVE_SOUNDS. @@ -182,15 +187,16 @@ class SoundRepositoryImpl @Inject constructor( current.size == 1 -> return else -> current.filterNot { it.sound.id == soundId } } - persist(updated) + persist(slot, updated) } } - override suspend fun setSoundLevel(soundId: String, level: Float) { + override suspend fun setSoundLevel(soundId: String, level: Float, slot: MixSlot) { mutex.withLock { - val current = currentMix() + val current = currentMix(slot) if (current.none { it.sound.id == soundId }) return persist( + slot, current.map { active -> if (active.sound.id == soundId) active.copy(level = level.coerceIn(0f, 1f)) else active @@ -199,27 +205,40 @@ class SoundRepositoryImpl @Inject constructor( } } - private suspend fun currentMix(): List = - MixCodec.decode(mixOverride.value ?: preferencesDataStore.preferences.first().lastMix, sounds) + /** The BREAK slot falls back to the focus mix so its first edit starts from what the user already has. */ + private fun stored(prefs: UserPreferences, slot: MixSlot): String = when (slot) { + MixSlot.FOCUS -> prefs.lastMix + MixSlot.BREAK -> prefs.breakMix ?: prefs.lastMix + } + + private suspend fun currentMix(slot: MixSlot): List = + MixCodec.decode( + overrides.getValue(slot).value ?: stored(preferencesDataStore.preferences.first(), slot), + sounds + ) /** - * Updates [mixOverride] optimistically so the caller's own toggle is visible + * Updates the slot's override optimistically so the caller's own toggle is visible * without waiting a frame for DataStore, but rolls it back if the store write * fails — a failed write must never leave the override claiming a mix the * store doesn't hold. The exception still propagates to the caller. */ - private suspend fun persist(mix: List) { + private suspend fun persist(slot: MixSlot, mix: List) { val encoded = MixCodec.encode( // Re-decoding normalises the order before it is written or observed. MixCodec.decode(MixCodec.encode(mix, withLevels = true), sounds), withLevels = true ) - val previous = mixOverride.value - mixOverride.value = encoded + val override = overrides.getValue(slot) + val previous = override.value + override.value = encoded try { - preferencesDataStore.setLastMix(encoded) + when (slot) { + MixSlot.FOCUS -> preferencesDataStore.setLastMix(encoded) + MixSlot.BREAK -> preferencesDataStore.setBreakMix(encoded) + } } catch (e: Exception) { - mixOverride.value = previous + override.value = previous throw e } } diff --git a/core/data/src/main/java/com/jbgsoft/ambio/core/data/session/SessionRunnerImpl.kt b/core/data/src/main/java/com/jbgsoft/ambio/core/data/session/SessionRunnerImpl.kt new file mode 100644 index 00000000..c047de23 --- /dev/null +++ b/core/data/src/main/java/com/jbgsoft/ambio/core/data/session/SessionRunnerImpl.kt @@ -0,0 +1,106 @@ +package com.jbgsoft.ambio.core.data.session + +import com.jbgsoft.ambio.core.common.di.DefaultDispatcher +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan +import com.jbgsoft.ambio.core.domain.model.TimerState +import com.jbgsoft.ambio.core.domain.repository.TimerRepository +import com.jbgsoft.ambio.core.domain.session.SessionEvent +import com.jbgsoft.ambio.core.domain.session.SessionProgress +import com.jbgsoft.ambio.core.domain.session.SessionRunner +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Advances on every [TimerState.Completed] the repository emits. Nothing else + * sets the timer Running after a Completed, so the StateFlow cannot conflate the + * Completed away before this collector sees it. The mutex serializes advance() + * against start() and stop(): a stop that lands during an advance either wins + * (progress null, advance returns early) or is applied right after it. + */ +@Singleton +class SessionRunnerImpl @Inject constructor( + private val timerRepository: TimerRepository, + @param:DefaultDispatcher dispatcher: CoroutineDispatcher +) : SessionRunner { + + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val mutex = Mutex() + + private val _progress = MutableStateFlow(null) + override val progress: StateFlow = _progress.asStateFlow() + + // Three events per step change at most; 64 is headroom, not a budget. + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + override val events: SharedFlow = _events.asSharedFlow() + + init { + scope.launch { + timerRepository.timerState.collect { state -> + if (state is TimerState.Completed) advance() + } + } + } + + override suspend fun start(plan: SessionPlan) { + require(plan.isValid) { "cannot start an invalid plan: $plan" } + mutex.withLock { + val first = SessionProgress(plan = plan, stepIndex = 0, lap = 0) + _progress.value = first + launchStep(first) + } + } + + override suspend fun pause() = timerRepository.pauseTimer() + + override suspend fun resume() = timerRepository.resumeTimer() + + override suspend fun stop() = mutex.withLock { + _progress.value = null + timerRepository.resetTimer() + } + + private suspend fun advance() = mutex.withLock { + val current = _progress.value ?: return + val plan = current.plan + val isLast = current.stepIndex == plan.steps.lastIndex + + if (isLast && !plan.repeat) { + _progress.value = null + timerRepository.resetTimer() + _events.emit(SessionEvent.PlanCompleted(current.step)) + return + } + + _events.emit(SessionEvent.StepCompleted(current.step, current.stepIndex)) + val next = if (isLast) { + current.copy(stepIndex = 0, lap = current.lap + 1) + } else { + current.copy(stepIndex = current.stepIndex + 1) + } + _progress.value = next + launchStep(next) + } + + private suspend fun launchStep(progress: SessionProgress) { + val step = progress.step + val durationMs = step.minutes * 60_000L + when (step) { + is PlanStep.Focus -> timerRepository.startTimer(durationMs) + is PlanStep.Break -> timerRepository.startBreak(durationMs) + } + _events.emit(SessionEvent.StepStarted(step, progress.stepIndex)) + } +} diff --git a/core/data/src/main/res/raw/birds_loop.ogg b/core/data/src/main/res/raw/birds_loop.ogg index 3e848e1c..c340a0e4 100644 Binary files a/core/data/src/main/res/raw/birds_loop.ogg and b/core/data/src/main/res/raw/birds_loop.ogg differ diff --git a/core/data/src/main/res/raw/brown_noise_loop.ogg b/core/data/src/main/res/raw/brown_noise_loop.ogg index 5d10b904..17279424 100644 Binary files a/core/data/src/main/res/raw/brown_noise_loop.ogg and b/core/data/src/main/res/raw/brown_noise_loop.ogg differ diff --git a/core/data/src/main/res/raw/cafe_loop.ogg b/core/data/src/main/res/raw/cafe_loop.ogg index 5e8ecf93..8f039c99 100644 Binary files a/core/data/src/main/res/raw/cafe_loop.ogg and b/core/data/src/main/res/raw/cafe_loop.ogg differ diff --git a/core/data/src/main/res/raw/cave_loop.ogg b/core/data/src/main/res/raw/cave_loop.ogg index 5fd50a8b..9efc53a4 100644 Binary files a/core/data/src/main/res/raw/cave_loop.ogg and b/core/data/src/main/res/raw/cave_loop.ogg differ diff --git a/core/data/src/main/res/raw/crickets_loop.ogg b/core/data/src/main/res/raw/crickets_loop.ogg index 1a39cc3e..9b6c6857 100644 Binary files a/core/data/src/main/res/raw/crickets_loop.ogg and b/core/data/src/main/res/raw/crickets_loop.ogg differ diff --git a/core/data/src/main/res/raw/fireplace_loop.ogg b/core/data/src/main/res/raw/fireplace_loop.ogg index f320ad25..b692236c 100644 Binary files a/core/data/src/main/res/raw/fireplace_loop.ogg and b/core/data/src/main/res/raw/fireplace_loop.ogg differ diff --git a/core/data/src/main/res/raw/forest_loop.ogg b/core/data/src/main/res/raw/forest_loop.ogg index 95b44b2e..cb2ccc43 100644 Binary files a/core/data/src/main/res/raw/forest_loop.ogg and b/core/data/src/main/res/raw/forest_loop.ogg differ diff --git a/core/data/src/main/res/raw/ocean_loop.ogg b/core/data/src/main/res/raw/ocean_loop.ogg index 95cf07c8..a238c712 100644 Binary files a/core/data/src/main/res/raw/ocean_loop.ogg and b/core/data/src/main/res/raw/ocean_loop.ogg differ diff --git a/core/data/src/main/res/raw/rain_loop.ogg b/core/data/src/main/res/raw/rain_loop.ogg index 362d3a10..7affd621 100644 Binary files a/core/data/src/main/res/raw/rain_loop.ogg and b/core/data/src/main/res/raw/rain_loop.ogg differ diff --git a/core/data/src/main/res/raw/stream_loop.ogg b/core/data/src/main/res/raw/stream_loop.ogg index a0410bbc..ecb75584 100644 Binary files a/core/data/src/main/res/raw/stream_loop.ogg and b/core/data/src/main/res/raw/stream_loop.ogg differ diff --git a/core/data/src/main/res/raw/success_chime.ogg b/core/data/src/main/res/raw/success_chime.ogg new file mode 100644 index 00000000..2312ba10 Binary files /dev/null and b/core/data/src/main/res/raw/success_chime.ogg differ diff --git a/core/data/src/main/res/raw/timer_chime.ogg b/core/data/src/main/res/raw/timer_chime.ogg index ca758403..c22ee1bf 100644 Binary files a/core/data/src/main/res/raw/timer_chime.ogg and b/core/data/src/main/res/raw/timer_chime.ogg differ diff --git a/core/data/src/main/res/raw/white_noise_loop.ogg b/core/data/src/main/res/raw/white_noise_loop.ogg index 471a2493..49a29997 100644 Binary files a/core/data/src/main/res/raw/white_noise_loop.ogg and b/core/data/src/main/res/raw/white_noise_loop.ogg differ diff --git a/core/data/src/main/res/raw/wind_loop.ogg b/core/data/src/main/res/raw/wind_loop.ogg index ed294a70..b57c2e59 100644 Binary files a/core/data/src/main/res/raw/wind_loop.ogg and b/core/data/src/main/res/raw/wind_loop.ogg differ diff --git a/core/data/src/test/java/com/jbgsoft/ambio/core/data/datastore/PreferencesDataStoreTest.kt b/core/data/src/test/java/com/jbgsoft/ambio/core/data/datastore/PreferencesDataStoreTest.kt index a838e9f8..9202c3ab 100644 --- a/core/data/src/test/java/com/jbgsoft/ambio/core/data/datastore/PreferencesDataStoreTest.kt +++ b/core/data/src/test/java/com/jbgsoft/ambio/core/data/datastore/PreferencesDataStoreTest.kt @@ -4,6 +4,8 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.PreferenceDataStoreFactory import com.google.common.truth.Truth.assertThat +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -70,4 +72,52 @@ class PreferencesDataStoreTest { fun `the mix defaults to rain when nothing was ever stored`() = runTest { assertThat(newPreferencesDataStore().preferences.first().lastMix).isEqualTo("rain") } + + @Test + fun `the break mix is absent until it is written`() = runTest { + val prefs = newPreferencesDataStore().preferences.first() + assertThat(prefs.breakMix).isNull() + } + + @Test + fun `writing the break mix leaves the focus mix alone`() = runTest { + val dataStore = newPreferencesDataStore() + dataStore.setLastMix("rain:1.00") + dataStore.setBreakMix("ocean:0.50") + + val prefs = dataStore.preferences.first() + assertThat(prefs.lastMix).isEqualTo("rain:1.00") + assertThat(prefs.breakMix).isEqualTo("ocean:0.50") + } + + @Test + fun `break sound is off by default and persists when enabled`() = runTest { + val dataStore = newPreferencesDataStore() + assertThat(dataStore.preferences.first().breakSoundEnabled).isFalse() + + dataStore.setBreakSoundEnabled(true) + + assertThat(dataStore.preferences.first().breakSoundEnabled).isTrue() + } + + @Test + fun `the sleep timer is off by default and persists a duration`() = runTest { + val dataStore = newPreferencesDataStore() + assertThat(dataStore.preferences.first().sleepMinutes).isEqualTo(0) + + dataStore.setSleepMinutes(30) + + assertThat(dataStore.preferences.first().sleepMinutes).isEqualTo(30) + } + + @Test + fun `the session plan defaults and round-trips`() = runTest { + val dataStore = newPreferencesDataStore() + assertThat(dataStore.preferences.first().sessionPlan).isEqualTo(SessionPlan.DEFAULT) + + val plan = SessionPlan(listOf(PlanStep.Focus(35), PlanStep.Break(15)), repeat = true) + dataStore.setSessionPlan(plan) + + assertThat(dataStore.preferences.first().sessionPlan).isEqualTo(plan) + } } diff --git a/core/data/src/test/java/com/jbgsoft/ambio/core/data/repository/SoundRepositoryImplTest.kt b/core/data/src/test/java/com/jbgsoft/ambio/core/data/repository/SoundRepositoryImplTest.kt index 641dec3a..c6f838c3 100644 --- a/core/data/src/test/java/com/jbgsoft/ambio/core/data/repository/SoundRepositoryImplTest.kt +++ b/core/data/src/test/java/com/jbgsoft/ambio/core/data/repository/SoundRepositoryImplTest.kt @@ -2,6 +2,7 @@ package com.jbgsoft.ambio.core.data.repository import com.google.common.truth.Truth.assertThat import com.jbgsoft.ambio.core.data.datastore.PreferencesDataStore +import com.jbgsoft.ambio.core.domain.model.MixSlot import com.jbgsoft.ambio.core.domain.model.SoundGlow import com.jbgsoft.ambio.core.domain.model.UserPreferences import io.mockk.coEvery @@ -21,9 +22,10 @@ class SoundRepositoryImplTest { private val dataStore = mockk(relaxed = true) - private fun repositoryStoring(lastMix: String): SoundRepositoryImpl { - every { dataStore.preferences } returns flowOf(UserPreferences(lastMix = lastMix)) + private fun repositoryStoring(lastMix: String, breakMix: String? = null): SoundRepositoryImpl { + every { dataStore.preferences } returns flowOf(UserPreferences(lastMix = lastMix, breakMix = breakMix)) coEvery { dataStore.setLastMix(any()) } returns Unit + coEvery { dataStore.setBreakMix(any()) } returns Unit return SoundRepositoryImpl(dataStore) } @@ -261,4 +263,80 @@ class SoundRepositoryImplTest { assertThat(sound.glow.name).isEqualTo(sound.id.uppercase()) } } + + @Test + fun `the break slot reads the focus mix until it has been edited`() = runTest { + val repository = repositoryStoring("rain:1.00,ocean:0.40") + + val breakMix = repository.getActiveMix(MixSlot.BREAK).first() + + assertThat(breakMix.map { it.sound.id }).containsExactly("rain", "ocean").inOrder() + assertThat(breakMix.map { it.level }).containsExactly(1.0f, 0.4f).inOrder() + } + + @Test + fun `a stored break mix wins over the focus mix`() = runTest { + val repository = repositoryStoring("rain", breakMix = "cave") + + assertThat(repository.getActiveMix(MixSlot.BREAK).first().map { it.sound.id }) + .containsExactly("cave") + assertThat(repository.getActiveMix(MixSlot.FOCUS).first().map { it.sound.id }) + .containsExactly("rain") + } + + @Test + fun `editing the break slot writes the break key and leaves the focus slot alone`() = runTest { + val repository = repositoryStoring("rain") + + repository.setSoundActive("cave", active = true, slot = MixSlot.BREAK) + + assertThat(repository.getActiveMix(MixSlot.BREAK).first().map { it.sound.id }) + .containsExactly("rain", "cave").inOrder() + assertThat(repository.getActiveMix(MixSlot.FOCUS).first().map { it.sound.id }) + .containsExactly("rain") + coVerify(exactly = 1) { dataStore.setBreakMix("rain:1.00,cave:1.00") } + coVerify(exactly = 0) { dataStore.setLastMix(any()) } + } + + @Test + fun `a level set on the break slot does not touch the focus slot`() = runTest { + val repository = repositoryStoring("rain,ocean") + + repository.setSoundLevel("ocean", 0.3f, slot = MixSlot.BREAK) + + assertThat(repository.getActiveMix(MixSlot.BREAK).first().first { it.sound.id == "ocean" }.level) + .isEqualTo(0.3f) + assertThat(repository.getActiveMix(MixSlot.FOCUS).first().first { it.sound.id == "ocean" }.level) + .isEqualTo(1.0f) + } + + @Test + fun `the break slot keeps the never-empty invariant`() = runTest { + val repository = repositoryStoring("rain") + + repository.setSoundActive("rain", active = false, slot = MixSlot.BREAK) + + assertThat(repository.getActiveMix(MixSlot.BREAK).first().map { it.sound.id }) + .containsExactly("rain") + } + + @Test + fun `the break slot keeps the three-sound ceiling`() = runTest { + val repository = repositoryStoring("rain,ocean,cave") + + repository.setSoundActive("wind", active = true, slot = MixSlot.BREAK) + + assertThat(repository.getActiveMix(MixSlot.BREAK).first()).hasSize(3) + } + + @Test + fun `a failed break write rolls back only the break override`() = runTest { + val repository = repositoryStoring("rain") + coEvery { dataStore.setBreakMix(any()) } throws IOException("disk full") + + runCatching { repository.setSoundActive("cave", active = true, slot = MixSlot.BREAK) } + + assertThat(repository.getActiveMix(MixSlot.BREAK).first().map { it.sound.id }) + .containsExactly("rain") + } } diff --git a/core/data/src/test/java/com/jbgsoft/ambio/core/data/session/SessionRunnerImplTest.kt b/core/data/src/test/java/com/jbgsoft/ambio/core/data/session/SessionRunnerImplTest.kt new file mode 100644 index 00000000..ac5a99fe --- /dev/null +++ b/core/data/src/test/java/com/jbgsoft/ambio/core/data/session/SessionRunnerImplTest.kt @@ -0,0 +1,204 @@ +package com.jbgsoft.ambio.core.data.session + +import com.google.common.truth.Truth.assertThat +import com.jbgsoft.ambio.core.data.repository.TimerRepositoryImpl +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan +import com.jbgsoft.ambio.core.domain.model.TimerState +import com.jbgsoft.ambio.core.domain.session.SessionEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +/** + * Drives a real TimerRepositoryImpl on virtual time. Never advanceUntilIdle(): + * a looping plan ticks forever, so time is advanced by exact amounts and the + * runner's own collector is flushed with runCurrent(). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SessionRunnerImplTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var timer: TimerRepositoryImpl + private lateinit var runner: SessionRunnerImpl + + private val oneMinute = 60_000L + + private val threeSteps = SessionPlan( + listOf(PlanStep.Focus(1), PlanStep.Break(1), PlanStep.Focus(2)), + repeat = false + ) + + @Before + fun setUp() { + timer = TimerRepositoryImpl(dispatcher) + runner = SessionRunnerImpl(timer, dispatcher) + } + + /** Collects events into a list for the rest of the test. */ + private fun kotlinx.coroutines.test.TestScope.recordEvents(): MutableList { + val events = mutableListOf() + backgroundScope.launch(dispatcher) { runner.events.collect { events += it } } + runCurrent() + return events + } + + private fun kotlinx.coroutines.test.TestScope.finishStep(minutes: Int) { + advanceTimeBy(minutes * oneMinute + 1) + runCurrent() + } + + @Test + fun `start puts the first step on the timer and reports it`() = runTest(dispatcher) { + val events = recordEvents() + + runner.start(threeSteps) + runCurrent() + + assertThat(runner.progress.value?.stepIndex).isEqualTo(0) + assertThat(runner.progress.value?.lap).isEqualTo(0) + val running = timer.timerState.first() as TimerState.Running + assertThat(running.totalMs).isEqualTo(oneMinute) + assertThat(running.isBreak).isFalse() + assertThat(events).containsExactly(SessionEvent.StepStarted(PlanStep.Focus(1), 0)) + } + + @Test + fun `a finished step is reported and the next one starts as a break`() = runTest(dispatcher) { + val events = recordEvents() + runner.start(threeSteps) + runCurrent() + + finishStep(1) + + assertThat(runner.progress.value?.stepIndex).isEqualTo(1) + val running = timer.timerState.first() as TimerState.Running + assertThat(running.isBreak).isTrue() + assertThat(events).containsExactly( + SessionEvent.StepStarted(PlanStep.Focus(1), 0), + SessionEvent.StepCompleted(PlanStep.Focus(1), 0), + SessionEvent.StepStarted(PlanStep.Break(1), 1) + ).inOrder() + } + + @Test + fun `the last step of an ending plan reports only PlanCompleted and clears progress`() = runTest(dispatcher) { + val events = recordEvents() + runner.start(threeSteps) + runCurrent() + + finishStep(1) + finishStep(1) + finishStep(2) + + assertThat(runner.progress.value).isNull() + assertThat(timer.timerState.first()).isEqualTo(TimerState.Idle) + assertThat(events.last()).isEqualTo(SessionEvent.PlanCompleted(PlanStep.Focus(2))) + assertThat(events.filterIsInstance().map { it.stepIndex }) + .containsExactly(0, 1).inOrder() + } + + @Test + fun `a looping plan wraps to the first step and counts the lap`() = runTest(dispatcher) { + val events = recordEvents() + val looping = threeSteps.copy(repeat = true) + runner.start(looping) + runCurrent() + + finishStep(1) + finishStep(1) + finishStep(2) + + assertThat(runner.progress.value?.stepIndex).isEqualTo(0) + assertThat(runner.progress.value?.lap).isEqualTo(1) + assertThat(events).contains(SessionEvent.StepCompleted(PlanStep.Focus(2), 2)) + assertThat(events.filterIsInstance()).isEmpty() + assertThat(events.last()).isEqualTo(SessionEvent.StepStarted(PlanStep.Focus(1), 0)) + + // A repeat=true plan never reaches Completed on its own, so the wrapped + // step is still actively ticking here. runTest's cleanup drains the + // shared TestCoroutineScheduler to true idle (kotlinx.coroutines.test's + // runTest -> finally { testScheduler.advanceUntilIdleOr { false } }), which + // never happens while this timer keeps rescheduling itself - stop it first, + // exactly like the other tests' cancelAndIgnoreRemainingEvents() equivalent. + runner.stop() + runCurrent() + } + + @Test + fun `pause and resume act on the current step`() = runTest(dispatcher) { + runner.start(threeSteps) + runCurrent() + advanceTimeBy(10_000) + runCurrent() + + runner.pause() + runCurrent() + assertThat(timer.timerState.first()).isInstanceOf(TimerState.Paused::class.java) + assertThat(runner.progress.value?.stepIndex).isEqualTo(0) + + runner.resume() + runCurrent() + val running = timer.timerState.first() as TimerState.Running + assertThat(running.remainingMs).isEqualTo(oneMinute - 10_000) + } + + @Test + fun `stop mid-step clears progress, resets the timer and reports nothing`() = runTest(dispatcher) { + val events = recordEvents() + runner.start(threeSteps) + runCurrent() + advanceTimeBy(10_000) + runCurrent() + + runner.stop() + runCurrent() + + assertThat(runner.progress.value).isNull() + assertThat(timer.timerState.first()).isEqualTo(TimerState.Idle) + assertThat(events).containsExactly(SessionEvent.StepStarted(PlanStep.Focus(1), 0)) + } + + @Test + fun `start while a plan runs replaces it from its first step`() = runTest(dispatcher) { + runner.start(threeSteps) + runCurrent() + finishStep(1) + + runner.start(SessionPlan.quick(50, 10)) + runCurrent() + + assertThat(runner.progress.value?.plan).isEqualTo(SessionPlan.quick(50, 10)) + assertThat(runner.progress.value?.stepIndex).isEqualTo(0) + assertThat((timer.timerState.first() as TimerState.Running).totalMs).isEqualTo(50 * oneMinute) + } + + @Test + fun `progress exposes the focus ordinal and count`() = runTest(dispatcher) { + runner.start(threeSteps) + runCurrent() + finishStep(1) + finishStep(1) + + val progress = runner.progress.value!! + assertThat(progress.step).isEqualTo(PlanStep.Focus(2)) + assertThat(progress.focusOrdinal).isEqualTo(2) + assertThat(progress.focusCount).isEqualTo(2) + } + + @Test + fun `start rejects an invalid plan`() = runTest(dispatcher) { + val invalid = SessionPlan(listOf(PlanStep.Break(5)), repeat = false) + + val result = runCatching { runner.start(invalid) } + + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalArgumentException::class.java) + assertThat(runner.progress.value).isNull() + } +} diff --git a/core/di/src/main/java/com/jbgsoft/ambio/core/di/RepositoryModule.kt b/core/di/src/main/java/com/jbgsoft/ambio/core/di/RepositoryModule.kt index 84f0d81f..3a2c6aea 100644 --- a/core/di/src/main/java/com/jbgsoft/ambio/core/di/RepositoryModule.kt +++ b/core/di/src/main/java/com/jbgsoft/ambio/core/di/RepositoryModule.kt @@ -5,11 +5,13 @@ import com.jbgsoft.ambio.core.data.repository.PreferencesRepositoryImpl import com.jbgsoft.ambio.core.data.repository.SessionRepositoryImpl import com.jbgsoft.ambio.core.data.repository.SoundRepositoryImpl import com.jbgsoft.ambio.core.data.repository.TimerRepositoryImpl +import com.jbgsoft.ambio.core.data.session.SessionRunnerImpl import com.jbgsoft.ambio.core.domain.repository.ChimeRepository import com.jbgsoft.ambio.core.domain.repository.PreferencesRepository import com.jbgsoft.ambio.core.domain.repository.SessionRepository import com.jbgsoft.ambio.core.domain.repository.SoundRepository import com.jbgsoft.ambio.core.domain.repository.TimerRepository +import com.jbgsoft.ambio.core.domain.session.SessionRunner import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -39,4 +41,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindChimeRepository(impl: ChimeRepositoryImpl): ChimeRepository + + @Binds + @Singleton + abstract fun bindSessionRunner(impl: SessionRunnerImpl): SessionRunner } diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/MixSlot.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/MixSlot.kt new file mode 100644 index 00000000..4fbfaf7a --- /dev/null +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/MixSlot.kt @@ -0,0 +1,4 @@ +package com.jbgsoft.ambio.core.domain.model + +/** Which of the two stored mixes a call refers to. FOCUS is the mix that existed before breaks had one. */ +enum class MixSlot { FOCUS, BREAK } diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/PlanCodec.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/PlanCodec.kt new file mode 100644 index 00000000..c58ca4e9 --- /dev/null +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/PlanCodec.kt @@ -0,0 +1,50 @@ +package com.jbgsoft.ambio.core.domain.model + +/** + * The storage format for a session plan, one string in DataStore. + * + * F25,B5,F25,B5,F35,B15;loop + * F25,B5;end + * + * Reading is tolerant for the same reason [MixCodec] is: a stored string that + * some later version cannot fully read must degrade to something playable, never + * to a crash or an unstartable plan. + */ +object PlanCodec { + + private const val LOOP = "loop" + private const val END = "end" + + fun encode(plan: SessionPlan): String { + val body = plan.steps.joinToString(",") { step -> + val letter = when (step) { + is PlanStep.Focus -> 'F' + is PlanStep.Break -> 'B' + } + "$letter${step.minutes}" + } + return "$body;${if (plan.repeat) LOOP else END}" + } + + /** Never returns an invalid plan: anything unusable falls back to [SessionPlan.DEFAULT]. */ + fun decode(encoded: String): SessionPlan { + val body = encoded.substringBefore(';') + val suffix = encoded.substringAfter(';', "").trim() + + val steps = body.split(',') + .mapNotNull { segment -> + val trimmed = segment.trim() + if (trimmed.length < 2) return@mapNotNull null + val minutes = trimmed.drop(1).toIntOrNull() ?: return@mapNotNull null + when (trimmed[0].uppercaseChar()) { + 'F' -> PlanStep.Focus(minutes.coerceIn(PlanStep.MIN_MINUTES, PlanStep.MAX_FOCUS_MINUTES)) + 'B' -> PlanStep.Break(minutes.coerceIn(PlanStep.MIN_MINUTES, PlanStep.MAX_BREAK_MINUTES)) + else -> null + } + } + .take(SessionPlan.MAX_STEPS) + + val plan = SessionPlan(steps, repeat = suffix.equals(LOOP, ignoreCase = true)) + return if (plan.isValid) plan else SessionPlan.DEFAULT + } +} diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/SessionPlan.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/SessionPlan.kt new file mode 100644 index 00000000..d14f2aab --- /dev/null +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/SessionPlan.kt @@ -0,0 +1,59 @@ +package com.jbgsoft.ambio.core.domain.model + +/** + * One slot of a session plan. "Loop from here" and "End it here" are not steps: + * they are the plan's terminator, see [SessionPlan.repeat]. + */ +sealed interface PlanStep { + val minutes: Int + + data class Focus(override val minutes: Int) : PlanStep + data class Break(override val minutes: Int) : PlanStep + + /** The largest number of minutes this kind of step may hold. */ + val maxMinutes: Int + get() = when (this) { + is Focus -> MAX_FOCUS_MINUTES + is Break -> MAX_BREAK_MINUTES + } + + companion object { + const val MIN_MINUTES = 1 + const val MAX_FOCUS_MINUTES = 120 + const val MAX_BREAK_MINUTES = 60 + const val DEFAULT_FOCUS_MINUTES = 25 + const val DEFAULT_BREAK_MINUTES = 5 + } +} + +/** + * An ordered list of steps and how it ends: [repeat] loops back to the first + * step forever, otherwise the plan stops after the last step. + */ +data class SessionPlan( + val steps: List, + val repeat: Boolean +) { + val isValid: Boolean + get() = steps.size in 1..MAX_STEPS && + steps.any { it is PlanStep.Focus } && + steps.all { it.minutes in PlanStep.MIN_MINUTES..it.maxMinutes } + + val focusCount: Int get() = steps.count { it is PlanStep.Focus } + + /** 1-based position of the focus step at or before [stepIndex] among the plan's focus steps. */ + fun focusOrdinal(stepIndex: Int): Int = + steps.take(stepIndex + 1).count { it is PlanStep.Focus } + + companion object { + const val MAX_STEPS = 12 + + val DEFAULT = quick(PlanStep.DEFAULT_FOCUS_MINUTES, PlanStep.DEFAULT_BREAK_MINUTES) + + /** The two-step plan the quick presets (25, 50, Custom) run as. */ + fun quick(focusMinutes: Int, breakMinutes: Int) = SessionPlan( + steps = listOf(PlanStep.Focus(focusMinutes), PlanStep.Break(breakMinutes)), + repeat = false + ) + } +} diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/SessionPlanEditing.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/SessionPlanEditing.kt new file mode 100644 index 00000000..aa889eda --- /dev/null +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/SessionPlanEditing.kt @@ -0,0 +1,49 @@ +package com.jbgsoft.ambio.core.domain.model + +/** + * What the dropdown on an editor row offers. The editor shows one row per step + * plus a terminator row at index `steps.size`; LOOP and END on any row make that + * row the terminator and discard the rows after it. + */ +enum class PlanRowChoice { FOCUS, BREAK, LOOP, END } + +fun SessionPlan.withRowChoice(index: Int, choice: PlanRowChoice): SessionPlan = when (choice) { + PlanRowChoice.LOOP -> copy(steps = steps.take(index), repeat = true) + PlanRowChoice.END -> copy(steps = steps.take(index), repeat = false) + PlanRowChoice.FOCUS, PlanRowChoice.BREAK -> { + val isFocus = choice == PlanRowChoice.FOCUS + when { + index < steps.size -> { + val minutes = steps[index].minutes + copy(steps = steps.toMutableList().also { it[index] = stepOf(isFocus, minutes) }) + } + steps.size >= SessionPlan.MAX_STEPS -> this + else -> { + val minutes = if (isFocus) PlanStep.DEFAULT_FOCUS_MINUTES else PlanStep.DEFAULT_BREAK_MINUTES + copy(steps = steps + stepOf(isFocus, minutes)) + } + } + } +} + +fun SessionPlan.withStepMinutes(index: Int, minutes: Int): SessionPlan { + if (index !in steps.indices) return this + val step = steps[index] + return copy(steps = steps.toMutableList().also { + it[index] = stepOf(step is PlanStep.Focus, minutes) + }) +} + +/** Appends a step of the opposite kind to the last one, so Focus and Break alternate. */ +fun SessionPlan.withAddedStep(): SessionPlan { + if (steps.size >= SessionPlan.MAX_STEPS) return this + val addFocus = steps.lastOrNull() !is PlanStep.Focus + val minutes = if (addFocus) PlanStep.DEFAULT_FOCUS_MINUTES else PlanStep.DEFAULT_BREAK_MINUTES + return copy(steps = steps + stepOf(addFocus, minutes)) +} + +private fun stepOf(isFocus: Boolean, minutes: Int): PlanStep = if (isFocus) { + PlanStep.Focus(minutes.coerceIn(PlanStep.MIN_MINUTES, PlanStep.MAX_FOCUS_MINUTES)) +} else { + PlanStep.Break(minutes.coerceIn(PlanStep.MIN_MINUTES, PlanStep.MAX_BREAK_MINUTES)) +} diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/TimerPreset.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/TimerPreset.kt index f941d723..00335ea7 100644 --- a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/TimerPreset.kt +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/TimerPreset.kt @@ -6,5 +6,7 @@ enum class TimerPreset( ) { FOCUS_25(25, 5), FOCUS_50(50, 10), - CUSTOM(0, 0) + CUSTOM(0, 0), + /** Runs the stored [SessionPlan] instead of a two-step focus and break. */ + PLAN(0, 0) } diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/UserPreferences.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/UserPreferences.kt index 9076783e..1e9a3ef8 100644 --- a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/UserPreferences.kt +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/model/UserPreferences.kt @@ -3,12 +3,18 @@ package com.jbgsoft.ambio.core.domain.model data class UserPreferences( // Session state — where the user left off val lastMix: String = "rain", + /** The break mix, encoded like [lastMix]. Null until the user edits it: readers fall back to [lastMix]. */ + val breakMix: String? = null, val volume: Float = 0.7f, val lastTimerMinutes: Int = 25, val breakMinutes: Int = 5, + val sessionPlan: SessionPlan = SessionPlan.DEFAULT, val lastMode: AppMode = AppMode.TIMER, + /** Minutes Ambient mode plays before stopping on its own. 0 is off: play until paused. */ + val sleepMinutes: Int = 0, // Preferences — how the user wants the app to behave val hapticsEnabled: Boolean = true, val chimeEnabled: Boolean = true, - val effectsEnabled: Boolean = true + val effectsEnabled: Boolean = true, + val breakSoundEnabled: Boolean = false ) diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/ChimeRepository.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/ChimeRepository.kt index dc3f79c7..996a1028 100644 --- a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/ChimeRepository.kt +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/ChimeRepository.kt @@ -12,4 +12,10 @@ interface ChimeRepository { */ @RawRes fun getTimerChimeResource(): Int + + /** + * Get the raw resource ID for the sound played when a whole session plan ends. + */ + @RawRes + fun getSuccessChimeResource(): Int } diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/PreferencesRepository.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/PreferencesRepository.kt index 7ca2c047..6d5aaa6d 100644 --- a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/PreferencesRepository.kt +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/PreferencesRepository.kt @@ -1,6 +1,7 @@ package com.jbgsoft.ambio.core.domain.repository import com.jbgsoft.ambio.core.domain.model.AppMode +import com.jbgsoft.ambio.core.domain.model.SessionPlan import com.jbgsoft.ambio.core.domain.model.UserPreferences import kotlinx.coroutines.flow.Flow @@ -10,7 +11,11 @@ interface PreferencesRepository { suspend fun setVolume(volume: Float) suspend fun setLastTimerMinutes(minutes: Int) suspend fun setBreakMinutes(minutes: Int) + suspend fun setBreakMix(encoded: String) + suspend fun setSessionPlan(plan: SessionPlan) + suspend fun setBreakSoundEnabled(enabled: Boolean) suspend fun setLastMode(mode: AppMode) + suspend fun setSleepMinutes(minutes: Int) suspend fun setHapticsEnabled(enabled: Boolean) suspend fun setChimeEnabled(enabled: Boolean) suspend fun setEffectsEnabled(enabled: Boolean) diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/SoundRepository.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/SoundRepository.kt index 791c2cdf..8a8a0cf2 100644 --- a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/SoundRepository.kt +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/repository/SoundRepository.kt @@ -1,6 +1,7 @@ package com.jbgsoft.ambio.core.domain.repository import com.jbgsoft.ambio.core.domain.model.ActiveSound +import com.jbgsoft.ambio.core.domain.model.MixSlot import com.jbgsoft.ambio.core.domain.model.Sound import kotlinx.coroutines.flow.Flow @@ -8,11 +9,14 @@ interface SoundRepository { fun getAllSounds(): List fun getSoundById(id: String): Sound? - /** Never emits an empty list: at least one sound is always active. */ - fun getActiveMix(): Flow> + /** + * Never emits an empty list: at least one sound is always active. + * The BREAK slot reads the FOCUS mix until it has been edited once. + */ + fun getActiveMix(slot: MixSlot = MixSlot.FOCUS): Flow> /** Deactivating the last active sound is a no-op, enforced here and not only in the UI. */ - suspend fun setSoundActive(soundId: String, active: Boolean) + suspend fun setSoundActive(soundId: String, active: Boolean, slot: MixSlot = MixSlot.FOCUS) - suspend fun setSoundLevel(soundId: String, level: Float) + suspend fun setSoundLevel(soundId: String, level: Float, slot: MixSlot = MixSlot.FOCUS) } diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/session/SessionEvent.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/session/SessionEvent.kt new file mode 100644 index 00000000..97376f7d --- /dev/null +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/session/SessionEvent.kt @@ -0,0 +1,13 @@ +package com.jbgsoft.ambio.core.domain.session + +import com.jbgsoft.ambio.core.domain.model.PlanStep + +sealed interface SessionEvent { + data class StepStarted(val step: PlanStep, val stepIndex: Int) : SessionEvent + + /** A step ended and the plan goes on. Not emitted for the last step of an ending plan. */ + data class StepCompleted(val step: PlanStep, val stepIndex: Int) : SessionEvent + + /** The last step of a non-looping plan ended. Emitted instead of [StepCompleted] for that step. */ + data class PlanCompleted(val lastStep: PlanStep) : SessionEvent +} diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/session/SessionProgress.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/session/SessionProgress.kt new file mode 100644 index 00000000..c44ed0cb --- /dev/null +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/session/SessionProgress.kt @@ -0,0 +1,21 @@ +package com.jbgsoft.ambio.core.domain.session + +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan + +/** + * Where a running plan is. The current step's countdown is not in here: + * TimerRepository already publishes it and the dial reads it from there. + */ +data class SessionProgress( + val plan: SessionPlan, + val stepIndex: Int, + /** 0 on the first pass; only a looping plan ever goes higher. */ + val lap: Int +) { + val step: PlanStep get() = plan.steps[stepIndex] + + /** 1-based: "Focus 2 of 3". */ + val focusOrdinal: Int get() = plan.focusOrdinal(stepIndex) + val focusCount: Int get() = plan.focusCount +} diff --git a/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/session/SessionRunner.kt b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/session/SessionRunner.kt new file mode 100644 index 00000000..4fbcdb50 --- /dev/null +++ b/core/domain/src/main/java/com/jbgsoft/ambio/core/domain/session/SessionRunner.kt @@ -0,0 +1,25 @@ +package com.jbgsoft.ambio.core.domain.session + +import com.jbgsoft.ambio.core.domain.model.SessionPlan +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Runs a [SessionPlan] step by step on top of TimerRepository. The only thing in + * Timer mode that starts or stops the timer; the ViewModel reacts to [events]. + */ +interface SessionRunner { + /** Null when no plan is running. Survives ViewModel recreation. */ + val progress: StateFlow + + /** Replay 0: an event fires once, for whoever is listening at the time. */ + val events: SharedFlow + + /** Starts [plan] from its first step, abandoning any plan in progress. Requires a valid plan. */ + suspend fun start(plan: SessionPlan) + suspend fun pause() + suspend fun resume() + + /** Abandons the plan: progress becomes null and the timer returns to Idle. Emits nothing. */ + suspend fun stop() +} diff --git a/core/domain/src/test/java/com/jbgsoft/ambio/core/domain/model/PlanCodecTest.kt b/core/domain/src/test/java/com/jbgsoft/ambio/core/domain/model/PlanCodecTest.kt new file mode 100644 index 00000000..2f33db16 --- /dev/null +++ b/core/domain/src/test/java/com/jbgsoft/ambio/core/domain/model/PlanCodecTest.kt @@ -0,0 +1,82 @@ +package com.jbgsoft.ambio.core.domain.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PlanCodecTest { + + private val six = SessionPlan( + listOf( + PlanStep.Focus(25), PlanStep.Break(5), + PlanStep.Focus(25), PlanStep.Break(5), + PlanStep.Focus(35), PlanStep.Break(15) + ), + repeat = true + ) + + @Test + fun `a looping plan encodes as letters, minutes and a loop suffix`() { + assertThat(PlanCodec.encode(six)).isEqualTo("F25,B5,F25,B5,F35,B15;loop") + } + + @Test + fun `an ending plan carries an end suffix`() { + assertThat(PlanCodec.encode(SessionPlan.DEFAULT)).isEqualTo("F25,B5;end") + } + + @Test + fun `encode then decode is the identity`() { + assertThat(PlanCodec.decode(PlanCodec.encode(six))).isEqualTo(six) + assertThat(PlanCodec.decode(PlanCodec.encode(SessionPlan.DEFAULT))).isEqualTo(SessionPlan.DEFAULT) + } + + @Test + fun `an empty string decodes to the default plan`() { + assertThat(PlanCodec.decode("")).isEqualTo(SessionPlan.DEFAULT) + } + + @Test + fun `garbage decodes to the default plan`() { + assertThat(PlanCodec.decode("hello;world")).isEqualTo(SessionPlan.DEFAULT) + } + + @Test + fun `a missing suffix reads as end`() { + assertThat(PlanCodec.decode("F10,B2")).isEqualTo( + SessionPlan(listOf(PlanStep.Focus(10), PlanStep.Break(2)), repeat = false) + ) + } + + @Test + fun `unreadable segments are skipped, not fatal`() { + assertThat(PlanCodec.decode("F10,,x9,B2;loop")).isEqualTo( + SessionPlan(listOf(PlanStep.Focus(10), PlanStep.Break(2)), repeat = true) + ) + } + + @Test + fun `minutes out of range are clamped rather than rejected`() { + assertThat(PlanCodec.decode("F999,B0;end")).isEqualTo( + SessionPlan(listOf(PlanStep.Focus(120), PlanStep.Break(1)), repeat = false) + ) + } + + @Test + fun `more than twelve steps are truncated to twelve`() { + val encoded = List(13) { "F1" }.joinToString(",") + ";end" + + assertThat(PlanCodec.decode(encoded).steps).hasSize(12) + } + + @Test + fun `a plan with only breaks decodes to the default plan`() { + assertThat(PlanCodec.decode("B5,B5;loop")).isEqualTo(SessionPlan.DEFAULT) + } + + @Test + fun `letters are read case-insensitively`() { + assertThat(PlanCodec.decode("f10,b2;LOOP")).isEqualTo( + SessionPlan(listOf(PlanStep.Focus(10), PlanStep.Break(2)), repeat = true) + ) + } +} diff --git a/core/domain/src/test/java/com/jbgsoft/ambio/core/domain/model/SessionPlanEditingTest.kt b/core/domain/src/test/java/com/jbgsoft/ambio/core/domain/model/SessionPlanEditingTest.kt new file mode 100644 index 00000000..44545239 --- /dev/null +++ b/core/domain/src/test/java/com/jbgsoft/ambio/core/domain/model/SessionPlanEditingTest.kt @@ -0,0 +1,94 @@ +package com.jbgsoft.ambio.core.domain.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class SessionPlanEditingTest { + + private val six = SessionPlan( + listOf( + PlanStep.Focus(25), PlanStep.Break(5), + PlanStep.Focus(25), PlanStep.Break(5), + PlanStep.Focus(35), PlanStep.Break(15) + ), + repeat = false + ) + + @Test + fun `choosing loop on a row drops the rows after it and sets repeat`() { + val edited = six.withRowChoice(index = 3, choice = PlanRowChoice.LOOP) + + assertThat(edited.steps).isEqualTo(six.steps.take(3)) + assertThat(edited.repeat).isTrue() + } + + @Test + fun `choosing end on a row drops the rows after it and clears repeat`() { + val edited = six.copy(repeat = true).withRowChoice(index = 2, choice = PlanRowChoice.END) + + assertThat(edited.steps).isEqualTo(six.steps.take(2)) + assertThat(edited.repeat).isFalse() + } + + @Test + fun `choosing loop on the terminator row keeps every step`() { + val edited = six.withRowChoice(index = six.steps.size, choice = PlanRowChoice.LOOP) + + assertThat(edited.steps).isEqualTo(six.steps) + assertThat(edited.repeat).isTrue() + } + + @Test + fun `changing a step's type keeps its minutes clamped to the new range`() { + val edited = SessionPlan(listOf(PlanStep.Focus(90)), repeat = false) + .withRowChoice(index = 0, choice = PlanRowChoice.BREAK) + + assertThat(edited.steps).containsExactly(PlanStep.Break(60)) + } + + @Test + fun `choosing a type on the terminator row appends a step with the default minutes`() { + val edited = six.withRowChoice(index = six.steps.size, choice = PlanRowChoice.FOCUS) + + assertThat(edited.steps).hasSize(7) + assertThat(edited.steps.last()).isEqualTo(PlanStep.Focus(25)) + } + + @Test + fun `choosing a type on the terminator row of a full plan changes nothing`() { + val full = SessionPlan(List(12) { PlanStep.Focus(1) }, repeat = false) + + assertThat(full.withRowChoice(index = 12, choice = PlanRowChoice.BREAK)).isEqualTo(full) + } + + @Test + fun `withStepMinutes clamps into the range of that step's type`() { + val edited = six.withStepMinutes(index = 1, minutes = 999) + + assertThat(edited.steps[1]).isEqualTo(PlanStep.Break(60)) + assertThat(six.withStepMinutes(index = 0, minutes = 0).steps[0]).isEqualTo(PlanStep.Focus(1)) + } + + @Test + fun `withAddedStep alternates the type of the previous step`() { + val afterBreak = six.withAddedStep() + assertThat(afterBreak.steps.last()).isEqualTo(PlanStep.Focus(25)) + + val afterFocus = afterBreak.withAddedStep() + assertThat(afterFocus.steps.last()).isEqualTo(PlanStep.Break(5)) + } + + @Test + fun `withAddedStep on an empty plan adds a focus step`() { + val edited = SessionPlan(emptyList(), repeat = false).withAddedStep() + + assertThat(edited.steps).containsExactly(PlanStep.Focus(25)) + } + + @Test + fun `withAddedStep on a full plan changes nothing`() { + val full = SessionPlan(List(12) { PlanStep.Focus(1) }, repeat = false) + + assertThat(full.withAddedStep()).isEqualTo(full) + } +} diff --git a/core/domain/src/test/java/com/jbgsoft/ambio/core/domain/model/SessionPlanTest.kt b/core/domain/src/test/java/com/jbgsoft/ambio/core/domain/model/SessionPlanTest.kt new file mode 100644 index 00000000..7e65cc1f --- /dev/null +++ b/core/domain/src/test/java/com/jbgsoft/ambio/core/domain/model/SessionPlanTest.kt @@ -0,0 +1,68 @@ +package com.jbgsoft.ambio.core.domain.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class SessionPlanTest { + + private fun plan(vararg steps: PlanStep, repeat: Boolean = false) = + SessionPlan(steps.toList(), repeat) + + @Test + fun `the default plan is one focus and one break, ending`() { + assertThat(SessionPlan.DEFAULT.steps) + .containsExactly(PlanStep.Focus(25), PlanStep.Break(5)).inOrder() + assertThat(SessionPlan.DEFAULT.repeat).isFalse() + assertThat(SessionPlan.DEFAULT.isValid).isTrue() + } + + @Test + fun `quick builds the two-step plan the presets use`() { + val quick = SessionPlan.quick(focusMinutes = 50, breakMinutes = 10) + + assertThat(quick.steps).containsExactly(PlanStep.Focus(50), PlanStep.Break(10)).inOrder() + assertThat(quick.repeat).isFalse() + } + + @Test + fun `a plan with no focus step is invalid`() { + assertThat(plan(PlanStep.Break(5)).isValid).isFalse() + } + + @Test + fun `an empty plan is invalid`() { + assertThat(plan().isValid).isFalse() + } + + @Test + fun `more than twelve steps is invalid`() { + val steps = List(13) { PlanStep.Focus(1) } + assertThat(SessionPlan(steps, repeat = false).isValid).isFalse() + assertThat(SessionPlan(steps.take(12), repeat = false).isValid).isTrue() + } + + @Test + fun `focus minutes must sit in one to one hundred and twenty`() { + assertThat(plan(PlanStep.Focus(0)).isValid).isFalse() + assertThat(plan(PlanStep.Focus(121)).isValid).isFalse() + assertThat(plan(PlanStep.Focus(120)).isValid).isTrue() + } + + @Test + fun `break minutes must sit in one to sixty`() { + assertThat(plan(PlanStep.Focus(1), PlanStep.Break(0)).isValid).isFalse() + assertThat(plan(PlanStep.Focus(1), PlanStep.Break(61)).isValid).isFalse() + assertThat(plan(PlanStep.Focus(1), PlanStep.Break(60)).isValid).isTrue() + } + + @Test + fun `focusOrdinal counts focus steps up to and including the index`() { + val p = plan(PlanStep.Focus(25), PlanStep.Break(5), PlanStep.Focus(25), PlanStep.Break(5), PlanStep.Focus(35)) + + assertThat(p.focusCount).isEqualTo(3) + assertThat(p.focusOrdinal(0)).isEqualTo(1) + assertThat(p.focusOrdinal(1)).isEqualTo(1) + assertThat(p.focusOrdinal(2)).isEqualTo(2) + assertThat(p.focusOrdinal(4)).isEqualTo(3) + } +} diff --git a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeEvent.kt b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeEvent.kt index 0ea08277..f0b5e021 100644 --- a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeEvent.kt +++ b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeEvent.kt @@ -1,6 +1,8 @@ package com.jbgsoft.ambio.feature.home import com.jbgsoft.ambio.core.domain.model.AppMode +import com.jbgsoft.ambio.core.domain.model.MixSlot +import com.jbgsoft.ambio.core.domain.model.PlanRowChoice import com.jbgsoft.ambio.core.domain.model.Sound import com.jbgsoft.ambio.core.domain.model.TimerPreset @@ -9,16 +11,23 @@ sealed class HomeEvent { data class ToggleSound(val sound: Sound) : HomeEvent() data class SetSoundLevel(val soundId: String, val level: Float) : HomeEvent() data class SoundLevelChangeFinished(val soundId: String) : HomeEvent() + data class SetPickerSlot(val slot: MixSlot) : HomeEvent() data class SelectPreset(val preset: TimerPreset) : HomeEvent() data class SetCustomMinutes(val minutes: Int) : HomeEvent() data object CustomMinutesChangeFinished : HomeEvent() data class SetBreakMinutes(val minutes: Int) : HomeEvent() data object BreakMinutesChangeFinished : HomeEvent() + data class SetSleepMinutes(val minutes: Int) : HomeEvent() data class SetVolume(val volume: Float) : HomeEvent() data object VolumeChangeFinished : HomeEvent() data object PlayPause : HomeEvent() data object Reset : HomeEvent() data object ShowSoundPicker : HomeEvent() data object HideSoundPicker : HomeEvent() - data object TimerCompleted : HomeEvent() + data object ShowPlanEditor : HomeEvent() + data object HidePlanEditor : HomeEvent() + data class SetPlanRowChoice(val index: Int, val choice: PlanRowChoice) : HomeEvent() + data class SetPlanStepMinutes(val index: Int, val minutes: Int) : HomeEvent() + data object AddPlanStep : HomeEvent() + data object SavePlan : HomeEvent() } diff --git a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeScreen.kt b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeScreen.kt index c935e861..dd5f44ea 100644 --- a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeScreen.kt @@ -55,13 +55,17 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlin.math.min import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.jbgsoft.ambio.core.domain.model.AppMode +import com.jbgsoft.ambio.core.domain.model.PlanRowChoice +import com.jbgsoft.ambio.core.domain.model.PlanStep import com.jbgsoft.ambio.core.domain.model.SoundGlow -import com.jbgsoft.ambio.core.domain.model.TimerPreset import com.jbgsoft.ambio.core.domain.model.TimerState import com.jbgsoft.ambio.core.domain.model.gradientOf import com.jbgsoft.ambio.feature.home.components.CurrentSoundBar import com.jbgsoft.ambio.feature.home.components.ModeToggle +import com.jbgsoft.ambio.feature.home.components.PlanEditorDialog +import com.jbgsoft.ambio.feature.home.components.PlanEditorSheet import com.jbgsoft.ambio.feature.home.components.PlayPauseButton +import com.jbgsoft.ambio.feature.home.components.SleepTimerSelector import com.jbgsoft.ambio.feature.home.components.SoundBottomSheet import com.jbgsoft.ambio.feature.home.components.SoundPickerContent import com.jbgsoft.ambio.feature.home.components.TimerDisplay @@ -176,10 +180,13 @@ fun HomeScreen( // parent would measure it with an unbounded height. SoundPickerContent( sounds = uiState.availableSounds, - activeMix = uiState.activeMix, + activeMix = uiState.pickerMix, onToggleSound = { onEvent(HomeEvent.ToggleSound(it)) }, onLevelChange = { id, level -> onEvent(HomeEvent.SetSoundLevel(id, level)) }, onLevelChangeFinished = { id -> onEvent(HomeEvent.SoundLevelChangeFinished(id)) }, + slot = uiState.pickerSlot, + showSlotSwitch = uiState.breakSoundEnabled, + onSlotChange = { onEvent(HomeEvent.SetPickerSlot(it)) }, modifier = Modifier .weight(1f) .fillMaxHeight(), @@ -207,11 +214,43 @@ fun HomeScreen( SoundBottomSheet( showSheet = uiState.showSoundPicker, sounds = uiState.availableSounds, - activeMix = uiState.activeMix, + activeMix = uiState.pickerMix, onToggleSound = { onEvent(HomeEvent.ToggleSound(it)) }, onLevelChange = { id, level -> onEvent(HomeEvent.SetSoundLevel(id, level)) }, onLevelChangeFinished = { id -> onEvent(HomeEvent.SoundLevelChangeFinished(id)) }, - onDismiss = { onEvent(HomeEvent.HideSoundPicker) } + onDismiss = { onEvent(HomeEvent.HideSoundPicker) }, + slot = uiState.pickerSlot, + showSlotSwitch = uiState.breakSoundEnabled, + onSlotChange = { onEvent(HomeEvent.SetPickerSlot(it)) } + ) + } + + val editorCallbacks = remember(onEvent) { + PlanEditorCallbacks( + onRowChoice = { index, choice -> onEvent(HomeEvent.SetPlanRowChoice(index, choice)) }, + onStepMinutes = { index, minutes -> onEvent(HomeEvent.SetPlanStepMinutes(index, minutes)) }, + onAddStep = { onEvent(HomeEvent.AddPlanStep) }, + onSave = { onEvent(HomeEvent.SavePlan) }, + onDismiss = { onEvent(HomeEvent.HidePlanEditor) } + ) + } + if (isExpanded) { + PlanEditorDialog( + draft = uiState.planDraft, + onRowChoice = editorCallbacks.onRowChoice, + onStepMinutes = editorCallbacks.onStepMinutes, + onAddStep = editorCallbacks.onAddStep, + onSave = editorCallbacks.onSave, + onDismiss = editorCallbacks.onDismiss + ) + } else { + PlanEditorSheet( + draft = uiState.planDraft, + onRowChoice = editorCallbacks.onRowChoice, + onStepMinutes = editorCallbacks.onStepMinutes, + onAddStep = editorCallbacks.onAddStep, + onSave = editorCallbacks.onSave, + onDismiss = editorCallbacks.onDismiss ) } } @@ -219,6 +258,14 @@ fun HomeScreen( } } +private class PlanEditorCallbacks( + val onRowChoice: (Int, PlanRowChoice) -> Unit, + val onStepMinutes: (Int, Int) -> Unit, + val onAddStep: () -> Unit, + val onSave: () -> Unit, + val onDismiss: () -> Unit +) + /** * The timer half of Home: mode toggle, timer, transport controls and the bar * naming the current mix. @@ -338,15 +385,19 @@ private fun HomeContentColumn( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { + val progress = uiState.sessionProgress + val stepLabel = progress?.takeIf { it.plan.steps.size > 2 || it.plan.repeat }?.let { + when (it.step) { + is PlanStep.Focus -> stringResource(R.string.timer_step_focus_of, it.focusOrdinal, it.focusCount) + is PlanStep.Break -> stringResource(R.string.timer_step_break) + } + } TimerDisplay( timerState = uiState.timerState, mode = uiState.mode, isPlaying = uiState.isPlaying, - selectedMinutes = when (uiState.selectedPreset) { - TimerPreset.FOCUS_25 -> 25 - TimerPreset.FOCUS_50 -> 50 - TimerPreset.CUSTOM -> uiState.customMinutes - }, + selectedMinutes = uiState.selectedMinutes, + stepLabel = stepLabel, size = timerDisplaySize ) @@ -362,11 +413,27 @@ private fun HomeContentColumn( selectedPreset = uiState.selectedPreset, customMinutes = uiState.customMinutes, breakMinutes = uiState.breakMinutes, + sessionPlan = uiState.sessionPlan, onPresetSelected = { onEvent(HomeEvent.SelectPreset(it)) }, onCustomMinutesChanged = { onEvent(HomeEvent.SetCustomMinutes(it)) }, onCustomMinutesChangeFinished = { onEvent(HomeEvent.CustomMinutesChangeFinished) }, onBreakMinutesChanged = { onEvent(HomeEvent.SetBreakMinutes(it)) }, onBreakMinutesChangeFinished = { onEvent(HomeEvent.BreakMinutesChangeFinished) }, + onEditPlan = { onEvent(HomeEvent.ShowPlanEditor) }, + modifier = Modifier.fillMaxWidth(), + isCompact = isSmallScreen + ) + } + + // Sleep timer (only in Ambient mode), in the presets' place + AnimatedVisibility( + visible = uiState.mode == AppMode.AMBIENT, + enter = fadeIn(tween(300)) + expandVertically(tween(300)), + exit = fadeOut(tween(300)) + shrinkVertically(tween(300)) + ) { + SleepTimerSelector( + sleepMinutes = uiState.sleepMinutes, + onSleepMinutesSelected = { onEvent(HomeEvent.SetSleepMinutes(it)) }, modifier = Modifier.fillMaxWidth(), isCompact = isSmallScreen ) diff --git a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeUiState.kt b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeUiState.kt index 7d5d4d7d..2974e755 100644 --- a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeUiState.kt +++ b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeUiState.kt @@ -2,23 +2,60 @@ package com.jbgsoft.ambio.feature.home import com.jbgsoft.ambio.core.domain.model.ActiveSound import com.jbgsoft.ambio.core.domain.model.AppMode +import com.jbgsoft.ambio.core.domain.model.MixSlot +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan import com.jbgsoft.ambio.core.domain.model.Sound import com.jbgsoft.ambio.core.domain.model.TimerPreset import com.jbgsoft.ambio.core.domain.model.TimerState +import com.jbgsoft.ambio.core.domain.session.SessionProgress data class HomeUiState( val mode: AppMode = AppMode.TIMER, - val activeMix: List = emptyList(), + val focusMix: List = emptyList(), + val breakMix: List = emptyList(), + /** The slot that plays, or would play, right now: BREAK only during a break with break sound on. */ + val audibleSlot: MixSlot = MixSlot.FOCUS, + /** The slot the picker edits. Independent of [audibleSlot]: a break mix can be prepared during focus. */ + val pickerSlot: MixSlot = MixSlot.FOCUS, val availableSounds: List = emptyList(), val timerState: TimerState = TimerState.Idle, + val sessionProgress: SessionProgress? = null, val selectedPreset: TimerPreset = TimerPreset.FOCUS_25, val customMinutes: Int = 25, val breakMinutes: Int = 5, + /** Ambient mode's sleep timer, in minutes. 0 is off. */ + val sleepMinutes: Int = 0, + val sessionPlan: SessionPlan = SessionPlan.DEFAULT, + val planDraft: SessionPlan? = null, val volume: Float = 0.7f, val isPlaying: Boolean = false, val showSoundPicker: Boolean = false, val isServiceConnected: Boolean = false, val hapticsEnabled: Boolean = true, val chimeEnabled: Boolean = true, - val effectsEnabled: Boolean = true -) + val effectsEnabled: Boolean = true, + val breakSoundEnabled: Boolean = false +) { + fun mixFor(slot: MixSlot): List = + if (slot == MixSlot.BREAK) breakMix else focusMix + + /** What sounds now, so the gradient, particles and mix bar show what plays. */ + val activeMix: List get() = mixFor(audibleSlot) + + /** What the picker shows. */ + val pickerMix: List get() = mixFor(pickerSlot) + + /** What pressing play would run: the quick presets are two-step plans, PLAN is the stored one. */ + val planForStart: SessionPlan + get() = when (selectedPreset) { + TimerPreset.FOCUS_25 -> SessionPlan.quick(TimerPreset.FOCUS_25.focusMinutes, breakMinutes) + TimerPreset.FOCUS_50 -> SessionPlan.quick(TimerPreset.FOCUS_50.focusMinutes, breakMinutes) + TimerPreset.CUSTOM -> SessionPlan.quick(customMinutes, breakMinutes) + TimerPreset.PLAN -> sessionPlan + } + + /** The minutes the dial shows while idle: the first step of what would start. */ + val selectedMinutes: Int + get() = planForStart.steps.firstOrNull()?.minutes ?: PlanStep.DEFAULT_FOCUS_MINUTES +} diff --git a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeViewModel.kt b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeViewModel.kt index 7730970c..60c28c72 100644 --- a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/HomeViewModel.kt @@ -8,13 +8,21 @@ import com.jbgsoft.ambio.core.common.resources.StringProvider import com.jbgsoft.ambio.core.domain.model.ActiveSound import com.jbgsoft.ambio.core.domain.model.AppMode import com.jbgsoft.ambio.core.domain.model.MixCodec +import com.jbgsoft.ambio.core.domain.model.MixSlot +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan import com.jbgsoft.ambio.core.domain.model.Sound import com.jbgsoft.ambio.core.domain.model.TimerPreset import com.jbgsoft.ambio.core.domain.model.TimerState +import com.jbgsoft.ambio.core.domain.model.withAddedStep +import com.jbgsoft.ambio.core.domain.model.withRowChoice +import com.jbgsoft.ambio.core.domain.model.withStepMinutes import com.jbgsoft.ambio.core.domain.repository.ChimeRepository import com.jbgsoft.ambio.core.domain.repository.PreferencesRepository import com.jbgsoft.ambio.core.domain.repository.SoundRepository import com.jbgsoft.ambio.core.domain.repository.TimerRepository +import com.jbgsoft.ambio.core.domain.session.SessionEvent +import com.jbgsoft.ambio.core.domain.session.SessionRunner import com.jbgsoft.ambio.core.domain.usecase.SaveSessionUseCase import com.jbgsoft.ambio.media.AudioServiceConnection import com.jbgsoft.ambio.media.MixEntry @@ -32,6 +40,7 @@ import javax.inject.Inject class HomeViewModel @Inject constructor( private val soundRepository: SoundRepository, private val timerRepository: TimerRepository, + private val sessionRunner: SessionRunner, private val preferencesRepository: PreferencesRepository, private val saveSessionUseCase: SaveSessionUseCase, private val hapticManager: HapticManager, @@ -48,6 +57,7 @@ class HomeViewModel @Inject constructor( connectAudioService() loadInitialData() observeTimerState() + observeSession() observePreferences() observeAudioServiceState() } @@ -84,14 +94,23 @@ class HomeViewModel @Inject constructor( val sounds = soundRepository.getAllSounds() _uiState.update { it.copy(availableSounds = sounds) } - soundRepository.getActiveMix() - .onEach { mix -> - _uiState.update { it.copy(activeMix = mix) } - pushMix(mix) - } + soundRepository.getActiveMix(MixSlot.FOCUS) + .onEach { mix -> onMixEmitted(MixSlot.FOCUS, mix) } + .launchIn(viewModelScope) + + soundRepository.getActiveMix(MixSlot.BREAK) + .onEach { mix -> onMixEmitted(MixSlot.BREAK, mix) } .launchIn(viewModelScope) } + /** Only the audible slot's emissions reach the service; the other one just lands in state. */ + private fun onMixEmitted(slot: MixSlot, mix: List) { + _uiState.update { state -> + if (slot == MixSlot.BREAK) state.copy(breakMix = mix) else state.copy(focusMix = mix) + } + if (_uiState.value.audibleSlot == slot) pushMix() + } + /** * The single place the service is told anything about the mix. * @@ -101,7 +120,8 @@ class HomeViewModel @Inject constructor( * every emission, on every reconnect, and before every play() — stop() releases * the service's tracks, so playing again has to re-declare them. */ - private fun pushMix(mix: List = _uiState.value.activeMix) { + private fun pushMix() { + val mix = _uiState.value.activeMix if (mix.isEmpty()) return audioServiceConnection.setMix( mix.map { MixEntry(it.sound.id, it.sound.audioRes, it.level) }, @@ -113,16 +133,62 @@ class HomeViewModel @Inject constructor( timerRepository.timerState .onEach { state -> _uiState.update { it.copy(timerState = state) } - if (state is TimerState.Completed) { - onTimerCompleted(wasBreak = state.wasBreak) + if (state is TimerState.Completed) onTimerCompleted() + } + .launchIn(viewModelScope) + } + + /** + * A plan's steps end through the runner, which owns that Completed (see + * [onSessionEvent]). A countdown with no plan behind it can only be Ambient + * mode's sleep timer, and the whole point of that one is a listener who is + * asleep: the audio goes, and nothing chimes, buzzes or gets recorded. + */ + private fun onTimerCompleted() { + val state = _uiState.value + if (state.mode != AppMode.AMBIENT || state.sessionProgress != null) return + audioServiceConnection.stop() + // Back to Idle, so the dial shows the infinity sign again rather than 00:00. + viewModelScope.launch { timerRepository.resetTimer() } + } + + private fun observeSession() { + sessionRunner.progress + .onEach { progress -> + val wasAudible = _uiState.value.audibleSlot + _uiState.update { it.copy(sessionProgress = progress) } + if (progress == null) { + // No plan in progress means FOCUS again. Left on BREAK, an idle or + // ambient screen would wear the break palette and — worse — drop every + // focus mix emission as "not the audible slot". + // + // A plan abandoned mid-break is the one path where the slot moves with + // no step event behind it: the runner just stops, so nothing else would + // ever tell the service to drop the break tracks and they would keep + // playing under a screen already showing the focus mix. + if (wasAudible == MixSlot.BREAK) audioServiceConnection.stop() + applyAudibility(null) + } else { + // A ViewModel recreated over a plan already in progress learns the step + // from here, not from a StepStarted it missed. The slot has to follow so + // the gradient and the mix bar match what is already playing — but the + // audio is the service's and is untouched. + _uiState.update { + it.copy(audibleSlot = audibleSlotFor(progress.step, it.breakSoundEnabled)) + } } } .launchIn(viewModelScope) + + sessionRunner.events + .onEach { event -> onSessionEvent(event) } + .launchIn(viewModelScope) } private fun observePreferences() { preferencesRepository.preferences .onEach { prefs -> + val previous = _uiState.value _uiState.update { state -> state.copy( volume = prefs.volume, @@ -130,52 +196,90 @@ class HomeViewModel @Inject constructor( customMinutes = prefs.lastTimerMinutes.takeIf { it !in listOf(25, 50) } ?: state.customMinutes, breakMinutes = prefs.breakMinutes, + sleepMinutes = prefs.sleepMinutes, + sessionPlan = prefs.sessionPlan, hapticsEnabled = prefs.hapticsEnabled, chimeEnabled = prefs.chimeEnabled, - effectsEnabled = prefs.effectsEnabled + effectsEnabled = prefs.effectsEnabled, + breakSoundEnabled = prefs.breakSoundEnabled ) } + if (previous.breakSoundEnabled != prefs.breakSoundEnabled) onBreakSoundToggled() } .launchIn(viewModelScope) } + /** + * Flipping the toggle mid-break must be audible at once — but only while the + * session is actually running. Paused or idle there is nothing to make audible: + * applying it would restart playback the user had paused. The slot still moves, + * so the gradient and mix bar show what the next resume would play, and the + * resume itself (which runs [applyAudibility] while the timer is still Paused) + * is what turns it into sound. + */ + private fun onBreakSoundToggled() { + // The break tab of the picker only exists while the toggle is on. Left there, + // the picker would silently edit a mix that can no longer play, and the switch + // that would move it back is the very thing the toggle just hid. + if (!_uiState.value.breakSoundEnabled) { + _uiState.update { it.copy(pickerSlot = MixSlot.FOCUS) } + } + if (_uiState.value.timerState is TimerState.Running) { + applyAudibility() + } else { + val state = _uiState.value + val slot = audibleSlotFor(state.sessionProgress?.step, state.breakSoundEnabled) + _uiState.update { it.copy(audibleSlot = slot) } + } + } + fun onEvent(event: HomeEvent) { when (event) { is HomeEvent.SetMode -> setMode(event.mode) is HomeEvent.ToggleSound -> toggleSound(event.sound) is HomeEvent.SetSoundLevel -> setSoundLevel(event.soundId, event.level) is HomeEvent.SoundLevelChangeFinished -> persistSoundLevel(event.soundId) + is HomeEvent.SetPickerSlot -> setPickerSlot(event.slot) is HomeEvent.SelectPreset -> selectPreset(event.preset) is HomeEvent.SetCustomMinutes -> setCustomMinutes(event.minutes) is HomeEvent.CustomMinutesChangeFinished -> persistCustomMinutes() is HomeEvent.SetBreakMinutes -> setBreakMinutes(event.minutes) is HomeEvent.BreakMinutesChangeFinished -> persistBreakMinutes() + is HomeEvent.SetSleepMinutes -> setSleepMinutes(event.minutes) is HomeEvent.SetVolume -> setVolume(event.volume, persist = false) is HomeEvent.VolumeChangeFinished -> persistVolume() is HomeEvent.PlayPause -> playPause() is HomeEvent.Reset -> reset() is HomeEvent.ShowSoundPicker -> showSoundPicker() is HomeEvent.HideSoundPicker -> hideSoundPicker() - is HomeEvent.TimerCompleted -> onTimerCompleted(wasBreak = false) + is HomeEvent.ShowPlanEditor -> showPlanEditor() + is HomeEvent.HidePlanEditor -> _uiState.update { it.copy(planDraft = null) } + is HomeEvent.SetPlanRowChoice -> editDraft { it.withRowChoice(event.index, event.choice) } + is HomeEvent.SetPlanStepMinutes -> editDraft { it.withStepMinutes(event.index, event.minutes) } + is HomeEvent.AddPlanStep -> editDraft { it.withAddedStep() } + is HomeEvent.SavePlan -> savePlan() } } private fun setMode(mode: AppMode) { haptic { click() } - // Reset timer when switching to Ambient mode if timer is active - if (mode == AppMode.AMBIENT) { - val timerState = _uiState.value.timerState - if (timerState is TimerState.Running || timerState is TimerState.Paused) { - viewModelScope.launch { - timerRepository.resetTimer() - } - } + // Abandon a running plan when switching to Ambient mode + if (mode == AppMode.AMBIENT && _uiState.value.sessionProgress != null) { + viewModelScope.launch { sessionRunner.stop() } } - // Stop audio when switching to Timer mode so button shows "play" - if (mode == AppMode.TIMER && _uiState.value.isPlaying) { - audioServiceConnection.stop() + if (mode == AppMode.TIMER) { + // Stop audio when switching to Timer mode so button shows "play" + if (_uiState.value.isPlaying) audioServiceConnection.stop() + // A sleep countdown has no plan behind it, so nothing else would ever + // clear it, and the timer screen would come up counting down something + // it never started. + if (_uiState.value.sessionProgress == null && + _uiState.value.timerState !is TimerState.Idle + ) { + viewModelScope.launch { timerRepository.resetTimer() } + } } _uiState.update { it.copy(mode = mode) } @@ -194,11 +298,13 @@ class HomeViewModel @Inject constructor( * buzz for a tap the repository would reject — not a correctness guarantee. */ private fun toggleSound(sound: Sound) { - val isActive = _uiState.value.activeMix.any { it.sound.id == sound.id } - if (isActive && _uiState.value.activeMix.size == 1) return + val slot = _uiState.value.pickerSlot + val mix = _uiState.value.pickerMix + val isActive = mix.any { it.sound.id == sound.id } + if (isActive && mix.size == 1) return haptic { heavyClick() } viewModelScope.launch { - soundRepository.setSoundActive(sound.id, active = !isActive) + soundRepository.setSoundActive(sound.id, active = !isActive, slot = slot) } } @@ -222,22 +328,29 @@ class HomeViewModel @Inject constructor( */ private fun setSoundLevel(soundId: String, level: Float) { val clampedLevel = level.coerceIn(0f, 1f) + val slot = _uiState.value.pickerSlot _uiState.update { state -> - state.copy( - activeMix = state.activeMix.map { active -> - if (active.sound.id == soundId) active.copy(level = clampedLevel) else active - } - ) + val updated = state.mixFor(slot).map { active -> + if (active.sound.id == soundId) active.copy(level = clampedLevel) else active + } + if (slot == MixSlot.BREAK) state.copy(breakMix = updated) else state.copy(focusMix = updated) } - pushMix() + // A level dragged on the slot that is not sounding must not change the audio. + if (slot == _uiState.value.audibleSlot) pushMix() } private fun persistSoundLevel(soundId: String) { - val level = _uiState.value.activeMix + val slot = _uiState.value.pickerSlot + val level = _uiState.value.mixFor(slot) .firstOrNull { it.sound.id == soundId } ?.level ?: return - viewModelScope.launch { soundRepository.setSoundLevel(soundId, level) } + viewModelScope.launch { soundRepository.setSoundLevel(soundId, level, slot) } + } + + private fun setPickerSlot(slot: MixSlot) { + haptic { click() } + _uiState.update { it.copy(pickerSlot = slot) } } private fun mixTitle(mix: List): String = @@ -261,7 +374,9 @@ class HomeViewModel @Inject constructor( private fun selectPreset(preset: TimerPreset) { haptic { click() } _uiState.update { it.copy(selectedPreset = preset) } - if (preset != TimerPreset.CUSTOM) { + // CUSTOM and PLAN both report 0 focus minutes: neither has a preset + // duration to remember, and persisting 0 would leave an invalid plan. + if (preset.focusMinutes > 0) { viewModelScope.launch { preferencesRepository.setLastTimerMinutes(preset.focusMinutes) } @@ -292,6 +407,27 @@ class HomeViewModel @Inject constructor( } } + /** + * The choice is measured from now, not from the earlier press of play: a + * countdown already running restarts at the new length, or is dropped when + * the timer is turned off, and playback itself is untouched either way. A + * paused countdown is dropped too, so the next play starts a fresh one. + */ + private fun setSleepMinutes(minutes: Int) { + haptic { click() } + _uiState.update { it.copy(sleepMinutes = minutes) } + viewModelScope.launch { + preferencesRepository.setSleepMinutes(minutes) + val state = _uiState.value + if (state.mode != AppMode.AMBIENT || state.timerState is TimerState.Idle) return@launch + if (state.isPlaying && minutes > 0) { + timerRepository.startTimer(minutes * 60_000L) + } else { + timerRepository.resetTimer() + } + } + } + private fun setVolume(volume: Float, persist: Boolean = true) { val clampedVolume = volume.coerceIn(0f, 1f) _uiState.update { it.copy(volume = clampedVolume) } @@ -318,33 +454,34 @@ class HomeViewModel @Inject constructor( viewModelScope.launch { when { state.mode == AppMode.AMBIENT -> { - // In ambient mode, just toggle play/pause for audio + // Ambient mode has no plan; the timer here is the sleep + // countdown, which follows the audio: paused with it, resumed + // with it, and started fresh when there is none to resume. if (state.isPlaying) { audioServiceConnection.pause() + if (state.timerState is TimerState.Running) timerRepository.pauseTimer() } else { startPlayback() + when { + state.timerState is TimerState.Paused -> timerRepository.resumeTimer() + state.sleepMinutes > 0 -> timerRepository.startTimer(state.sleepMinutes * 60_000L) + } } } state.timerState is TimerState.Running -> { - // Pause both timer and audio - timerRepository.pauseTimer() + sessionRunner.pause() audioServiceConnection.pause() } state.timerState is TimerState.Paused -> { - // Resume both timer and audio - timerRepository.resumeTimer() - audioServiceConnection.play() + sessionRunner.resume() + // Not a bare play(): resuming inside a silent break would otherwise + // come back playing the focus mix. + applyAudibility() } else -> { - // Start timer and play sound - val minutes = when (state.selectedPreset) { - TimerPreset.FOCUS_25 -> 25 - TimerPreset.FOCUS_50 -> 50 - TimerPreset.CUSTOM -> state.customMinutes - } - val durationMs = minutes * 60 * 1000L - timerRepository.startTimer(durationMs) - startPlayback() + // Audio starts when the runner reports the first step, so the + // first step and every later one go through the same path. + sessionRunner.start(state.planForStart) } } } @@ -353,7 +490,7 @@ class HomeViewModel @Inject constructor( private fun reset() { haptic { click() } viewModelScope.launch { - timerRepository.resetTimer() + sessionRunner.stop() audioServiceConnection.stop() } } @@ -367,45 +504,93 @@ class HomeViewModel @Inject constructor( _uiState.update { it.copy(showSoundPicker = false) } } - private fun onTimerCompleted(wasBreak: Boolean) { - val state = _uiState.value - - // Stop the ambient sound when timer completes - audioServiceConnection.stop() + private fun showPlanEditor() { + haptic { click() } + _uiState.update { it.copy(planDraft = it.sessionPlan) } + } - // Play timer completion chime and haptic feedback - if (_uiState.value.chimeEnabled) { - chimePlayer.playChime(chimeRepository.getTimerChimeResource()) + /** Edits apply to the draft only; the stored plan changes on SavePlan. */ + private fun editDraft(edit: (SessionPlan) -> SessionPlan) { + // No draft, no edit — and so no buzz for something that did not happen. + if (_uiState.value.planDraft == null) return + haptic { tick() } + _uiState.update { state -> + val draft = state.planDraft ?: return@update state + state.copy(planDraft = edit(draft)) } - haptic { timerComplete() } + } - if (wasBreak) { - // Break finished - reset to idle state, don't start another break - viewModelScope.launch { - timerRepository.resetTimer() + private fun savePlan() { + val draft = _uiState.value.planDraft ?: return + if (!draft.isValid) return + haptic { click() } + viewModelScope.launch { preferencesRepository.setSessionPlan(draft) } + _uiState.update { it.copy(planDraft = null) } + } + + /** + * The runner decides what happens next; this only makes it audible and + * records it. A step end always stops the audio: a silent break stays + * stopped, a sounding one is restarted by the StepStarted that follows. + */ + private fun onSessionEvent(event: SessionEvent) { + when (event) { + is SessionEvent.StepStarted -> onStepStarted(event.step) + is SessionEvent.StepCompleted -> { + audioServiceConnection.stop() + chime(chimeRepository.getTimerChimeResource()) + haptic { timerComplete() } + recordIfFocus(event.step) + } + is SessionEvent.PlanCompleted -> { + audioServiceConnection.stop() + chime(chimeRepository.getSuccessChimeResource()) + haptic { timerComplete() } + recordIfFocus(event.lastStep) } - return } + } - // Focus session completed - viewModelScope.launch { - // Save the completed session against the whole mix, not one sound - val mix = state.activeMix - if (mix.isNotEmpty()) { - val minutes = when (state.selectedPreset) { - TimerPreset.FOCUS_25 -> 25 - TimerPreset.FOCUS_50 -> 50 - TimerPreset.CUSTOM -> state.customMinutes - } - saveSessionUseCase( - soundId = MixCodec.encode(mix, withLevels = false), - durationMinutes = minutes, - wasCompleted = true - ) - } + private fun onStepStarted(step: PlanStep) = applyAudibility(step) + + /** + * Decides which slot is audible from the step and the toggle, then makes the + * audio match. Called with the step on every StepStarted, and with the step from + * progress when a pause is resumed or the toggle flips mid-run, so a break can go + * from silent to sounding without waiting for the next step. A null step — nothing + * in progress — resolves to FOCUS and issues no audio call at all, which is also + * how a finished plan hands the slot back. + */ + private fun applyAudibility(step: PlanStep? = _uiState.value.sessionProgress?.step) { + val slot = audibleSlotFor(step, _uiState.value.breakSoundEnabled) + _uiState.update { it.copy(audibleSlot = slot) } + when { + step == null -> Unit + step is PlanStep.Focus -> startPlayback() + slot == MixSlot.BREAK -> startPlayback() + else -> audioServiceConnection.stop() + } + } + + /** The whole rule: BREAK only during a break the user asked to hear. */ + private fun audibleSlotFor(step: PlanStep?, breakSoundEnabled: Boolean): MixSlot = + if (step is PlanStep.Break && breakSoundEnabled) MixSlot.BREAK else MixSlot.FOCUS - // Start break timer - timerRepository.startBreak(state.breakMinutes * 60 * 1000L) + private fun chime(resource: Int) { + if (_uiState.value.chimeEnabled) chimePlayer.playChime(resource) + } + + /** A completed focus step is a session against the whole mix, as before. */ + private fun recordIfFocus(step: PlanStep) { + if (step !is PlanStep.Focus) return + val mix = _uiState.value.focusMix + if (mix.isEmpty()) return + viewModelScope.launch { + saveSessionUseCase( + soundId = MixCodec.encode(mix, withLevels = false), + durationMinutes = step.minutes, + wasCompleted = true + ) } } diff --git a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/PlanEditor.kt b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/PlanEditor.kt new file mode 100644 index 00000000..64cc033c --- /dev/null +++ b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/PlanEditor.kt @@ -0,0 +1,295 @@ +package com.jbgsoft.ambio.feature.home.components + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.jbgsoft.ambio.core.domain.model.PlanRowChoice +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan +import com.jbgsoft.ambio.feature.home.R + +/** + * One row per step and a terminator row after them. Choosing Loop or End on any + * row makes it the terminator and the rows after it disappear, so the user + * always sees how the plan ends: there is no implicit end. + */ +@Composable +fun PlanEditorContent( + draft: SessionPlan, + onRowChoice: (index: Int, choice: PlanRowChoice) -> Unit, + onStepMinutes: (index: Int, minutes: Int) -> Unit, + onAddStep: () -> Unit, + onSave: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.plan_editor_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + draft.steps.forEachIndexed { index, step -> + StepRow( + index = index, + step = step, + onChoice = { onRowChoice(index, it) }, + onMinutes = { onStepMinutes(index, it) } + ) + } + TerminatorRow( + index = draft.steps.size, + repeat = draft.repeat, + onChoice = { onRowChoice(draft.steps.size, it) } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (!draft.isValid) { + Text( + text = stringResource(R.string.plan_needs_focus), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onAddStep, + enabled = draft.steps.size < SessionPlan.MAX_STEPS + ) { + Text(stringResource(R.string.plan_add_step)) + } + Button(onClick = onSave, enabled = draft.isValid) { + Text(stringResource(R.string.plan_done)) + } + } + } +} + +@Composable +private fun StepRow( + index: Int, + step: PlanStep, + onChoice: (PlanRowChoice) -> Unit, + onMinutes: (Int) -> Unit +) { + val isFocus = step is PlanStep.Focus + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RowNumber(index) + // The stepper is a fixed pair of buttons, so the dropdown absorbs the + // slack and ellipsizes rather than pushing the stepper off the row. + ChoiceDropdown( + index = index, + current = if (isFocus) PlanRowChoice.FOCUS else PlanRowChoice.BREAK, + onChoice = onChoice, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + NumberStepper( + value = step.minutes, + onValueChange = onMinutes, + onValueChangeFinished = {}, + minValue = PlanStep.MIN_MINUTES, + maxValue = step.maxMinutes, + step = 5, + decreaseDescription = if (isFocus) R.string.a11y_decrease_focus else R.string.a11y_decrease_pause, + increaseDescription = if (isFocus) R.string.a11y_increase_focus else R.string.a11y_increase_pause, + isCompact = true + ) + } +} + +@Composable +private fun TerminatorRow(index: Int, repeat: Boolean, onChoice: (PlanRowChoice) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RowNumber(index) + ChoiceDropdown( + index = index, + current = if (repeat) PlanRowChoice.LOOP else PlanRowChoice.END, + onChoice = onChoice, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun RowNumber(index: Int) { + Text( + text = (index + 1).toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(28.dp) + ) +} + +/** + * The button and its menu share a [Box] so the popup anchors to the button + * rather than to the row slot beside it. + * + * The content description names the control and the state description carries + * the row's number and current type, because merging the button's semantics + * would otherwise drop the label and leave a screen reader with "Step type" + * seven times over. + */ +@Composable +private fun ChoiceDropdown( + index: Int, + current: PlanRowChoice, + onChoice: (PlanRowChoice) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + val description = stringResource(R.string.a11y_plan_row_choice) + val state = stringResource(R.string.a11y_plan_row_state, index + 1, current.label()) + Box(modifier = modifier) { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.semantics { + contentDescription = description + stateDescription = state + } + ) { + Text( + text = current.label(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + PlanRowChoice.entries.forEach { choice -> + DropdownMenuItem( + text = { Text(choice.label()) }, + onClick = { + expanded = false + onChoice(choice) + } + ) + } + } + } +} + +@Composable +private fun PlanRowChoice.label(): String = stringResource( + when (this) { + PlanRowChoice.FOCUS -> R.string.plan_step_focus + PlanRowChoice.BREAK -> R.string.plan_step_pause + PlanRowChoice.LOOP -> R.string.plan_step_loop + PlanRowChoice.END -> R.string.plan_step_end + } +) + +/** Compact widths: a sheet, like the sound picker. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlanEditorSheet( + draft: SessionPlan?, + onRowChoice: (Int, PlanRowChoice) -> Unit, + onStepMinutes: (Int, Int) -> Unit, + onAddStep: () -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit +) { + if (draft == null) return + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surface + ) { + PlanEditorContent( + draft = draft, + onRowChoice = onRowChoice, + onStepMinutes = onStepMinutes, + onAddStep = onAddStep, + onSave = onSave, + modifier = Modifier.padding(bottom = 32.dp) + ) + } +} + +/** Expanded widths: the right pane is already the sound picker, so the editor is a dialog. */ +@Composable +fun PlanEditorDialog( + draft: SessionPlan?, + onRowChoice: (Int, PlanRowChoice) -> Unit, + onStepMinutes: (Int, Int) -> Unit, + onAddStep: () -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit +) { + if (draft == null) return + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = {}, + text = { + PlanEditorContent( + draft = draft, + onRowChoice = onRowChoice, + onStepMinutes = onStepMinutes, + onAddStep = onAddStep, + onSave = onSave + ) + } + ) +} diff --git a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/PlanSummary.kt b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/PlanSummary.kt new file mode 100644 index 00000000..b8d1a96a --- /dev/null +++ b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/PlanSummary.kt @@ -0,0 +1,68 @@ +package com.jbgsoft.ambio.feature.home.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan + +private const val LOOP_GLYPH = "↻" +private const val END_GLYPH = "✓" +private const val SEPARATOR = " · " + +/** + * The one place the summary's shape lives: minutes in order, then how the plan + * ends. Pass the colours to tint focus and break minutes; leave them out and the + * result is the same line unstyled, which is what [planSummaryText] returns. + */ +private fun planSummary( + plan: SessionPlan, + focusColor: Color? = null, + breakColor: Color? = null +): AnnotatedString = buildAnnotatedString { + plan.steps.forEachIndexed { index, step -> + if (index > 0) append(SEPARATOR) + val color = if (step is PlanStep.Focus) focusColor else breakColor + if (color == null) { + append(step.minutes.toString()) + } else { + withStyle(SpanStyle(color = color)) { append(step.minutes.toString()) } + } + } + append(" ") + append(if (plan.repeat) LOOP_GLYPH else END_GLYPH) +} + +/** The plain-text form, and exactly the characters the styled line shows. */ +fun planSummaryText(plan: SessionPlan): String = planSummary(plan).text + +/** + * One line, focus minutes in the foreground colour and break minutes dimmed, + * so the alternation reads without labels. + * + * No content description: the text is the description, and overriding it would + * have a screen reader say "Session plan" in place of the plan. + */ +@Composable +fun PlanSummary(plan: SessionPlan, modifier: Modifier = Modifier) { + val focusColor = MaterialTheme.colorScheme.onSurface + val breakColor = MaterialTheme.colorScheme.onSurfaceVariant + val text = remember(plan, focusColor, breakColor) { + planSummary(plan, focusColor, breakColor) + } + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier + ) +} diff --git a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/SleepTimerSelector.kt b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/SleepTimerSelector.kt new file mode 100644 index 00000000..8cb46df1 --- /dev/null +++ b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/SleepTimerSelector.kt @@ -0,0 +1,92 @@ +package com.jbgsoft.ambio.feature.home.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +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.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +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.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.jbgsoft.ambio.feature.home.R + +/** The durations offered, in minutes. 0 is "off" and is always the first chip. */ +private val SLEEP_OPTIONS = listOf(0, 15, 30, 45, 60, 90) + +/** + * Ambient mode's sleep timer: one chip per duration, where Timer mode shows the + * focus presets. Bedtime is the use, so the choice is made once and persisted, + * and pressing play starts the countdown (see HomeViewModel). + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun SleepTimerSelector( + sleepMinutes: Int, + onSleepMinutesSelected: (Int) -> Unit, + modifier: Modifier = Modifier, + isCompact: Boolean = false +) { + val horizontalPadding = if (isCompact) 16.dp else 24.dp + val labelSpacing = if (isCompact) 6.dp else 8.dp + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.label_sleep_timer), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(labelSpacing)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + SLEEP_OPTIONS.forEach { minutes -> + val isSelected = sleepMinutes == minutes + FilterChip( + selected = isSelected, + onClick = { onSleepMinutesSelected(minutes) }, + label = { + Text( + if (minutes == 0) stringResource(R.string.sleep_timer_off) + else pluralStringResource(R.plurals.duration_minutes, minutes, minutes) + ) + }, + leadingIcon = if (isSelected) { + { + Icon( + imageVector = Icons.Filled.Done, + contentDescription = null, + modifier = Modifier.size(FilterChipDefaults.IconSize) + ) + } + } else null, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.tertiaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onTertiaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + ) + } + } + } +} diff --git a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/SoundBottomSheet.kt b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/SoundBottomSheet.kt index 33eba8d5..4eec881a 100644 --- a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/SoundBottomSheet.kt +++ b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/SoundBottomSheet.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.jbgsoft.ambio.core.domain.model.ActiveSound +import com.jbgsoft.ambio.core.domain.model.MixSlot import com.jbgsoft.ambio.core.domain.model.Sound @OptIn(ExperimentalMaterial3Api::class) @@ -20,7 +21,10 @@ fun SoundBottomSheet( onToggleSound: (Sound) -> Unit, onLevelChange: (String, Float) -> Unit, onLevelChangeFinished: (String) -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, + slot: MixSlot = MixSlot.FOCUS, + showSlotSwitch: Boolean = false, + onSlotChange: (MixSlot) -> Unit = {} ) { val sheetState = rememberModalBottomSheetState() @@ -36,6 +40,9 @@ fun SoundBottomSheet( onToggleSound = onToggleSound, onLevelChange = onLevelChange, onLevelChangeFinished = onLevelChangeFinished, + slot = slot, + showSlotSwitch = showSlotSwitch, + onSlotChange = onSlotChange, modifier = Modifier.padding(bottom = 32.dp) ) } diff --git a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/SoundPickerContent.kt b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/SoundPickerContent.kt index 35be3276..5be7fe58 100644 --- a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/SoundPickerContent.kt +++ b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/SoundPickerContent.kt @@ -8,14 +8,21 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.jbgsoft.ambio.core.domain.model.ActiveSound import com.jbgsoft.ambio.core.domain.model.MixCodec +import com.jbgsoft.ambio.core.domain.model.MixSlot import com.jbgsoft.ambio.core.domain.model.Sound import com.jbgsoft.ambio.feature.home.R @@ -26,6 +33,7 @@ import com.jbgsoft.ambio.feature.home.R * directly as the right pane. Keeping one composable is deliberate — two * pickers would drift apart in both mix rules and translations. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SoundPickerContent( sounds: List, @@ -34,6 +42,9 @@ fun SoundPickerContent( onLevelChange: (String, Float) -> Unit, onLevelChangeFinished: (String) -> Unit, modifier: Modifier = Modifier, + slot: MixSlot = MixSlot.FOCUS, + showSlotSwitch: Boolean = false, + onSlotChange: (MixSlot) -> Unit = {}, columns: Int = 3 ) { Column(modifier = modifier.fillMaxWidth()) { @@ -44,6 +55,37 @@ fun SoundPickerContent( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) + if (showSlotSwitch) { + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + MixSlot.entries.forEachIndexed { index, candidate -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index = index, count = MixSlot.entries.size), + onClick = { onSlotChange(candidate) }, + selected = slot == candidate, + label = { + Text( + text = stringResource( + if (candidate == MixSlot.FOCUS) R.string.picker_slot_focus + else R.string.picker_slot_break + ), + maxLines = 1, + softWrap = false, + autoSize = TextAutoSize.StepBased( + minFontSize = 10.sp, + maxFontSize = 14.sp, + stepSize = 1.sp + ) + ) + } + ) + } + } + } + // Three columns is the default for a twelve-sound catalogue on full width: // at two, only four tiles would be visible and most options would be below // the fold. Narrower containers, such as a side pane on tablets, should pass 2. diff --git a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/TimerDisplay.kt b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/TimerDisplay.kt index e9d74ac0..61ba9a99 100644 --- a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/TimerDisplay.kt +++ b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/TimerDisplay.kt @@ -38,6 +38,7 @@ fun TimerDisplay( isPlaying: Boolean, selectedMinutes: Int, modifier: Modifier = Modifier, + stepLabel: String? = null, size: Dp = 300.dp ) { val progress = when (timerState) { @@ -49,7 +50,11 @@ fun TimerDisplay( val isAnimating = timerState is TimerState.Running && isPlaying + // In Ambient mode the only timer is the sleep countdown, and it shows while it + // runs or waits; with none, the mode has no end and the dial says so. val displayText = when { + mode == AppMode.AMBIENT && timerState is TimerState.Running -> formatTime(timerState.remainingMs) + mode == AppMode.AMBIENT && timerState is TimerState.Paused -> formatTime(timerState.remainingMs) mode == AppMode.AMBIENT -> "∞" timerState is TimerState.Running -> formatTime(timerState.remainingMs) timerState is TimerState.Paused -> formatTime(timerState.remainingMs) @@ -58,7 +63,12 @@ fun TimerDisplay( } val subtitleText = when { + mode == AppMode.AMBIENT && timerState is TimerState.Running -> stringResource(R.string.state_sleep_timer) + mode == AppMode.AMBIENT && timerState is TimerState.Paused -> stringResource(R.string.state_paused) mode == AppMode.AMBIENT -> stringResource(R.string.state_ambient_mode) + // Running only: a paused plan has to say so, and this is the only place + // the dial spells that out. + stepLabel != null && timerState is TimerState.Running -> stepLabel timerState is TimerState.Running && timerState.isBreak -> stringResource(R.string.state_break_time) timerState is TimerState.Running -> stringResource(R.string.state_focus) timerState is TimerState.Paused -> stringResource(R.string.state_paused) diff --git a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/TimerPresetSelector.kt b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/TimerPresetSelector.kt index 446784f4..ba44fc1b 100644 --- a/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/TimerPresetSelector.kt +++ b/feature/home/src/main/java/com/jbgsoft/ambio/feature/home/components/TimerPresetSelector.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Done @@ -30,6 +31,7 @@ import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -38,6 +40,8 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.jbgsoft.ambio.core.domain.model.SessionPlan import com.jbgsoft.ambio.core.domain.model.TimerPreset import com.jbgsoft.ambio.feature.home.R @@ -49,11 +53,13 @@ fun TimerPresetSelector( selectedPreset: TimerPreset, customMinutes: Int, breakMinutes: Int, + sessionPlan: SessionPlan, onPresetSelected: (TimerPreset) -> Unit, onCustomMinutesChanged: (Int) -> Unit, onCustomMinutesChangeFinished: () -> Unit, onBreakMinutesChanged: (Int) -> Unit, onBreakMinutesChangeFinished: () -> Unit, + onEditPlan: () -> Unit, modifier: Modifier = Modifier, isCompact: Boolean = false ) { @@ -89,7 +95,18 @@ fun TimerPresetSelector( ), onClick = { onPresetSelected(preset) }, selected = selectedPreset == preset, - label = { Text(preset.label()) } + label = { + Text( + text = preset.label(), + maxLines = 1, + softWrap = false, + autoSize = TextAutoSize.StepBased( + minFontSize = 10.sp, + maxFontSize = 14.sp, + stepSize = 1.sp + ) + ) + } ) } } @@ -123,48 +140,67 @@ fun TimerPresetSelector( Spacer(modifier = Modifier.height(sectionSpacing)) - // Break Duration - Filter Chips - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.label_break_duration), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(labelSpacing)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() + if (selectedPreset == TimerPreset.PLAN) { + // The plan carries its own breaks, so the chips would be misleading here. + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { - BREAK_OPTIONS.forEach { minutes -> - val isSelected = breakMinutes == minutes - FilterChip( - selected = isSelected, - onClick = { - onBreakMinutesChanged(minutes) - onBreakMinutesChangeFinished() - }, - label = { Text(pluralStringResource(R.plurals.duration_minutes, minutes, minutes)) }, - leadingIcon = if (isSelected) { - { - Icon( - imageVector = Icons.Filled.Done, - contentDescription = null, - modifier = Modifier.size(FilterChipDefaults.IconSize) - ) - } - } else null, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.tertiaryContainer, - selectedLabelColor = MaterialTheme.colorScheme.onTertiaryContainer, - selectedLeadingIconColor = MaterialTheme.colorScheme.onTertiaryContainer + // fill = false so a short plan stays centred next to the button, + // while a twelve-step one gives way to it instead of pushing it off. + PlanSummary(plan = sessionPlan, modifier = Modifier.weight(1f, fill = false)) + Spacer(modifier = Modifier.width(12.dp)) + TextButton(onClick = onEditPlan) { + Text(stringResource(R.string.plan_edit)) + } + } + } else { + // Break Duration - Filter Chips + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.label_break_duration), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(labelSpacing)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + BREAK_OPTIONS.forEach { minutes -> + val isSelected = breakMinutes == minutes + FilterChip( + selected = isSelected, + onClick = { + onBreakMinutesChanged(minutes) + onBreakMinutesChangeFinished() + }, + label = { Text(pluralStringResource(R.plurals.duration_minutes, minutes, minutes)) }, + leadingIcon = if (isSelected) { + { + Icon( + imageVector = Icons.Filled.Done, + contentDescription = null, + modifier = Modifier.size(FilterChipDefaults.IconSize) + ) + } + } else null, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.tertiaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onTertiaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onTertiaryContainer + ) ) - ) + } } } } @@ -172,7 +208,7 @@ fun TimerPresetSelector( } @Composable -private fun NumberStepper( +internal fun NumberStepper( value: Int, onValueChange: (Int) -> Unit, onValueChangeFinished: () -> Unit, @@ -252,5 +288,6 @@ private fun TimerPreset.label(): String = stringResource( TimerPreset.FOCUS_25 -> R.string.preset_25_min TimerPreset.FOCUS_50 -> R.string.preset_50_min TimerPreset.CUSTOM -> R.string.preset_custom + TimerPreset.PLAN -> R.string.preset_plan } ) diff --git a/feature/home/src/main/res/values-af/strings.xml b/feature/home/src/main/res/values-af/strings.xml index a48baae8..b0ae379e 100644 --- a/feature/home/src/main/res/values-af/strings.xml +++ b/feature/home/src/main/res/values-af/strings.xml @@ -50,8 +50,33 @@ Fokusduur Rusduur + + Stop ná + Af + Slaaptimer + Verminder fokusduur Vermeerder fokusduur Volume + + + Plan + Wysig + Fokus %1$d van %2$d + Rus + Sessieplan + Fokus + Rus + Herhaal van hier af + Eindig hier + Voeg stap by + Klaar + Voeg ten minste een fokusstap by + Verminder rusduur + Vermeerder rusduur + Staptipe + Stap %1$d: %2$s + Fokus + Rus diff --git a/feature/home/src/main/res/values-ar/strings.xml b/feature/home/src/main/res/values-ar/strings.xml index 4060a666..d7f2d809 100644 --- a/feature/home/src/main/res/values-ar/strings.xml +++ b/feature/home/src/main/res/values-ar/strings.xml @@ -58,8 +58,33 @@ مدة التركيز مدة الاستراحة + + إيقاف بعد + إيقاف + مؤقت النوم + تقليل مدة التركيز زيادة مدة التركيز مستوى الصوت + + + خطة + تعديل + تركيز %1$d من %2$d + استراحة + خطة الجلسة + تركيز + استراحة + التكرار من هنا + الإنهاء هنا + إضافة خطوة + تم + أضف خطوة تركيز واحدة على الأقل + تقليل مدة الاستراحة + زيادة مدة الاستراحة + نوع الخطوة + الخطوة %1$d: %2$s + تركيز + استراحة diff --git a/feature/home/src/main/res/values-b+bn+BD/strings.xml b/feature/home/src/main/res/values-b+bn+BD/strings.xml index 076d36d4..e37d51ff 100644 --- a/feature/home/src/main/res/values-b+bn+BD/strings.xml +++ b/feature/home/src/main/res/values-b+bn+BD/strings.xml @@ -50,8 +50,33 @@ ফোকাসের সময়কাল বিরতির সময়কাল + + এর পরে থামুন + বন্ধ + ঘুমের টাইমার + ফোকাসের সময়কাল কমান ফোকাসের সময়কাল বাড়ান ভলিউম + + + প্ল্যান + সম্পাদনা + %2$d-এর মধ্যে ফোকাস %1$d + বিরতি + সেশনের পরিকল্পনা + ফোকাস + বিরতি + এখান থেকে পুনরাবৃত্তি + এখানে শেষ + ধাপ যোগ করুন + সম্পন্ন + অন্তত একটি ফোকাস ধাপ যোগ করুন + বিরতির সময়কাল কমান + বিরতির সময়কাল বাড়ান + ধাপের ধরন + ধাপ %1$d: %2$s + ফোকাস + বিরতি diff --git a/feature/home/src/main/res/values-b+es+419/strings.xml b/feature/home/src/main/res/values-b+es+419/strings.xml index bf7cf393..164c8f11 100644 --- a/feature/home/src/main/res/values-b+es+419/strings.xml +++ b/feature/home/src/main/res/values-b+es+419/strings.xml @@ -52,8 +52,33 @@ Duración de la concentración Duración del descanso + + Detener después de + Apagado + Temporizador de sueño + Reducir la duración de la concentración Aumentar la duración de la concentración Volumen + + + Plan + Editar + Concentración %1$d de %2$d + Descanso + Plan de sesión + Concentración + Descanso + Repetir desde aquí + Terminar aquí + Agregar paso + Listo + Agrega al menos un paso de concentración + Reducir la duración del descanso + Aumentar la duración del descanso + Tipo de paso + Paso %1$d: %2$s + Concentración + Descanso diff --git a/feature/home/src/main/res/values-b+pt+BR/strings.xml b/feature/home/src/main/res/values-b+pt+BR/strings.xml index c11fac5a..0652f78f 100644 --- a/feature/home/src/main/res/values-b+pt+BR/strings.xml +++ b/feature/home/src/main/res/values-b+pt+BR/strings.xml @@ -52,8 +52,33 @@ Duração do foco Duração do intervalo + + Parar após + Desligado + Temporizador de sono + Diminuir a duração do foco Aumentar a duração do foco Volume + + + Plano + Editar + Foco %1$d de %2$d + Intervalo + Plano da sessão + Foco + Intervalo + Repetir a partir daqui + Terminar aqui + Adicionar etapa + Concluir + Adicione pelo menos uma etapa de foco + Diminuir a duração do intervalo + Aumentar a duração do intervalo + Tipo de etapa + Etapa %1$d: %2$s + Foco + Intervalo diff --git a/feature/home/src/main/res/values-b+pt+PT/strings.xml b/feature/home/src/main/res/values-b+pt+PT/strings.xml index faef2a68..fdbb6dc0 100644 --- a/feature/home/src/main/res/values-b+pt+PT/strings.xml +++ b/feature/home/src/main/res/values-b+pt+PT/strings.xml @@ -52,8 +52,33 @@ Duração da concentração Duração da pausa + + Parar após + Desligado + Temporizador de sono + Diminuir a duração da concentração Aumentar a duração da concentração Volume + + + Plano + Editar + Concentração %1$d de %2$d + Pausa + Plano da sessão + Concentração + Pausa + Repetir a partir daqui + Terminar aqui + Adicionar passo + Concluir + Adicione pelo menos um passo de concentração + Diminuir a duração da pausa + Aumentar a duração da pausa + Tipo de passo + Passo %1$d: %2$s + Concentração + Pausa diff --git a/feature/home/src/main/res/values-b+zh+Hans/strings.xml b/feature/home/src/main/res/values-b+zh+Hans/strings.xml index 2686fd35..6a8eece6 100644 --- a/feature/home/src/main/res/values-b+zh+Hans/strings.xml +++ b/feature/home/src/main/res/values-b+zh+Hans/strings.xml @@ -48,8 +48,33 @@ 专注时长 休息时长 + + 定时停止 + 关闭 + 睡眠定时器 + 缩短专注时长 延长专注时长 音量 + + + 计划 + 编辑 + 专注 %1$d/%2$d + 休息 + 时段计划 + 专注 + 休息 + 从此处循环 + 在此结束 + 添加步骤 + 完成 + 至少添加一个专注步骤 + 缩短休息时长 + 延长休息时长 + 步骤类型 + 步骤 %1$d:%2$s + 专注 + 休息 diff --git a/feature/home/src/main/res/values-b+zh+Hant+HK/strings.xml b/feature/home/src/main/res/values-b+zh+Hant+HK/strings.xml index a7439d48..25e7bc9f 100644 --- a/feature/home/src/main/res/values-b+zh+Hant+HK/strings.xml +++ b/feature/home/src/main/res/values-b+zh+Hant+HK/strings.xml @@ -48,8 +48,33 @@ 專注時長 休息時長 + + 定時停止 + 關閉 + 睡眠計時器 + 縮短專注時長 延長專注時長 音量 + + + 計劃 + 編輯 + 專注 %1$d/%2$d + 休息 + 時段計劃 + 專注 + 休息 + 由此循環 + 在此結束 + 新增步驟 + 完成 + 至少新增一個專注步驟 + 縮短休息時長 + 延長休息時長 + 步驟類型 + 步驟 %1$d:%2$s + 專注 + 休息 diff --git a/feature/home/src/main/res/values-b+zh+Hant+TW/strings.xml b/feature/home/src/main/res/values-b+zh+Hant+TW/strings.xml index b4fa2991..39e2231d 100644 --- a/feature/home/src/main/res/values-b+zh+Hant+TW/strings.xml +++ b/feature/home/src/main/res/values-b+zh+Hant+TW/strings.xml @@ -48,8 +48,33 @@ 專注時長 休息時長 + + 定時停止 + 關閉 + 睡眠計時器 + 縮短專注時長 延長專注時長 音量 + + + 計畫 + 編輯 + 專注 %1$d/%2$d + 休息 + 時段計畫 + 專注 + 休息 + 從這裡循環 + 在此結束 + 新增步驟 + 完成 + 至少新增一個專注步驟 + 縮短休息時長 + 延長休息時長 + 步驟類型 + 步驟 %1$d:%2$s + 專注 + 休息 diff --git a/feature/home/src/main/res/values-bg/strings.xml b/feature/home/src/main/res/values-bg/strings.xml index ea13cc71..289b1e5e 100644 --- a/feature/home/src/main/res/values-bg/strings.xml +++ b/feature/home/src/main/res/values-bg/strings.xml @@ -50,8 +50,33 @@ Продължителност на фокуса Продължителност на почивката + + Спиране след + Изкл. + Таймер за сън + Намаляване на продължителността на фокуса Увеличаване на продължителността на фокуса Сила на звука + + + План + Редактиране + Фокус %1$d от %2$d + Почивка + План на сесията + Фокус + Почивка + Повтаряне оттук + Край дотук + Добавяне на стъпка + Готово + Добавете поне една стъпка за фокус + Намаляване на продължителността на почивката + Увеличаване на продължителността на почивката + Тип на стъпката + Стъпка %1$d: %2$s + Фокус + Почивка diff --git a/feature/home/src/main/res/values-ca/strings.xml b/feature/home/src/main/res/values-ca/strings.xml index e3014a41..f3e929c8 100644 --- a/feature/home/src/main/res/values-ca/strings.xml +++ b/feature/home/src/main/res/values-ca/strings.xml @@ -52,8 +52,33 @@ Durada de la concentració Durada del descans + + Atura després de + Desactivat + Temporitzador de son + Redueix la durada de la concentració Augmenta la durada de la concentració Volum + + + Pla + Edita + Concentració %1$d de %2$d + Descans + Pla de la sessió + Concentració + Descans + Repeteix des d\'aquí + Acaba aquí + Afegeix un pas + Fet + Afegeix com a mínim un pas de concentració + Redueix la durada del descans + Augmenta la durada del descans + Tipus de pas + Pas %1$d: %2$s + Concentració + Descans diff --git a/feature/home/src/main/res/values-cs/strings.xml b/feature/home/src/main/res/values-cs/strings.xml index a95455f1..6a8d915f 100644 --- a/feature/home/src/main/res/values-cs/strings.xml +++ b/feature/home/src/main/res/values-cs/strings.xml @@ -54,8 +54,33 @@ Délka soustředění Délka přestávky + + Zastavit po + Vypnuto + Časovač spánku + Zkrátit délku soustředění Prodloužit délku soustředění Hlasitost + + + Plán + Upravit + Soustředění %1$d z %2$d + Přestávka + Plán sezení + Soustředění + Přestávka + Opakovat odsud + Skončit zde + Přidat krok + Hotovo + Přidejte alespoň jeden krok soustředění + Zkrátit délku přestávky + Prodloužit délku přestávky + Typ kroku + Krok %1$d: %2$s + Soustředění + Přestávka diff --git a/feature/home/src/main/res/values-da/strings.xml b/feature/home/src/main/res/values-da/strings.xml index 7fe8470d..fda24c2d 100644 --- a/feature/home/src/main/res/values-da/strings.xml +++ b/feature/home/src/main/res/values-da/strings.xml @@ -50,8 +50,33 @@ Fokusvarighed Pausevarighed + + Stop efter + Fra + Sovetimer + Reducer fokusvarigheden Forøg fokusvarigheden Lydstyrke + + + Plan + Rediger + Fokus %1$d af %2$d + Pause + Sessionsplan + Fokus + Pause + Gentag herfra + Slut her + Tilføj trin + Færdig + Tilføj mindst ét fokustrin + Reducer pausevarigheden + Forøg pausevarigheden + Trintype + Trin %1$d: %2$s + Fokus + Pause diff --git a/feature/home/src/main/res/values-de/strings.xml b/feature/home/src/main/res/values-de/strings.xml index 4a2e0070..10b5e08b 100644 --- a/feature/home/src/main/res/values-de/strings.xml +++ b/feature/home/src/main/res/values-de/strings.xml @@ -50,8 +50,33 @@ Fokusdauer Pausendauer + + Stoppen nach + Aus + Schlaf-Timer + Fokusdauer verringern Fokusdauer erhöhen Lautstärke + + + Plan + Bearbeiten + Fokus %1$d von %2$d + Pause + Sitzungsplan + Fokus + Pause + Ab hier wiederholen + Hier beenden + Schritt hinzufügen + Fertig + Füge mindestens einen Fokusschritt hinzu + Pausendauer verringern + Pausendauer erhöhen + Schritttyp + Schritt %1$d: %2$s + Fokus + Pause diff --git a/feature/home/src/main/res/values-el/strings.xml b/feature/home/src/main/res/values-el/strings.xml index 0995d9df..711359ad 100644 --- a/feature/home/src/main/res/values-el/strings.xml +++ b/feature/home/src/main/res/values-el/strings.xml @@ -50,8 +50,33 @@ Διάρκεια εστίασης Διάρκεια διαλείμματος + + Διακοπή μετά από + Ανενεργό + Χρονόμετρο ύπνου + Μείωση διάρκειας εστίασης Αύξηση διάρκειας εστίασης Ένταση + + + Πλάνο + Επεξεργασία + Εστίαση %1$d από %2$d + Διάλειμμα + Πλάνο συνεδρίας + Εστίαση + Διάλειμμα + Επανάληψη από εδώ + Λήξη εδώ + Προσθήκη βήματος + Τέλος + Προσθέστε τουλάχιστον ένα βήμα εστίασης + Μείωση διάρκειας διαλείμματος + Αύξηση διάρκειας διαλείμματος + Τύπος βήματος + Βήμα %1$d: %2$s + Εστίαση + Διάλειμμα diff --git a/feature/home/src/main/res/values-en-rGB/strings.xml b/feature/home/src/main/res/values-en-rGB/strings.xml index dd0e919d..9ed3b852 100644 --- a/feature/home/src/main/res/values-en-rGB/strings.xml +++ b/feature/home/src/main/res/values-en-rGB/strings.xml @@ -50,8 +50,33 @@ Focus Duration Break Duration + + Stop after + Off + Sleep timer + Decrease focus duration Increase focus duration Volume + + + Plan + Edit + Focus %1$d of %2$d + Break + Session plan + Focus + Break + Loop from here + End here + Add step + Done + Add at least one focus step + Decrease break duration + Increase break duration + Step type + Step %1$d: %2$s + Focus + Break diff --git a/feature/home/src/main/res/values-es/strings.xml b/feature/home/src/main/res/values-es/strings.xml index 302d4cb2..bc6794df 100644 --- a/feature/home/src/main/res/values-es/strings.xml +++ b/feature/home/src/main/res/values-es/strings.xml @@ -52,8 +52,33 @@ Duración de la concentración Duración del descanso + + Detener después de + Apagado + Temporizador de sueño + Reducir la duración de la concentración Aumentar la duración de la concentración Volumen + + + Plan + Editar + Concentración %1$d de %2$d + Descanso + Plan de sesión + Concentración + Descanso + Repetir desde aquí + Terminar aquí + Añadir paso + Listo + Añade al menos un paso de concentración + Reducir la duración del descanso + Aumentar la duración del descanso + Tipo de paso + Paso %1$d: %2$s + Concentración + Descanso diff --git a/feature/home/src/main/res/values-et/strings.xml b/feature/home/src/main/res/values-et/strings.xml index 08981a89..910666ba 100644 --- a/feature/home/src/main/res/values-et/strings.xml +++ b/feature/home/src/main/res/values-et/strings.xml @@ -50,8 +50,33 @@ Keskendumise kestus Pausi kestus + + Peata pärast + Väljas + Unetaimer + Vähenda keskendumise kestust Suurenda keskendumise kestust Helitugevus + + + Plaan + Muuda + Keskendumine %1$d/%2$d + Paus + Seansi plaan + Keskendumine + Paus + Korda siit + Lõpeta siin + Lisa samm + Valmis + Lisa vähemalt üks keskendumise samm + Vähenda pausi kestust + Suurenda pausi kestust + Sammu tüüp + Samm %1$d: %2$s + Keskendumine + Paus diff --git a/feature/home/src/main/res/values-fi/strings.xml b/feature/home/src/main/res/values-fi/strings.xml index b0106de3..8af685b9 100644 --- a/feature/home/src/main/res/values-fi/strings.xml +++ b/feature/home/src/main/res/values-fi/strings.xml @@ -50,8 +50,33 @@ Keskittymisen kesto Tauon kesto + + Pysäytä + Pois + Uniajastin + Lyhennä keskittymisen kestoa Pidennä keskittymisen kestoa Äänenvoimakkuus + + + Suunnitelma + Muokkaa + Keskittyminen %1$d/%2$d + Tauko + Jakson suunnitelma + Keskittyminen + Tauko + Toista tästä + Lopeta tähän + Lisää vaihe + Valmis + Lisää vähintään yksi keskittymisvaihe + Lyhennä tauon kestoa + Pidennä tauon kestoa + Vaiheen tyyppi + Vaihe %1$d: %2$s + Keskittyminen + Tauko diff --git a/feature/home/src/main/res/values-fil/strings.xml b/feature/home/src/main/res/values-fil/strings.xml index 64ba160b..847e2bba 100644 --- a/feature/home/src/main/res/values-fil/strings.xml +++ b/feature/home/src/main/res/values-fil/strings.xml @@ -50,8 +50,33 @@ Tagal ng focus Tagal ng pahinga + + Ihinto pagkatapos ng + Naka-off + Sleep timer + Bawasan ang tagal ng focus Dagdagan ang tagal ng focus Lakas ng tunog + + + Plano + I-edit + Focus %1$d sa %2$d + Pahinga + Plano ng session + Focus + Pahinga + Ulitin mula rito + Tapusin dito + Magdagdag ng hakbang + Tapos na + Magdagdag ng kahit isang focus na hakbang + Bawasan ang tagal ng pahinga + Dagdagan ang tagal ng pahinga + Uri ng hakbang + Hakbang %1$d: %2$s + Focus + Pahinga diff --git a/feature/home/src/main/res/values-fr-rCA/strings.xml b/feature/home/src/main/res/values-fr-rCA/strings.xml index 78c55a8f..f4751a11 100644 --- a/feature/home/src/main/res/values-fr-rCA/strings.xml +++ b/feature/home/src/main/res/values-fr-rCA/strings.xml @@ -52,8 +52,33 @@ Durée de concentration Durée de pause + + Arrêter après + Désactivé + Minuterie de sommeil + Réduire la durée de concentration Augmenter la durée de concentration Volume + + + Plan + Modifier + Concentration %1$d sur %2$d + Pause + Plan de séance + Concentration + Pause + Répéter à partir d\'ici + Terminer ici + Ajouter une étape + Terminé + Ajoutez au moins une étape de concentration + Réduire la durée de pause + Augmenter la durée de pause + Type d\'étape + Étape %1$d : %2$s + Concentration + Pause diff --git a/feature/home/src/main/res/values-fr/strings.xml b/feature/home/src/main/res/values-fr/strings.xml index ed102cc3..22e4a55a 100644 --- a/feature/home/src/main/res/values-fr/strings.xml +++ b/feature/home/src/main/res/values-fr/strings.xml @@ -52,8 +52,33 @@ Durée de concentration Durée de pause + + Arrêter après + Désactivé + Minuteur de sommeil + Réduire la durée de concentration Augmenter la durée de concentration Volume + + + Plan + Modifier + Concentration %1$d sur %2$d + Pause + Plan de séance + Concentration + Pause + Répéter à partir d\'ici + Terminer ici + Ajouter une étape + Terminé + Ajoutez au moins une étape de concentration + Réduire la durée de pause + Augmenter la durée de pause + Type d\'étape + Étape %1$d : %2$s + Concentration + Pause diff --git a/feature/home/src/main/res/values-hi/strings.xml b/feature/home/src/main/res/values-hi/strings.xml index 1f7883b6..98cf4724 100644 --- a/feature/home/src/main/res/values-hi/strings.xml +++ b/feature/home/src/main/res/values-hi/strings.xml @@ -50,8 +50,33 @@ फ़ोकस की अवधि ब्रेक की अवधि + + इसके बाद रोकें + बंद + स्लीप टाइमर + फ़ोकस की अवधि घटाएं फ़ोकस की अवधि बढ़ाएं वॉल्यूम + + + प्लान + एडिट करें + फ़ोकस %1$d/%2$d + ब्रेक + सेशन प्लान + फ़ोकस + ब्रेक + यहां से दोहराएं + यहीं खत्म करें + स्टेप जोड़ें + हो गया + कम से कम एक फ़ोकस स्टेप जोड़ें + ब्रेक की अवधि घटाएं + ब्रेक की अवधि बढ़ाएं + स्टेप का टाइप + स्टेप %1$d: %2$s + फ़ोकस + ब्रेक diff --git a/feature/home/src/main/res/values-hr/strings.xml b/feature/home/src/main/res/values-hr/strings.xml index 0494ea1d..77c5c67e 100644 --- a/feature/home/src/main/res/values-hr/strings.xml +++ b/feature/home/src/main/res/values-hr/strings.xml @@ -52,8 +52,33 @@ Trajanje fokusiranja Trajanje pauze + + Zaustavi nakon + Isključeno + Mjerač vremena za spavanje + Smanji trajanje fokusiranja Povećaj trajanje fokusiranja Glasnoća + + + Plan + Uredi + Fokusiranje %1$d od %2$d + Pauza + Plan sesije + Fokusiranje + Pauza + Ponovi odavde + Završi ovdje + Dodaj korak + Gotovo + Dodajte barem jedan korak fokusiranja + Smanji trajanje pauze + Povećaj trajanje pauze + Vrsta koraka + Korak %1$d: %2$s + Fokusiranje + Pauza diff --git a/feature/home/src/main/res/values-hu/strings.xml b/feature/home/src/main/res/values-hu/strings.xml index 01236750..221e0906 100644 --- a/feature/home/src/main/res/values-hu/strings.xml +++ b/feature/home/src/main/res/values-hu/strings.xml @@ -50,8 +50,33 @@ Fókusz hossza Szünet hossza + + Leállítás ennyi után + Ki + Alvásidőzítő + Fókuszidő csökkentése Fókuszidő növelése Hangerő + + + Terv + Szerkesztés + Fókusz %1$d/%2$d + Szünet + Munkamenet terve + Fókusz + Szünet + Ismétlés innen + Befejezés itt + Lépés hozzáadása + Kész + Adj hozzá legalább egy fókuszlépést + Szünetidő csökkentése + Szünetidő növelése + Lépés típusa + %1$d. lépés: %2$s + Fókusz + Szünet diff --git a/feature/home/src/main/res/values-in/strings.xml b/feature/home/src/main/res/values-in/strings.xml index ec28b3e2..4b9408b4 100644 --- a/feature/home/src/main/res/values-in/strings.xml +++ b/feature/home/src/main/res/values-in/strings.xml @@ -48,8 +48,33 @@ Durasi fokus Durasi istirahat + + Berhenti setelah + Nonaktif + Timer tidur + Kurangi durasi fokus Tambah durasi fokus Volume + + + Rencana + Edit + Fokus %1$d dari %2$d + Istirahat + Rencana sesi + Fokus + Istirahat + Ulangi dari sini + Akhiri di sini + Tambah langkah + Selesai + Tambahkan setidaknya satu langkah fokus + Kurangi durasi istirahat + Tambah durasi istirahat + Jenis langkah + Langkah %1$d: %2$s + Fokus + Istirahat diff --git a/feature/home/src/main/res/values-it/strings.xml b/feature/home/src/main/res/values-it/strings.xml index c32e9425..cd81a635 100644 --- a/feature/home/src/main/res/values-it/strings.xml +++ b/feature/home/src/main/res/values-it/strings.xml @@ -52,8 +52,33 @@ Durata della concentrazione Durata della pausa + + Ferma dopo + Disattivato + Timer di spegnimento + Riduci la durata della concentrazione Aumenta la durata della concentrazione Volume + + + Piano + Modifica + Concentrazione %1$d di %2$d + Pausa + Piano della sessione + Concentrazione + Pausa + Ripeti da qui + Termina qui + Aggiungi passaggio + Fine + Aggiungi almeno un passaggio di concentrazione + Riduci la durata della pausa + Aumenta la durata della pausa + Tipo di passaggio + Passaggio %1$d: %2$s + Concentrazione + Pausa diff --git a/feature/home/src/main/res/values-iw/strings.xml b/feature/home/src/main/res/values-iw/strings.xml index 101b2024..12e5bee1 100644 --- a/feature/home/src/main/res/values-iw/strings.xml +++ b/feature/home/src/main/res/values-iw/strings.xml @@ -52,8 +52,33 @@ משך המיקוד משך ההפסקה + + עצירה אחרי + כבוי + טיימר שינה + קיצור משך המיקוד הארכת משך המיקוד עוצמת קול + + + תוכנית + עריכה + מיקוד %1$d מתוך %2$d + הפסקה + תוכנית הסשן + מיקוד + הפסקה + לולאה מכאן + סיום כאן + הוספת שלב + סיום + צריך להוסיף לפחות שלב מיקוד אחד + קיצור משך ההפסקה + הארכת משך ההפסקה + סוג השלב + שלב %1$d: %2$s + מיקוד + הפסקה diff --git a/feature/home/src/main/res/values-ja/strings.xml b/feature/home/src/main/res/values-ja/strings.xml index 51347265..2daade78 100644 --- a/feature/home/src/main/res/values-ja/strings.xml +++ b/feature/home/src/main/res/values-ja/strings.xml @@ -48,8 +48,33 @@ 集中時間 休憩時間 + + 停止までの時間 + オフ + スリープタイマー + 集中時間を減らす 集中時間を増やす 音量 + + + プラン + 編集 + 集中 %1$d/%2$d + 休憩 + セッションプラン + 集中 + 休憩 + ここから繰り返す + ここで終了 + ステップを追加 + 完了 + 集中ステップを1つ以上追加してください + 休憩時間を減らす + 休憩時間を増やす + ステップの種類 + ステップ %1$d:%2$s + 集中 + 休憩 diff --git a/feature/home/src/main/res/values-ko/strings.xml b/feature/home/src/main/res/values-ko/strings.xml index 1720d3dd..a302c6a1 100644 --- a/feature/home/src/main/res/values-ko/strings.xml +++ b/feature/home/src/main/res/values-ko/strings.xml @@ -48,8 +48,33 @@ 집중 시간 휴식 시간 + + 다음 시간 후 정지 + 끄기 + 취침 타이머 + 집중 시간 줄이기 집중 시간 늘리기 음량 + + + 플랜 + 수정 + 집중 %1$d/%2$d + 휴식 + 세션 플랜 + 집중 + 휴식 + 여기부터 반복 + 여기서 종료 + 단계 추가 + 완료 + 집중 단계를 하나 이상 추가하세요 + 휴식 시간 줄이기 + 휴식 시간 늘리기 + 단계 유형 + %1$d단계: %2$s + 집중 + 휴식 diff --git a/feature/home/src/main/res/values-lt/strings.xml b/feature/home/src/main/res/values-lt/strings.xml index ac9ae47b..23c70a99 100644 --- a/feature/home/src/main/res/values-lt/strings.xml +++ b/feature/home/src/main/res/values-lt/strings.xml @@ -54,8 +54,33 @@ Susikaupimo trukmė Pertraukos trukmė + + Sustabdyti po + Išjungta + Miego laikmatis + Sumažinti susikaupimo trukmę Padidinti susikaupimo trukmę Garsumas + + + Planas + Redaguoti + Susikaupimas %1$d iš %2$d + Pertrauka + Sesijos planas + Susikaupimas + Pertrauka + Kartoti nuo čia + Baigti čia + Pridėti žingsnį + Atlikta + Pridėkite bent vieną susikaupimo žingsnį + Sumažinti pertraukos trukmę + Padidinti pertraukos trukmę + Žingsnio tipas + Žingsnis %1$d: %2$s + Susikaupimas + Pertrauka diff --git a/feature/home/src/main/res/values-lv/strings.xml b/feature/home/src/main/res/values-lv/strings.xml index 158a23f1..3cbb8ed1 100644 --- a/feature/home/src/main/res/values-lv/strings.xml +++ b/feature/home/src/main/res/values-lv/strings.xml @@ -52,8 +52,33 @@ Koncentrēšanās ilgums Pārtraukuma ilgums + + Apturēt pēc + Izslēgts + Miega taimeris + Samazināt koncentrēšanās ilgumu Palielināt koncentrēšanās ilgumu Skaļums + + + Plāns + Rediģēt + Koncentrēšanās %1$d no %2$d + Pārtraukums + Sesijas plāns + Koncentrēšanās + Pārtraukums + Atkārtot no šejienes + Beigt šeit + Pievienot soli + Gatavs + Pievienojiet vismaz vienu koncentrēšanās soli + Samazināt pārtraukuma ilgumu + Palielināt pārtraukuma ilgumu + Soļa veids + %1$d. solis: %2$s + Koncentrēšanās + Pārtraukums diff --git a/feature/home/src/main/res/values-ms/strings.xml b/feature/home/src/main/res/values-ms/strings.xml index 4d8ad7d2..04fb83c4 100644 --- a/feature/home/src/main/res/values-ms/strings.xml +++ b/feature/home/src/main/res/values-ms/strings.xml @@ -48,8 +48,33 @@ Tempoh fokus Tempoh rehat + + Berhenti selepas + Mati + Pemasa tidur + Kurangkan tempoh fokus Tambahkan tempoh fokus Kelantangan + + + Pelan + Edit + Fokus %1$d daripada %2$d + Rehat + Pelan sesi + Fokus + Rehat + Ulang dari sini + Tamat di sini + Tambah langkah + Selesai + Tambah sekurang-kurangnya satu langkah fokus + Kurangkan tempoh rehat + Tambahkan tempoh rehat + Jenis langkah + Langkah %1$d: %2$s + Fokus + Rehat diff --git a/feature/home/src/main/res/values-nb/strings.xml b/feature/home/src/main/res/values-nb/strings.xml index b36bf488..3c5cf713 100644 --- a/feature/home/src/main/res/values-nb/strings.xml +++ b/feature/home/src/main/res/values-nb/strings.xml @@ -50,8 +50,33 @@ Fokusvarighet Pausevarighet + + Stopp etter + Av + Sovetimer + Reduser fokusvarigheten Øk fokusvarigheten Volum + + + Plan + Rediger + Fokus %1$d av %2$d + Pause + Øktplan + Fokus + Pause + Gjenta herfra + Slutt her + Legg til trinn + Ferdig + Legg til minst ett fokustrinn + Reduser pausevarigheten + Øk pausevarigheten + Trinntype + Trinn %1$d: %2$s + Fokus + Pause diff --git a/feature/home/src/main/res/values-nl/strings.xml b/feature/home/src/main/res/values-nl/strings.xml index a3b3dcc5..21840e43 100644 --- a/feature/home/src/main/res/values-nl/strings.xml +++ b/feature/home/src/main/res/values-nl/strings.xml @@ -50,8 +50,33 @@ Focusduur Pauzeduur + + Stoppen na + Uit + Slaaptimer + Focusduur verlagen Focusduur verhogen Volume + + + Plan + Bewerken + Focus %1$d van %2$d + Pauze + Sessieplan + Focus + Pauze + Herhalen vanaf hier + Hier eindigen + Stap toevoegen + Klaar + Voeg minstens één focusstap toe + Pauzeduur verlagen + Pauzeduur verhogen + Staptype + Stap %1$d: %2$s + Focus + Pauze diff --git a/feature/home/src/main/res/values-pl/strings.xml b/feature/home/src/main/res/values-pl/strings.xml index beb3a499..ce55333c 100644 --- a/feature/home/src/main/res/values-pl/strings.xml +++ b/feature/home/src/main/res/values-pl/strings.xml @@ -54,8 +54,33 @@ Czas skupienia Czas przerwy + + Zatrzymaj po + Wył. + Wyłącznik czasowy + Skróć czas skupienia Wydłuż czas skupienia Głośność + + + Plan + Edytuj + Skupienie %1$d z %2$d + Przerwa + Plan sesji + Skupienie + Przerwa + Powtarzaj od tego miejsca + Zakończ tutaj + Dodaj krok + Gotowe + Dodaj co najmniej jeden krok skupienia + Skróć czas przerwy + Wydłuż czas przerwy + Typ kroku + Krok %1$d: %2$s + Skupienie + Przerwa diff --git a/feature/home/src/main/res/values-ro/strings.xml b/feature/home/src/main/res/values-ro/strings.xml index 06e53413..542d047f 100644 --- a/feature/home/src/main/res/values-ro/strings.xml +++ b/feature/home/src/main/res/values-ro/strings.xml @@ -52,8 +52,33 @@ Durata concentrării Durata pauzei + + Oprește după + Dezactivat + Temporizator de somn + Redu durata concentrării Mărește durata concentrării Volum + + + Plan + Editează + Concentrare %1$d din %2$d + Pauză + Planul sesiunii + Concentrare + Pauză + Repetă de aici + Încheie aici + Adaugă un pas + Gata + Adaugă cel puțin un pas de concentrare + Redu durata pauzei + Mărește durata pauzei + Tipul pasului + Pasul %1$d: %2$s + Concentrare + Pauză diff --git a/feature/home/src/main/res/values-ru/strings.xml b/feature/home/src/main/res/values-ru/strings.xml index 0b819648..5d58b294 100644 --- a/feature/home/src/main/res/values-ru/strings.xml +++ b/feature/home/src/main/res/values-ru/strings.xml @@ -54,8 +54,33 @@ Длительность концентрации Длительность перерыва + + Остановить через + Выкл. + Таймер сна + Уменьшить длительность концентрации Увеличить длительность концентрации Громкость + + + План + Изменить + Концентрация %1$d из %2$d + Перерыв + План сеанса + Концентрация + Перерыв + Повторять отсюда + Завершить здесь + Добавить шаг + Готово + Добавьте хотя бы один шаг концентрации + Уменьшить длительность перерыва + Увеличить длительность перерыва + Тип шага + Шаг %1$d: %2$s + Концентрация + Перерыв diff --git a/feature/home/src/main/res/values-sk/strings.xml b/feature/home/src/main/res/values-sk/strings.xml index d3a47163..7b00553f 100644 --- a/feature/home/src/main/res/values-sk/strings.xml +++ b/feature/home/src/main/res/values-sk/strings.xml @@ -54,8 +54,33 @@ Dĺžka sústredenia Dĺžka prestávky + + Zastaviť po + Vypnuté + Časovač spánku + Skrátiť dĺžku sústredenia Predĺžiť dĺžku sústredenia Hlasitosť + + + Plán + Upraviť + Sústredenie %1$d z %2$d + Prestávka + Plán relácie + Sústredenie + Prestávka + Opakovať odtiaľto + Skončiť tu + Pridať krok + Hotovo + Pridajte aspoň jeden krok sústredenia + Skrátiť dĺžku prestávky + Predĺžiť dĺžku prestávky + Typ kroku + Krok %1$d: %2$s + Sústredenie + Prestávka diff --git a/feature/home/src/main/res/values-sl/strings.xml b/feature/home/src/main/res/values-sl/strings.xml index b8607f05..da04fb10 100644 --- a/feature/home/src/main/res/values-sl/strings.xml +++ b/feature/home/src/main/res/values-sl/strings.xml @@ -54,8 +54,33 @@ Trajanje zbranosti Trajanje odmora + + Ustavi po + Izklopljeno + Časovnik spanja + Skrajšaj trajanje zbranosti Podaljšaj trajanje zbranosti Glasnost + + + Načrt + Uredi + Zbranost %1$d od %2$d + Odmor + Načrt seje + Zbranost + Odmor + Ponovi od tu + Končaj tu + Dodaj korak + Končano + Dodajte vsaj en korak zbranosti + Skrajšaj trajanje odmora + Podaljšaj trajanje odmora + Vrsta koraka + Korak %1$d: %2$s + Zbranost + Odmor diff --git a/feature/home/src/main/res/values-sr/strings.xml b/feature/home/src/main/res/values-sr/strings.xml index 47332135..97686d1c 100644 --- a/feature/home/src/main/res/values-sr/strings.xml +++ b/feature/home/src/main/res/values-sr/strings.xml @@ -52,8 +52,33 @@ Трајање фокусирања Трајање паузе + + Заустави после + Искључено + Тајмер за спавање + Смањи трајање фокусирања Повећај трајање фокусирања Јачина звука + + + План + Измени + Фокусирање %1$d од %2$d + Пауза + План сесије + Фокусирање + Пауза + Понављај одавде + Заврши овде + Додај корак + Готово + Додајте бар један корак фокусирања + Смањи трајање паузе + Повећај трајање паузе + Врста корака + Корак %1$d: %2$s + Фокусирање + Пауза diff --git a/feature/home/src/main/res/values-sv/strings.xml b/feature/home/src/main/res/values-sv/strings.xml index 21254ec0..84aba011 100644 --- a/feature/home/src/main/res/values-sv/strings.xml +++ b/feature/home/src/main/res/values-sv/strings.xml @@ -50,8 +50,33 @@ Fokuslängd Rastlängd + + Stoppa efter + Av + Sovtimer + Minska fokuslängden Öka fokuslängden Volym + + + Plan + Redigera + Fokus %1$d av %2$d + Rast + Passplan + Fokus + Rast + Upprepa härifrån + Sluta här + Lägg till steg + Klar + Lägg till minst ett fokussteg + Minska rastlängden + Öka rastlängden + Stegtyp + Steg %1$d: %2$s + Fokus + Rast diff --git a/feature/home/src/main/res/values-sw/strings.xml b/feature/home/src/main/res/values-sw/strings.xml index 9ccff8bc..e0f4bd28 100644 --- a/feature/home/src/main/res/values-sw/strings.xml +++ b/feature/home/src/main/res/values-sw/strings.xml @@ -50,8 +50,33 @@ Muda wa umakini Muda wa mapumziko + + Simamisha baada ya + Imezimwa + Kipima muda cha kulala + Punguza muda wa umakini Ongeza muda wa umakini Kiwango cha sauti + + + Mpango + Hariri + Umakini %1$d kati ya %2$d + Mapumziko + Mpango wa kipindi + Umakini + Mapumziko + Rudia kuanzia hapa + Maliza hapa + Ongeza hatua + Nimemaliza + Ongeza angalau hatua moja ya umakini + Punguza muda wa mapumziko + Ongeza muda wa mapumziko + Aina ya hatua + Hatua %1$d: %2$s + Umakini + Mapumziko diff --git a/feature/home/src/main/res/values-th/strings.xml b/feature/home/src/main/res/values-th/strings.xml index 2ba98329..6500e119 100644 --- a/feature/home/src/main/res/values-th/strings.xml +++ b/feature/home/src/main/res/values-th/strings.xml @@ -48,8 +48,33 @@ ระยะเวลาโฟกัส ระยะเวลาพัก + + หยุดหลังจาก + ปิด + ตัวตั้งเวลานอน + ลดระยะเวลาโฟกัส เพิ่มระยะเวลาโฟกัส ระดับเสียง + + + แผน + แก้ไข + โฟกัส %1$d จาก %2$d + พัก + แผนเซสชัน + โฟกัส + พัก + วนซ้ำจากตรงนี้ + จบตรงนี้ + เพิ่มขั้นตอน + เสร็จสิ้น + เพิ่มขั้นตอนโฟกัสอย่างน้อยหนึ่งขั้น + ลดระยะเวลาพัก + เพิ่มระยะเวลาพัก + ประเภทขั้นตอน + ขั้นตอนที่ %1$d: %2$s + โฟกัส + พัก diff --git a/feature/home/src/main/res/values-tr/strings.xml b/feature/home/src/main/res/values-tr/strings.xml index 5a375d86..2382519e 100644 --- a/feature/home/src/main/res/values-tr/strings.xml +++ b/feature/home/src/main/res/values-tr/strings.xml @@ -50,8 +50,33 @@ Odaklanma süresi Mola süresi + + Şu süre sonra durdur + Kapalı + Uyku zamanlayıcısı + Odaklanma süresini azalt Odaklanma süresini artır Ses düzeyi + + + Plan + Düzenle + Odaklanma %1$d/%2$d + Mola + Oturum planı + Odaklanma + Mola + Buradan tekrarla + Burada bitir + Adım ekle + Bitti + En az bir odaklanma adımı ekleyin + Mola süresini azalt + Mola süresini artır + Adım türü + Adım %1$d: %2$s + Odaklanma + Mola diff --git a/feature/home/src/main/res/values-uk/strings.xml b/feature/home/src/main/res/values-uk/strings.xml index 4d29ec4b..70320728 100644 --- a/feature/home/src/main/res/values-uk/strings.xml +++ b/feature/home/src/main/res/values-uk/strings.xml @@ -54,8 +54,33 @@ Тривалість зосередження Тривалість перерви + + Зупинити через + Вимк. + Таймер сну + Зменшити тривалість зосередження Збільшити тривалість зосередження Гучність + + + План + Редагувати + Зосередження %1$d з %2$d + Перерва + План сеансу + Зосередження + Перерва + Повторювати звідси + Завершити тут + Додати крок + Готово + Додайте принаймні один крок зосередження + Зменшити тривалість перерви + Збільшити тривалість перерви + Тип кроку + Крок %1$d: %2$s + Зосередження + Перерва diff --git a/feature/home/src/main/res/values-vi/strings.xml b/feature/home/src/main/res/values-vi/strings.xml index 06406c47..2aaed13a 100644 --- a/feature/home/src/main/res/values-vi/strings.xml +++ b/feature/home/src/main/res/values-vi/strings.xml @@ -48,8 +48,33 @@ Thời lượng tập trung Thời lượng nghỉ + + Dừng sau + Tắt + Hẹn giờ ngủ + Giảm thời lượng tập trung Tăng thời lượng tập trung Âm lượng + + + Kế hoạch + Chỉnh sửa + Tập trung %1$d/%2$d + Nghỉ + Kế hoạch phiên + Tập trung + Nghỉ + Lặp lại từ đây + Kết thúc tại đây + Thêm bước + Xong + Hãy thêm ít nhất một bước tập trung + Giảm thời lượng nghỉ + Tăng thời lượng nghỉ + Loại bước + Bước %1$d: %2$s + Tập trung + Nghỉ diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index c01e0543..b0e4334f 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ 25 min 50 min Custom + Plan @@ -35,6 +36,8 @@ Select Sound No sound selected + Focus + Break @@ -50,8 +53,30 @@ Focus Duration Break Duration + + Stop after + Off + Sleep timer + Decrease focus duration Increase focus duration Volume + + + Edit + Focus %1$d of %2$d + Break + Session plan + Focus + Break + Loop from here + End here + Add step + Done + Add at least one focus step + Decrease break duration + Increase break duration + Step type + Step %1$d: %2$s diff --git a/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/HomeScreenLayoutTest.kt b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/HomeScreenLayoutTest.kt index e3579a8b..5a882a2a 100644 --- a/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/HomeScreenLayoutTest.kt +++ b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/HomeScreenLayoutTest.kt @@ -13,13 +13,19 @@ import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.unit.dp import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.jbgsoft.ambio.core.domain.model.ActiveSound +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan import com.jbgsoft.ambio.core.domain.model.Sound import com.jbgsoft.ambio.core.domain.model.SoundGlow import com.jbgsoft.ambio.core.domain.model.SoundTheme +import com.jbgsoft.ambio.core.domain.model.TimerPreset +import com.jbgsoft.ambio.core.domain.model.TimerState +import com.jbgsoft.ambio.core.domain.session.SessionProgress import com.jbgsoft.ambio.feature.home.test.R as TestR import org.junit.Rule import org.junit.Test @@ -48,7 +54,7 @@ class HomeScreenLayoutTest { ) private val state = HomeUiState( - activeMix = listOf(ActiveSound(rain, 1f)), + focusMix = listOf(ActiveSound(rain, 1f)), availableSounds = listOf(rain), effectsEnabled = false ) @@ -209,4 +215,121 @@ class HomeScreenLayoutTest { context.getString(R.string.action_change_sound) ).assertDoesNotExist() } + + @Test + @Config(sdk = [34], qualifiers = "w360dp-h740dp") + fun `the preset row shows four segments at 360dp`() { + compose.setContent { + Box(Modifier.size(360.dp, 740.dp)) { + HomeScreen(uiState = state, onEvent = {}, onNavigateToSettings = {}, onNavigateToStats = {}) + } + } + + // 360x740 is not tall enough to hold the dial and the presets at once, so + // the row starts a couple of px below the scroll fold — it has ever since + // the transport was pinned, and it has nothing to do with how many + // segments the row holds. Scroll it in first; what this test is about is + // whether four segments still fit across 360dp of width, and a label that + // wrapped or was clipped away would fail assertIsDisplayed all the same. + compose.onNodeWithText(context.getString(R.string.preset_plan)).performScrollTo() + + compose.onNodeWithText(context.getString(R.string.preset_25_min)).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.preset_50_min)).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.preset_custom)).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.preset_plan)).assertIsDisplayed() + } + + @Test + @Config(sdk = [34], qualifiers = "w411dp-h891dp") + fun `with the plan preset the break chips give way to the summary and edit`() { + compose.setContent { + Box(Modifier.size(411.dp, 891.dp)) { + HomeScreen( + uiState = state.copy(selectedPreset = TimerPreset.PLAN), + onEvent = {}, onNavigateToSettings = {}, onNavigateToStats = {} + ) + } + } + + compose.onNodeWithText(context.getString(R.string.label_break_duration)).assertDoesNotExist() + compose.onNodeWithText("25 · 5 ✓").assertExists() + compose.onNodeWithText(context.getString(R.string.plan_edit)).assertExists() + } + + @Test + @Config(sdk = [34], qualifiers = "w411dp-h891dp") + fun `the dial names the focus step during a plan of more than two steps`() { + val plan = SessionPlan( + listOf(PlanStep.Focus(25), PlanStep.Break(5), PlanStep.Focus(25)), + repeat = false + ) + compose.setContent { + Box(Modifier.size(411.dp, 891.dp)) { + HomeScreen( + uiState = state.copy( + selectedPreset = TimerPreset.PLAN, + sessionPlan = plan, + sessionProgress = SessionProgress(plan, 2, 0), + timerState = TimerState.Running(remainingMs = 1000, totalMs = 1500) + ), + onEvent = {}, onNavigateToSettings = {}, onNavigateToStats = {} + ) + } + } + + compose.onNodeWithText(context.getString(R.string.timer_step_focus_of, 2, 2)).assertExists() + } + + @Test + @Config(sdk = [34], qualifiers = "w411dp-h891dp") + fun `a paused plan says paused, not which step it stopped on`() { + val plan = SessionPlan( + listOf(PlanStep.Focus(25), PlanStep.Break(5), PlanStep.Focus(25)), + repeat = false + ) + compose.setContent { + Box(Modifier.size(411.dp, 891.dp)) { + HomeScreen( + uiState = state.copy( + selectedPreset = TimerPreset.PLAN, + sessionPlan = plan, + sessionProgress = SessionProgress(plan, 2, 0), + timerState = TimerState.Paused(remainingMs = 1000, totalMs = 1500) + ), + onEvent = {}, onNavigateToSettings = {}, onNavigateToStats = {} + ) + } + } + + // The dial's subtitle is the only place a pause is spelled out, so the + // step label must not take it over: a plan tells you where it is while it + // runs, and that it has stopped when it has. + compose.onNodeWithText(context.getString(R.string.state_paused)).assertExists() + compose.onNodeWithText(context.getString(R.string.timer_step_focus_of, 2, 2)).assertDoesNotExist() + } + + @Test + @Config(sdk = [34], qualifiers = "w360dp-h740dp") + fun `a full-length plan does not push the edit button off the row`() { + compose.setContent { + Box(Modifier.size(360.dp, 740.dp)) { + HomeScreen( + uiState = state.copy( + selectedPreset = TimerPreset.PLAN, + sessionPlan = SessionPlan( + List(12) { if (it % 2 == 0) PlanStep.Focus(120) else PlanStep.Break(60) }, + repeat = true + ) + ), + onEvent = {}, onNavigateToSettings = {}, onNavigateToStats = {} + ) + } + } + + // The longest plan the domain allows, at the narrowest width: the summary + // has to give way to the button rather than the other way round, or the + // only way back into the editor is gone. + compose.onNodeWithText(context.getString(R.string.plan_edit)).performScrollTo() + compose.onNodeWithText(context.getString(R.string.plan_edit)).assertIsDisplayed() + } } diff --git a/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/HomeViewModelTest.kt b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/HomeViewModelTest.kt index 5610b137..ac53906e 100644 --- a/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/HomeViewModelTest.kt +++ b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/HomeViewModelTest.kt @@ -9,6 +9,10 @@ import com.jbgsoft.ambio.core.common.haptics.HapticManager import com.jbgsoft.ambio.core.common.resources.StringProvider import com.jbgsoft.ambio.core.domain.model.ActiveSound import com.jbgsoft.ambio.core.domain.model.AppMode +import com.jbgsoft.ambio.core.domain.model.MixSlot +import com.jbgsoft.ambio.core.domain.model.PlanRowChoice +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan import com.jbgsoft.ambio.core.domain.model.Sound import com.jbgsoft.ambio.core.domain.model.SoundGlow import com.jbgsoft.ambio.core.domain.model.SoundTheme @@ -19,6 +23,9 @@ import com.jbgsoft.ambio.core.domain.repository.ChimeRepository import com.jbgsoft.ambio.core.domain.repository.PreferencesRepository import com.jbgsoft.ambio.core.domain.repository.SoundRepository import com.jbgsoft.ambio.core.domain.repository.TimerRepository +import com.jbgsoft.ambio.core.domain.session.SessionEvent +import com.jbgsoft.ambio.core.domain.session.SessionProgress +import com.jbgsoft.ambio.core.domain.session.SessionRunner import com.jbgsoft.ambio.core.domain.usecase.SaveSessionUseCase import com.jbgsoft.ambio.media.AudioServiceConnection import com.jbgsoft.ambio.media.MixEntry @@ -33,6 +40,7 @@ import io.mockk.verify import io.mockk.verifyOrder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher @@ -66,6 +74,7 @@ class HomeViewModelTest { // Mocks private lateinit var soundRepository: SoundRepository private lateinit var timerRepository: TimerRepository + private lateinit var sessionRunner: SessionRunner private lateinit var preferencesRepository: PreferencesRepository private lateinit var saveSessionUseCase: SaveSessionUseCase private lateinit var hapticManager: HapticManager @@ -81,9 +90,12 @@ class HomeViewModelTest { // Flows for controlling state private lateinit var timerStateFlow: MutableStateFlow private lateinit var activeMixFlow: MutableStateFlow> + private lateinit var breakMixFlow: MutableStateFlow> private lateinit var preferencesFlow: MutableStateFlow private lateinit var isConnectedFlow: MutableStateFlow private lateinit var isPlayingFlow: MutableStateFlow + private lateinit var progressFlow: MutableStateFlow + private lateinit var eventsFlow: MutableSharedFlow // Test data private val testSound = Sound( @@ -113,6 +125,7 @@ class HomeViewModelTest { // Initialize flows timerStateFlow = MutableStateFlow(TimerState.Idle) activeMixFlow = MutableStateFlow(listOf(ActiveSound(testSound, 1.0f))) + breakMixFlow = MutableStateFlow(listOf(ActiveSound(testSoundForest, 0.5f))) preferencesFlow = MutableStateFlow(UserPreferences()) isConnectedFlow = MutableStateFlow(false) isPlayingFlow = MutableStateFlow(false) @@ -120,9 +133,10 @@ class HomeViewModelTest { // Create mocks soundRepository = mockk { every { getAllSounds() } returns testSounds - every { getActiveMix() } returns activeMixFlow - coEvery { setSoundActive(any(), any()) } just Runs - coEvery { setSoundLevel(any(), any()) } just Runs + every { getActiveMix(MixSlot.FOCUS) } returns activeMixFlow + every { getActiveMix(MixSlot.BREAK) } returns breakMixFlow + coEvery { setSoundActive(any(), any(), any()) } just Runs + coEvery { setSoundLevel(any(), any(), any()) } just Runs } timerRepository = mockk { @@ -134,12 +148,26 @@ class HomeViewModelTest { coEvery { startBreak(any()) } just Runs } + progressFlow = MutableStateFlow(null) + eventsFlow = MutableSharedFlow(extraBufferCapacity = 16) + sessionRunner = mockk { + every { progress } returns progressFlow + every { events } returns eventsFlow + coEvery { start(any()) } just Runs + coEvery { pause() } just Runs + coEvery { resume() } just Runs + coEvery { stop() } just Runs + } + preferencesRepository = mockk { every { preferences } returns preferencesFlow coEvery { setLastMix(any()) } just Runs coEvery { setVolume(any()) } just Runs coEvery { setLastTimerMinutes(any()) } just Runs + coEvery { setBreakMinutes(any()) } just Runs + coEvery { setSessionPlan(any()) } just Runs coEvery { setLastMode(any()) } just Runs + coEvery { setSleepMinutes(any()) } just Runs } saveSessionUseCase = mockk() @@ -176,6 +204,7 @@ class HomeViewModelTest { chimeRepository = mockk { every { getTimerChimeResource() } returns 100 // Dummy resource ID + every { getSuccessChimeResource() } returns 200 } } @@ -188,6 +217,7 @@ class HomeViewModelTest { return HomeViewModel( soundRepository = soundRepository, timerRepository = timerRepository, + sessionRunner = sessionRunner, preferencesRepository = preferencesRepository, saveSessionUseCase = saveSessionUseCase, hapticManager = hapticManager, @@ -373,7 +403,7 @@ class HomeViewModelTest { } @Test - fun `selectPreset saves minutes for non-custom presets`() = runTest(testDispatcher) { + fun `selectPreset saves minutes for the fixed-duration presets`() = runTest(testDispatcher) { val viewModel = createViewModel() advanceUntilIdle() @@ -394,6 +424,20 @@ class HomeViewModelTest { coVerify(exactly = 0) { preferencesRepository.setLastTimerMinutes(any()) } } + @Test + fun `selectPreset does not save minutes for the plan preset`() = runTest(testDispatcher) { + // PLAN has no duration of its own. Persisting its 0 focus minutes would land + // in customMinutes and make the next Custom plan invalid, which the runner + // refuses to start. + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.SelectPreset(TimerPreset.PLAN)) + advanceUntilIdle() + + coVerify(exactly = 0) { preferencesRepository.setLastTimerMinutes(any()) } + } + // --- Custom Minutes Tests --- @Test @@ -512,102 +556,141 @@ class HomeViewModelTest { // --- Play/Pause Tests (Timer Mode) --- @Test - fun `playPause in timer mode starts timer when idle`() = runTest(testDispatcher) { + fun `playPause in timer mode starts the preset's two-step plan when idle`() = runTest(testDispatcher) { val viewModel = createViewModel() advanceUntilIdle() viewModel.onEvent(HomeEvent.PlayPause) advanceUntilIdle() - // 25 minutes in milliseconds - coVerify { timerRepository.startTimer(25 * 60 * 1000L) } + coVerify { sessionRunner.start(SessionPlan.quick(25, 5)) } + coVerify(exactly = 0) { timerRepository.startTimer(any()) } } @Test - fun `playPause in timer mode starts audio when idle`() = runTest(testDispatcher) { + fun `the 50 minute preset starts a 50 and break plan`() = runTest(testDispatcher) { val viewModel = createViewModel() advanceUntilIdle() + viewModel.onEvent(HomeEvent.SelectPreset(TimerPreset.FOCUS_50)) + viewModel.onEvent(HomeEvent.SetBreakMinutes(10)) viewModel.onEvent(HomeEvent.PlayPause) advanceUntilIdle() - // The mix already reached the service; starting it is volume plus the fade-in. - verify { audioServiceConnection.setVolume(0.7f) } - verify { audioServiceConnection.play() } + coVerify { sessionRunner.start(SessionPlan.quick(50, 10)) } } @Test - fun `playPause in timer mode pauses when running`() = runTest(testDispatcher) { - timerStateFlow.value = TimerState.Running( - remainingMs = 1000000L, - totalMs = 1500000L - ) + fun `the custom preset uses the custom minutes`() = runTest(testDispatcher) { val viewModel = createViewModel() advanceUntilIdle() + viewModel.onEvent(HomeEvent.SelectPreset(TimerPreset.CUSTOM)) + viewModel.onEvent(HomeEvent.SetCustomMinutes(40)) viewModel.onEvent(HomeEvent.PlayPause) advanceUntilIdle() - coVerify { timerRepository.pauseTimer() } - verify { audioServiceConnection.pause() } + coVerify { sessionRunner.start(SessionPlan.quick(40, 5)) } } @Test - fun `playPause in timer mode resumes when paused`() = runTest(testDispatcher) { - timerStateFlow.value = TimerState.Paused( - remainingMs = 500000L, - totalMs = 1500000L - ) + fun `the plan preset starts the stored plan`() = runTest(testDispatcher) { + val plan = SessionPlan(listOf(PlanStep.Focus(35), PlanStep.Break(15)), repeat = true) + preferencesFlow.value = UserPreferences(sessionPlan = plan) val viewModel = createViewModel() advanceUntilIdle() + viewModel.onEvent(HomeEvent.SelectPreset(TimerPreset.PLAN)) viewModel.onEvent(HomeEvent.PlayPause) advanceUntilIdle() - coVerify { timerRepository.resumeTimer() } - verify { audioServiceConnection.play() } + coVerify { sessionRunner.start(plan) } } @Test - fun `playPause triggers heavy click haptic`() = runTest(testDispatcher) { + fun `a started step plays the mix`() = runTest(testDispatcher) { + createViewModel() + advanceUntilIdle() + + eventsFlow.emit(SessionEvent.StepStarted(PlanStep.Focus(25), 0)) + advanceUntilIdle() + + verifyOrder { + audioServiceConnection.setMix(any(), any()) + audioServiceConnection.play() + } + } + + @Test + fun `a started break step stops the audio`() = runTest(testDispatcher) { + createViewModel() + advanceUntilIdle() + + eventsFlow.emit(SessionEvent.StepStarted(PlanStep.Break(5), 1)) + advanceUntilIdle() + + verify { audioServiceConnection.stop() } + verify(exactly = 0) { audioServiceConnection.play() } + } + + @Test + fun `playPause in timer mode pauses the runner and the audio when running`() = runTest(testDispatcher) { val viewModel = createViewModel() advanceUntilIdle() + timerStateFlow.value = TimerState.Running(remainingMs = 60_000, totalMs = 60_000) + advanceUntilIdle() viewModel.onEvent(HomeEvent.PlayPause) advanceUntilIdle() - verify { hapticManager.heavyClick() } + coVerify { sessionRunner.pause() } + verify { audioServiceConnection.pause() } } @Test - fun `playPause with 50 min preset starts correct duration`() = runTest(testDispatcher) { + fun `playPause in timer mode resumes the runner and the audio when paused`() = runTest(testDispatcher) { val viewModel = createViewModel() advanceUntilIdle() - - viewModel.onEvent(HomeEvent.SelectPreset(TimerPreset.FOCUS_50)) + // A focus step is in progress: resume re-applies audibility, and with no step + // at all it would (correctly) leave the audio alone and this would prove nothing. + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 0, 0) + timerStateFlow.value = TimerState.Paused(remainingMs = 30_000, totalMs = 60_000) advanceUntilIdle() viewModel.onEvent(HomeEvent.PlayPause) advanceUntilIdle() - // 50 minutes in milliseconds - coVerify { timerRepository.startTimer(50 * 60 * 1000L) } + coVerify { sessionRunner.resume() } + verify { audioServiceConnection.play() } } @Test - fun `playPause with custom preset uses custom minutes`() = runTest(testDispatcher) { + fun `resuming during a silent break does not start audio`() = runTest(testDispatcher) { + // Resume must go through the same audibility decision as a step start, or a + // pause taken during a silent break comes back playing the focus mix. val viewModel = createViewModel() advanceUntilIdle() + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 1, 0) + timerStateFlow.value = TimerState.Paused(remainingMs = 30_000, totalMs = 60_000) + advanceUntilIdle() - viewModel.onEvent(HomeEvent.SelectPreset(TimerPreset.CUSTOM)) - viewModel.onEvent(HomeEvent.SetCustomMinutes(30)) + viewModel.onEvent(HomeEvent.PlayPause) + advanceUntilIdle() + + coVerify { sessionRunner.resume() } + verify { audioServiceConnection.stop() } + verify(exactly = 0) { audioServiceConnection.play() } + } + + @Test + fun `playPause triggers heavy click haptic`() = runTest(testDispatcher) { + val viewModel = createViewModel() advanceUntilIdle() viewModel.onEvent(HomeEvent.PlayPause) advanceUntilIdle() - // 30 minutes in milliseconds - coVerify { timerRepository.startTimer(30 * 60 * 1000L) } + verify { hapticManager.heavyClick() } } // --- Play/Pause Tests (Ambient Mode) --- @@ -641,17 +724,159 @@ class HomeViewModelTest { verify { audioServiceConnection.pause() } } + // --- Sleep timer (Ambient mode) --- + + private fun ambientViewModel(sleepMinutes: Int): HomeViewModel { + preferencesFlow.value = UserPreferences(lastMode = AppMode.AMBIENT, sleepMinutes = sleepMinutes) + val viewModel = createViewModel() + return viewModel + } + + @Test + fun `ambient play with a sleep timer set starts a countdown of that length`() = runTest(testDispatcher) { + val viewModel = ambientViewModel(sleepMinutes = 30) + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.PlayPause) + advanceUntilIdle() + + verify { audioServiceConnection.play() } + coVerify { timerRepository.startTimer(30 * 60_000L) } + } + + @Test + fun `ambient play with the sleep timer off starts no countdown`() = runTest(testDispatcher) { + val viewModel = ambientViewModel(sleepMinutes = 0) + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.PlayPause) + advanceUntilIdle() + + verify { audioServiceConnection.play() } + coVerify(exactly = 0) { timerRepository.startTimer(any()) } + } + + @Test + fun `a finished sleep timer stops the audio with no chime and no session`() = runTest(testDispatcher) { + val viewModel = ambientViewModel(sleepMinutes = 30) + advanceUntilIdle() + viewModel.onEvent(HomeEvent.PlayPause) + advanceUntilIdle() + isPlayingFlow.value = true + timerStateFlow.value = TimerState.Running(remainingMs = 1_000L, totalMs = 30 * 60_000L) + advanceUntilIdle() + + timerStateFlow.value = TimerState.Completed() + advanceUntilIdle() + + verify { audioServiceConnection.stop() } + coVerify { timerRepository.resetTimer() } + verify(exactly = 0) { chimePlayer.playChime(any()) } + verify(exactly = 0) { hapticManager.timerComplete() } + coVerify(exactly = 0) { saveSessionUseCase(any(), any(), any()) } + } + + @Test + fun `a timer completing inside a plan is left to the runner`() = runTest(testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 0, 0) + timerStateFlow.value = TimerState.Running(remainingMs = 1_000L, totalMs = 25 * 60_000L) + advanceUntilIdle() + + timerStateFlow.value = TimerState.Completed() + advanceUntilIdle() + + // The runner emits StepCompleted for this; the ViewModel must not stop + // the audio on its own or the break that follows would start from a + // second stop. + verify(exactly = 0) { audioServiceConnection.stop() } + coVerify(exactly = 0) { timerRepository.resetTimer() } + assertThat(viewModel.uiState.value.sessionProgress).isNotNull() + } + + @Test + fun `pausing ambient playback pauses the sleep countdown`() = runTest(testDispatcher) { + isPlayingFlow.value = true + timerStateFlow.value = TimerState.Running(remainingMs = 10_000L, totalMs = 30 * 60_000L) + val viewModel = ambientViewModel(sleepMinutes = 30) + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.PlayPause) + advanceUntilIdle() + + verify { audioServiceConnection.pause() } + coVerify { timerRepository.pauseTimer() } + } + + @Test + fun `resuming ambient playback resumes the paused countdown rather than restarting it`() = runTest(testDispatcher) { + timerStateFlow.value = TimerState.Paused(remainingMs = 10_000L, totalMs = 30 * 60_000L) + val viewModel = ambientViewModel(sleepMinutes = 30) + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.PlayPause) + advanceUntilIdle() + + verify { audioServiceConnection.play() } + coVerify { timerRepository.resumeTimer() } + coVerify(exactly = 0) { timerRepository.startTimer(any()) } + } + + @Test + fun `switching to timer mode drops a sleep countdown`() = runTest(testDispatcher) { + isPlayingFlow.value = true + timerStateFlow.value = TimerState.Running(remainingMs = 10_000L, totalMs = 30 * 60_000L) + val viewModel = ambientViewModel(sleepMinutes = 30) + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.SetMode(AppMode.TIMER)) + advanceUntilIdle() + + coVerify { timerRepository.resetTimer() } + } + + @Test + fun `choosing a sleep duration persists it and restarts a running countdown`() = runTest(testDispatcher) { + isPlayingFlow.value = true + timerStateFlow.value = TimerState.Running(remainingMs = 10_000L, totalMs = 15 * 60_000L) + val viewModel = ambientViewModel(sleepMinutes = 15) + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.SetSleepMinutes(45)) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.sleepMinutes).isEqualTo(45) + coVerify { preferencesRepository.setSleepMinutes(45) } + coVerify { timerRepository.startTimer(45 * 60_000L) } + } + + @Test + fun `turning the sleep timer off while playing drops the countdown and keeps playing`() = runTest(testDispatcher) { + isPlayingFlow.value = true + timerStateFlow.value = TimerState.Running(remainingMs = 10_000L, totalMs = 15 * 60_000L) + val viewModel = ambientViewModel(sleepMinutes = 15) + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.SetSleepMinutes(0)) + advanceUntilIdle() + + coVerify { timerRepository.resetTimer() } + verify(exactly = 0) { audioServiceConnection.stop() } + verify(exactly = 0) { audioServiceConnection.pause() } + } + // --- Reset Tests --- @Test - fun `reset stops timer and audio`() = runTest(testDispatcher) { + fun `reset stops the runner and the audio`() = runTest(testDispatcher) { val viewModel = createViewModel() advanceUntilIdle() viewModel.onEvent(HomeEvent.Reset) advanceUntilIdle() - coVerify { timerRepository.resetTimer() } + coVerify { sessionRunner.stop() } verify { audioServiceConnection.stop() } } @@ -666,6 +891,32 @@ class HomeViewModelTest { verify { hapticManager.click() } } + @Test + fun `switching to ambient mode stops a running plan`() = runTest(testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 0, 0) + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.SetMode(AppMode.AMBIENT)) + advanceUntilIdle() + + coVerify { sessionRunner.stop() } + } + + @Test + fun `switching to ambient mode with no plan in progress stops nothing`() = runTest(testDispatcher) { + // progressFlow stays null: there is no plan to abandon, so the runner is + // left alone rather than being told to stop on every mode toggle. + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.SetMode(AppMode.AMBIENT)) + advanceUntilIdle() + + coVerify(exactly = 0) { sessionRunner.stop() } + } + // --- Sound Picker Tests --- @Test @@ -712,97 +963,82 @@ class HomeViewModelTest { verify { hapticManager.click() } } - // --- Timer Completion Tests --- + // --- Session Event Tests --- @Test - fun `timer completion stops audio`() = runTest(testDispatcher) { - val viewModel = createViewModel() + fun `a completed focus step stops audio, chimes, buzzes and saves the session`() = runTest(testDispatcher) { + createViewModel() advanceUntilIdle() - // Simulate timer completion - timerStateFlow.value = TimerState.Completed(wasBreak = false) + eventsFlow.emit(SessionEvent.StepCompleted(PlanStep.Focus(25), 0)) advanceUntilIdle() verify { audioServiceConnection.stop() } + verify { chimePlayer.playChime(100) } + verify { hapticManager.timerComplete() } + coVerify { saveSessionUseCase(soundId = "rain", durationMinutes = 25, wasCompleted = true) } } @Test - fun `timer completion plays chime`() = runTest(testDispatcher) { - val viewModel = createViewModel() + fun `a completed break step chimes but saves nothing`() = runTest(testDispatcher) { + createViewModel() advanceUntilIdle() - // Simulate timer completion - timerStateFlow.value = TimerState.Completed(wasBreak = false) + eventsFlow.emit(SessionEvent.StepCompleted(PlanStep.Break(5), 1)) advanceUntilIdle() verify { chimePlayer.playChime(100) } + coVerify(exactly = 0) { saveSessionUseCase(any(), any(), any()) } } @Test - fun `timer completion triggers haptic`() = runTest(testDispatcher) { - val viewModel = createViewModel() + fun `a completed plan plays the success chime instead of the step chime`() = runTest(testDispatcher) { + createViewModel() advanceUntilIdle() - // Simulate timer completion - timerStateFlow.value = TimerState.Completed(wasBreak = false) + eventsFlow.emit(SessionEvent.PlanCompleted(PlanStep.Break(5))) advanceUntilIdle() + verify { audioServiceConnection.stop() } + verify { chimePlayer.playChime(200) } + verify(exactly = 0) { chimePlayer.playChime(100) } verify { hapticManager.timerComplete() } } @Test - fun `timer completion saves session`() = runTest(testDispatcher) { - val viewModel = createViewModel() - advanceUntilIdle() - - // Simulate timer completion - timerStateFlow.value = TimerState.Completed(wasBreak = false) - advanceUntilIdle() - - coVerify { saveSessionUseCase.invoke("rain", 25, true) } - } - - @Test - fun `timer completion starts break for 25 min preset`() = runTest(testDispatcher) { - val viewModel = createViewModel() + fun `a plan that ends on a focus step still saves that session`() = runTest(testDispatcher) { + createViewModel() advanceUntilIdle() - // Simulate timer completion with 25 min preset (default) - timerStateFlow.value = TimerState.Completed(wasBreak = false) + eventsFlow.emit(SessionEvent.PlanCompleted(PlanStep.Focus(35))) advanceUntilIdle() - // 5 minute break - coVerify { timerRepository.startBreak(5 * 60 * 1000L) } + coVerify { saveSessionUseCase(soundId = "rain", durationMinutes = 35, wasCompleted = true) } } @Test - fun `timer completion starts break with configured break minutes`() = runTest(testDispatcher) { - val viewModel = createViewModel() - advanceUntilIdle() - - viewModel.onEvent(HomeEvent.SelectPreset(TimerPreset.FOCUS_50)) + fun `no chime plays when the chime is disabled`() = runTest(testDispatcher) { + preferencesFlow.value = UserPreferences(chimeEnabled = false) + createViewModel() advanceUntilIdle() - // Simulate timer completion (uses breakMinutes which defaults to 5) - timerStateFlow.value = TimerState.Completed(wasBreak = false) + eventsFlow.emit(SessionEvent.StepCompleted(PlanStep.Focus(25), 0)) + eventsFlow.emit(SessionEvent.PlanCompleted(PlanStep.Break(5))) advanceUntilIdle() - // Default 5 minute break - coVerify { timerRepository.startBreak(5 * 60 * 1000L) } + verify(exactly = 0) { chimePlayer.playChime(any()) } } @Test - fun `break completion resets timer instead of starting another break`() = runTest(testDispatcher) { + fun `session progress lands in state`() = runTest(testDispatcher) { val viewModel = createViewModel() advanceUntilIdle() + val progress = SessionProgress(SessionPlan.DEFAULT, 1, 0) - // Simulate break completion (wasBreak = true) - timerStateFlow.value = TimerState.Completed(wasBreak = true) + progressFlow.value = progress advanceUntilIdle() - // Should reset timer, not start another break - coVerify { timerRepository.resetTimer() } - coVerify(exactly = 0) { timerRepository.startBreak(any()) } + assertThat(viewModel.uiState.value.sessionProgress).isEqualTo(progress) } // --- Audio Service Connection State Tests --- @@ -972,7 +1208,7 @@ class HomeViewModelTest { viewModel.onEvent(HomeEvent.ToggleSound(testSoundForest)) advanceUntilIdle() - coVerify { soundRepository.setSoundActive("forest", true) } + coVerify { soundRepository.setSoundActive("forest", true, MixSlot.FOCUS) } // The service hears about it through getActiveMix, never from the event: two // writers would be two sources of truth that can disagree. verify(exactly = 0) { audioServiceConnection.setMix(any(), any()) } @@ -988,7 +1224,7 @@ class HomeViewModelTest { viewModel.onEvent(HomeEvent.ToggleSound(testSoundForest)) advanceUntilIdle() - coVerify { soundRepository.setSoundActive("forest", false) } + coVerify { soundRepository.setSoundActive("forest", false, MixSlot.FOCUS) } verify(exactly = 0) { audioServiceConnection.setMix(any(), any()) } } @@ -1003,7 +1239,7 @@ class HomeViewModelTest { viewModel.onEvent(HomeEvent.ToggleSound(testSound)) advanceUntilIdle() - coVerify(exactly = 0) { soundRepository.setSoundActive("rain", false) } + coVerify(exactly = 0) { soundRepository.setSoundActive("rain", false, MixSlot.FOCUS) } verify(exactly = 0) { audioServiceConnection.setMix(any(), any()) } } @@ -1039,7 +1275,7 @@ class HomeViewModelTest { verify { audioServiceConnection.setMix(listOf(MixEntry("rain", 1, 0.3f)), any()) } // But no write: the repository's setSoundLevel is a DataStore edit behind a // mutex, and a drag would queue one per frame. - coVerify(exactly = 0) { soundRepository.setSoundLevel(any(), any()) } + coVerify(exactly = 0) { soundRepository.setSoundLevel(any(), any(), any()) } } @Test @@ -1097,7 +1333,7 @@ class HomeViewModelTest { viewModel.onEvent(HomeEvent.SoundLevelChangeFinished("rain")) advanceUntilIdle() - coVerify(exactly = 1) { soundRepository.setSoundLevel("rain", 0.6f) } + coVerify(exactly = 1) { soundRepository.setSoundLevel("rain", 0.6f, MixSlot.FOCUS) } } @Test @@ -1167,6 +1403,10 @@ class HomeViewModelTest { viewModel.onEvent(HomeEvent.PlayPause) advanceUntilIdle() + // The runner, not playPause, starts the audio now: the step it reports is + // what re-declares the mix. + eventsFlow.emit(SessionEvent.StepStarted(PlanStep.Focus(25), 0)) + advanceUntilIdle() verifyOrder { audioServiceConnection.setMix(listOf(MixEntry("rain", 1, 1f)), any()) @@ -1180,7 +1420,7 @@ class HomeViewModelTest { val viewModel = createViewModel() advanceUntilIdle() - timerStateFlow.value = TimerState.Completed(wasBreak = false) + eventsFlow.emit(SessionEvent.StepCompleted(PlanStep.Focus(25), 0)) advanceUntilIdle() coVerify { @@ -1191,4 +1431,374 @@ class HomeViewModelTest { ) } } + + // --- Break mix tests --- + // + // Two stored mixes, two independent slots: which one sounds is decided by the step + // and the break-sound toggle, which one the picker edits is decided by the user. + + private fun breakStarted() = SessionEvent.StepStarted(PlanStep.Break(5), 1) + + @Test + fun `a break with break sound off stops the audio`() = runTest(testDispatcher) { + createViewModel() + advanceUntilIdle() + + eventsFlow.emit(breakStarted()) + advanceUntilIdle() + + verify { audioServiceConnection.stop() } + verify(exactly = 0) { audioServiceConnection.play() } + } + + @Test + fun `a break with break sound on plays the break mix`() = runTest(testDispatcher) { + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + val viewModel = createViewModel() + advanceUntilIdle() + clearMocks(audioServiceConnection, answers = false) + + eventsFlow.emit(breakStarted()) + advanceUntilIdle() + + verify { audioServiceConnection.setMix(listOf(MixEntry("forest", 3, 0.5f)), any()) } + verify { audioServiceConnection.play() } + assertThat(viewModel.uiState.value.audibleSlot).isEqualTo(MixSlot.BREAK) + assertThat(viewModel.uiState.value.activeMix.map { it.sound.id }).containsExactly("forest") + } + + @Test + fun `the next focus step returns to the focus mix`() = runTest(testDispatcher) { + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + val viewModel = createViewModel() + advanceUntilIdle() + eventsFlow.emit(breakStarted()) + advanceUntilIdle() + clearMocks(audioServiceConnection, answers = false) + + eventsFlow.emit(SessionEvent.StepStarted(PlanStep.Focus(25), 2)) + advanceUntilIdle() + + verify { audioServiceConnection.setMix(listOf(MixEntry("rain", 1, 1.0f)), any()) } + assertThat(viewModel.uiState.value.audibleSlot).isEqualTo(MixSlot.FOCUS) + } + + @Test + fun `enabling break sound during a break starts the break mix`() = runTest(testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + timerStateFlow.value = TimerState.Running(remainingMs = 300_000, totalMs = 300_000) + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 1, 0) + eventsFlow.emit(breakStarted()) + advanceUntilIdle() + clearMocks(audioServiceConnection, answers = false) + + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + advanceUntilIdle() + + verify { audioServiceConnection.play() } + assertThat(viewModel.uiState.value.audibleSlot).isEqualTo(MixSlot.BREAK) + } + + @Test + fun `disabling break sound during a break stops the audio`() = runTest(testDispatcher) { + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + val viewModel = createViewModel() + advanceUntilIdle() + timerStateFlow.value = TimerState.Running(remainingMs = 300_000, totalMs = 300_000) + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 1, 0) + eventsFlow.emit(breakStarted()) + advanceUntilIdle() + clearMocks(audioServiceConnection, answers = false) + + preferencesFlow.value = UserPreferences(breakSoundEnabled = false) + advanceUntilIdle() + + verify { audioServiceConnection.stop() } + assertThat(viewModel.uiState.value.audibleSlot).isEqualTo(MixSlot.FOCUS) + } + + @Test + fun `the picker edits the slot it is set to`() = runTest(testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.SetPickerSlot(MixSlot.BREAK)) + viewModel.onEvent(HomeEvent.ToggleSound(testSound)) + advanceUntilIdle() + + coVerify { soundRepository.setSoundActive("rain", true, MixSlot.BREAK) } + assertThat(viewModel.uiState.value.pickerMix.map { it.sound.id }).containsExactly("forest") + } + + @Test + fun `dragging a level on the slot that is not audible does not reach the service`() = runTest(testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onEvent(HomeEvent.SetPickerSlot(MixSlot.BREAK)) + clearMocks(audioServiceConnection, answers = false) + + viewModel.onEvent(HomeEvent.SetSoundLevel("forest", 0.2f)) + + verify(exactly = 0) { audioServiceConnection.setMix(any(), any()) } + assertThat(viewModel.uiState.value.breakMix.single().level).isEqualTo(0.2f) + assertThat(viewModel.uiState.value.focusMix.single().level).isEqualTo(1.0f) + } + + @Test + fun `letting go of a break level persists it on the break slot`() = runTest(testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onEvent(HomeEvent.SetPickerSlot(MixSlot.BREAK)) + viewModel.onEvent(HomeEvent.SetSoundLevel("forest", 0.2f)) + + viewModel.onEvent(HomeEvent.SoundLevelChangeFinished("forest")) + advanceUntilIdle() + + coVerify { soundRepository.setSoundLevel("forest", 0.2f, MixSlot.BREAK) } + } + + @Test + fun `a break mix emission while a break sounds is pushed to the service`() = runTest(testDispatcher) { + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + createViewModel() + advanceUntilIdle() + eventsFlow.emit(breakStarted()) + advanceUntilIdle() + clearMocks(audioServiceConnection, answers = false) + + breakMixFlow.value = listOf(ActiveSound(testSoundForest, 0.9f)) + advanceUntilIdle() + + verify { audioServiceConnection.setMix(listOf(MixEntry("forest", 3, 0.9f)), any()) } + } + + @Test + fun `a break mix emission while focus sounds is not pushed`() = runTest(testDispatcher) { + createViewModel() + advanceUntilIdle() + clearMocks(audioServiceConnection, answers = false) + + breakMixFlow.value = listOf(ActiveSound(testSoundForest, 0.9f)) + advanceUntilIdle() + + verify(exactly = 0) { audioServiceConnection.setMix(any(), any()) } + } + + @Test + fun `flipping break sound while paused does not start audio`() = runTest(testDispatcher) { + // A toggle flip is not a transport command. Paused, the only thing that may move + // is which slot would sound; the audio stays where the user left it. + val viewModel = createViewModel() + advanceUntilIdle() + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 0, 0) + timerStateFlow.value = TimerState.Paused(remainingMs = 30_000, totalMs = 60_000) + advanceUntilIdle() + clearMocks(audioServiceConnection, answers = false) + + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + advanceUntilIdle() + + verify(exactly = 0) { audioServiceConnection.play() } + verify(exactly = 0) { audioServiceConnection.stop() } + } + + @Test + fun `flipping break sound while paused on a break still moves the audible slot`() = runTest(testDispatcher) { + // The slot has to follow so the gradient and mix bar show what resuming would + // play; the audio itself waits for the resume. + val viewModel = createViewModel() + advanceUntilIdle() + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 1, 0) + timerStateFlow.value = TimerState.Paused(remainingMs = 30_000, totalMs = 60_000) + advanceUntilIdle() + clearMocks(audioServiceConnection, answers = false) + + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.audibleSlot).isEqualTo(MixSlot.BREAK) + verify(exactly = 0) { audioServiceConnection.play() } + } + + @Test + fun `the audible slot returns to focus when the plan ends`() = runTest(testDispatcher) { + // Left on BREAK, an idle screen would wear the break palette and, worse, the + // next focus mix emission would be dropped as "not the audible slot". + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + val viewModel = createViewModel() + advanceUntilIdle() + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 1, 0) + eventsFlow.emit(breakStarted()) + advanceUntilIdle() + assertThat(viewModel.uiState.value.audibleSlot).isEqualTo(MixSlot.BREAK) + clearMocks(audioServiceConnection, answers = false) + + progressFlow.value = null + advanceUntilIdle() + + assertThat(viewModel.uiState.value.audibleSlot).isEqualTo(MixSlot.FOCUS) + // A new level, or the StateFlow would hold an equal value and never re-emit. + activeMixFlow.value = listOf(ActiveSound(testSound, 0.9f)) + advanceUntilIdle() + + verify { audioServiceConnection.setMix(listOf(MixEntry("rain", 1, 0.9f)), any()) } + } + + @Test + fun `switching to ambient during a sounding break stops the audio`() = runTest(testDispatcher) { + // Abandoning a plan mid-break is the one path with no step event behind it: + // the runner just stops. Without an explicit stop the service keeps playing + // the break tracks under a screen that has already gone back to the focus mix. + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + val viewModel = createViewModel() + advanceUntilIdle() + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 1, 0) + eventsFlow.emit(breakStarted()) + advanceUntilIdle() + assertThat(viewModel.uiState.value.audibleSlot).isEqualTo(MixSlot.BREAK) + clearMocks(audioServiceConnection, answers = false) + + viewModel.onEvent(HomeEvent.SetMode(AppMode.AMBIENT)) + advanceUntilIdle() + // The runner is mocked, so its progress is nulled here the way a real stop would. + progressFlow.value = null + advanceUntilIdle() + + verify { audioServiceConnection.stop() } + assertThat(viewModel.uiState.value.audibleSlot).isEqualTo(MixSlot.FOCUS) + } + + @Test + fun `a restored plan in progress recomputes the audible slot without touching audio`() = + runTest(testDispatcher) { + // A ViewModel recreated over a running plan never sees the StepStarted that + // opened the break: progress is where it learns the step. The slot has to + // follow so the gradient matches what the service is already playing — and + // only the slot, because the audio is not this ViewModel's to restart. + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 1, 0) + + val viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.audibleSlot).isEqualTo(MixSlot.BREAK) + verify(exactly = 0) { audioServiceConnection.play() } + } + + @Test + fun `a break arriving only through progress still moves the audible slot`() = runTest(testDispatcher) { + // The same restore, with the toggle already settled before the step arrives: + // nothing flips breakSoundEnabled here, so the slot can only come from progress. + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + val viewModel = createViewModel() + advanceUntilIdle() + clearMocks(audioServiceConnection, answers = false) + + progressFlow.value = SessionProgress(SessionPlan.DEFAULT, 1, 0) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.audibleSlot).isEqualTo(MixSlot.BREAK) + verify(exactly = 0) { audioServiceConnection.play() } + verify(exactly = 0) { audioServiceConnection.stop() } + } + + @Test + fun `turning break sound off returns the picker to the focus slot`() = runTest(testDispatcher) { + // The slot switch is only drawn while break sound is on. Left on BREAK, the + // picker would edit a mix that can no longer play, with nothing on screen to + // say so and no way back. + preferencesFlow.value = UserPreferences(breakSoundEnabled = true) + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onEvent(HomeEvent.SetPickerSlot(MixSlot.BREAK)) + advanceUntilIdle() + assertThat(viewModel.uiState.value.pickerSlot).isEqualTo(MixSlot.BREAK) + + preferencesFlow.value = UserPreferences(breakSoundEnabled = false) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.pickerSlot).isEqualTo(MixSlot.FOCUS) + } + + // --- Plan Editor Tests --- + + @Test + fun `opening the editor copies the stored plan into the draft`() = runTest(testDispatcher) { + val plan = SessionPlan(listOf(PlanStep.Focus(35), PlanStep.Break(15)), repeat = true) + preferencesFlow.value = UserPreferences(sessionPlan = plan) + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.ShowPlanEditor) + + assertThat(viewModel.uiState.value.planDraft).isEqualTo(plan) + } + + @Test + fun `closing the editor drops the draft without saving`() = runTest(testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onEvent(HomeEvent.ShowPlanEditor) + viewModel.onEvent(HomeEvent.AddPlanStep) + + viewModel.onEvent(HomeEvent.HidePlanEditor) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.planDraft).isNull() + coVerify(exactly = 0) { preferencesRepository.setSessionPlan(any()) } + } + + @Test + fun `row choice, minutes and add step edit the draft`() = runTest(testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onEvent(HomeEvent.ShowPlanEditor) + + viewModel.onEvent(HomeEvent.AddPlanStep) // F25 B5 F25 + viewModel.onEvent(HomeEvent.SetPlanStepMinutes(2, 35)) // F25 B5 F35 + viewModel.onEvent(HomeEvent.SetPlanRowChoice(3, PlanRowChoice.LOOP)) + + assertThat(viewModel.uiState.value.planDraft).isEqualTo( + SessionPlan(listOf(PlanStep.Focus(25), PlanStep.Break(5), PlanStep.Focus(35)), repeat = true) + ) + } + + @Test + fun `saving a valid draft persists it and closes the editor`() = runTest(testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onEvent(HomeEvent.ShowPlanEditor) + viewModel.onEvent(HomeEvent.SetPlanRowChoice(2, PlanRowChoice.LOOP)) + + viewModel.onEvent(HomeEvent.SavePlan) + advanceUntilIdle() + + coVerify { preferencesRepository.setSessionPlan(SessionPlan.DEFAULT.copy(repeat = true)) } + assertThat(viewModel.uiState.value.planDraft).isNull() + } + + @Test + fun `saving an invalid draft does nothing`() = runTest(testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onEvent(HomeEvent.ShowPlanEditor) + viewModel.onEvent(HomeEvent.SetPlanRowChoice(0, PlanRowChoice.END)) // no steps left + + viewModel.onEvent(HomeEvent.SavePlan) + advanceUntilIdle() + + coVerify(exactly = 0) { preferencesRepository.setSessionPlan(any()) } + assertThat(viewModel.uiState.value.planDraft).isNotNull() + } + + @Test + fun `edits while the editor is closed are ignored`() = runTest(testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEvent(HomeEvent.AddPlanStep) + + assertThat(viewModel.uiState.value.planDraft).isNull() + } } diff --git a/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/SleepTimerUiTest.kt b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/SleepTimerUiTest.kt new file mode 100644 index 00000000..59def083 --- /dev/null +++ b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/SleepTimerUiTest.kt @@ -0,0 +1,121 @@ +package com.jbgsoft.ambio.feature.home + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.jbgsoft.ambio.core.domain.model.AppMode +import com.jbgsoft.ambio.core.domain.model.TimerState +import com.jbgsoft.ambio.feature.home.components.TimerDisplay +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Ambient mode's sleep timer, as the screen shows it: the dial counts it down + * where it used to show the infinity sign, and the row under the dial offers + * the durations where Timer mode offers the focus presets. + */ +// SDK pinned to 34: Robolectric 4.16.1 supports at most 36, and its API 36 +// shadow needs Java 21 while this toolchain is 17. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SleepTimerUiTest { + + @get:Rule + val compose = createComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun `the dial counts the sleep timer down in ambient mode`() { + compose.setContent { + TimerDisplay( + timerState = TimerState.Running(remainingMs = 12 * 60_000L + 5_000L, totalMs = 30 * 60_000L), + mode = AppMode.AMBIENT, + isPlaying = true, + selectedMinutes = 25 + ) + } + + compose.onNodeWithText("12:05").assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.state_sleep_timer)).assertIsDisplayed() + compose.onNodeWithText("∞").assertDoesNotExist() + } + + @Test + fun `a paused sleep timer says so on the dial`() { + compose.setContent { + TimerDisplay( + timerState = TimerState.Paused(remainingMs = 9 * 60_000L, totalMs = 30 * 60_000L), + mode = AppMode.AMBIENT, + isPlaying = false, + selectedMinutes = 25 + ) + } + + compose.onNodeWithText("09:00").assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.state_paused)).assertIsDisplayed() + } + + @Test + fun `the dial keeps the infinity sign in ambient mode with no sleep timer running`() { + compose.setContent { + TimerDisplay( + timerState = TimerState.Idle, + mode = AppMode.AMBIENT, + isPlaying = true, + selectedMinutes = 25 + ) + } + + compose.onNodeWithText("∞").assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.state_ambient_mode)).assertIsDisplayed() + } + + // The phone window the transport test uses: wide enough for the chip row, + // tall enough that the row under the dial is on screen without scrolling. + @Test + @Config(sdk = [34], qualifiers = "w411dp-h866dp") + fun `ambient mode offers the sleep durations where timer mode offers the presets`() { + val events = mutableListOf() + compose.setContent { + HomeScreen( + uiState = HomeUiState(mode = AppMode.AMBIENT, sleepMinutes = 15, effectsEnabled = false), + onEvent = { events += it }, + onNavigateToSettings = {}, + onNavigateToStats = {} + ) + } + + compose.onNodeWithText(context.getString(R.string.label_sleep_timer)).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.label_focus_duration)).assertDoesNotExist() + + compose.onNodeWithText("30 min").performClick() + assertThat(events).contains(HomeEvent.SetSleepMinutes(30)) + + compose.onNodeWithText(context.getString(R.string.sleep_timer_off)).performClick() + assertThat(events).contains(HomeEvent.SetSleepMinutes(0)) + } + + @Test + @Config(sdk = [34], qualifiers = "w411dp-h866dp") + fun `timer mode does not offer the sleep durations`() { + compose.setContent { + HomeScreen( + uiState = HomeUiState(mode = AppMode.TIMER, effectsEnabled = false), + onEvent = {}, + onNavigateToSettings = {}, + onNavigateToStats = {} + ) + } + + compose.onNodeWithText(context.getString(R.string.label_sleep_timer)).assertDoesNotExist() + compose.onNodeWithText(context.getString(R.string.label_focus_duration)).assertIsDisplayed() + } +} diff --git a/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/components/PlanEditorTest.kt b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/components/PlanEditorTest.kt new file mode 100644 index 00000000..ac332df5 --- /dev/null +++ b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/components/PlanEditorTest.kt @@ -0,0 +1,140 @@ +package com.jbgsoft.ambio.feature.home.components + +import android.content.Context +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.jbgsoft.ambio.core.domain.model.PlanRowChoice +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan +import com.jbgsoft.ambio.feature.home.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PlanEditorTest { + + @get:Rule + val compose = createComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val six = SessionPlan( + listOf( + PlanStep.Focus(25), PlanStep.Break(5), + PlanStep.Focus(25), PlanStep.Break(5), + PlanStep.Focus(35), PlanStep.Break(15) + ), + repeat = false + ) + + @Test + fun `one dropdown per step plus the terminator row`() { + compose.setContent { + PlanEditorContent(draft = six, onRowChoice = { _, _ -> }, onStepMinutes = { _, _ -> }, onAddStep = {}, onSave = {}) + } + + compose.onAllNodesWithContentDescription(context.getString(R.string.a11y_plan_row_choice)) + .assertCountEquals(7) + } + + @Test + fun `a row's dropdown states its number and current type`() { + compose.setContent { + PlanEditorContent(draft = six, onRowChoice = { _, _ -> }, onStepMinutes = { _, _ -> }, onAddStep = {}, onSave = {}) + } + + val expected = context.getString( + R.string.a11y_plan_row_state, + 2, + context.getString(R.string.plan_step_pause) + ) + compose.onAllNodesWithContentDescription(context.getString(R.string.a11y_plan_row_choice))[1] + .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, expected)) + } + + @Test + fun `choosing loop on a row reports the row and the choice`() { + var reported: Pair? = null + compose.setContent { + PlanEditorContent( + draft = six, + onRowChoice = { index, choice -> reported = index to choice }, + onStepMinutes = { _, _ -> }, onAddStep = {}, onSave = {} + ) + } + + compose.onAllNodesWithContentDescription(context.getString(R.string.a11y_plan_row_choice))[2] + .performClick() + compose.onNodeWithText(context.getString(R.string.plan_step_loop)).performClick() + + assertThat(reported).isEqualTo(2 to PlanRowChoice.LOOP) + } + + @Test + fun `done is disabled while the draft has no focus step`() { + compose.setContent { + PlanEditorContent( + draft = SessionPlan(listOf(PlanStep.Break(5)), repeat = false), + onRowChoice = { _, _ -> }, onStepMinutes = { _, _ -> }, onAddStep = {}, onSave = {} + ) + } + + compose.onNodeWithText(context.getString(R.string.plan_done)).assertIsNotEnabled() + compose.onNodeWithText(context.getString(R.string.plan_needs_focus)).assertExists() + } + + @Test + fun `done is enabled for a valid draft and reports save`() { + var saved = false + compose.setContent { + PlanEditorContent(draft = six, onRowChoice = { _, _ -> }, onStepMinutes = { _, _ -> }, onAddStep = {}, onSave = { saved = true }) + } + + compose.onNodeWithText(context.getString(R.string.plan_done)).assertIsEnabled().performClick() + + assertThat(saved).isTrue() + } + + @Test + fun `add step is disabled at twelve steps`() { + compose.setContent { + PlanEditorContent( + draft = SessionPlan(List(12) { PlanStep.Focus(1) }, repeat = false), + onRowChoice = { _, _ -> }, onStepMinutes = { _, _ -> }, onAddStep = {}, onSave = {} + ) + } + + compose.onNodeWithText(context.getString(R.string.plan_add_step)).assertIsNotEnabled() + } + + @Test + fun `the plus on a break row reports that row's new minutes`() { + var reported: Pair? = null + compose.setContent { + PlanEditorContent( + draft = six, onRowChoice = { _, _ -> }, + onStepMinutes = { index, minutes -> reported = index to minutes }, + onAddStep = {}, onSave = {} + ) + } + + compose.onAllNodesWithContentDescription(context.getString(R.string.a11y_increase_pause))[0] + .performClick() + + assertThat(reported).isEqualTo(1 to 10) + } +} diff --git a/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/components/PlanSummaryTest.kt b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/components/PlanSummaryTest.kt new file mode 100644 index 00000000..a7ab2291 --- /dev/null +++ b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/components/PlanSummaryTest.kt @@ -0,0 +1,24 @@ +package com.jbgsoft.ambio.feature.home.components + +import com.google.common.truth.Truth.assertThat +import com.jbgsoft.ambio.core.domain.model.PlanStep +import com.jbgsoft.ambio.core.domain.model.SessionPlan +import org.junit.Test + +class PlanSummaryTest { + + @Test + fun `a looping plan lists its minutes and ends with the loop glyph`() { + val plan = SessionPlan( + listOf(PlanStep.Focus(25), PlanStep.Break(5), PlanStep.Focus(35), PlanStep.Break(15)), + repeat = true + ) + + assertThat(planSummaryText(plan)).isEqualTo("25 · 5 · 35 · 15 ↻") + } + + @Test + fun `an ending plan ends with the check glyph`() { + assertThat(planSummaryText(SessionPlan.DEFAULT)).isEqualTo("25 · 5 ✓") + } +} diff --git a/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/components/SoundBottomSheetTest.kt b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/components/SoundBottomSheetTest.kt index f425613a..b780ca6f 100644 --- a/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/components/SoundBottomSheetTest.kt +++ b/feature/home/src/test/java/com/jbgsoft/ambio/feature/home/components/SoundBottomSheetTest.kt @@ -11,11 +11,16 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat import com.jbgsoft.ambio.core.domain.model.ActiveSound +import com.jbgsoft.ambio.core.domain.model.MixSlot import com.jbgsoft.ambio.core.domain.model.Sound import com.jbgsoft.ambio.core.domain.model.SoundGlow import com.jbgsoft.ambio.core.domain.model.SoundTheme +import com.jbgsoft.ambio.feature.home.R import com.jbgsoft.ambio.feature.home.test.R as TestR import org.junit.Rule import org.junit.Test @@ -115,4 +120,39 @@ class SoundBottomSheetTest { compose.onNodeWithContentDescription(limitLabel).assertIsNotEnabled() } + + @Test + fun `the slot switch is hidden while break sound is off`() { + val sounds = listOf(sound("rain", TestR.string.test_sound_name, SoundTheme.RAIN, Icons.Default.WaterDrop)) + compose.setContent { + SoundPickerContent( + sounds = sounds, + activeMix = sounds.map { ActiveSound(it, 1f) }, + onToggleSound = {}, onLevelChange = { _, _ -> }, onLevelChangeFinished = {}, + showSlotSwitch = false + ) + } + + compose.onNodeWithText(context.getString(R.string.picker_slot_break)).assertDoesNotExist() + } + + @Test + fun `the slot switch reports the tapped slot`() { + var chosen: MixSlot? = null + val sounds = listOf(sound("rain", TestR.string.test_sound_name, SoundTheme.RAIN, Icons.Default.WaterDrop)) + compose.setContent { + SoundPickerContent( + sounds = sounds, + activeMix = sounds.map { ActiveSound(it, 1f) }, + onToggleSound = {}, onLevelChange = { _, _ -> }, onLevelChangeFinished = {}, + slot = MixSlot.FOCUS, + showSlotSwitch = true, + onSlotChange = { chosen = it } + ) + } + + compose.onNodeWithText(context.getString(R.string.picker_slot_break)).performClick() + + assertThat(chosen).isEqualTo(MixSlot.BREAK) + } } diff --git a/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsScreen.kt index 3dd8c59a..fa80488c 100644 --- a/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsScreen.kt @@ -38,6 +38,7 @@ fun SettingsScreen( uiState = uiState, onHapticsChanged = viewModel::onHapticsChanged, onChimeChanged = viewModel::onChimeChanged, + onBreakSoundChanged = viewModel::onBreakSoundChanged, onEffectsChanged = viewModel::onEffectsChanged, onNavigateBack = onNavigateBack ) @@ -48,6 +49,7 @@ fun SettingsScreen( uiState: SettingsUiState, onHapticsChanged: (Boolean) -> Unit, onChimeChanged: (Boolean) -> Unit, + onBreakSoundChanged: (Boolean) -> Unit, onEffectsChanged: (Boolean) -> Unit, onNavigateBack: () -> Unit ) { @@ -94,6 +96,12 @@ fun SettingsScreen( checked = uiState.chimeEnabled, onCheckedChange = onChimeChanged ) + SettingRow( + title = stringResource(R.string.settings_break_sound), + summary = stringResource(R.string.settings_break_sound_summary), + checked = uiState.breakSoundEnabled, + onCheckedChange = onBreakSoundChanged + ) SettingRow( title = stringResource(R.string.settings_effects), summary = stringResource(R.string.settings_effects_summary), diff --git a/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsUiState.kt b/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsUiState.kt index 8cc9cd39..64409bb3 100644 --- a/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsUiState.kt +++ b/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsUiState.kt @@ -3,5 +3,6 @@ package com.jbgsoft.ambio.feature.settings data class SettingsUiState( val hapticsEnabled: Boolean = true, val chimeEnabled: Boolean = true, - val effectsEnabled: Boolean = true + val effectsEnabled: Boolean = true, + val breakSoundEnabled: Boolean = false ) diff --git a/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsViewModel.kt index 197ef1ff..a0d65d9d 100644 --- a/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/jbgsoft/ambio/feature/settings/SettingsViewModel.kt @@ -24,7 +24,8 @@ class SettingsViewModel @Inject constructor( _uiState.value = SettingsUiState( hapticsEnabled = prefs.hapticsEnabled, chimeEnabled = prefs.chimeEnabled, - effectsEnabled = prefs.effectsEnabled + effectsEnabled = prefs.effectsEnabled, + breakSoundEnabled = prefs.breakSoundEnabled ) } } @@ -41,4 +42,8 @@ class SettingsViewModel @Inject constructor( fun onEffectsChanged(enabled: Boolean) { viewModelScope.launch { preferencesRepository.setEffectsEnabled(enabled) } } + + fun onBreakSoundChanged(enabled: Boolean) { + viewModelScope.launch { preferencesRepository.setBreakSoundEnabled(enabled) } + } } diff --git a/feature/settings/src/main/res/values-af/strings.xml b/feature/settings/src/main/res/values-af/strings.xml index e71793e8..42db28fa 100644 --- a/feature/settings/src/main/res/values-af/strings.xml +++ b/feature/settings/src/main/res/values-af/strings.xml @@ -10,4 +10,8 @@ Speel \'n klank wanneer \'n fokussessie eindig Omgewingseffekte Geanimeerde deeltjies agter die tydhouer + + + Klank tydens rus + Speel \'n aparte mengsel terwyl jy rus diff --git a/feature/settings/src/main/res/values-ar/strings.xml b/feature/settings/src/main/res/values-ar/strings.xml index 616ab6f3..2f5a15df 100644 --- a/feature/settings/src/main/res/values-ar/strings.xml +++ b/feature/settings/src/main/res/values-ar/strings.xml @@ -10,4 +10,8 @@ تشغيل صوت عند انتهاء جلسة تركيز التأثيرات المحيطة جسيمات متحركة خلف المؤقت + + + الصوت أثناء الاستراحة + تشغيل مزيج منفصل أثناء الراحة diff --git a/feature/settings/src/main/res/values-b+bn+BD/strings.xml b/feature/settings/src/main/res/values-b+bn+BD/strings.xml index 201bc6c4..45ac5ce5 100644 --- a/feature/settings/src/main/res/values-b+bn+BD/strings.xml +++ b/feature/settings/src/main/res/values-b+bn+BD/strings.xml @@ -10,4 +10,8 @@ ফোকাস সেশন শেষ হলে একটি শব্দ বাজায় অ্যাম্বিয়েন্ট ইফেক্ট টাইমারের পিছনে অ্যানিমেটেড কণা + + + বিরতির সময় শব্দ + বিশ্রামের সময় আলাদা একটি মিক্স বাজায় diff --git a/feature/settings/src/main/res/values-b+es+419/strings.xml b/feature/settings/src/main/res/values-b+es+419/strings.xml index 89ea2401..14e97b1a 100644 --- a/feature/settings/src/main/res/values-b+es+419/strings.xml +++ b/feature/settings/src/main/res/values-b+es+419/strings.xml @@ -10,4 +10,8 @@ Reproduce un sonido cuando termina una sesión de concentración Efectos ambientales Partículas animadas detrás del temporizador + + + Sonido en los descansos + Reproduce una mezcla aparte mientras descansas diff --git a/feature/settings/src/main/res/values-b+pt+BR/strings.xml b/feature/settings/src/main/res/values-b+pt+BR/strings.xml index 4d404f82..ac231a09 100644 --- a/feature/settings/src/main/res/values-b+pt+BR/strings.xml +++ b/feature/settings/src/main/res/values-b+pt+BR/strings.xml @@ -10,4 +10,8 @@ Toca um som quando uma sessão de foco termina Efeitos de ambiente Partículas animadas atrás do timer + + + Som durante os intervalos + Toca uma mixagem separada enquanto você descansa diff --git a/feature/settings/src/main/res/values-b+pt+PT/strings.xml b/feature/settings/src/main/res/values-b+pt+PT/strings.xml index 274aa57d..62ec8f4b 100644 --- a/feature/settings/src/main/res/values-b+pt+PT/strings.xml +++ b/feature/settings/src/main/res/values-b+pt+PT/strings.xml @@ -10,4 +10,8 @@ Reproduz um som quando termina uma sessão de concentração Efeitos ambientais Partículas animadas atrás do temporizador + + + Som durante as pausas + Reproduz uma mistura separada enquanto descansa diff --git a/feature/settings/src/main/res/values-b+zh+Hans/strings.xml b/feature/settings/src/main/res/values-b+zh+Hans/strings.xml index b259167e..2b92bcee 100644 --- a/feature/settings/src/main/res/values-b+zh+Hans/strings.xml +++ b/feature/settings/src/main/res/values-b+zh+Hans/strings.xml @@ -10,4 +10,8 @@ 专注时段结束时播放提示音 环境特效 计时器背后的动态粒子 + + + 休息时的声音 + 休息时播放另一组混音 diff --git a/feature/settings/src/main/res/values-b+zh+Hant+HK/strings.xml b/feature/settings/src/main/res/values-b+zh+Hant+HK/strings.xml index 8a3f5036..02feaa03 100644 --- a/feature/settings/src/main/res/values-b+zh+Hant+HK/strings.xml +++ b/feature/settings/src/main/res/values-b+zh+Hant+HK/strings.xml @@ -10,4 +10,8 @@ 專注時段結束時播放提示音 環境特效 計時器後方的動態粒子 + + + 休息時的聲音 + 休息時播放另一組混音 diff --git a/feature/settings/src/main/res/values-b+zh+Hant+TW/strings.xml b/feature/settings/src/main/res/values-b+zh+Hant+TW/strings.xml index 8a3f5036..02feaa03 100644 --- a/feature/settings/src/main/res/values-b+zh+Hant+TW/strings.xml +++ b/feature/settings/src/main/res/values-b+zh+Hant+TW/strings.xml @@ -10,4 +10,8 @@ 專注時段結束時播放提示音 環境特效 計時器後方的動態粒子 + + + 休息時的聲音 + 休息時播放另一組混音 diff --git a/feature/settings/src/main/res/values-bg/strings.xml b/feature/settings/src/main/res/values-bg/strings.xml index be0020dd..0998147c 100644 --- a/feature/settings/src/main/res/values-bg/strings.xml +++ b/feature/settings/src/main/res/values-bg/strings.xml @@ -10,4 +10,8 @@ Възпроизвежда звук в края на сесия за фокусиране Атмосферни ефекти Анимирани частици зад таймера + + + Звук по време на почивка + Възпроизвежда отделен микс, докато почивате diff --git a/feature/settings/src/main/res/values-ca/strings.xml b/feature/settings/src/main/res/values-ca/strings.xml index 2d7eaaa5..b1f49060 100644 --- a/feature/settings/src/main/res/values-ca/strings.xml +++ b/feature/settings/src/main/res/values-ca/strings.xml @@ -10,4 +10,8 @@ Reprodueix un so quan acaba una sessió de concentració Efectes ambientals Partícules animades darrere del temporitzador + + + So durant els descansos + Reprodueix una mescla a part mentre descanses diff --git a/feature/settings/src/main/res/values-cs/strings.xml b/feature/settings/src/main/res/values-cs/strings.xml index ba069c39..00a36fae 100644 --- a/feature/settings/src/main/res/values-cs/strings.xml +++ b/feature/settings/src/main/res/values-cs/strings.xml @@ -10,4 +10,8 @@ Přehraje zvuk na konci soustředění Atmosférické efekty Animované částice za časovačem + + + Zvuk během přestávek + Přehraje během odpočinku samostatný mix diff --git a/feature/settings/src/main/res/values-da/strings.xml b/feature/settings/src/main/res/values-da/strings.xml index 55b20aca..5d6343f6 100644 --- a/feature/settings/src/main/res/values-da/strings.xml +++ b/feature/settings/src/main/res/values-da/strings.xml @@ -10,4 +10,8 @@ Afspiller en lyd, når en fokussession slutter Stemningseffekter Animerede partikler bag timeren + + + Lyd under pauser + Afspiller et separat miks, mens du holder pause diff --git a/feature/settings/src/main/res/values-de/strings.xml b/feature/settings/src/main/res/values-de/strings.xml index e21acec8..82519ae5 100644 --- a/feature/settings/src/main/res/values-de/strings.xml +++ b/feature/settings/src/main/res/values-de/strings.xml @@ -10,4 +10,8 @@ Spielt einen Ton ab, wenn eine Fokussitzung endet Umgebungseffekte Animierte Partikel hinter dem Timer + + + Sound in Pausen + Spielt einen eigenen Mix ab, während du ruhst diff --git a/feature/settings/src/main/res/values-el/strings.xml b/feature/settings/src/main/res/values-el/strings.xml index 023cc417..221a51e4 100644 --- a/feature/settings/src/main/res/values-el/strings.xml +++ b/feature/settings/src/main/res/values-el/strings.xml @@ -10,4 +10,8 @@ Αναπαράγει έναν ήχο όταν τελειώνει μια συνεδρία εστίασης Εφέ ατμόσφαιρας Κινούμενα σωματίδια πίσω από το χρονόμετρο + + + Ήχος στα διαλείμματα + Αναπαράγει ξεχωριστό μείγμα όσο ξεκουράζεστε diff --git a/feature/settings/src/main/res/values-en-rGB/strings.xml b/feature/settings/src/main/res/values-en-rGB/strings.xml index 8dfa0b9b..f7d3f517 100644 --- a/feature/settings/src/main/res/values-en-rGB/strings.xml +++ b/feature/settings/src/main/res/values-en-rGB/strings.xml @@ -10,4 +10,8 @@ Play a sound when a focus session ends Ambient effects Animated particles behind the timer + + + Sound during breaks + Play a separate mix while you rest diff --git a/feature/settings/src/main/res/values-es/strings.xml b/feature/settings/src/main/res/values-es/strings.xml index 9f06b3c5..38e14e67 100644 --- a/feature/settings/src/main/res/values-es/strings.xml +++ b/feature/settings/src/main/res/values-es/strings.xml @@ -10,4 +10,8 @@ Reproduce un sonido cuando termina una sesión de concentración Efectos ambientales Partículas animadas detrás del temporizador + + + Sonido en los descansos + Reproduce una mezcla aparte mientras descansas diff --git a/feature/settings/src/main/res/values-et/strings.xml b/feature/settings/src/main/res/values-et/strings.xml index dfbeb5d3..8c84e5cb 100644 --- a/feature/settings/src/main/res/values-et/strings.xml +++ b/feature/settings/src/main/res/values-et/strings.xml @@ -10,4 +10,8 @@ Esitab heli, kui keskendumisseanss lõpeb Atmosfääriefektid Animeeritud osakesed taimeri taga + + + Heli pauside ajal + Esitab puhkamise ajal eraldi miksi diff --git a/feature/settings/src/main/res/values-fi/strings.xml b/feature/settings/src/main/res/values-fi/strings.xml index 99d332bb..3a66d54c 100644 --- a/feature/settings/src/main/res/values-fi/strings.xml +++ b/feature/settings/src/main/res/values-fi/strings.xml @@ -10,4 +10,8 @@ Toistaa äänen, kun keskittymisjakso päättyy Tunnelmatehosteet Animoituja hiukkasia ajastimen takana + + + Ääni taukojen aikana + Toistaa erillisen miksauksen levon aikana diff --git a/feature/settings/src/main/res/values-fil/strings.xml b/feature/settings/src/main/res/values-fil/strings.xml index 61206c86..913f3543 100644 --- a/feature/settings/src/main/res/values-fil/strings.xml +++ b/feature/settings/src/main/res/values-fil/strings.xml @@ -10,4 +10,8 @@ Nagpe-play ng tunog kapag natapos ang isang focus session Mga ambient effect Mga animated na particle sa likod ng timer + + + Tunog habang nagpapahinga + Nagpe-play ng hiwalay na mix habang nagpapahinga ka diff --git a/feature/settings/src/main/res/values-fr-rCA/strings.xml b/feature/settings/src/main/res/values-fr-rCA/strings.xml index 74f8efbf..94188e3e 100644 --- a/feature/settings/src/main/res/values-fr-rCA/strings.xml +++ b/feature/settings/src/main/res/values-fr-rCA/strings.xml @@ -10,4 +10,8 @@ Émet un son à la fin d\'une session de concentration Effets d\'ambiance Particules animées derrière le minuteur + + + Son pendant les pauses + Diffuse un mélange distinct pendant le repos diff --git a/feature/settings/src/main/res/values-fr/strings.xml b/feature/settings/src/main/res/values-fr/strings.xml index 74f8efbf..94188e3e 100644 --- a/feature/settings/src/main/res/values-fr/strings.xml +++ b/feature/settings/src/main/res/values-fr/strings.xml @@ -10,4 +10,8 @@ Émet un son à la fin d\'une session de concentration Effets d\'ambiance Particules animées derrière le minuteur + + + Son pendant les pauses + Diffuse un mélange distinct pendant le repos diff --git a/feature/settings/src/main/res/values-hi/strings.xml b/feature/settings/src/main/res/values-hi/strings.xml index 316324ef..29e7e06c 100644 --- a/feature/settings/src/main/res/values-hi/strings.xml +++ b/feature/settings/src/main/res/values-hi/strings.xml @@ -10,4 +10,8 @@ फ़ोकस सेशन खत्म होने पर आवाज़ चलाता है एंबियंट इफ़ेक्ट टाइमर के पीछे एनिमेटेड कण + + + ब्रेक के दौरान आवाज़ + आराम के दौरान अलग मिक्स चलाता है diff --git a/feature/settings/src/main/res/values-hr/strings.xml b/feature/settings/src/main/res/values-hr/strings.xml index c3e1c163..f8bcec52 100644 --- a/feature/settings/src/main/res/values-hr/strings.xml +++ b/feature/settings/src/main/res/values-hr/strings.xml @@ -10,4 +10,8 @@ Reproducira zvuk kad završi sesija fokusiranja Ambijentalni efekti Animirane čestice iza mjerača vremena + + + Zvuk tijekom pauze + Reproducira zaseban miks dok se odmarate diff --git a/feature/settings/src/main/res/values-hu/strings.xml b/feature/settings/src/main/res/values-hu/strings.xml index c8c8d95e..7226031b 100644 --- a/feature/settings/src/main/res/values-hu/strings.xml +++ b/feature/settings/src/main/res/values-hu/strings.xml @@ -10,4 +10,8 @@ Hangot játszik le, amikor egy fókuszidőszak véget ér Környezeti effektek Animált részecskék az időzítő mögött + + + Hang a szünetekben + Külön mixet játszik le pihenés közben diff --git a/feature/settings/src/main/res/values-in/strings.xml b/feature/settings/src/main/res/values-in/strings.xml index c47b86f1..0c41393e 100644 --- a/feature/settings/src/main/res/values-in/strings.xml +++ b/feature/settings/src/main/res/values-in/strings.xml @@ -10,4 +10,8 @@ Memutar suara saat sesi fokus berakhir Efek suasana Partikel animasi di belakang timer + + + Suara saat istirahat + Memutar mix terpisah saat Anda beristirahat diff --git a/feature/settings/src/main/res/values-it/strings.xml b/feature/settings/src/main/res/values-it/strings.xml index b11c3a60..e18a7298 100644 --- a/feature/settings/src/main/res/values-it/strings.xml +++ b/feature/settings/src/main/res/values-it/strings.xml @@ -10,4 +10,8 @@ Riproduce un suono al termine di una sessione di concentrazione Effetti ambientali Particelle animate dietro il timer + + + Suono durante le pause + Riproduce un mix separato mentre riposi diff --git a/feature/settings/src/main/res/values-iw/strings.xml b/feature/settings/src/main/res/values-iw/strings.xml index 0dc41522..957921c8 100644 --- a/feature/settings/src/main/res/values-iw/strings.xml +++ b/feature/settings/src/main/res/values-iw/strings.xml @@ -10,4 +10,8 @@ משמיע צליל כשסשן מיקוד מסתיים אפקטים סביבתיים חלקיקים מונפשים מאחורי הטיימר + + + צליל בזמן הפסקות + משמיע מיקס נפרד בזמן המנוחה diff --git a/feature/settings/src/main/res/values-ja/strings.xml b/feature/settings/src/main/res/values-ja/strings.xml index 44efec18..4dfa8916 100644 --- a/feature/settings/src/main/res/values-ja/strings.xml +++ b/feature/settings/src/main/res/values-ja/strings.xml @@ -10,4 +10,8 @@ 集中セッションが終わると音を鳴らします アンビエント効果 タイマーの背後に動くパーティクル + + + 休憩中のサウンド + 休憩中は別のミックスを再生します diff --git a/feature/settings/src/main/res/values-ko/strings.xml b/feature/settings/src/main/res/values-ko/strings.xml index e61372d8..2d41c7d7 100644 --- a/feature/settings/src/main/res/values-ko/strings.xml +++ b/feature/settings/src/main/res/values-ko/strings.xml @@ -10,4 +10,8 @@ 집중 세션이 끝나면 소리를 재생합니다 앰비언트 효과 타이머 뒤에서 움직이는 입자 + + + 휴식 중 사운드 + 휴식하는 동안 별도의 믹스를 재생합니다 diff --git a/feature/settings/src/main/res/values-lt/strings.xml b/feature/settings/src/main/res/values-lt/strings.xml index f944431d..5f7baa8b 100644 --- a/feature/settings/src/main/res/values-lt/strings.xml +++ b/feature/settings/src/main/res/values-lt/strings.xml @@ -10,4 +10,8 @@ Pasibaigus susikaupimo sesijai paleidžia garsą Aplinkos efektai Animuotos dalelės už laikmačio + + + Garsas per pertraukas + Ilsintis paleidžia atskirą miksą diff --git a/feature/settings/src/main/res/values-lv/strings.xml b/feature/settings/src/main/res/values-lv/strings.xml index 6a5c380b..ecb4cf07 100644 --- a/feature/settings/src/main/res/values-lv/strings.xml +++ b/feature/settings/src/main/res/values-lv/strings.xml @@ -10,4 +10,8 @@ Atskaņo skaņu, kad beidzas koncentrēšanās sesija Vides efekti Animētas daļiņas aiz taimera + + + Skaņa pārtraukumos + Atpūtas laikā atskaņo atsevišķu miksu diff --git a/feature/settings/src/main/res/values-ms/strings.xml b/feature/settings/src/main/res/values-ms/strings.xml index e9b27846..62564039 100644 --- a/feature/settings/src/main/res/values-ms/strings.xml +++ b/feature/settings/src/main/res/values-ms/strings.xml @@ -10,4 +10,8 @@ Memainkan bunyi apabila sesi fokus tamat Kesan suasana Zarah beranimasi di belakang pemasa + + + Bunyi semasa rehat + Memainkan campuran berasingan semasa anda berehat diff --git a/feature/settings/src/main/res/values-nb/strings.xml b/feature/settings/src/main/res/values-nb/strings.xml index 1d3ff0a1..83232dd3 100644 --- a/feature/settings/src/main/res/values-nb/strings.xml +++ b/feature/settings/src/main/res/values-nb/strings.xml @@ -10,4 +10,8 @@ Spiller en lyd når en fokusøkt er ferdig Stemningseffekter Animerte partikler bak tidtakeren + + + Lyd i pauser + Spiller en egen miks mens du hviler diff --git a/feature/settings/src/main/res/values-nl/strings.xml b/feature/settings/src/main/res/values-nl/strings.xml index 72fc28f7..11fe5682 100644 --- a/feature/settings/src/main/res/values-nl/strings.xml +++ b/feature/settings/src/main/res/values-nl/strings.xml @@ -10,4 +10,8 @@ Speelt een geluid af als een focussessie eindigt Sfeereffecten Geanimeerde deeltjes achter de timer + + + Geluid tijdens pauzes + Speelt een aparte mix af terwijl je rust diff --git a/feature/settings/src/main/res/values-pl/strings.xml b/feature/settings/src/main/res/values-pl/strings.xml index 4808b837..4de317de 100644 --- a/feature/settings/src/main/res/values-pl/strings.xml +++ b/feature/settings/src/main/res/values-pl/strings.xml @@ -10,4 +10,8 @@ Odtwarza dźwięk po zakończeniu sesji skupienia Efekty otoczenia Animowane cząsteczki za minutnikiem + + + Dźwięk podczas przerw + Odtwarza osobny miks podczas odpoczynku diff --git a/feature/settings/src/main/res/values-ro/strings.xml b/feature/settings/src/main/res/values-ro/strings.xml index 04fe9e8a..db241c75 100644 --- a/feature/settings/src/main/res/values-ro/strings.xml +++ b/feature/settings/src/main/res/values-ro/strings.xml @@ -10,4 +10,8 @@ Redă un sunet când se încheie o sesiune de concentrare Efecte de ambianță Particule animate în spatele temporizatorului + + + Sunet în pauze + Redă un mix separat cât te odihnești diff --git a/feature/settings/src/main/res/values-ru/strings.xml b/feature/settings/src/main/res/values-ru/strings.xml index c6125356..47f4ae7c 100644 --- a/feature/settings/src/main/res/values-ru/strings.xml +++ b/feature/settings/src/main/res/values-ru/strings.xml @@ -10,4 +10,8 @@ Воспроизводит звук в конце сеанса концентрации Фоновые эффекты Анимированные частицы за таймером + + + Звук во время перерывов + Воспроизводит отдельный микс во время отдыха diff --git a/feature/settings/src/main/res/values-sk/strings.xml b/feature/settings/src/main/res/values-sk/strings.xml index 3abf2af9..68ca9196 100644 --- a/feature/settings/src/main/res/values-sk/strings.xml +++ b/feature/settings/src/main/res/values-sk/strings.xml @@ -10,4 +10,8 @@ Prehrá zvuk na konci sústredenia Atmosférické efekty Animované častice za časovačom + + + Zvuk počas prestávok + Počas oddychu prehrá samostatný mix diff --git a/feature/settings/src/main/res/values-sl/strings.xml b/feature/settings/src/main/res/values-sl/strings.xml index 01691373..0e15a4c4 100644 --- a/feature/settings/src/main/res/values-sl/strings.xml +++ b/feature/settings/src/main/res/values-sl/strings.xml @@ -10,4 +10,8 @@ Predvaja zvok, ko se seja zbranosti konča Učinki vzdušja Animirani delci za časovnikom + + + Zvok med odmori + Med počitkom predvaja ločeno mešanico diff --git a/feature/settings/src/main/res/values-sr/strings.xml b/feature/settings/src/main/res/values-sr/strings.xml index c1c0b75a..072b0d3e 100644 --- a/feature/settings/src/main/res/values-sr/strings.xml +++ b/feature/settings/src/main/res/values-sr/strings.xml @@ -10,4 +10,8 @@ Пушта звук када се сесија фокусирања заврши Амбијентални ефекти Анимиране честице иза тајмера + + + Звук током паузе + Пушта посебан микс док се одмарате diff --git a/feature/settings/src/main/res/values-sv/strings.xml b/feature/settings/src/main/res/values-sv/strings.xml index 362e0af4..ba9e6ff5 100644 --- a/feature/settings/src/main/res/values-sv/strings.xml +++ b/feature/settings/src/main/res/values-sv/strings.xml @@ -10,4 +10,8 @@ Spelar upp ett ljud när ett fokuspass tar slut Stämningseffekter Animerade partiklar bakom timern + + + Ljud under raster + Spelar upp en separat mix medan du vilar diff --git a/feature/settings/src/main/res/values-sw/strings.xml b/feature/settings/src/main/res/values-sw/strings.xml index d5b20334..f9b650ad 100644 --- a/feature/settings/src/main/res/values-sw/strings.xml +++ b/feature/settings/src/main/res/values-sw/strings.xml @@ -10,4 +10,8 @@ Hucheza sauti kipindi cha umakini kinapoisha Athari za mandhari Chembe zinazosogea nyuma ya kipima muda + + + Sauti wakati wa mapumziko + Hucheza mchanganyiko tofauti unapopumzika diff --git a/feature/settings/src/main/res/values-th/strings.xml b/feature/settings/src/main/res/values-th/strings.xml index ad96e1c2..33cda875 100644 --- a/feature/settings/src/main/res/values-th/strings.xml +++ b/feature/settings/src/main/res/values-th/strings.xml @@ -10,4 +10,8 @@ เล่นเสียงเมื่อเซสชันโฟกัสสิ้นสุด เอฟเฟกต์บรรยากาศ อนุภาคเคลื่อนไหวด้านหลังตัวจับเวลา + + + เสียงระหว่างพัก + เล่นมิกซ์แยกต่างหากขณะพัก diff --git a/feature/settings/src/main/res/values-tr/strings.xml b/feature/settings/src/main/res/values-tr/strings.xml index 62879595..df60bdaa 100644 --- a/feature/settings/src/main/res/values-tr/strings.xml +++ b/feature/settings/src/main/res/values-tr/strings.xml @@ -10,4 +10,8 @@ Odaklanma oturumu bittiğinde ses çalar Ortam efektleri Zamanlayıcının arkasında hareketli parçacıklar + + + Molalarda ses + Dinlenirken ayrı bir miks çalar diff --git a/feature/settings/src/main/res/values-uk/strings.xml b/feature/settings/src/main/res/values-uk/strings.xml index 1154a492..c67929e1 100644 --- a/feature/settings/src/main/res/values-uk/strings.xml +++ b/feature/settings/src/main/res/values-uk/strings.xml @@ -10,4 +10,8 @@ Відтворює звук у кінці сеансу зосередження Фонові ефекти Анімовані частинки за таймером + + + Звук під час перерв + Відтворює окремий мікс під час відпочинку diff --git a/feature/settings/src/main/res/values-vi/strings.xml b/feature/settings/src/main/res/values-vi/strings.xml index 1aac6416..116a9257 100644 --- a/feature/settings/src/main/res/values-vi/strings.xml +++ b/feature/settings/src/main/res/values-vi/strings.xml @@ -10,4 +10,8 @@ Phát âm thanh khi phiên tập trung kết thúc Hiệu ứng nền Các hạt chuyển động phía sau bộ hẹn giờ + + + Âm thanh trong giờ nghỉ + Phát một bản phối riêng khi bạn nghỉ diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 8dfa0b9b..46415c04 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -8,6 +8,8 @@ Vibrate on taps and timer events Completion chime Play a sound when a focus session ends + Sound during breaks + Play a separate mix while you rest Ambient effects Animated particles behind the timer diff --git a/feature/settings/src/test/java/com/jbgsoft/ambio/feature/settings/SettingsScreenTest.kt b/feature/settings/src/test/java/com/jbgsoft/ambio/feature/settings/SettingsScreenTest.kt index c7b7f897..11a566bc 100644 --- a/feature/settings/src/test/java/com/jbgsoft/ambio/feature/settings/SettingsScreenTest.kt +++ b/feature/settings/src/test/java/com/jbgsoft/ambio/feature/settings/SettingsScreenTest.kt @@ -34,6 +34,7 @@ class SettingsScreenTest { fun `each toggle reports through its own callback`() { var haptics: Boolean? = null var chime: Boolean? = null + var breakSound: Boolean? = null var effects: Boolean? = null compose.setContent { @@ -41,27 +42,31 @@ class SettingsScreenTest { uiState = SettingsUiState( hapticsEnabled = false, chimeEnabled = false, + breakSoundEnabled = false, effectsEnabled = false ), onHapticsChanged = { haptics = it }, onChimeChanged = { chime = it }, + onBreakSoundChanged = { breakSound = it }, onEffectsChanged = { effects = it }, onNavigateBack = {} ) } // SettingRow puts onCheckedChange on the Switch, not on the Row — tapping - // the title text does nothing. The three switches appear in declaration - // order: haptics, chime, effects. + // the title text does nothing. The four switches appear in declaration + // order: haptics, chime, break sound, effects. val switches = compose.onAllNodes(isToggleable()) switches[0].performClick() switches[1].performClick() switches[2].performClick() + switches[3].performClick() // Each callback must receive the toggled value, and no callback may be // wired to the wrong row — the mistake this test exists to catch. assertThat(haptics).isTrue() assertThat(chime).isTrue() + assertThat(breakSound).isTrue() assertThat(effects).isTrue() } @@ -72,6 +77,7 @@ class SettingsScreenTest { uiState = SettingsUiState(), onHapticsChanged = {}, onChimeChanged = {}, + onBreakSoundChanged = {}, onEffectsChanged = {}, onNavigateBack = {} ) @@ -79,6 +85,7 @@ class SettingsScreenTest { compose.onNodeWithText(context.getString(R.string.settings_haptics)).assertExists() compose.onNodeWithText(context.getString(R.string.settings_chime)).assertExists() + compose.onNodeWithText(context.getString(R.string.settings_break_sound)).assertExists() compose.onNodeWithText(context.getString(R.string.settings_effects)).assertExists() // The summaries matter as much as the titles: SettingRow takes both as @@ -86,6 +93,7 @@ class SettingsScreenTest { // wrong explanation and still look correct from the titles alone. compose.onNodeWithText(context.getString(R.string.settings_haptics_summary)).assertExists() compose.onNodeWithText(context.getString(R.string.settings_chime_summary)).assertExists() + compose.onNodeWithText(context.getString(R.string.settings_break_sound_summary)).assertExists() compose.onNodeWithText(context.getString(R.string.settings_effects_summary)).assertExists() } @@ -97,6 +105,7 @@ class SettingsScreenTest { uiState = SettingsUiState(), onHapticsChanged = {}, onChimeChanged = {}, + onBreakSoundChanged = {}, onEffectsChanged = {}, onNavigateBack = {} ) @@ -117,6 +126,7 @@ class SettingsScreenTest { uiState = SettingsUiState(), onHapticsChanged = {}, onChimeChanged = {}, + onBreakSoundChanged = {}, onEffectsChanged = {}, onNavigateBack = {} ) @@ -134,6 +144,7 @@ class SettingsScreenTest { uiState = SettingsUiState(), onHapticsChanged = {}, onChimeChanged = {}, + onBreakSoundChanged = {}, onEffectsChanged = {}, onNavigateBack = {} ) diff --git a/feature/settings/src/test/java/com/jbgsoft/ambio/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/test/java/com/jbgsoft/ambio/feature/settings/SettingsViewModelTest.kt index ab8c76ad..1cffc690 100644 --- a/feature/settings/src/test/java/com/jbgsoft/ambio/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/test/java/com/jbgsoft/ambio/feature/settings/SettingsViewModelTest.kt @@ -61,4 +61,28 @@ class SettingsViewModelTest { coVerify(exactly = 1) { preferencesRepository.setHapticsEnabled(false) } } + + @Test + fun `state reflects the break sound preference`() = runTest { + every { preferencesRepository.preferences } returns flowOf(UserPreferences(breakSoundEnabled = true)) + + val viewModel = SettingsViewModel(preferencesRepository) + advanceUntilIdle() + + viewModel.uiState.test { + assertThat(awaitItem().breakSoundEnabled).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `toggling break sound writes through to the repository`() = runTest { + every { preferencesRepository.preferences } returns flowOf(UserPreferences()) + val viewModel = SettingsViewModel(preferencesRepository) + + viewModel.onBreakSoundChanged(true) + advanceUntilIdle() + + coVerify { preferencesRepository.setBreakSoundEnabled(true) } + } } diff --git a/media/src/main/java/com/jbgsoft/ambio/media/AudioServiceConnection.kt b/media/src/main/java/com/jbgsoft/ambio/media/AudioServiceConnection.kt index 721a5983..a1fb94fe 100644 --- a/media/src/main/java/com/jbgsoft/ambio/media/AudioServiceConnection.kt +++ b/media/src/main/java/com/jbgsoft/ambio/media/AudioServiceConnection.kt @@ -169,6 +169,10 @@ class AudioServiceConnection @Inject constructor( fadeJob?.cancel() Log.d(TAG, "Play requested") controller?.apply { + // A stop() right before this may have cancelled its own fade-out above, + // before that fade reached controller.stop() — the controller was never + // actually stopped, so onIsPlayingChanged will not fire for it. + _isPlaying.value = true // Update UI immediately volume = 0f play() fadeIn(targetVolume) diff --git a/media/src/test/java/com/jbgsoft/ambio/media/AudioServiceConnectionTest.kt b/media/src/test/java/com/jbgsoft/ambio/media/AudioServiceConnectionTest.kt index f4e66a2a..be8936ad 100644 --- a/media/src/test/java/com/jbgsoft/ambio/media/AudioServiceConnectionTest.kt +++ b/media/src/test/java/com/jbgsoft/ambio/media/AudioServiceConnectionTest.kt @@ -171,6 +171,26 @@ class AudioServiceConnectionTest { assertThat(connection.isConnected.value).isFalse() } + @Test + fun `play after stop reports playing even before the controller confirms`() { + val connection = connection() + val controller = mockk(relaxed = true) + every { controller.isPlaying } returns true + + connection.connect() + futures[0].completeWith(controller) + futures[0].dispatch() + + // stop() starts a fade-out coroutine that would eventually call + // controller.stop(), but it is never run here (that needs the main looper to + // be idled) — mirroring a play() that lands before the fade gets there and + // cancels it, leaving the controller still playing under the hood. + connection.stop() + connection.play() + + assertThat(connection.isPlaying.value).isTrue() + } + @Test fun `a failed attempt leaves nothing behind that would block the next connect`() { val connection = connection() diff --git a/metadata/en-US/images/phoneScreenshots/1_mix.png b/metadata/en-US/images/phoneScreenshots/1_mix.png index a39059c8..70aaf2f0 100644 Binary files a/metadata/en-US/images/phoneScreenshots/1_mix.png and b/metadata/en-US/images/phoneScreenshots/1_mix.png differ diff --git a/metadata/en-US/images/phoneScreenshots/3_timer.png b/metadata/en-US/images/phoneScreenshots/3_timer.png index 324b96c5..070ce6ea 100644 Binary files a/metadata/en-US/images/phoneScreenshots/3_timer.png and b/metadata/en-US/images/phoneScreenshots/3_timer.png differ diff --git a/metadata/en-US/images/phoneScreenshots/5_settings.png b/metadata/en-US/images/phoneScreenshots/5_settings.png index 910f2c7e..501e23f7 100644 Binary files a/metadata/en-US/images/phoneScreenshots/5_settings.png and b/metadata/en-US/images/phoneScreenshots/5_settings.png differ diff --git a/screenshots/gplay-panorama.png b/screenshots/gplay-panorama.png index 6a485666..f7bb8144 100644 Binary files a/screenshots/gplay-panorama.png and b/screenshots/gplay-panorama.png differ diff --git a/store-assets/src/main/java/com/jbgsoft/ambio/storeassets/StoreScenes.kt b/store-assets/src/main/java/com/jbgsoft/ambio/storeassets/StoreScenes.kt index 0e26c814..76df253c 100644 --- a/store-assets/src/main/java/com/jbgsoft/ambio/storeassets/StoreScenes.kt +++ b/store-assets/src/main/java/com/jbgsoft/ambio/storeassets/StoreScenes.kt @@ -90,7 +90,7 @@ private fun mixOf(vararg parts: Pair): List = private fun sound(id: String) = SOUND_CATALOGUE.first { it.id == id } private fun homeState(mix: List) = HomeUiState( - activeMix = mix, + focusMix = mix, availableSounds = SOUND_CATALOGUE, volume = 0.7f, effectsEnabled = true @@ -148,6 +148,7 @@ private fun Settings() = SettingsScreen( ), onHapticsChanged = {}, onChimeChanged = {}, + onBreakSoundChanged = {}, onEffectsChanged = {}, onNavigateBack = {} ) diff --git a/tools/check-translations.sh b/tools/check-translations.sh new file mode 100755 index 00000000..3c924e1f --- /dev/null +++ b/tools/check-translations.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Every key in values/strings.xml must appear in every values-*/strings.xml of +# the same module. Lint's MissingTranslation does this too, but only on a full +# lint run; this answers in a second. +set -euo pipefail +status=0 +for module in feature/home feature/settings feature/stats; do + base="$module/src/main/res/values/strings.xml" + [ -f "$base" ] || continue + keys=$(grep -oE '<(string|plurals) name="[^"]+"' "$base" | sed -E 's/.*name="([^"]+)"/\1/') + for dir in "$module"/src/main/res/values-*/; do + file="$dir/strings.xml" + for key in $keys; do + grep -q "name=\"$key\"" "$file" || { echo "missing $key in $file"; status=1; } + done + done +done +[ $status -eq 0 ] && echo "all keys translated" +exit $status diff --git a/tools/process-audio.sh b/tools/process-audio.sh index f128466e..00715a49 100755 --- a/tools/process-audio.sh +++ b/tools/process-audio.sh @@ -8,13 +8,14 @@ # # 1. optional high-pass (forest only -- see HIGHPASS below) # 2. seamless loop bake (tail crossfaded over head, output trimmed by XFADE) -# 3. loudness match (linear gain to TARGET_I, no dynamic compression) +# 3. loudness match (linear gain to TARGET_I, then a peak limiter that +# only touches samples that would cross LIMIT_DB -- see normalise_and_encode) # + resample to 48k, then a single Opus encode. # -# One-shots (the timer chime) skip 1 and 2 and get only the loudness match. They -# still need it: with the ambience at -30 LUFS the chime was 7.4dB hotter than -# the bed it plays over, and ChimePlayer uses its own MediaPlayer outside the -# master volume, so at half volume the gap was closer to 13dB. +# One-shots (the chimes) skip 1 and 2 and get only the loudness match. They +# still need it: matched to the bed, the chime plays at the level the user set +# for the ambience -- ChimePlayer uses its own MediaPlayer outside the master +# volume, so an unmatched chime at half volume was 13dB hotter than the bed. # # Opus rather than Vorbis: Homebrew's ffmpeg ships no libvorbis, only FFmpeg's # markedly worse native encoder, and spending the one unavoidable lossy @@ -48,8 +49,31 @@ OUT="${2:-build/processed-audio}" FETCHED="${3:-build/audio-src-fetched}" XFADE=4 # seconds of loop crossfade, also how much shorter the output is -TARGET_I=-30 # LUFS. Set by cave: it peaks at -3.1dB and cannot go up. -TARGET_TP=-3 # dBTP + +# Loudness target. -30 LUFS was the level every loop could reach as pure gain +# (cave peaked at -3.1dB and could not go up), and it shipped in 2.0. It also +# sits 14-16dB under what any music app plays at (-14 to -16 LUFS), and users +# reported having to run the phone near maximum to hear the app at all +# (GitHub issue #12). -20 LUFS is about twice as loud and still leaves the +# equal-power bus in MixPlayer, which keeps a five-sound mix at the same level +# as one sound, well clear of full scale. +# +# What -20 costs: two files no longer fit as pure gain. cave and fireplace peak +# 25dB above their loudness on a handful of drips and crackles (five transients +# in cave covering 0.014% of its samples, two in fireplace covering 0.001%). A +# lookahead limiter holds those at the ceiling, about 7dB down; birds, stream +# and forest lose 1.3, 0.7 and 0.1dB on their single loudest peaks, and the +# other nine files are untouched. Every other sample everywhere is still pure +# gain. That is not the dynamic compression loudnorm falls back to, which the +# check below still rejects: the limiter has no effect on a signal that stays +# under the ceiling, and it does not pump. +TARGET_I=-20 # LUFS +TARGET_TP=-1 # dBTP, what loudnorm verifies after the limiter +# dB sample peak the limiter holds. alimiter sees samples, loudnorm verifies +# true peak, and fireplace's crackles carry inter-sample peaks 1.05dB above +# their sample peak: with a 0.5dB margin that file measured -0.45 dBTP and +# loudnorm fell back to dynamic mode. 1.5dB covers it. +LIMIT_DB=-2.5 # Target loudness range, in LU. loudnorm only stays in linear mode while the # measured LRA fits the target; past that it silently switches to dynamic @@ -60,7 +84,23 @@ TARGET_LRA=11 LOOPS=(cave_loop fireplace_loop forest_loop ocean_loop rain_loop stream_loop crickets_loop wind_loop birds_loop cafe_loop white_noise_loop brown_noise_loop) -ONESHOTS=(timer_chime) +ONESHOTS=(timer_chime success_chime) + +# ONLY= restricts a run to that one file. Needed for anything that is not +# a Freesound loop: without the fetched sources, the loop pass fails on the first +# missing file before the one-shots are ever reached. +if [ -n "${ONLY:-}" ]; then + LOOPS=() + ONESHOTS=() + for name in cave_loop fireplace_loop forest_loop ocean_loop rain_loop stream_loop \ + crickets_loop wind_loop birds_loop cafe_loop white_noise_loop brown_noise_loop; do + [ "$name" = "$ONLY" ] && LOOPS=("$name") + done + for name in timer_chime success_chime; do + [ "$name" = "$ONLY" ] && ONESHOTS=("$name") + done + [ ${#LOOPS[@]} -eq 0 ] && [ ${#ONESHOTS[@]} -eq 0 ] && { echo "unknown ONLY=$ONLY"; exit 1; } +fi # Per-file high-pass, in Hz. forest's loudest band is 63-125Hz at -20.6dB and it # barely varies (3.7dB between the whole file and its quietest tenth) -- steady @@ -121,18 +161,50 @@ failed=0 # Loudness-match a lossless intermediate and encode it once. Shared by both # paths because the only difference between a loop and a one-shot is what # happened before this point. -normalise_and_encode() { - local pre="$1" out="$2" bitrate="$3" - +# loudnorm's first pass, as five numbers. Used twice: once on the raw +# intermediate to decide the gain, once on the limited one to check it. +measure() { local m - m=$(ffmpeg -hide_banner -nostats -i "$pre" \ + m=$(ffmpeg -hide_banner -nostats -i "$1" \ -af "loudnorm=I=$TARGET_I:TP=$TARGET_TP:LRA=$TARGET_LRA:print_format=json" \ -f null - 2>&1 | sed -n '/{/,/}/p') - local mi mtp mlra mthresh moff - read -r mi mtp mlra mthresh moff <<<"$(python3 -c " + python3 -c " import json j=json.loads('''$m''') -print(j['input_i'],j['input_tp'],j['input_lra'],j['input_thresh'],j['target_offset'])")" +print(j['input_i'],j['input_tp'],j['input_lra'],j['input_thresh'],j['target_offset'])" +} + +normalise_and_encode() { + local pre="$1" out="$2" bitrate="$3" + + local mi mtp mlra mthresh moff + read -r mi mtp mlra mthresh moff <<<"$(measure "$pre")" + local gain + gain=$(python3 -c "print(f'{$TARGET_I-($mi):.2f}')") + echo " measured I=$mi TP=$mtp LRA=$mlra gain ${gain} dB" + + # Gain first, then the limiter at the shipped level. The limiter is a + # lookahead one (5ms attack, 50ms release, latency compensated so the sample + # count is untouched -- verify-loop.py compares lengths against this file). + # level=false, or it would re-normalise its output to the ceiling and undo + # the gain it was given. Written over $pre so the encode below and the loop + # verification both see the limited signal, not the raw one. + local over + over=$(python3 -c "print(f'{max(0.0, $mtp + ($gain) - ($LIMIT_DB)):.1f}')") + ffmpeg -hide_banner -loglevel error -y -i "$pre" -af \ + "volume=${gain}dB,alimiter=limit=$(python3 -c "print(10**($LIMIT_DB/20))"):\ +attack=5:release=50:level=false:latency=true" \ + -c:a pcm_f32le "$pre.limited.wav" + mv "$pre.limited.wav" "$pre" + if [ "$over" != "0.0" ]; then + echo " limited peaks reached ${over} dB over ${LIMIT_DB} dB and were held there" + else + echo " limited nothing: every peak already under ${LIMIT_DB} dB" + fi + + # Re-measure the limited file. The gain left to apply is now within a few + # tenths of a dB, and the TP check is against what the limiter actually did. + read -r mi mtp mlra mthresh moff <<<"$(measure "$pre")" # A perfectly stationary signal measures LRA exactly 0, which is a correct # measurement that loudnorm cannot always consume: for some parameter sets it # then refuses linear mode and silently switches to dynamic compression. The @@ -142,7 +214,7 @@ print(j['input_i'],j['input_tp'],j['input_lra'],j['input_thresh'],j['target_offs # path deterministic rather than dependent on the rest of the measurement. mlra=$(python3 -c "print(f'{max(float(\"$mlra\"), 0.1):.2f}')") - echo " measured I=$mi TP=$mtp LRA=$mlra gain $(python3 -c "print(f'{$TARGET_I-($mi):+.1f}')") dB" + echo " limited I=$mi TP=$mtp LRA=$mlra residual $(python3 -c "print(f'{$TARGET_I-($mi):+.1f}')") dB" # linear=true is the point: a pumping ambience is worse than an uneven one. local log @@ -174,7 +246,11 @@ aresample=48000" \ [ "$codec" = "opus" ] || { echo " FAIL: codec $codec"; failed=1; } } -for name in "${LOOPS[@]}"; do +# ${LOOPS[@]+"${LOOPS[@]}"} rather than a plain "${LOOPS[@]}": with ONLY set, +# LOOPS can be an empty array, and under `set -u` on bash 3.2 (stock macOS +# /bin/bash, as opposed to a newer bash first on PATH) expanding an empty +# array's "[@]" is an unbound-variable error, not an empty expansion. +for name in ${LOOPS[@]+"${LOOPS[@]}"}; do src=$(find_source "$name") || { echo "missing source for $name in $SRC or $FETCHED" echo " run tools/fetch-sources.sh and tools/synth-noise.sh first" @@ -212,7 +288,8 @@ for name in "${LOOPS[@]}"; do echo done -for name in "${ONESHOTS[@]}"; do +# Same empty-safe idiom as the LOOPS loop above, for the same bash-3.2 reason. +for name in ${ONESHOTS[@]+"${ONESHOTS[@]}"}; do src=$(find_source "$name") || { echo "missing source for $name"; exit 1; } echo "=== $name (one-shot: loudness only, no loop bake)" diff --git a/tools/synth-success-chime.sh b/tools/synth-success-chime.sh new file mode 100755 index 00000000..5ca47f4e --- /dev/null +++ b/tools/synth-success-chime.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# Generates the end-of-plan success chime: three rising sine notes (C5, E5, G5) +# with a short decay, mono, 48 kHz, ~1.4 s. +# +# Synthesised rather than sourced for the same reason as the noise beds: it is +# public domain by construction and needs no licence record. Every parameter is +# fixed, so the output is reproducible. process-audio.sh loudness-matches it +# like timer_chime (one-shot path: no loop bake). +# +# Usage: tools/synth-success-chime.sh [OUT_DIR] + +set -euo pipefail + +OUT="${1:-build/audio-src-fetched}" +RATE=48000 +AMPLITUDE=0.2 # well under full scale; the pipeline sets the shipped level + +command -v ffmpeg >/dev/null || { echo "ffmpeg not found"; exit 1; } +mkdir -p "$OUT" + +out="$OUT/success_chime.flac" + +# Each note: a sine with a soft second harmonic, faded out over its length. +# The second and third notes are delayed so the three ring in sequence. +ffmpeg -hide_banner -loglevel error -y \ + -f lavfi -i "sine=frequency=523.25:sample_rate=${RATE}:duration=1.4" \ + -f lavfi -i "sine=frequency=659.25:sample_rate=${RATE}:duration=1.2" \ + -f lavfi -i "sine=frequency=783.99:sample_rate=${RATE}:duration=1.0" \ + -filter_complex " + [0:a]volume=${AMPLITUDE},afade=t=out:st=0.05:d=1.3[n0]; + [1:a]volume=${AMPLITUDE},afade=t=out:st=0.05:d=1.1,adelay=180[n1]; + [2:a]volume=${AMPLITUDE},afade=t=out:st=0.05:d=0.9,adelay=360[n2]; + [n0][n1][n2]amix=inputs=3:normalize=0,aformat=channel_layouts=mono[o]" \ + -map "[o]" -ar "$RATE" -sample_fmt s16 -c:a flac "$out" + +dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$out") +printf "success chime %ss %s\n" "$dur" "$(du -h "$out" | cut -f1)" +python3 -c "import sys; d=float('$dur'); sys.exit(0 if 1.2 < d < 1.6 else 1)" || { + echo " FAIL: expected a chime between 1.2 and 1.6 s"; exit 1; } + +echo "success chime ready in $OUT"