Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d0c1304
feat: model a session as an ordered plan of focus and break steps
jaimebg Sep 16, 2026
2996432
feat: encode a session plan as one tolerant DataStore string
jaimebg Sep 16, 2026
d5e6c0a
feat: persist the session plan, the break mix and the break-sound toggle
jaimebg Sep 16, 2026
86ef0bb
feat: give the sound repository a second mix slot for breaks
jaimebg Sep 16, 2026
6d9bdc7
feat: run a session plan step by step on top of the timer
jaimebg Sep 16, 2026
d8be7dc
feat: synthesise a success chime for the end of a session plan
jaimebg Sep 16, 2026
1cd41c1
fix: keep the ONLY filter working on the stock macOS bash
jaimebg Sep 16, 2026
18dd7d5
feat: add a settings toggle for sound during breaks
jaimebg Sep 16, 2026
00a1a0e
feat: drive the timer through session plans, quick presets included
jaimebg Sep 16, 2026
83be953
test: pin the plan preset guard and the silent break
jaimebg Sep 16, 2026
3ff1cdf
feat: play the break mix during breaks and let the picker edit either…
jaimebg Sep 16, 2026
d13479e
fix: keep the audible slot honest while paused and once a session ends
jaimebg Sep 16, 2026
45054ee
feat: edit the session plan as a draft and persist it on save
jaimebg Sep 16, 2026
5067a10
feat: add the Plan preset with a summary line and step labels on the …
jaimebg Sep 16, 2026
2162443
fix: let screen readers hear the plan and keep Paused on the dial
jaimebg Sep 16, 2026
c6a721d
feat: edit the session plan in a sheet with a type dropdown per row
jaimebg Sep 16, 2026
2135cb5
fix: announce each plan row's number and type to screen readers
jaimebg Sep 16, 2026
2662e9e
feat: switch the sound picker between the focus and break mixes
jaimebg Sep 16, 2026
9c6a2dd
feat: translate the session plan and break mix strings into 47 locales
jaimebg Sep 16, 2026
a2f591c
docs: point validation at the translation completeness check
jaimebg Sep 16, 2026
920a362
fix: stop the break mix when a session is abandoned mid-break
jaimebg Sep 16, 2026
7d434ee
fix: return the picker to the focus slot when break sound is turned off
jaimebg Sep 16, 2026
c080a22
fix: keep modifier first in the two composables lint flagged
jaimebg Sep 16, 2026
e65e588
refactor: tidy the small leftovers the branch review listed
jaimebg Sep 16, 2026
f28ffe2
fix: call a break a break in the plan editor, in every locale
jaimebg Sep 16, 2026
9bb3bc0
fix: report playing as soon as play is requested, not only when the c…
jaimebg Sep 16, 2026
61a6c11
fix: shrink segmented labels to one line instead of wrapping
jaimebg Sep 16, 2026
a2b19b1
docs: refresh the README strip with the Plan preset and the break-sou…
jaimebg Sep 16, 2026
6797c75
fix: master the sounds at -20 LUFS instead of -30 so the app is audib…
jaimebg Sep 16, 2026
4d745b8
feat: add a sleep timer to Ambient mode
jaimebg Sep 16, 2026
fd4370b
docs: refresh the store shots and the README strip with the sleep timer
jaimebg Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions ATTRIBUTION.md
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Clean and build:
- Build: `./gradlew assembleDebug`
- Lint: `./gradlew lint`
- Tests: `./gradlew test`
- Translations: tools/check-translations.sh

## Operational Notes

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+
Expand Down
8 changes: 7 additions & 1 deletion audio-src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,26 @@ 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

```bash
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<UserPreferences> = 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
)
}

Expand Down Expand Up @@ -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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -151,28 +153,31 @@ class SoundRepositoryImpl @Inject constructor(

private val sounds = SOUND_CATALOGUE

private val mixOverride = MutableStateFlow<String?>(null)
// One optimistic override per slot, see persist().
private val overrides: Map<MixSlot, MutableStateFlow<String?>> =
MixSlot.entries.associateWith { MutableStateFlow<String?>(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<Sound> = sounds

override fun getSoundById(id: String): Sound? = sounds.find { it.id == id }

override fun getActiveMix(): Flow<List<ActiveSound>> = combine(
mixOverride,
override fun getActiveMix(slot: MixSlot): Flow<List<ActiveSound>> = 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.
Expand All @@ -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
Expand All @@ -199,27 +205,40 @@ class SoundRepositoryImpl @Inject constructor(
}
}

private suspend fun currentMix(): List<ActiveSound> =
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<ActiveSound> =
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<ActiveSound>) {
private suspend fun persist(slot: MixSlot, mix: List<ActiveSound>) {
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
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SessionProgress?>(null)
override val progress: StateFlow<SessionProgress?> = _progress.asStateFlow()

// Three events per step change at most; 64 is headroom, not a budget.
private val _events = MutableSharedFlow<SessionEvent>(extraBufferCapacity = 64)
override val events: SharedFlow<SessionEvent> = _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))
}
}
Binary file modified core/data/src/main/res/raw/birds_loop.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/brown_noise_loop.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/cafe_loop.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/cave_loop.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/crickets_loop.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/fireplace_loop.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/forest_loop.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/ocean_loop.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/rain_loop.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/stream_loop.ogg
Binary file not shown.
Binary file added core/data/src/main/res/raw/success_chime.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/timer_chime.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/white_noise_loop.ogg
Binary file not shown.
Binary file modified core/data/src/main/res/raw/wind_loop.ogg
Binary file not shown.
Loading
Loading