diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/ShellInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/ShellInstrumentedTest.kt index 4d0fd00..be32cee 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/ShellInstrumentedTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/ShellInstrumentedTest.kt @@ -47,7 +47,11 @@ class ShellInstrumentedTest { fun tappingATabSwapsTheContent() { composeTestRule.onNodeWithText(string(R.string.tab_timers)).performClick() - composeTestRule.onNodeWithText(string(R.string.timers_empty)).assertIsDisplayed() + // The empty Timers tab hosts the setup panel inline (D23), so "Start" is + // what is on screen. `timers_set_title` is only the FAB's + // `contentDescription`, which `onNodeWithText` does not match — and the + // FAB is rendered only once a timer exists. + composeTestRule.onNodeWithText(string(R.string.timers_setup_start)).assertIsDisplayed() composeTestRule.onNodeWithText(string(R.string.alarms_empty)).assertDoesNotExist() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt index 3e311ca..4049499 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt @@ -12,9 +12,11 @@ import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities import de.jeanlucmakiola.clockula.alarm.AlarmEngine import de.jeanlucmakiola.clockula.alarm.AlarmIntents -import de.jeanlucmakiola.clockula.domain.alarm.RingFallbackPolicy import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy import de.jeanlucmakiola.clockula.domain.alarm.RingSession +import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy +import de.jeanlucmakiola.clockula.ring.RingAudioPlayer +import de.jeanlucmakiola.clockula.ring.RingVibrator import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -43,8 +45,8 @@ class AlarmRingService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val audio by lazy { AlarmAudioPlayer(this) } - private val vibrator by lazy { AlarmVibrator(this) } + private val audio by lazy { RingAudioPlayer(this) } + private val vibrator by lazy { RingVibrator(this) } private var wakeLock: PowerManager.WakeLock? = null private var ringing: Job? = null diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt index c998995..33aad91 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt @@ -61,7 +61,7 @@ class SystemRingtonePreviewer @Inject constructor( val prepared = withContext(io) { prepare(parsed) } ?: return // Stopped while we were preparing — by the next preview, by taking a // row, or by leaving the picker: release rather than start, or the - // sound would outlive what asked for it (as `AlarmAudioPlayer` does). + // sound would outlive what asked for it (as `RingAudioPlayer` does). if (stopped) { release(prepared) return diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt index 2c716ff..96103c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt @@ -1,12 +1,12 @@ package de.jeanlucmakiola.clockula.domain.alarm import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes /** - * The ring cycle's constants. They are values rather than settings: v1 does not - * offer them to the user (docs/PLAN.md §4, slice plan D8/D19). + * The alarm ring cycle's constants. They are values rather than settings: v1 + * does not offer them to the user (docs/PLAN.md §4). The audio chain's own + * constants live in `domain/ring/RingAudio`, shared with the timer's ring. */ object AlarmRing { /** @@ -20,10 +20,4 @@ object AlarmRing { * boot or a `TIME_SET` nudge still rings; one missed by hours does not. */ val FIRE_GRACE: Duration = 2.minutes - - /** How often the ringing service steps the volume ramp. */ - val RAMP_TICK: Duration = 200.milliseconds - - /** The ramp's floor — an alarm never starts at true silence. */ - const val MIN_VOLUME: Float = 0.05f } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt index c891954..810ed74 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt @@ -1,7 +1,5 @@ package de.jeanlucmakiola.clockula.domain.alarm -import de.jeanlucmakiola.clockula.domain.Ringtones - /** What the platform will currently let the app do. */ data class CapabilitySnapshot( val canScheduleExactAlarms: Boolean, @@ -29,12 +27,6 @@ object RingPresentationPolicy { ) } -object RingFallbackPolicy { - /** True when the alarm must vibrate — including when it wants to but cannot make a sound. */ - fun vibrationRequired(audioAvailable: Boolean, vibrateSetting: Boolean): Boolean = - vibrateSetting || !audioAvailable -} - enum class ScheduleMode { EXACT_ALARM_CLOCK, INEXACT_FALLBACK } object SchedulingPolicy { @@ -42,34 +34,3 @@ object SchedulingPolicy { fun modeFor(canScheduleExactAlarms: Boolean): ScheduleMode = if (canScheduleExactAlarms) ScheduleMode.EXACT_ALARM_CLOCK else ScheduleMode.INEXACT_FALLBACK } - -/** Long buzz, short gap, long buzz, longer gap — loudly rhythmic, not a doorbell. */ -object AlarmVibrationPattern { - val TIMINGS: LongArray = longArrayOf(500L, 250L, 500L, 1_000L) - - val AMPLITUDES: IntArray = intArrayOf(255, 0, 255, 0) - - /** The index the pattern loops from: 0, so the whole pattern repeats. */ - const val REPEAT_INDEX: Int = 0 -} - -enum class RingtoneSourceKind { ALARM_OWN, DEVICE_DEFAULT, BUILT_IN_DEFAULT } - -object AudioSourcePolicy { - /** - * The audio sources to try, in order. **Empty** for the silent sentinel — - * which is what turns forced vibration on, so the alarm still does something. - */ - fun sourcesFor(ringtoneUri: String?): List = when { - Ringtones.isSilent(ringtoneUri) -> emptyList() - Ringtones.normalise(ringtoneUri) == null -> listOf( - RingtoneSourceKind.DEVICE_DEFAULT, - RingtoneSourceKind.BUILT_IN_DEFAULT, - ) - else -> listOf( - RingtoneSourceKind.ALARM_OWN, - RingtoneSourceKind.DEVICE_DEFAULT, - RingtoneSourceKind.BUILT_IN_DEFAULT, - ) - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/AudioSourcePolicy.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/AudioSourcePolicy.kt new file mode 100644 index 0000000..c51675b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/AudioSourcePolicy.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.clockula.domain.ring + +import de.jeanlucmakiola.clockula.domain.Ringtones + +enum class RingtoneSourceKind { + /** The ringing thing's own URI — an alarm's or a timer's. */ + ALARM_OWN, + DEVICE_DEFAULT, + BUILT_IN_DEFAULT, +} + +object AudioSourcePolicy { + /** + * The audio sources to try, in order. **Empty** for the silent sentinel — + * which is what turns forced vibration on, so the ring still does something. + */ + fun sourcesFor(ringtoneUri: String?): List = when { + Ringtones.isSilent(ringtoneUri) -> emptyList() + Ringtones.normalise(ringtoneUri) == null -> listOf( + RingtoneSourceKind.DEVICE_DEFAULT, + RingtoneSourceKind.BUILT_IN_DEFAULT, + ) + else -> listOf( + RingtoneSourceKind.ALARM_OWN, + RingtoneSourceKind.DEVICE_DEFAULT, + RingtoneSourceKind.BUILT_IN_DEFAULT, + ) + } +} + +object RingFallbackPolicy { + /** True when the ring must vibrate — including when it wants to sound but cannot. */ + fun vibrationRequired(audioAvailable: Boolean, vibrateSetting: Boolean): Boolean = + vibrateSetting || !audioAvailable +} + +/** Long buzz, short gap, long buzz, longer gap — loudly rhythmic, not a doorbell. */ +object VibrationPattern { + val TIMINGS: LongArray = longArrayOf(500L, 250L, 500L, 1_000L) + + val AMPLITUDES: IntArray = intArrayOf(255, 0, 255, 0) + + /** The index the pattern loops from: 0, so the whole pattern repeats. */ + const val REPEAT_INDEX: Int = 0 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/RingAudio.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/RingAudio.kt new file mode 100644 index 0000000..848cc34 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/RingAudio.kt @@ -0,0 +1,17 @@ +package de.jeanlucmakiola.clockula.domain.ring + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * The audio chain's constants, shared by both ring paths. They moved out of + * `AlarmRing` when the timer's ring started using the same player: a constant + * named for one caller reads as that caller's private business (slice plan D8). + */ +object RingAudio { + /** How often a ringing service steps the volume ramp. */ + val RAMP_TICK: Duration = 200.milliseconds + + /** The ramp's floor — a ramping ring never starts at true silence. */ + const val MIN_VOLUME: Float = 0.05f +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRamp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/VolumeRamp.kt similarity index 59% rename from app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRamp.kt rename to app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/VolumeRamp.kt index a6b2f8d..beb4e01 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRamp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/VolumeRamp.kt @@ -1,4 +1,4 @@ -package de.jeanlucmakiola.clockula.domain.alarm +package de.jeanlucmakiola.clockula.domain.ring import kotlin.time.Duration @@ -7,13 +7,13 @@ import kotlin.time.Duration * sounds like a steady climb rather than a jump at the end. */ object VolumeRamp { - /** `AlarmRing.MIN_VOLUME`..1f. Never throws. */ + /** `RingAudio.MIN_VOLUME`..1f. Never throws. */ fun levelAt(elapsed: Duration, ramp: Duration): Float { if (ramp <= Duration.ZERO || elapsed >= ramp) return 1f - if (elapsed <= Duration.ZERO) return AlarmRing.MIN_VOLUME + if (elapsed <= Duration.ZERO) return RingAudio.MIN_VOLUME val progress = (elapsed / ramp).toFloat() - val level = AlarmRing.MIN_VOLUME + (1f - AlarmRing.MIN_VOLUME) * progress * progress - return level.coerceIn(AlarmRing.MIN_VOLUME, 1f) + val level = RingAudio.MIN_VOLUME + (1f - RingAudio.MIN_VOLUME) * progress * progress + return level.coerceIn(RingAudio.MIN_VOLUME, 1f) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmAudioPlayer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ring/RingAudioPlayer.kt similarity index 87% rename from app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmAudioPlayer.kt rename to app/src/main/java/de/jeanlucmakiola/clockula/ring/RingAudioPlayer.kt index cafcf35..7c486e3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmAudioPlayer.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ring/RingAudioPlayer.kt @@ -1,4 +1,4 @@ -package de.jeanlucmakiola.clockula.alarm.ring +package de.jeanlucmakiola.clockula.ring import android.content.Context import android.media.AudioAttributes @@ -7,10 +7,10 @@ import android.media.AudioManager import android.media.MediaPlayer import android.media.RingtoneManager import android.net.Uri -import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing -import de.jeanlucmakiola.clockula.domain.alarm.AudioSourcePolicy -import de.jeanlucmakiola.clockula.domain.alarm.RingtoneSourceKind -import de.jeanlucmakiola.clockula.domain.alarm.VolumeRamp +import de.jeanlucmakiola.clockula.domain.ring.AudioSourcePolicy +import de.jeanlucmakiola.clockula.domain.ring.RingAudio +import de.jeanlucmakiola.clockula.domain.ring.RingtoneSourceKind +import de.jeanlucmakiola.clockula.domain.ring.VolumeRamp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -27,10 +27,10 @@ import kotlin.time.Duration.Companion.seconds * which would edit the user's own alarm volume and leave it edited. * * [start] returns false when no audio source could be opened at all — which is - * what turns forced vibration on (`RingFallbackPolicy`), so the alarm is never - * silent. + * what turns forced vibration on (`RingFallbackPolicy`), so neither an alarm + * nor a timer is ever silent. */ -class AlarmAudioPlayer(private val context: Context) { +class RingAudioPlayer(private val context: Context) { private val audioManager: AudioManager = context.getSystemService(AudioManager::class.java) @@ -48,8 +48,8 @@ class AlarmAudioPlayer(private val context: Context) { private var stopped: Boolean = false /** - * Tries the alarm's ringtone, then the device default, then the built-in - * default. Suspending, because [MediaPlayer.prepare] is blocking I/O and the + * Tries the ringing thing's own ringtone, then the device default, then the + * built-in default. Suspending, because [MediaPlayer.prepare] is blocking I/O and the * moment the alarm rings is the worst possible moment to block the main * thread with it. */ @@ -77,8 +77,8 @@ class AlarmAudioPlayer(private val context: Context) { ramp = scope.launch { var elapsed = Duration.ZERO while (isActive && elapsed < rampFor) { - delay(AlarmRing.RAMP_TICK) - elapsed += AlarmRing.RAMP_TICK + delay(RingAudio.RAMP_TICK) + elapsed += RingAudio.RAMP_TICK val level = VolumeRamp.levelAt(elapsed, rampFor) runCatching { player?.setVolume(level, level) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmVibrator.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ring/RingVibrator.kt similarity index 74% rename from app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmVibrator.kt rename to app/src/main/java/de/jeanlucmakiola/clockula/ring/RingVibrator.kt index 7554f15..44089b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmVibrator.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ring/RingVibrator.kt @@ -1,4 +1,4 @@ -package de.jeanlucmakiola.clockula.alarm.ring +package de.jeanlucmakiola.clockula.ring import android.content.Context import android.media.AudioAttributes @@ -6,14 +6,14 @@ import android.os.Build import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager -import de.jeanlucmakiola.clockula.domain.alarm.AlarmVibrationPattern +import de.jeanlucmakiola.clockula.domain.ring.VibrationPattern /** * The waveform, looped, with `USAGE_ALARM` attributes so Do Not Disturb treats - * it the way it treats the sound. `minSdk` is 29, so `VibratorManager` (API 31+) + * it the way it treats the sound. Shared by the alarm's ring and the timer's. `minSdk` is 29, so `VibratorManager` (API 31+) * is guarded and the older accessor stands in below it. */ -class AlarmVibrator(private val context: Context) { +class RingVibrator(private val context: Context) { private val attributes: AudioAttributes = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ALARM) @@ -30,9 +30,9 @@ class AlarmVibrator(private val context: Context) { fun start() { val device = vibrator?.takeIf { it.hasVibrator() } ?: return val effect = VibrationEffect.createWaveform( - AlarmVibrationPattern.TIMINGS, - AlarmVibrationPattern.AMPLITUDES, - AlarmVibrationPattern.REPEAT_INDEX, + VibrationPattern.TIMINGS, + VibrationPattern.AMPLITUDES, + VibrationPattern.REPEAT_INDEX, ) runCatching { @Suppress("DEPRECATION") diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerScreen.kt new file mode 100644 index 0000000..a728681 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerScreen.kt @@ -0,0 +1,184 @@ +package de.jeanlucmakiola.clockula.ui.common + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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 de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.Ringtones +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf + +/** + * The alarm sound picker: App default, Silent, the user's own file when the + * device does not list it, and the device's own alarm tones — over a + * `LazyColumn`, because a device's tone list can be hundreds of rows (M5 D17). + * + * "Choose from files…" is an action row rather than an option, so the option + * list stays a list of selectable states. Each unselected row can be + * auditioned through the play button; taking a row stops the preview and + * closes the picker (D21). + * + * The SAF launcher is **not** registered here: this screen only exists while + * the ViewModel says the picker is open, and that flag does not survive process + * death — a result restored into a fresh process would have nowhere to land. + * [onPickFile] comes from the host, which registers it unconditionally (D20). + */ +@Composable +internal fun RingtonePickerScreen( + state: RingtonePickerUiState, + defaultUri: String?, + onChoose: (String?) -> Unit, + onPreview: (String?) -> Unit, + onPickFile: () -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker( + title = stringResource(R.string.ringtone_picker_title), + onDismiss = onDismiss, + scrollable = false, + ) { + val rows = state.options.size + 1 + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + if (state.deviceHasNoSounds) { + item { + Text( + text = stringResource(R.string.ringtone_none_available), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + horizontal = GroupedListInset, + vertical = 8.dp, + ), + ) + } + } + if (state.loading) { + item { + CircularProgressIndicator( + modifier = Modifier.padding(GroupedListInset), + ) + } + } + ringtoneOptions(state, rows, onChoose, onPreview, defaultUri) + item { + GroupedRow( + title = stringResource(R.string.ringtone_pick_file), + position = positionOf(rows - 1, rows), + onClick = onPickFile, + ) + } + } + } +} + +private fun LazyListScope.ringtoneOptions( + state: RingtonePickerUiState, + rows: Int, + onChoose: (String?) -> Unit, + onPreview: (String?) -> Unit, + defaultUri: String?, +) { + state.options.forEachIndexed { index, option -> + item(key = optionKey(option)) { + RingtoneOptionRow( + option = option, + position = positionOf(index, rows), + selected = RingtonePicker.isSelected(option, state.selectedUri), + defaultUri = defaultUri, + defaultTitle = state.defaultTitle, + onChoose = onChoose, + onPreview = onPreview, + ) + } + } +} + +private fun optionKey(option: RingtoneChoice): String = when (option) { + RingtoneChoice.AppDefault -> "app-default" + RingtoneChoice.Silent -> "silent" + is RingtoneChoice.Sound -> option.uri +} + +@Composable +private fun RingtoneOptionRow( + option: RingtoneChoice, + position: Position, + selected: Boolean, + defaultUri: String?, + defaultTitle: String?, + onChoose: (String?) -> Unit, + onPreview: (String?) -> Unit, +) { + val unknown = stringResource(R.string.ringtone_unknown) + val deviceDefault = stringResource(R.string.ringtone_device_default) + val silentTitle = stringResource(R.string.ringtone_silent) + val title = when (option) { + RingtoneChoice.AppDefault -> stringResource(R.string.override_app_default) + RingtoneChoice.Silent -> stringResource(R.string.ringtone_silent) + is RingtoneChoice.Sound -> option.title.ifBlank { unknown } + } + val summary = when (option) { + // What "App default" currently resolves to, so inheriting is not blind. + // The title is the *resolved* one, not a scan of the device's tone list: + // a SAF-picked default is absent from that list, and the row would then + // claim the device default instead (M5 D16). + RingtoneChoice.AppDefault -> when { + defaultUri == null -> deviceDefault + Ringtones.isSilent(defaultUri) -> silentTitle + else -> defaultTitle ?: unknown + } + RingtoneChoice.Silent -> stringResource(R.string.ringtone_silent_summary) + is RingtoneChoice.Sound -> null + } + val previewUri = when (option) { + RingtoneChoice.AppDefault -> defaultUri + RingtoneChoice.Silent -> null + is RingtoneChoice.Sound -> option.uri + } + val chosenUri = when (option) { + RingtoneChoice.AppDefault -> null + RingtoneChoice.Silent -> Ringtones.SILENT_URI + is RingtoneChoice.Sound -> option.uri + } + + GroupedRow( + title = title, + summary = summary, + position = position, + selected = selected, + trailing = when { + selected -> { + { SelectedCheck() } + } + previewUri != null -> { + { + IconButton(onClick = { onPreview(previewUri) }) { + Icon(Icons.Rounded.PlayArrow, contentDescription = title) + } + } + } + else -> null + }, + onClick = { onChoose(chosenUri) }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerState.kt new file mode 100644 index 0000000..ff6b42a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerState.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.clockula.ui.common + +import de.jeanlucmakiola.clockula.domain.RingtoneOption +import de.jeanlucmakiola.clockula.domain.Ringtones + +sealed interface RingtoneChoice { + data object AppDefault : RingtoneChoice + data object Silent : RingtoneChoice + data class Sound(val uri: String, val title: String) : RingtoneChoice +} + +data class RingtonePickerUiState( + val loading: Boolean, + val options: List, + /** The alarm's stored override: null = App default, sentinel = Silent. */ + val selectedUri: String?, + /** The device reported no alarm sounds at all (M5 D19). */ + val deviceHasNoSounds: Boolean, + /** + * The app default's own resolved title, or null when nothing named it — so + * the "App default" row can say what inheriting means even when the default + * is a SAF-picked file the device's tone list does not contain (M5 D16). + */ + val defaultTitle: String?, +) + +internal object RingtonePicker { + /** Pure. App default, Silent, the stored sound when absent from [deviceTones], then [deviceTones]. */ + fun optionsFor( + deviceTones: List, + storedUri: String?, + storedTitle: String?, + ): List { + val stored = Ringtones.normalise(storedUri) + // The user's own sound sits directly under Silent when the device does + // not list it — a SAF-picked file would otherwise be invisible, and so + // uncheckable, in the picker that chose it (M5 D17). + val own = stored + ?.takeUnless { Ringtones.isSilent(it) } + ?.takeUnless { uri -> deviceTones.any { it.uri == uri } } + ?.let { RingtoneChoice.Sound(it, storedTitle.orEmpty()) } + + val sounds = (listOfNotNull(own) + deviceTones.map { RingtoneChoice.Sound(it.uri, it.title) }) + .distinctBy { it.uri } + return listOf(RingtoneChoice.AppDefault, RingtoneChoice.Silent) + sounds + } + + fun isSelected(option: RingtoneChoice, storedUri: String?): Boolean = when (option) { + RingtoneChoice.AppDefault -> storedUri == null + RingtoneChoice.Silent -> Ringtones.isSilent(storedUri) + is RingtoneChoice.Sound -> option.uri == storedUri + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt index 73cb2b0..821acd8 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt @@ -25,6 +25,18 @@ class ArchitectureRulesTest { private fun List.containing(vararg needles: String): List = filter { file -> needles.any { it in file.readText() } }.map { it.invariantSeparatorsPath } + /** + * As [containing], but blind to comments: prose may name a platform class + * it explains — `AlarmEditorViewModel`'s KDoc says "no AlarmManager + * traffic" — while code that *uses* one is the offence. + */ + private fun List.usingInCode(vararg needles: String): List = filter { file -> + file.readLines() + .map { it.trim() } + .filterNot { it.startsWith("//") || it.startsWith("*") || it.startsWith("/*") } + .any { line -> needles.any { it in line } } + }.map { it.invariantSeparatorsPath } + @Test fun `Room does not leak above the data layer`() { val offenders = outsideDataLayer.containing("androidx.room") @@ -123,6 +135,7 @@ class ArchitectureRulesTest { val path = file.invariantSeparatorsPath path.contains("de/jeanlucmakiola/clockula/domain/alarm/") || path.endsWith("de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt") || + path.endsWith("de/jeanlucmakiola/clockula/timer/TimerEngine.kt") || path.endsWith("de/jeanlucmakiola/clockula/alarm/AlarmResolver.kt") || path.contains("de/jeanlucmakiola/clockula/system/") } @@ -171,4 +184,33 @@ class ArchitectureRulesTest { assertThat(offenders).isEmpty() } + + /** + * M6 D31: the screen schedules nothing and decides no ring — the mirror of + * "the screen re-derives none of the engine's time arithmetic". + */ + @Test + fun `the screen schedules no timer and decides no ring`() { + val ui = kotlinSources + .filter { it.invariantSeparatorsPath.contains("de/jeanlucmakiola/clockula/ui/") } + + val offenders = ui.usingInCode("TimerScheduler", "TimerService", "TimerRingPolicy", "AlarmManager") + + assertThat(offenders).isEmpty() + } + + /** + * M6 D31: "reuses M3's audio path" made mechanical. One `MediaPlayer` + * wrapper for both ring paths and one for the picker's preview; a third + * audio path cannot be added without the build failing. The `/ring/` match + * covers the wrapper both before and after D8's package move. + */ + @Test + fun `MediaPlayer lives only in the ring path and the ringtone catalog`() { + val offenders = kotlinSources.containing("MediaPlayer").filterNot { path -> + path.contains("/ring/") || path.contains("de/jeanlucmakiola/clockula/data/ringtones/") + } + + assertThat(offenders).isEmpty() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt index 1b7f691..43d4cea 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt @@ -387,4 +387,151 @@ class TimerRepositoryTest { assertThat(repaired.remaining - 90.minutes).isLessThan(1.seconds) assertThat(90.minutes - repaired.remaining).isLessThan(1.seconds) } + + // --- M6: "+1 min" on an expired timer, and the two named edits (§5.9, D3/D12) --- + + /** §5.9 #1 */ + @Test + fun `adding time to an expired timer resumes it with exactly the extra`() = runTest { + val id = startedTimer() + elapsed.value = 1_400.seconds + repository.markExpired(id) + + elapsed.value = 2_000.seconds + repository.addTime(id, 1.minutes) + + val timer = repository.find(id)!! + assertThat( + listOf( + timer.state, + timer.remaining, + timer.startedAtElapsedRealtime, + timer.endsAtElapsedRealtime, + timer.endsAtWallClock, + ), + ).containsExactly( + TimerState.RUNNING, + 1.minutes, + 2_000.seconds, + 2_060.seconds, + T0 + 1.minutes, + ).inOrder() + } + + /** §5.9 #2 */ + @Test + fun `adding time to an expired timer leaves its configured duration alone`() = runTest { + val id = startedTimer() + repository.markExpired(id) + + repository.addTime(id, 1.minutes) + + assertThat(repository.find(id)!!.duration).isEqualTo(5.minutes) + } + + /** §5.9 #3 */ + @Test + fun `setting an idle timer's duration moves its remaining with it`() = runTest { + val id = repository.create(TimerDraft(5.minutes, "Pasta")) + + repository.setDuration(id, 90.seconds) + + val timer = repository.find(id)!! + assertThat(listOf(timer.duration, timer.remaining)).containsExactly(90.seconds, 90.seconds) + assertThat( + listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock), + ).containsExactly(null, null, null) + } + + /** §5.9 #4 */ + @Test + fun `a running timer's duration is not the caller's to change`() = runTest { + startedTimer() + val before = dao.stored.single() + + repository.setDuration(before.id, 90.seconds) + + assertThat(dao.stored.single()).isEqualTo(before) + } + + /** §5.9 #5 */ + @Test + fun `a paused timer's duration is not the caller's to change either`() = runTest { + val id = startedTimer() + elapsed.value = 1_100.seconds + repository.pause(id) + val before = dao.stored.single() + + repository.setDuration(id, 90.seconds) + + assertThat(dao.stored.single()).isEqualTo(before) + } + + /** §5.9 #6 */ + @Test + fun `a negative duration is stored as zero, never as a negative`() = runTest { + val id = repository.create(TimerDraft(5.minutes, "Pasta")) + + repository.setDuration(id, -5.seconds) + + assertThat(repository.find(id)!!.duration).isEqualTo(Duration.ZERO) + } + + /** §5.9 #7 */ + @Test + fun `a ringtone is trimmed, and a blank one is absence`() = runTest { + val id = repository.create(TimerDraft(5.minutes, "Pasta")) + + repository.setRingtoneUri(id, " content://x ") + val trimmed = repository.find(id)!!.ringtoneUri + repository.setRingtoneUri(id, " ") + + assertThat(trimmed to repository.find(id)!!.ringtoneUri).isEqualTo("content://x" to null) + } + + /** §5.9 #8 */ + @Test + fun `both new edits touch no anchor and go through the DAO transaction`() = runTest { + val spy = TransactionSpyTimerDao() + val repository = TimerRepositoryImpl(spy, elapsed, wallClock) + val id = repository.create(TimerDraft(5.minutes, "Pasta")) + repository.start(id) + val anchors = repository.find(id)!!.let { + listOf(it.startedAtElapsedRealtime, it.endsAtElapsedRealtime, it.endsAtWallClock) + } + + repository.setRingtoneUri(id, "content://x") + repository.setDuration(id, 90.seconds) + + val timer = repository.find(id)!! + assertThat( + listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock), + ).isEqualTo(anchors) + assertThat(spy.updatesOutsideATransaction).isEqualTo(0) + } + + /** §5.9 #9 — added with the review fix for finding 1. */ + @Test + fun `re-anchoring writes nothing when the wall clock has not moved`() = runTest { + val id = startedTimer() + val before = repository.find(id)!! + + repository.reanchorWallClocks() + + assertThat(repository.find(id)).isEqualTo(before) + } + + /** §5.9 #10 — added with the review fix for finding 1. */ + @Test + fun `re-anchoring leaves a paused timer alone`() = runTest { + val id = repository.create(TimerDraft(5.minutes, "Pasta")) + repository.start(id) + repository.pause(id) + val before = repository.find(id)!! + + wallClock.advance(1.hours) + repository.reanchorWallClocks() + + assertThat(repository.find(id)).isEqualTo(before) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/RingPoliciesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/RingPoliciesTest.kt index 3011bcf..64faaaa 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/RingPoliciesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/RingPoliciesTest.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.clockula.domain.alarm import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy import org.junit.jupiter.api.Test /** @@ -96,17 +97,4 @@ class RingPoliciesTest { // There is no third branch to fall into — "do not schedule" is not a mode. assertThat(ScheduleMode.entries).hasSize(2) } - - @Test - fun `the vibration pattern is a loopable, non-empty pair of timings and amplitudes`() { - val timings = AlarmVibrationPattern.TIMINGS - val amplitudes = AlarmVibrationPattern.AMPLITUDES - - assertThat(timings.size).isEqualTo(amplitudes.size) - assertThat(timings.size).isGreaterThan(0) - assertThat(AlarmVibrationPattern.REPEAT_INDEX).isIn(timings.indices.toList()) - assertThat(timings.filter { it <= 0L }).isEmpty() - assertThat(amplitudes.filterNot { it in 0..255 }).isEmpty() - assertThat(amplitudes.filter { it > 0 }).isNotEmpty() - } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AudioSourcePolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/AudioSourcePolicyTest.kt similarity index 98% rename from app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AudioSourcePolicyTest.kt rename to app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/AudioSourcePolicyTest.kt index f537d9b..943a6ad 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AudioSourcePolicyTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/AudioSourcePolicyTest.kt @@ -1,4 +1,4 @@ -package de.jeanlucmakiola.clockula.domain.alarm +package de.jeanlucmakiola.clockula.domain.ring import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/VibrationPatternTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/VibrationPatternTest.kt new file mode 100644 index 0000000..c3f3191 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/VibrationPatternTest.kt @@ -0,0 +1,25 @@ +package de.jeanlucmakiola.clockula.domain.ring + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The looped waveform, now shared by both ring paths (slice plan D8). Moved + * here with its assertion unchanged: the pattern did not change, only where it + * lives. + */ +class VibrationPatternTest { + + @Test + fun `the vibration pattern is a loopable, non-empty pair of timings and amplitudes`() { + val timings = VibrationPattern.TIMINGS + val amplitudes = VibrationPattern.AMPLITUDES + + assertThat(timings.size).isEqualTo(amplitudes.size) + assertThat(timings.size).isGreaterThan(0) + assertThat(VibrationPattern.REPEAT_INDEX).isIn(timings.indices.toList()) + assertThat(timings.filter { it <= 0L }).isEmpty() + assertThat(amplitudes.filterNot { it in 0..255 }).isEmpty() + assertThat(amplitudes.filter { it > 0 }).isNotEmpty() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRampTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/VolumeRampTest.kt similarity index 88% rename from app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRampTest.kt rename to app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/VolumeRampTest.kt index 0057c29..0089e7e 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRampTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/VolumeRampTest.kt @@ -1,4 +1,4 @@ -package de.jeanlucmakiola.clockula.domain.alarm +package de.jeanlucmakiola.clockula.domain.ring import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test @@ -17,7 +17,7 @@ class VolumeRampTest { fun `the ramp starts at the floor rather than at silence`() { val level = VolumeRamp.levelAt(Duration.ZERO, ramp) - assertThat(level).isEqualTo(AlarmRing.MIN_VOLUME) + assertThat(level).isEqualTo(RingAudio.MIN_VOLUME) } @Test @@ -44,7 +44,7 @@ class VolumeRampTest { fun `a negative elapsed reading never falls below the floor`() { val level = VolumeRamp.levelAt((-5).seconds, ramp) - assertThat(level).isEqualTo(AlarmRing.MIN_VOLUME) + assertThat(level).isEqualTo(RingAudio.MIN_VOLUME) } @Test @@ -60,6 +60,6 @@ class VolumeRampTest { VolumeRamp.levelAt((-5).seconds + (35.seconds / 100) * step, ramp) } - assertThat(samples.filterNot { it >= AlarmRing.MIN_VOLUME && it <= 1f }).isEmpty() + assertThat(samples.filterNot { it >= RingAudio.MIN_VOLUME && it <= 1f }).isEmpty() } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt index a904bba..88a1c69 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt @@ -169,7 +169,12 @@ class RecordingTimerRepository : TimerRepository { override suspend fun reset(id: Long): Unit = TODO("not used by the reboot gate") override suspend fun addTime(id: Long, extra: Duration): Unit = TODO("not used by the reboot gate") override suspend fun markExpired(id: Long): Unit = TODO("not used by the reboot gate") + override suspend fun setDuration(id: Long, duration: Duration): Unit = + TODO("not used by the reboot gate") + override suspend fun setRingtoneUri(id: Long, uri: String?): Unit = + TODO("not used by the reboot gate") override suspend fun reorder(idsInOrder: List): Unit = TODO("not used by the reboot gate") + override suspend fun reanchorWallClocks(): Unit = TODO("not used by the reboot gate") } /** A [StopwatchRepository] that records only the one call the reboot gate makes. */ diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt index 375b11d..b93c99c 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt @@ -8,6 +8,9 @@ import de.jeanlucmakiola.clockula.domain.StopwatchState import de.jeanlucmakiola.clockula.domain.Timer import de.jeanlucmakiola.clockula.domain.TimerDraft import de.jeanlucmakiola.clockula.domain.TimerState +import de.jeanlucmakiola.clockula.domain.snapshotAt +import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock +import de.jeanlucmakiola.clockula.domain.time.WallClock import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map @@ -19,10 +22,16 @@ import kotlin.time.Duration * observing flow exactly as Room's would, and every mutating call is logged so * "the pill stopped it rather than deleting it" is assertable. * - * It has no clock, so the anchors it writes are deliberately absent — a test - * that cares about them hands the rows over with [set] instead. + * Its clocks are injected and default to standing still, so the anchors it + * writes are the ones the real repository would write from one reading of both + * — which is what lets a real `TimerEngine` register a deadline over it (M6 + * D25). A test that wants a particular row still hands it over with [set]. */ -class FakeTimerRepository(initial: List = emptyList()) : TimerRepository { +class FakeTimerRepository( + initial: List = emptyList(), + private val elapsed: ElapsedRealtimeClock = FakeElapsedRealtimeClock(Duration.ZERO), + private val wall: WallClock = FakeWallClock(T0), +) : TimerRepository { private val rows = MutableStateFlow(initial) private val log = mutableListOf() @@ -72,14 +81,32 @@ class FakeTimerRepository(initial: List = emptyList()) : TimerRepository override suspend fun start(id: Long) { log += "start($id)" - mutate(id) { it.copy(state = TimerState.RUNNING) } + val now = elapsed.elapsedRealtime() + val wallNow = wall.now() + mutate(id) { timer -> + if (timer.state == TimerState.RUNNING) { + timer + } else { + val left = if (timer.state == TimerState.PAUSED) timer.remaining else timer.duration + timer.copy( + state = TimerState.RUNNING, + remaining = left, + startedAtElapsedRealtime = now, + endsAtElapsedRealtime = now + left, + endsAtWallClock = wallNow + left, + ) + } + } } override suspend fun pause(id: Long) { log += "pause($id)" + val now = elapsed.elapsedRealtime() + val wallNow = wall.now() mutate(id) { it.copy( state = TimerState.PAUSED, + remaining = it.snapshotAt(now, wallNow).remaining, startedAtElapsedRealtime = null, endsAtElapsedRealtime = null, endsAtWallClock = null, @@ -110,11 +137,36 @@ class FakeTimerRepository(initial: List = emptyList()) : TimerRepository mutate(id) { it.copy(state = TimerState.EXPIRED, remaining = Duration.ZERO) } } + override suspend fun setDuration(id: Long, duration: Duration) { + log += "setDuration($id)" + mutate(id) { if (it.state == TimerState.IDLE) it.copy(duration = duration, remaining = duration) else it } + } + + override suspend fun setRingtoneUri(id: Long, uri: String?) { + log += "setRingtoneUri($id)" + mutate(id) { it.copy(ringtoneUri = uri?.trim()?.takeIf(String::isNotEmpty)) } + } + override suspend fun reorder(idsInOrder: List) { log += "reorder(${idsInOrder.joinToString(",")})" rows.update { all -> all.map { it.copy(sortOrder = idsInOrder.indexOf(it.id)) } } } + override suspend fun reanchorWallClocks() { + log += "reanchorWallClocks()" + val now = elapsed.elapsedRealtime() + val wallNow = wall.now() + rows.update { all -> + all.map { timer -> + if (timer.state != TimerState.RUNNING) { + timer + } else { + timer.copy(endsAtWallClock = wallNow + timer.snapshotAt(now, wallNow).remaining) + } + } + } + } + override suspend fun repairAfterReboot() { log += "repairAfterReboot()" } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt index 2c5e727..1d474fd 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt @@ -17,6 +17,7 @@ import java.time.LocalDateTime import java.time.OffsetDateTime import java.time.ZoneId import java.time.ZonedDateTime +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -216,3 +217,108 @@ fun upcomingAlarm( nextFire = nextFire, source = source, ) + +// --- M6: timer builders, beside `runningTimer()`'s every-field-distinct row --- + +/** + * One timer with only the fields M6 reasons about spelled out. The anchors are + * passed, never derived, so a test can hand over the corrupt and stale + * combinations a repository would never write. + */ +fun timerAt( + id: Long = 1L, + label: String = "Pasta", + duration: Duration = 5.minutes, + state: TimerState = TimerState.IDLE, + remaining: Duration = duration, + startedAtElapsedRealtime: Duration? = null, + endsAtElapsedRealtime: Duration? = null, + endsAtWallClock: Instant? = null, + ringtoneUri: String? = null, + sortOrder: Int = 0, +): Timer = Timer( + id = id, + label = label, + duration = duration, + state = state, + remaining = remaining, + startedAtElapsedRealtime = startedAtElapsedRealtime, + endsAtElapsedRealtime = endsAtElapsedRealtime, + endsAtWallClock = endsAtWallClock, + ringtoneUri = ringtoneUri, + sortOrder = sortOrder, + createdAt = T0, + updatedAt = T0, +) + +fun idleTimer( + id: Long = 1L, + duration: Duration = 5.minutes, + label: String = "Pasta", + sortOrder: Int = 0, + ringtoneUri: String? = null, +): Timer = timerAt( + id = id, + label = label, + duration = duration, + state = TimerState.IDLE, + remaining = duration, + sortOrder = sortOrder, + ringtoneUri = ringtoneUri, +) + +/** RUNNING with all three anchors derived from one reading of both clocks, [left] from now. */ +fun runningTimerWith( + id: Long = 1L, + left: Duration, + elapsedNow: Duration = Duration.ZERO, + wallNow: Instant = T0, + duration: Duration = 5.minutes, + label: String = "Pasta", + sortOrder: Int = 0, + ringtoneUri: String? = null, +): Timer = timerAt( + id = id, + label = label, + duration = duration, + state = TimerState.RUNNING, + remaining = left, + startedAtElapsedRealtime = elapsedNow, + endsAtElapsedRealtime = elapsedNow + left, + endsAtWallClock = wallNow + left, + sortOrder = sortOrder, + ringtoneUri = ringtoneUri, +) + +fun pausedTimer( + id: Long = 1L, + remaining: Duration = 1.minutes, + duration: Duration = 5.minutes, + label: String = "Pasta", + sortOrder: Int = 0, + ringtoneUri: String? = null, +): Timer = timerAt( + id = id, + label = label, + duration = duration, + state = TimerState.PAUSED, + remaining = remaining, + sortOrder = sortOrder, + ringtoneUri = ringtoneUri, +) + +fun expiredTimer( + id: Long = 1L, + duration: Duration = 5.minutes, + label: String = "Pasta", + sortOrder: Int = 0, + ringtoneUri: String? = null, +): Timer = timerAt( + id = id, + label = label, + duration = duration, + state = TimerState.EXPIRED, + remaining = Duration.ZERO, + sortOrder = sortOrder, + ringtoneUri = ringtoneUri, +) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt index 659bc7a..4e9c0b9 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension import de.jeanlucmakiola.clockula.testing.PreviewEvent +import de.jeanlucmakiola.clockula.ui.common.RingtoneChoice import de.jeanlucmakiola.clockula.testing.RingEvent import de.jeanlucmakiola.clockula.testing.alarmAt import de.jeanlucmakiola.clockula.testing.alarmEngineHarness diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerTest.kt similarity index 99% rename from app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerTest.kt rename to app/src/test/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerTest.kt index 864209c..ba712d8 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerTest.kt @@ -1,4 +1,4 @@ -package de.jeanlucmakiola.clockula.ui.alarms +package de.jeanlucmakiola.clockula.ui.common import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt index e38f7b2..8f6a482 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.clockula.ui.shell import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.timer.TimerIntents +import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes import org.junit.jupiter.api.Test /** @@ -138,4 +140,36 @@ class ShellNavigationTest { fun `back from a nested destination is not the shell's to intercept`() { assertThat(ShellNavigation.onBack("alarms/12")).isEqualTo(ShellBackAction.Exit) } + + // --- M6 §5.15: the timer editor's route, and the notification's deep link --- + + /** §5.15 #1 */ + @Test + fun `the timer editor is no tab, so the timers tab stays selected`() { + assertThat(ShellNavigation.destinationOf(TimerRoutes.editor(7L))).isNull() + } + + /** §5.15 #2 */ + @Test + fun `the shell stands aside on back, leaving the timer editor to pop itself`() { + assertThat(ShellNavigation.onBack(TimerRoutes.editor(7L))).isEqualTo(ShellBackAction.Exit) + } + + /** §5.15 #3 */ + @Test + fun `a timer notification asks for the timers tab`() { + assertThat(ShellNavigation.tabForAction(TimerIntents.ACTION_SHOW_TIMERS)) + .isEqualTo(ClockulaDestination.TIMERS) + } + + /** §5.15 #4 */ + @Test + fun `an absent or unknown action asks for no tab at all`() { + val answers = listOf( + ShellNavigation.tabForAction(null), + ShellNavigation.tabForAction("android.intent.action.MAIN"), + ) + + assertThat(answers).containsExactly(null, null) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt index 6c351da..4c6fad5 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt @@ -15,15 +15,18 @@ import de.jeanlucmakiola.clockula.testing.FakeStopwatchRepository import de.jeanlucmakiola.clockula.testing.FakeTicker import de.jeanlucmakiola.clockula.testing.FakeTimerRepository import de.jeanlucmakiola.clockula.testing.FakeWallClock +import de.jeanlucmakiola.clockula.testing.TimerEngineSeams import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension import de.jeanlucmakiola.clockula.testing.instantAt import de.jeanlucmakiola.clockula.testing.runningTimer +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import kotlin.time.Duration import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds /** * The pill's two buttons, and the promise behind them: the primary one pauses @@ -39,10 +42,21 @@ class ShellViewModelTest { private val now = 1.hours private val wallInstant = instantAt(BERLIN, "2026-06-10T12:00") - private val timers = FakeTimerRepository() + private val timers = FakeTimerRepository( + elapsed = FakeElapsedRealtimeClock(now), + wall = FakeWallClock(wallInstant), + ) private val stopwatch = FakeStopwatchRepository() private val ticker = FakeTicker() + /** + * The pill's timer branch goes through the **real** engine over the same + * repository (M6 D25): without it, pausing from the pill would leave the + * expiry slot pointing at a dead deadline. The repository's call log is + * therefore still what the older cases assert on. + */ + private val seams = TimerEngineSeams(timers = timers, now = wallInstant, uptime = now) + private fun viewModel() = ShellViewModel( source = LivePillSource( timers = timers, @@ -53,6 +67,7 @@ class ShellViewModelTest { ), timers = timers, stopwatch = stopwatch, + timerEngine = seams.engine, ) private fun timerState(id: Long = 7L, mode: LivePillMode) = LivePillState( @@ -69,6 +84,31 @@ class ShellViewModelTest { otherActiveTimers = 0, ) + private fun pausedTimerRow(id: Long = 7L, left: Duration = 4.minutes): Timer = + runningTimer().copy( + id = id, + state = TimerState.PAUSED, + remaining = left, + startedAtElapsedRealtime = null, + endsAtElapsedRealtime = null, + endsAtWallClock = null, + ) + + private fun expiredTimerRow(id: Long = 7L): Timer = runningTimer().copy( + id = id, + state = TimerState.EXPIRED, + remaining = Duration.ZERO, + startedAtElapsedRealtime = null, + endsAtElapsedRealtime = null, + endsAtWallClock = null, + ) + + private fun rowFor(mode: LivePillMode): Timer = when (mode) { + LivePillMode.RUNNING -> runningTimerRow() + LivePillMode.PAUSED -> pausedTimerRow() + LivePillMode.EXPIRED -> expiredTimerRow() + } + private fun runningTimerRow(id: Long = 7L, left: Duration = 4.minutes): Timer = runningTimer().copy( id = id, @@ -95,6 +135,8 @@ class ShellViewModelTest { @Test fun `the primary action pauses a running timer`() = runTest(main.dispatcher) { + timers.set(listOf(runningTimerRow())) + viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.RUNNING)) assertThat(timers.calls.last()).isEqualTo("pause(7)") @@ -102,6 +144,8 @@ class ShellViewModelTest { @Test fun `the primary action resumes a paused timer`() = runTest(main.dispatcher) { + timers.set(listOf(pausedTimerRow())) + viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.PAUSED)) assertThat(timers.calls.last()).isEqualTo("start(7)") @@ -132,7 +176,12 @@ class ShellViewModelTest { fun `stop resets a timer in any mode and never deletes it`() = runTest(main.dispatcher) { val viewModel = viewModel() - LivePillMode.entries.forEach { viewModel.onPillStop(timerState(mode = it)) } + LivePillMode.entries.forEach { mode -> + // Re-seeded, not re-created: `reset` is a no-op on a timer that is + // already idle, and each mode is its own question. + timers.set(listOf(rowFor(mode))) + viewModel.onPillStop(timerState(mode = mode)) + } assertThat(timers.calls).containsExactly("reset(7)", "reset(7)", "reset(7)") } @@ -160,4 +209,53 @@ class ShellViewModelTest { cancel() } } + + // --- M6 §5.14: the pill's timer branch goes through the engine (D25) --- + + /** §5.14 #1 */ + @Test + fun `pausing from the pill cancels the expiry slot with it`() = runTest(main.dispatcher) { + timers.set(listOf(runningTimerRow())) + seams.engine.resync() + + viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.RUNNING)) + + assertThat(timers.timers().first().single().state to seams.scheduler.expiryAt) + .isEqualTo(TimerState.PAUSED to null) + } + + /** §5.14 #2 */ + @Test + fun `resuming from the pill registers the deadline again`() = runTest(main.dispatcher) { + timers.set(listOf(pausedTimerRow(left = 4.minutes))) + + viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.PAUSED)) + + assertThat(timers.timers().first().single().state to seams.scheduler.expiryAt) + .isEqualTo(TimerState.RUNNING to now + 4.minutes) + } + + /** §5.14 #3 */ + @Test + fun `stopping a sounding timer from the pill closes the ring session`() = runTest(main.dispatcher) { + timers.set(listOf(expiredTimerRow())) + seams.ringState.set(wallInstant - 30.seconds) + + viewModel().onPillStop(timerState(mode = LivePillMode.EXPIRED)) + + assertThat(timers.timers().first().single().state to seams.ringState.current()) + .isEqualTo(TimerState.IDLE to null) + } + + /** §5.14 #4 */ + @Test + fun `the stopwatch's half of the pill does not reach the timer engine`() = runTest(main.dispatcher) { + val viewModel = viewModel() + + viewModel.onPillPrimaryAction(stopwatchState(mode = LivePillMode.RUNNING)) + viewModel.onPillStop(stopwatchState(mode = LivePillMode.RUNNING)) + + assertThat(seams.scheduler.scheduled).isEmpty() + assertThat(seams.service.transitions).isEmpty() + } }