feat(timers): the timers tab, and the pill that finally has something to show

Multiple concurrent timers with labels, presets, add, pause, reset and +1 min.
The setup panel is inline on an empty tab, so the first timer costs no
navigation.

The live pill has been waiting since M4 for something that could start a
timer. It now goes through the engine rather than the repository, because a
scheduler registration and a foreground service have to move with the state —
its own contract is unchanged.

Three new architecture rules keep it that way: no screen may name the
scheduler, the service or AlarmManager, and a second MediaPlayer anywhere
outside the ring path fails the build.
This commit is contained in:
2026-09-12 16:48:36 +02:00
parent 7f9219e929
commit b67142a727
24 changed files with 2733 additions and 12 deletions
@@ -0,0 +1,154 @@
package de.jeanlucmakiola.clockula.ui
import android.content.Context
import android.content.Intent
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* §5.19 — 4 cases. What only a device can answer about the Timers tab: that the
* keypad and the bottom sheet really compose and really write, that the typed
* digits survive a real activity recreation (which is the only thing that
* proves `rememberSaveable`, D23), and that a real system back press from the
* editor lands on the tab with the tab still selected (D24).
*
* Compiled in the gate; **not run here** — no device is attached (§5.19).
*/
@RunWith(AndroidJUnit4::class)
class TimersInstrumentedTest {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Seams {
fun timers(): TimerRepository
}
@get:Rule
val composeTestRule = createEmptyComposeRule()
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
private val seams: Seams =
EntryPointAccessors.fromApplication(context.applicationContext, Seams::class.java)
private var scenario: ActivityScenario<MainActivity>? = null
@After
fun tearDown() {
scenario?.close()
runBlocking { seams.timers().timers().first().forEach { seams.timers().delete(it.id) } }
}
private fun string(id: Int): String = context.getString(id)
private fun stored(): List<Timer> = runBlocking { seams.timers().timers().first() }
private fun givenTimer(label: String, running: Boolean = true): Long = runBlocking {
val id = seams.timers().create(TimerDraft(5.minutes, label))
if (running) seams.timers().start(id)
id
}
private fun openTimersTab(): ActivityScenario<MainActivity> {
val launched = ActivityScenario.launch<MainActivity>(
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
).also { scenario = it }
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.tab_timers)).performClick()
composeTestRule.waitForIdle()
return launched
}
private fun typeDigits(vararg digits: Char) {
digits.forEach {
composeTestRule.onNodeWithText(it.toString()).performClick()
composeTestRule.waitForIdle()
}
}
/** §5.19 #1 */
@Test
fun typingOnTheKeypadAndPressingStartCreatesOneRunningTimer() {
openTimersTab()
typeDigits('1', '3', '0')
composeTestRule.onNodeWithText(string(R.string.timers_setup_start)).performClick()
composeTestRule.waitForIdle()
val timers = stored()
assertThat(timers.map { it.state to it.duration })
.containsExactly(TimerState.RUNNING to 90.seconds)
}
/** §5.19 #2 */
@Test
fun theTypedDigitsSurviveAnActivityRecreation() {
val launched = openTimersTab()
typeDigits('1', '3', '0')
launched.recreate()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("0:01:30").assertIsDisplayed()
}
/** §5.19 #3 */
@Test
fun pausingOneRowLeavesTheOtherRunning() {
val paused = givenTimer("Pasta")
val untouched = givenTimer("Eggs")
openTimersTab()
// Storage order, so the first row is Pasta's (D22).
composeTestRule.onAllNodesWithText(string(R.string.timer_pause))[0].performClick()
composeTestRule.waitForIdle()
val byId = stored().associateBy { it.id }
assertThat(byId[paused]?.state to byId[untouched]?.state)
.isEqualTo(TimerState.PAUSED to TimerState.RUNNING)
}
/** §5.19 #4 */
@Test
fun backFromTheTimerEditorReturnsToTheTimersTab() {
givenTimer("Pasta", running = false)
openTimersTab()
// The card announces its own content and labels the tap "Timer settings"
// through `onClick`, so the row is found by the timer it is.
composeTestRule.onNodeWithText("Pasta").performClick()
composeTestRule.waitForIdle()
Espresso.pressBack()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Pasta").assertIsDisplayed()
assertThat(scenario?.state).isNotEqualTo(Lifecycle.State.DESTROYED)
}
}
@@ -18,7 +18,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.clockula.ui.shell.ClockulaDestination
import de.jeanlucmakiola.clockula.ui.shell.ClockulaShell
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
import de.jeanlucmakiola.clockula.ui.shell.RequestNotificationPermissionOnce
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
@@ -49,6 +51,14 @@ class MainActivity : ComponentActivity() {
// crash-loop is handled out of band, before setContent — see below.
private var pendingCrashReport by mutableStateOf<String?>(null)
/**
* A tab an incoming intent asked for, consumed once by the shell (M6 D24).
* Read in both `onCreate` and `onNewIntent`, because the activity is
* `singleTop`: tapping a timer notification with the app already open
* delivers a new intent to the running instance rather than creating one.
*/
private var openTab by mutableStateOf<ClockulaDestination?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -64,6 +74,8 @@ class MainActivity : ComponentActivity() {
// Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
openTab = ShellNavigation.tabForAction(intent?.action)
setContent {
// Until the first snapshot arrives, the family's defaults stand in —
// follow the system, dynamic colour on — so the first frame is never
@@ -82,7 +94,13 @@ class MainActivity : ComponentActivity() {
darkTheme = darkTheme,
dynamicColor = appearance.dynamicColor,
) {
ClockulaShell(Modifier.fillMaxSize())
ClockulaShell(
modifier = Modifier.fillMaxSize(),
openTab = openTab,
// Consumed once: a request left standing would re-select the
// tab on every recomposition and fight the user's taps.
onTabOpened = { openTab = null },
)
// First launch asks for notifications once, and only here:
// it belongs to starting the app, not to any one tab.
RequestNotificationPermissionOnce(settingsPrefs)
@@ -111,6 +129,12 @@ class MainActivity : ComponentActivity() {
)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
openTab = ShellNavigation.tabForAction(intent.action)
}
override fun onResume() {
super.onResume()
// A successful start breaks any loop; reset the timing trail so a later
@@ -14,6 +14,7 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItem
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -32,6 +33,8 @@ import de.jeanlucmakiola.clockula.ui.alarms.AlarmEditorScreen
import de.jeanlucmakiola.clockula.ui.alarms.AlarmRoutes
import de.jeanlucmakiola.clockula.ui.alarms.AlarmsScreen
import de.jeanlucmakiola.clockula.ui.stopwatch.StopwatchScreen
import de.jeanlucmakiola.clockula.ui.timers.TimerEditorScreen
import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes
import de.jeanlucmakiola.clockula.ui.timers.TimersScreen
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockScreen
import de.jeanlucmakiola.floret.identity.fadeThrough
@@ -52,6 +55,8 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
@Composable
fun ClockulaShell(
modifier: Modifier = Modifier,
openTab: ClockulaDestination? = null,
onTabOpened: () -> Unit = {},
viewModel: ShellViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(),
) {
@@ -65,6 +70,15 @@ fun ClockulaShell(
// rather than sliding: a slide would claim a hierarchy that is not there.
val fade = fadeThrough()
// A notification's deep link, replayed as exactly the command a tab tap
// produces — which is what the user expects, and it leaves M4's asserted
// back policy alone: the start destination is still Alarms (M6 D24).
LaunchedEffect(openTab) {
val target = openTab ?: return@LaunchedEffect
navController.replay(ShellNavigation.onTabSelected(route, target))
onTabOpened()
}
NavigationSuiteScaffold(
navigationItems = {
ShellNavigation.topLevel.forEach { destination ->
@@ -118,7 +132,20 @@ fun ClockulaShell(
) {
AlarmEditorScreen(onBack = { navController.popBackStack() })
}
composable(ClockulaDestination.TIMERS.route) { TimersScreen() }
composable(ClockulaDestination.TIMERS.route) {
TimersScreen(onEditTimer = { navController.navigate(TimerRoutes.editor(it)) })
}
// A sibling route in the same flat host, exactly as the
// alarm editor is: it is no tab, so the Timers tab stays
// selected and the shell stands aside on back (M5 D1).
composable(
route = TimerRoutes.EDITOR_PATTERN,
arguments = listOf(
navArgument(TimerRoutes.ARG_TIMER_ID) { type = NavType.LongType },
),
) {
TimerEditorScreen(onBack = { navController.popBackStack() })
}
composable(ClockulaDestination.STOPWATCH.route) { StopwatchScreen() }
composable(ClockulaDestination.WORLD_CLOCK.route) { WorldClockScreen() }
}
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.clockula.ui.shell
import de.jeanlucmakiola.clockula.timer.TimerIntents
/** What a tab tap does to the back stack. Pure data; the composable replays it. */
data class TabNavCommand(
val route: String,
@@ -45,6 +47,17 @@ object ShellNavigation {
launchSingleTop = true,
)
/**
* The tab an incoming intent action asks for. Null for null and for
* anything unknown, so an unrecognised action leaves the start destination
* alone (M6 D24). M9 extends this with the rest of the `AlarmClock`
* contract; M6 wires exactly one action to it.
*/
fun tabForAction(action: String?): ClockulaDestination? = when (action) {
TimerIntents.ACTION_SHOW_TIMERS -> ClockulaDestination.TIMERS
else -> null
}
/**
* Alarms is the shell's root: back from any other tab returns to it, and
* back from Alarms leaves the app. An unrecognised route is a nested
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.live.LivePillMode
import de.jeanlucmakiola.clockula.domain.live.LivePillState
import de.jeanlucmakiola.clockula.domain.live.LivePillSubject
import de.jeanlucmakiola.clockula.timer.TimerEngine
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
@@ -20,6 +21,9 @@ class ShellViewModel @Inject internal constructor(
source: LivePillSource,
private val timers: TimerRepository,
private val stopwatch: StopwatchRepository,
// M6 D25: the pill's timer branch goes through the engine, so pausing from
// the pill moves the expiry slot and the service with it.
private val timerEngine: TimerEngine,
) : ViewModel() {
val livePill: StateFlow<LivePillState?> = source.state()
@@ -31,8 +35,11 @@ class ShellViewModel @Inject internal constructor(
val resume = state.mode == LivePillMode.PAUSED
viewModelScope.launch {
when (val subject = state.subject) {
// Through the engine, not the repository: without it, pausing
// from the pill would leave the expiry slot pointing at a dead
// deadline and the service posting a stale notification (D25).
is LivePillSubject.TimerSubject ->
if (resume) timers.start(subject.timerId) else timers.pause(subject.timerId)
if (resume) timerEngine.start(subject.timerId) else timerEngine.pause(subject.timerId)
LivePillSubject.StopwatchSubject ->
if (resume) stopwatch.start() else stopwatch.pause()
@@ -45,8 +52,10 @@ class ShellViewModel @Inject internal constructor(
viewModelScope.launch {
when (val subject = state.subject) {
// Reset, not delete: the pill must not be able to destroy a
// timer the user configured and will want again.
is LivePillSubject.TimerSubject -> timers.reset(subject.timerId)
// timer the user configured and will want again — and it is what
// makes a timer its own preset (D13). Through the engine, so
// stopping a *sounding* timer closes the ring session with it.
is LivePillSubject.TimerSubject -> timerEngine.reset(subject.timerId)
LivePillSubject.StopwatchSubject -> stopwatch.reset()
}
}
@@ -0,0 +1,278 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
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.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerScreen
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
/**
* The timer editor: a label, the length — **only while the timer is idle**
* (D3) — and the one per-timer override a timer has, its ringtone (D27).
* Every control writes at once, so there is no Save button, no dirty state and
* no discard dialog: back is just back (M5 D22).
*/
@Composable
fun TimerEditorScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TimerEditorViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle()
// Registered unconditionally, and deliberately not inside the `picker?.let`
// below: the picker's open flag is ViewModel state that does not survive
// process death, so a launcher registered only while the picker is on
// screen would not be there to take a result restored into a fresh process
// (M5 D20).
val pickDocument = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri ->
// The Uri never crosses into the ViewModel, which is what keeps it
// JVM-testable. A cancelled pick is a null, which changes nothing.
viewModel.onDocumentPicked(uri?.toString())
}
LaunchedEffect(state) {
// Deleted here, or somewhere else: there is nothing left to edit.
if (state is TimerEditorUiState.Gone) onBack()
}
CollapsingScaffold(
title = stringResource(R.string.timer_editor_title),
onBack = onBack,
modifier = modifier,
predictiveBack = true,
) {
val editing = state as? TimerEditorUiState.Editing ?: return@CollapsingScaffold
TimerEditorContent(editing = editing, viewModel = viewModel)
}
picker?.let { pickerState ->
val editing = state as? TimerEditorUiState.Editing
RingtonePickerScreen(
state = pickerState,
defaultUri = editing?.defaults?.timerRingtoneUri,
onChoose = viewModel::onRingtoneChosen,
onPreview = viewModel::onPreview,
onPickFile = { pickDocument.launch(arrayOf(AUDIO_MIME)) },
onDismiss = viewModel::onCloseRingtonePicker,
)
}
}
private const val AUDIO_MIME = "audio/*"
@Composable
private fun TimerEditorContent(
editing: TimerEditorUiState.Editing,
viewModel: TimerEditorViewModel,
) {
// Saveable, not remembered: a dialog open across a rotation must still be
// open afterwards (M5's lesson).
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
var editingLength by rememberSaveable { mutableStateOf(false) }
var digits by rememberSaveable { mutableStateOf("") }
val entry = TimerDurationEntry(digits)
LengthCard(
editing = editing,
onClick = {
if (editing.canEditDuration) {
digits = ""
editingLength = true
}
},
)
LabelField(label = editing.label, onLabelTyped = viewModel::onLabelTyped)
SoundRow(editing = editing, onClick = viewModel::onOpenRingtonePicker)
Spacer(Modifier.height(16.dp))
DeleteRow(onClick = { confirmingDelete = true })
Spacer(Modifier.height(LocalLivePillInset.current))
if (editingLength) {
// The same keypad the setup panel uses, over the same pure state
// machine: one answer to "type a duration" in the whole app (D23).
AlertDialog(
onDismissRequest = { editingLength = false },
title = { Text(stringResource(R.string.timer_editor_duration)) },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = entry.display,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(16.dp))
TimerKeypad(
onDigit = { digits = entry.plusDigit(it).digits },
onBackspace = { digits = entry.backspace().digits },
)
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.onDurationCommitted(entry.duration)
editingLength = false
},
enabled = entry.canStart,
) {
Text(stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = { editingLength = false }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
if (confirmingDelete) {
DeleteConfirmation(
onConfirm = {
confirmingDelete = false
viewModel.onDelete()
},
onDismiss = { confirmingDelete = false },
)
}
}
/** The configured length, at display size — the one thing this page is about. */
@Composable
private fun LengthCard(
editing: TimerEditorUiState.Editing,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
GroupedSurface(
position = Position.Alone,
modifier = modifier.padding(horizontal = GroupedListInset),
onClick = onClick,
) {
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) {
Text(
text = ClockFormat.countdown(editing.duration),
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (!editing.canEditDuration) {
// Refused rather than silently ignored: changing the length of a
// countdown already under way is ambiguous, and every answer is
// a surprise (D3).
Text(
text = stringResource(R.string.timer_editor_duration_locked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun LabelField(label: String, onLabelTyped: (String) -> Unit, modifier: Modifier = Modifier) {
GroupedSurface(
position = Position.Alone,
modifier = modifier.padding(horizontal = GroupedListInset),
) {
InlineTextField(
value = label,
onValueChange = onLabelTyped,
placeholder = stringResource(R.string.timer_editor_label),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
)
}
}
/** M5's picker, so every one of its behaviours arrives here for free (D27). */
@Composable
private fun SoundRow(editing: TimerEditorUiState.Editing, onClick: () -> Unit) {
val silent = stringResource(R.string.ringtone_silent)
val unknown = stringResource(R.string.ringtone_unknown)
val deviceDefault = stringResource(R.string.ringtone_device_default)
val appDefault = stringResource(R.string.override_app_default)
val ringtone = editing.ringtone
val value = when {
ringtone.silent -> silent
ringtone.effectiveUri == null -> deviceDefault
else -> ringtone.title ?: unknown
}
val summary = buildList {
add(if (ringtone.inherited) "$appDefault ($value)" else value)
if (ringtone.unavailable) add(stringResource(R.string.ringtone_unavailable))
if (ringtone.accessMayLapse) add(stringResource(R.string.ringtone_access_may_lapse))
}
GroupedRow(
title = stringResource(R.string.timer_editor_sound),
summary = summary.joinToString("\n"),
position = Position.Alone,
onClick = onClick,
)
}
/** Tinted through the AnnotatedString overload, so no literal colour is written. */
@Composable
private fun DeleteRow(onClick: () -> Unit) {
val error = MaterialTheme.colorScheme.error
val label = stringResource(R.string.timer_editor_delete)
val title: AnnotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = error)) { append(label) }
}
GroupedRow(title = title, position = Position.Alone, onClick = onClick)
}
/**
* A confirmation rather than an undo chip: M5's answer, and the cheap moment
* to ask is before.
*/
@Composable
private fun DeleteConfirmation(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.timer_editor_delete)) },
text = { Text(stringResource(R.string.timer_editor_delete_confirm)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
},
)
}
@@ -0,0 +1,34 @@
package de.jeanlucmakiola.clockula.ui.timers
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import kotlin.time.Duration
sealed interface TimerEditorUiState {
data object Loading : TimerEditorUiState
/** Deleted, here or elsewhere: there is nothing left to edit. */
data object Gone : TimerEditorUiState
data class Editing(
val timerId: Long,
val label: String,
val duration: Duration,
val mode: TimerMode,
/** Only an IDLE timer's length may be edited (D3). */
val canEditDuration: Boolean,
val ringtone: TimerRingtoneSetting,
val defaults: ClockDefaults,
) : TimerEditorUiState
}
data class TimerRingtoneSetting(
val storedUri: String?,
val effectiveUri: String?,
val inherited: Boolean,
val silent: Boolean,
val title: String?,
/** Reported, never rewritten (M5 D19). */
val unavailable: Boolean,
val accessMayLapse: Boolean,
)
@@ -0,0 +1,279 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog
import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.ui.common.RingtonePicker
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerUiState
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlin.time.Duration
import javax.inject.Inject
/**
* One timer's label, its length (IDLE only, D3) and its one override — the
* ringtone, through M5's picker. Writes as the user goes, exactly as the alarm
* editor does: no Save, no dirty state, no discard dialog (M5 D22).
*/
@HiltViewModel
class TimerEditorViewModel @Inject internal constructor(
savedStateHandle: SavedStateHandle,
private val timers: TimerRepository,
private val engine: TimerEngine,
private val settings: SettingsPrefs,
private val ringtones: RingtoneCatalog,
private val preview: RingtonePreviewer,
// `@param:` — the qualifier has no explicit @Target, and K2 resolves a bare
// annotation on a constructor `val` to the property instead.
@param:ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() {
val timerId: Long = savedStateHandle.get<Long>(TimerRoutes.ARG_TIMER_ID) ?: 0L
/**
* The label as typed, conflated: a `MutableStateFlow` writes the first value
* and the latest, **in order**, so rapid typing can neither interleave nor
* land out of order — and there is no debounce job to lose on the way out
* (M5 D24).
*/
private val labelWrites = MutableStateFlow<String?>(null)
/** A picked document whose read grant could not be made persistent (M5 D20). */
private val accessMayLapse = MutableStateFlow(false)
private val _state = MutableStateFlow<TimerEditorUiState>(TimerEditorUiState.Loading)
val state: StateFlow<TimerEditorUiState> = _state.asStateFlow()
private val _ringtonePicker = MutableStateFlow<RingtonePickerUiState?>(null)
/** Null while closed. M5's type. */
val ringtonePicker: StateFlow<RingtonePickerUiState?> = _ringtonePicker.asStateFlow()
/** What the previewer is sounding, so the next preview knows to stop it. */
private var previewing: String? = null
/** Memoised catalog answers, keyed on URI — see [metaOf]. */
private val ringtoneMeta = mutableMapOf<String, RingtoneMeta>()
/** Set by [onDelete]: the row is gone, and a late repository emission must not undo that. */
private var deleted: Boolean = false
init {
viewModelScope.launch {
combine(
timers.timer(timerId),
settings.defaults,
labelWrites,
accessMayLapse,
) { timer, defaults, typedLabel, mayLapse ->
Inputs(timer, defaults, typedLabel, mayLapse)
}.collectLatest { inputs ->
if (!deleted) _state.value = render(inputs)
}
}
viewModelScope.launch {
labelWrites.collect { text ->
if (text != null) timers.rename(timerId, text)
}
}
}
/** Verbatim, conflated — one write per keystroke, in order (M5 D24). */
fun onLabelTyped(text: String) {
// Untrimmed: `Timer.label` is verbatim everywhere else, and trimming as
// the user types would eat the space they just pressed.
labelWrites.value = text
}
/**
* Ignored unless the timer is IDLE — the repository refuses it anyway (D3),
* and the editor hides the keypad, so this is the third guard on a question
* whose every answer is a surprise.
*/
fun onDurationCommitted(duration: Duration) {
viewModelScope.launch { timers.setDuration(timerId, duration) }
}
fun onOpenRingtonePicker() {
val editing = _state.value as? TimerEditorUiState.Editing ?: return
val stored = editing.ringtone.storedUri
val appDefault = editing.defaults.timerRingtoneUri
_ringtonePicker.value = RingtonePickerUiState(
loading = true,
options = emptyList(),
selectedUri = stored,
deviceHasNoSounds = false,
defaultTitle = null,
)
viewModelScope.launch {
// A device list can be hundreds of rows, so it is read once per
// opening rather than on every recomposition.
val tones = ringtones.alarmTones()
_ringtonePicker.value = RingtonePickerUiState(
loading = false,
options = RingtonePicker.optionsFor(tones, stored, editing.ringtone.title),
selectedUri = stored,
deviceHasNoSounds = tones.isEmpty(),
// Resolved, not looked up in the tone list: an app default that
// is a SAF-picked file is absent from that list (M5 D16).
defaultTitle = appDefault
?.takeUnless { Ringtones.isSilent(it) }
?.let { metaOf(it).title },
)
}
}
fun onCloseRingtonePicker() {
stopPreview()
_ringtonePicker.value = null
}
/** null = App default; [Ringtones.SILENT_URI] = Silent; otherwise a sound. */
fun onRingtoneChosen(uri: String?) {
accessMayLapse.value = false
onCloseRingtonePicker()
viewModelScope.launch { timers.setRingtoneUri(timerId, uri) }
}
/** The SAF result as a String. null = cancelled, which changes nothing. */
fun onDocumentPicked(uri: String?) {
val picked = Ringtones.normalise(uri) ?: return
onCloseRingtonePicker()
viewModelScope.launch {
// A grant that cannot be made persistent is disclosed, not refused:
// the fallback chain still protects the ring (M5 D20).
accessMayLapse.value = !ringtones.persistPickedDocument(picked)
timers.setRingtoneUri(timerId, picked)
}
}
/** null or the silent sentinel stops rather than plays. */
fun onPreview(uri: String?) {
val sound = Ringtones.normalise(uri)?.takeUnless { Ringtones.isSilent(it) }
if (sound == null) {
stopPreview()
return
}
// There is one previewer, so a sound already playing stops first: two
// tones at once is not a preview of either (M5 D21).
if (previewing != null) stopPreview()
previewing = sound
viewModelScope.launch { preview.play(sound) }
}
/** Goes through the engine, then renders [TimerEditorUiState.Gone]. */
fun onDelete() {
viewModelScope.launch {
// The engine, not the repository: deleting a *ringing* timer has to
// close the ring session and move the expiry slot with it (D2).
engine.delete(timerId)
deleted = true
_state.value = TimerEditorUiState.Gone
}
}
/** Flushes the label on the application scope; stops the preview. */
public override fun onCleared() {
stopPreview()
// The last keystroke is written on a scope that outlives this ViewModel:
// leaving the editor is exactly when the user has just finished typing.
labelWrites.value?.let { text ->
appScope.launch { timers.rename(timerId, text) }
}
super.onCleared()
}
private fun stopPreview() {
previewing = null
preview.stop()
}
private suspend fun render(inputs: Inputs): TimerEditorUiState {
val timer = inputs.timer ?: return TimerEditorUiState.Gone
return TimerEditorUiState.Editing(
timerId = timer.id,
label = inputs.typedLabel ?: timer.label,
duration = timer.duration,
mode = timer.state.asMode(),
canEditDuration = timer.state == TimerState.IDLE,
ringtone = ringtoneSetting(timer, inputs.defaults, inputs.accessMayLapse),
defaults = inputs.defaults,
)
}
private suspend fun ringtoneSetting(
timer: Timer,
defaults: ClockDefaults,
accessMayLapse: Boolean,
): TimerRingtoneSetting {
val effective = timer.ringtoneUri ?: defaults.timerRingtoneUri
val silent = Ringtones.isSilent(effective)
val meta = effective?.takeUnless { silent }?.let { metaOf(it) }
return TimerRingtoneSetting(
storedUri = timer.ringtoneUri,
effectiveUri = effective,
inherited = timer.ringtoneUri == null,
silent = silent,
title = meta?.title,
// Reported, never rewritten: "unreadable right now" and "gone
// forever" are indistinguishable, and an unmounted card comes back
// (M5 D19).
unavailable = meta != null && !meta.playable,
accessMayLapse = accessMayLapse,
)
}
/**
* The catalog's answer for one URI, memoised for the life of the editor.
*
* `titleOf` is a `ContentResolver.query` and `isPlayable` an
* `openInputStream` — both blocking provider I/O, and the state is
* re-rendered on **every keystroke** (M5 D24). Keyed on the URI, so a
* *change* of sound still asks and typing asks nothing.
*/
private suspend fun metaOf(uri: String): RingtoneMeta = ringtoneMeta[uri]
?: RingtoneMeta(title = ringtones.titleOf(uri), playable = ringtones.isPlayable(uri))
.also { ringtoneMeta[uri] = it }
/** What the catalog says about one URI: its title, and whether it opens. */
private data class RingtoneMeta(val title: String?, val playable: Boolean)
/**
* The **stored** state, not a reading: the editor has no clock, and the one
* thing it gates — whether the length may be edited — is a question about
* the stored state (D3). A row that has just reached zero is flipped to
* EXPIRED by the engine's sweep, and this re-renders when it is.
*/
private fun TimerState.asMode(): TimerMode = when (this) {
TimerState.IDLE -> TimerMode.IDLE
TimerState.RUNNING -> TimerMode.RUNNING
TimerState.PAUSED -> TimerMode.PAUSED
TimerState.EXPIRED -> TimerMode.EXPIRED
}
/** The four inputs the state is a pure function of, once the catalog has answered. */
private data class Inputs(
val timer: Timer?,
val defaults: ClockDefaults,
val typedLabel: String?,
val accessMayLapse: Boolean,
)
}
@@ -0,0 +1,94 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Backspace
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.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.unit.dp
import de.jeanlucmakiola.clockula.R
/**
* Ten digits and a backspace, on a grid whose keys are comfortably past the
* 48 dp minimum: a keypad is aimed at without looking.
*
* The keys are the canonical M3 `TextButton`/`FilledTonalIconButton` rather
* than anything hand-rolled, so they inherit the ripple, the shape morph and
* the disabled colours from the theme.
*/
@Composable
internal fun TimerKeypad(
onDigit: (Char) -> Unit,
onBackspace: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(KeyGap),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ROWS.forEach { row ->
Row(
horizontalArrangement = Arrangement.spacedBy(KeyGap),
verticalAlignment = Alignment.CenterVertically,
) {
row.forEach { key -> DigitKey(digit = key, onDigit = onDigit) }
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(KeyGap),
verticalAlignment = Alignment.CenterVertically,
) {
DigitKey(digit = '0', onDigit = onDigit)
BackspaceKey(onBackspace = onBackspace)
}
}
}
@Composable
private fun DigitKey(digit: Char, onDigit: (Char) -> Unit) {
val text = digit.toString()
// The glyph is the label, so the announcement says what pressing it does
// rather than reading a bare numeral out of context.
val description = stringResource(R.string.timer_keypad_digit, text)
TextButton(
onClick = { onDigit(digit) },
modifier = Modifier
.size(TimersDefaults.KeypadKeySize)
.semantics { contentDescription = description },
) {
Text(text = text, style = MaterialTheme.typography.headlineSmall)
}
}
@Composable
private fun BackspaceKey(onBackspace: () -> Unit) {
val description = stringResource(R.string.timer_keypad_backspace)
FilledTonalIconButton(
onClick = onBackspace,
modifier = Modifier.size(TimersDefaults.KeypadKeySize),
) {
Icon(Icons.AutoMirrored.Rounded.Backspace, contentDescription = description)
}
}
/** The 8 dp spacing step, so the grid's gaps are the system's rather than ours. */
private val KeyGap = 8.dp
private val ROWS = listOf(
listOf('1', '2', '3'),
listOf('4', '5', '6'),
listOf('7', '8', '9'),
)
@@ -0,0 +1,28 @@
package de.jeanlucmakiola.clockula.ui.timers
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject
import javax.inject.Singleton
/** The repository, both clocks and the ticker, combined into the list's rows. */
@Singleton
internal class TimerListSource @Inject constructor(
private val timers: TimerRepository,
private val elapsed: ElapsedRealtimeClock,
private val wall: WallClock,
private val ticker: Ticker,
) {
/** Distinct-until-changed; the cadence is one second — the readout's own resolution. */
fun rows(): Flow<List<TimerRowState>> =
combine(timers.timers(), ticker.ticks(TimersDefaults.ReadoutTick)) { rows, _ ->
// Both clocks read once per emission, and the arithmetic is the
// domain's: the screen re-derives none of it (M5 D8).
TimerListRows.from(rows, elapsed.elapsedRealtime(), wall.now())
}.distinctUntilChanged()
}
@@ -0,0 +1,9 @@
package de.jeanlucmakiola.clockula.ui.timers
/** The editor's route and the name its argument is declared under. */
internal object TimerRoutes {
const val ARG_TIMER_ID: String = "timerId"
const val EDITOR_PATTERN: String = "timers/edit/{timerId}"
fun editor(timerId: Long): String = "timers/edit/$timerId"
}
@@ -0,0 +1,189 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ButtonGroup
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.WavyProgressIndicatorDefaults
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
/**
* One timer as a card in the grouped vocabulary every other surface in the
* family uses: the readout, the label, the wavy progress and the two controls
* for whatever the timer is currently doing.
*
* The progress indicator's **amplitude is the state** — the default wave while
* the timer runs, flat while it is paused or finished. That is
* `docs/PLAN.md` §8's "refinement comes from shape, colour, space and motion,
* never from enlarging text", and timer progress is one of the three places §8
* sanctions a showpiece (D29).
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun TimerRow(
row: TimerRowState,
position: Position,
onOpen: () -> Unit,
onStart: () -> Unit,
onPause: () -> Unit,
onReset: () -> Unit,
onAddMinute: () -> Unit,
modifier: Modifier = Modifier,
) {
val untitled = stringResource(R.string.timer_untitled)
val openDescription = stringResource(R.string.timer_row_open)
val readout = readoutOf(row)
// "%1$s left" is a sentence only while something is left: an expired timer
// reads "Finished", and "Finished left" is not a readout. The card's own
// text is otherwise announced as it stands.
val readoutDescription = when (row.mode) {
TimerMode.EXPIRED -> readout
else -> stringResource(R.string.timer_row_progress, readout)
}
GroupedSurface(
position = position,
modifier = modifier
.padding(horizontal = GroupedListInset)
// The *purpose of the tap*, not a description of the card: a
// `contentDescription` here would be read instead of the card's own
// text, and the one thing a user needs from a list of timers — which
// timer this is — would never be announced. `action = null` labels
// the click the surface already installs.
.semantics { onClick(label = openDescription, action = null) },
onClick = onOpen,
color = containerFor(row.mode),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().animateContentSizeMotion(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Bottom,
) {
Text(
text = readout,
style = MaterialTheme.typography.headlineLarge,
color = contentColorFor(containerFor(row.mode)),
modifier = Modifier.semantics { contentDescription = readoutDescription },
)
Text(
text = row.label.ifBlank { untitled },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp),
)
}
LinearWavyProgressIndicator(
// A lambda, so the bar animates without recomposing the row.
progress = { row.progress },
amplitude = if (row.mode == TimerMode.RUNNING) {
WavyProgressIndicatorDefaults.indicatorAmplitude
} else {
FLAT
},
modifier = Modifier.fillMaxWidth(),
)
TimerControls(
mode = row.mode,
onStart = onStart,
onPause = onPause,
onReset = onReset,
onAddMinute = onAddMinute,
)
}
}
}
/** Two buttons, and the same pair the notification offers for that mode (D17). */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun TimerControls(
mode: TimerMode,
onStart: () -> Unit,
onPause: () -> Unit,
onReset: () -> Unit,
onAddMinute: () -> Unit,
modifier: Modifier = Modifier,
) {
val primary: Pair<Int, () -> Unit> = when (mode) {
TimerMode.RUNNING -> R.string.timer_pause to onPause
TimerMode.PAUSED -> R.string.timer_resume to onStart
TimerMode.EXPIRED -> R.string.timer_stop to onReset
TimerMode.IDLE -> R.string.timer_start to onStart
}
val secondary: Pair<Int, () -> Unit> = when (mode) {
TimerMode.RUNNING, TimerMode.EXPIRED -> R.string.timer_add_minute to onAddMinute
TimerMode.PAUSED -> R.string.timer_reset to onReset
TimerMode.IDLE -> R.string.timer_add_minute to onAddMinute
}
ButtonGroup(modifier = modifier) {
listOf(primary, secondary).forEach { (label, action) ->
// `animateWidth` is the expressive press-widening — the component's
// whole point, and the reason the pair is a ButtonGroup rather than
// two buttons in a Row.
val interaction = remember { MutableInteractionSource() }
FilledTonalButton(
onClick = action,
interactionSource = interaction,
modifier = Modifier.animateWidth(interaction),
) {
Text(stringResource(label))
}
}
}
}
/**
* An expired card is `tertiaryContainer` — exactly the token M4's live pill
* already uses for an expired timer, so the two surfaces agree. A running one
* is the kit's own default, and a paused one is dimmed by the same rule that
* dims a switched-off alarm.
*/
@Composable
private fun containerFor(mode: TimerMode): Color = when (mode) {
TimerMode.EXPIRED -> MaterialTheme.colorScheme.tertiaryContainer
TimerMode.PAUSED, TimerMode.IDLE -> MaterialTheme.colorScheme.surfaceContainer
TimerMode.RUNNING -> MaterialTheme.colorScheme.surfaceContainerHigh
}
/** "Finished" says more than 0:00 does; everything else is the countdown. */
@Composable
private fun readoutOf(row: TimerRowState): String = when (row.mode) {
TimerMode.EXPIRED -> stringResource(R.string.timer_finished)
else -> ClockFormat.countdown(row.remaining)
}
/** A paused or finished timer's bar does not wave: the amplitude *is* the state. */
private val FLAT: (Float) -> Float = { 0f }
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.clockula.ui.timers
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.domain.timer.TimerReadings
import kotlin.time.Duration
import kotlin.time.Instant
data class TimerRowState(
val id: Long,
/** Verbatim; blank stays blank. */
val label: String,
val mode: TimerMode,
/** What the row reads: the configured duration when IDLE, the snapshot otherwise. */
val remaining: Duration,
val duration: Duration,
/** remaining/duration, clamped 0f..1f. 1f when IDLE, 0f when EXPIRED or duration is zero. */
val progress: Float,
)
sealed interface TimersUiState {
/** Before the first repository emission. Not the empty state. */
data object Loading : TimersUiState
data class Loaded(val rows: List<TimerRowState>, val presets: List<Duration>) : TimersUiState
}
internal object TimerListRows {
/**
* Pure. Storage order — `sortOrder`, then `id` — which is exactly the order
* the repository already emits, so the screen re-asserts it rather than
* inventing one of its own. An expired timer does **not** jump to the top:
* a row that moves under a thumb is worse than a row the user has to look
* for (D22). Never throws.
*/
fun from(
timers: List<Timer>,
elapsedRealtime: Duration,
wallClock: Instant,
): List<TimerRowState> = timers.sortedWith(compareBy({ it.sortOrder }, { it.id })).map { timer ->
val reading = TimerReadings.of(timer, elapsedRealtime, wallClock)
TimerRowState(
id = timer.id,
label = timer.label,
mode = reading.mode,
remaining = reading.remaining,
duration = timer.duration,
progress = progressOf(reading.remaining, timer.duration),
)
}
/** A zero-length timer divides by nothing, and a longer remainder is clamped full. */
private fun progressOf(remaining: Duration, duration: Duration): Float {
if (duration <= Duration.ZERO) return 0f
return (remaining / duration).toFloat().coerceIn(0f, 1f)
}
}
@@ -0,0 +1,188 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults
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.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
import kotlin.time.Duration
/**
* Setting a timer: the readout of what has been typed, the preset chips, the
* keypad and one Start. The most common action in the app must not cost four
* steps, so the same panel is hosted in a bottom sheet over a non-empty list
* and *inline* on an empty one — where it is the empty state, and there is no
* "no timers yet" card to look at and then dismiss (D23).
*
* The panel holds no state: [entry] and its two callbacks live in the caller as
* `rememberSaveable`, so the typed digits survive rotation *and* process death.
*/
@Composable
internal fun TimerSetupPanel(
entry: TimerDurationEntry,
presets: List<Duration>,
onDigit: (Char) -> Unit,
onBackspace: () -> Unit,
onPreset: (Duration) -> Unit,
onSavePreset: (Duration) -> Unit,
onRemovePreset: (Duration) -> Unit,
onStart: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Readout(entry = entry)
PresetChips(
entry = entry,
presets = presets,
onPreset = onPreset,
onSavePreset = onSavePreset,
onRemovePreset = onRemovePreset,
)
TimerKeypad(onDigit = onDigit, onBackspace = onBackspace)
Button(
onClick = onStart,
// Nothing outside 1 s…24 h is startable, which is `ClockPrefs`' own
// clamp — so nothing the user can choose is clamped away on the next
// read (M5 D16).
enabled = entry.canStart,
) {
Text(stringResource(R.string.timers_setup_start))
}
}
}
/**
* `displayMedium`, a plain scale role: the app-wide big-readout ramp is settled
* against a working stopwatch in M7, not guessed at here (D28).
*/
@Composable
private fun Readout(entry: TimerDurationEntry, modifier: Modifier = Modifier) {
Column(
modifier = modifier.animateContentSizeMotion(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = entry.display,
style = MaterialTheme.typography.displayMedium,
color = if (entry.isEmpty) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
)
if (!entry.isEmpty && !entry.canStart) {
Text(
text = stringResource(R.string.timers_setup_range_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* The whole preset-management surface: tap to fill the readout, the chip's own
* trailing icon to remove, and a Save chip when the typed duration is not
* already one. A presets *screen* is M10's if anyone wants one (D13).
*
* `InputChip` rather than `AssistChip`, which is where the plan's D29 landed:
* a preset **is** a user-supplied value, which is the case `InputChip` and its
* `trailingIcon` slot exist for. The plan asked for a long-press instead, and
* that cannot be built over a Material chip without hand-rolling one — the
* chip's own `clickable` consumes the gesture before any wrapper sees it — and
* a long-press with no affordance is undiscoverable either way.
*/
@Composable
private fun PresetChips(
entry: TimerDurationEntry,
presets: List<Duration>,
onPreset: (Duration) -> Unit,
onSavePreset: (Duration) -> Unit,
onRemovePreset: (Duration) -> Unit,
modifier: Modifier = Modifier,
) {
val typed = entry.duration
val canSave = entry.canStart && typed !in presets
if (presets.isEmpty() && !canSave) return
val remove = stringResource(R.string.timers_preset_remove)
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.timers_presets),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = GroupedListInset),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
presets.forEach { preset ->
InputChip(
selected = preset == typed,
onClick = { onPreset(preset) },
label = { Text(ClockFormat.countdown(preset)) },
trailingIcon = {
// An `IconButton`, not a `clickable` icon: removing a
// preset is destructive and it sits inside the chip's own
// click area, so it needs the platform's 48 dp minimum
// target and the Button role rather than a 24 dp square.
// The chip grows to that height with it, which is the
// right direction for a target the user must be able to
// hit — or miss — deliberately.
IconButton(onClick = { onRemovePreset(preset) }) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = remove,
modifier = Modifier.size(InputChipDefaults.AvatarSize),
)
}
},
)
}
if (canSave) {
AssistChip(
onClick = { onSavePreset(typed) },
label = { Text(stringResource(R.string.timers_preset_save)) },
)
}
}
}
}
@@ -0,0 +1,19 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
internal object TimersDefaults {
/** The readout formats to whole seconds, so one second is its own resolution. */
val ReadoutTick: Duration = 1.seconds
/** The band the FAB occupies: its 56 dp plus the 16 dp margin. */
val FabBand: Dp = 72.dp
val ListBottomPadding: Dp = 8.dp
/** Comfortably past the 48 dp minimum: a keypad is aimed at without looking. */
val KeypadKeySize: Dp = 64.dp
}
@@ -1,17 +1,165 @@
package de.jeanlucmakiola.clockula.ui.timers
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.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.shell.EmptyTabScreen
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.animateItemMotion
import kotlin.time.Duration
/** A top-level tab: its title bar and its empty state. Creating a timer is M6's. */
/**
* The Timers tab: several timers at once, each with its own controls, and the
* setup panel that starts a new one.
*
* On a **non-empty** list the FAB opens the panel as a `ModalBottomSheet`; on
* an **empty** one the tab hosts the same panel inline, because that *is* the
* empty state (D23). Deliberately not M5's create-then-navigate: an alarm is
* live the moment it exists, so creating it first is honest, while a timer has
* a natural commit point — "Start" — and a row created by an accidental FAB tap
* would be litter.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimersScreen(modifier: Modifier = Modifier) {
EmptyTabScreen(
fun TimersScreen(
onEditTimer: (Long) -> Unit,
modifier: Modifier = Modifier,
viewModel: TimersViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val pillInset = LocalLivePillInset.current
val addTimer = stringResource(R.string.timers_set_title)
// Saveable, not remembered: the typed digits and the sheet's open flag then
// survive rotation *and* process death, and the pure `TimerDurationEntry`
// is derived from the saved digit string (D23).
var digits by rememberSaveable { mutableStateOf("") }
var sheetOpen by rememberSaveable { mutableStateOf(false) }
val entry = TimerDurationEntry(digits)
val sheetState = rememberModalBottomSheetState()
val loaded = state as? TimersUiState.Loaded
val rows = loaded?.rows
val presets = loaded?.presets.orEmpty()
fun start() {
viewModel.onStartNew(entry.duration)
digits = ""
sheetOpen = false
}
CollapsingScaffold(
title = stringResource(R.string.tab_timers),
message = stringResource(R.string.timers_empty),
modifier = modifier,
)
scrollable = false,
floatingActionButton = {
// Only over a list: on an empty tab the panel is already on screen,
// and a FAB that opens what is in front of you is noise.
if (rows != null && rows.isNotEmpty()) {
FloatingActionButton(
onClick = { sheetOpen = true },
// The pill is bottom-centre and the FAB bottom-end, so on a
// narrow phone they would otherwise overlap (M5 D6).
modifier = Modifier.padding(bottom = pillInset),
) {
Icon(Icons.Rounded.Add, contentDescription = addTimer)
}
}
},
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = TimersDefaults.ListBottomPadding,
bottom = TimersDefaults.ListBottomPadding + TimersDefaults.FabBand + pillInset,
),
) {
if (rows != null && rows.isEmpty()) {
item {
TimerSetupPanel(
entry = entry,
presets = presets,
onDigit = { digits = entry.plusDigit(it).digits },
onBackspace = { digits = entry.backspace().digits },
onPreset = { digits = digitsFor(it) },
onSavePreset = viewModel::onSavePreset,
onRemovePreset = viewModel::onRemovePreset,
onStart = ::start,
modifier = Modifier.padding(vertical = 16.dp),
)
}
}
itemsIndexed(rows.orEmpty(), key = { _, row -> row.id }) { index, row ->
TimerRow(
row = row,
position = positionOf(index, rows.orEmpty().size),
onOpen = { onEditTimer(row.id) },
onStart = { viewModel.onStart(row.id) },
onPause = { viewModel.onPause(row.id) },
onReset = { viewModel.onReset(row.id) },
onAddMinute = { viewModel.onAddMinute(row.id) },
// A timer that finishes stays where it is (D22); the motion
// is for a row arriving or leaving.
modifier = animateItemMotion(),
)
}
}
}
if (sheetOpen) {
ModalBottomSheet(
onDismissRequest = { sheetOpen = false },
sheetState = sheetState,
) {
TimerSetupPanel(
entry = entry,
presets = presets,
onDigit = { digits = entry.plusDigit(it).digits },
onBackspace = { digits = entry.backspace().digits },
onPreset = { digits = digitsFor(it) },
onSavePreset = viewModel::onSavePreset,
onRemovePreset = viewModel::onRemovePreset,
onStart = ::start,
modifier = Modifier.padding(bottom = 24.dp),
)
}
}
}
/**
* A preset fills the readout as if it had been typed, so the keypad and the
* chips share one state and a chip can still be corrected by hand.
*/
private fun digitsFor(duration: Duration): String {
val total = duration.inWholeSeconds
val hours = total / 3_600
val minutes = (total % 3_600) / 60
val seconds = total % 60
return buildString {
append(hours.toString().padStart(2, '0'))
append(minutes.toString().padStart(2, '0'))
append(seconds.toString().padStart(2, '0'))
}.trimStart('0')
}
@@ -0,0 +1,97 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.clockula.timer.TimerEngine
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration
import javax.inject.Inject
/**
* The Timers tab: what it shows, the setup panel's one Start, and the six
* lifecycle verbs — every one of which goes through [TimerEngine], because each
* can move the expiry slot or the service (D2).
*/
@HiltViewModel
class TimersViewModel @Inject internal constructor(
private val timers: TimerRepository,
private val engine: TimerEngine,
private val settings: SettingsPrefs,
source: TimerListSource,
) : ViewModel() {
/** `WhileSubscribed(5_000)`, seeded [TimersUiState.Loading]. */
val state: StateFlow<TimersUiState> =
combine(source.rows(), settings.timerPresets) { rows, presets ->
TimersUiState.Loaded(rows, presets)
}
// `WhileSubscribed`, not `Eagerly`: the upstream is a one-second
// ticker over every timer, and eager sharing would keep it running
// with nobody looking — the editor on top, or the app in the
// background. The timeout is what gives rotation the same resilience
// eager sharing did (M5's lesson).
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), TimersUiState.Loading)
/**
* True from the tap until the timer exists. A double-tapped Start would
* otherwise create **two** timers, and the second would be litter the user
* has to find and delete (M5's other lesson, D23).
*/
private var creating: Boolean = false
/** Creates a timer of [duration] and starts it. Re-entrant taps create one timer (D23). */
fun onStartNew(duration: Duration) {
if (creating) return
// Outside `ClockPrefs`' own clamp there is nothing honest to create: the
// panel's Start is disabled there, so this is the stale-caller guard.
if (duration !in TimerPresets.MIN..TimerPresets.MAX) return
creating = true
viewModelScope.launch {
try {
val id = timers.create(TimerDraft(duration = duration))
// "Start" is the commit point, so the row is running the moment
// it exists — which is what pressing Start asked for (D23).
engine.start(id)
} finally {
creating = false
}
}
}
fun onStart(timerId: Long) = launchOnEngine { engine.start(timerId) }
fun onPause(timerId: Long) = launchOnEngine { engine.pause(timerId) }
fun onReset(timerId: Long) = launchOnEngine { engine.reset(timerId) }
fun onAddMinute(timerId: Long) = launchOnEngine { engine.addTime(timerId) }
/** Through the engine, so a *ringing* timer cannot be deleted into a stuck ring (D2). */
fun onDelete(timerId: Long) = launchOnEngine { engine.delete(timerId) }
fun onSavePreset(duration: Duration) {
viewModelScope.launch { settings.addTimerPreset(duration) }
}
fun onRemovePreset(duration: Duration) {
viewModelScope.launch { settings.removeTimerPreset(duration) }
}
private fun launchOnEngine(block: suspend () -> Unit) {
viewModelScope.launch { block() }
}
private companion object {
/** The window a rotation has to re-subscribe in before the ticker is dropped. */
const val SUBSCRIPTION_GRACE_MS = 5_000L
}
}
+39 -1
View File
@@ -24,9 +24,47 @@
<!-- M4: the tabs' empty states -->
<string name="alarms_empty">No alarms yet</string>
<string name="timers_empty">No timers yet</string>
<string name="world_clocks_empty">No world clocks yet</string>
<!-- M6: the timers tab, its rows and the setup panel -->
<string name="timers_set_title">Set timer</string>
<string name="timers_setup_start">Start</string>
<string name="timers_setup_range_hint">Between 1 second and 24 hours</string>
<string name="timers_presets">Presets</string>
<string name="timers_preset_save">Save</string>
<string name="timers_preset_remove">Remove preset</string>
<string name="timer_keypad_backspace">Delete a digit</string>
<string name="timer_keypad_digit">Type %1$s</string>
<string name="timer_start">Start</string>
<string name="timer_pause">Pause</string>
<string name="timer_resume">Resume</string>
<string name="timer_reset">Reset</string>
<string name="timer_stop">Stop</string>
<string name="timer_add_minute">+1 min</string>
<string name="timer_finished">Finished</string>
<string name="timer_paused_at">Paused \u00b7 %1$s</string>
<string name="timer_untitled">Timer</string>
<string name="timer_row_progress">%1$s left</string>
<string name="timer_row_open">Timer settings</string>
<!-- M6: the timer editor -->
<string name="timer_editor_title">Timer</string>
<string name="timer_editor_label">Label</string>
<string name="timer_editor_duration">Length</string>
<string name="timer_editor_duration_locked">Reset the timer to change its length</string>
<string name="timer_editor_sound">Sound</string>
<string name="timer_editor_delete">Delete timer</string>
<string name="timer_editor_delete_confirm">Delete this timer?</string>
<!-- M6: the timer notification. One channel, one id, however many timers. -->
<string name="channel_timers_name">Timers</string>
<string name="channel_timers_description">The running countdown and the sound when a timer finishes. Turning this off does not silence a timer.</string>
<string name="timer_notification_title">Timer</string>
<plurals name="timer_more_active">
<item quantity="one">+%1$d more timer</item>
<item quantity="other">+%1$d more timers</item>
</plurals>
<!-- M4: the live pill -->
<string name="live_pill_pause">Pause</string>
<string name="live_pill_resume">Resume</string>
@@ -0,0 +1,105 @@
package de.jeanlucmakiola.clockula.testing
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
import de.jeanlucmakiola.clockula.timer.AlarmRingStatus
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.timer.TimerScheduler
import de.jeanlucmakiola.clockula.timer.TimerServiceHandle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlin.time.Duration
import kotlin.time.Instant
/**
* The expiry slot, held as **the value it currently holds** rather than as a
* call log: a test then asserts what AlarmManager would be holding, which is
* the thing that wakes the device (M3's shape, M6 D30).
*/
class FakeTimerScheduler : TimerScheduler {
var expiryAt: Duration? = null
private set
private val log = mutableListOf<Duration?>()
/** Every transition, in order — null for a cancel. */
val scheduled: List<Duration?> get() = log.toList()
override fun scheduleExpiry(at: Duration) {
expiryAt = at
log += at
}
override fun cancelExpiry() {
expiryAt = null
log += null
}
}
/** The foreground service's up/down transitions, in order — the order is the contract (D6). */
class FakeTimerServiceHandle : TimerServiceHandle {
var active: Boolean = false
private set
private val log = mutableListOf<Boolean>()
val transitions: List<Boolean> get() = log.toList()
override fun sync(active: Boolean) {
this.active = active
log += active
}
}
/** "Is an alarm ringing", as a settable boolean whose flow re-emits (D9). */
class FakeAlarmRingStatus(ringing: Boolean = false) : AlarmRingStatus {
private val state = MutableStateFlow(ringing)
var ringing: Boolean
get() = state.value
set(value) {
state.value = value
}
override fun isRinging(): Flow<Boolean> = state
override suspend fun currentlyRinging(): Boolean = state.value
}
/**
* A real [de.jeanlucmakiola.clockula.timer.TimerEngine] over a caller-supplied
* repository and an in-memory store — the pill's arrangement (D25), where the
* repository is the one whose call log the older cases assert on.
*/
class TimerEngineSeams(
val timers: TimerRepository,
now: Instant = T0,
uptime: Duration = Duration.ZERO,
) {
val store: PrefStore = PrefStore(InMemoryPreferencesDataStore())
val settings: SettingsPrefs = SettingsPrefs(store)
val ringState: TimerRingStateStore = TimerRingStateStore(store)
val scheduler: FakeTimerScheduler = FakeTimerScheduler()
val service: FakeTimerServiceHandle = FakeTimerServiceHandle()
val alarmRing: FakeAlarmRingStatus = FakeAlarmRingStatus()
val rebootRepair: RecordingRebootRepair = RecordingRebootRepair()
val wallClock: FakeWallClock = FakeWallClock(now)
val elapsed: FakeElapsedRealtimeClock = FakeElapsedRealtimeClock(uptime)
val engine: TimerEngine = TimerEngine(
timers = timers,
settings = settings,
ringState = ringState,
scheduler = scheduler,
service = service,
alarmRing = alarmRing,
rebootRepair = rebootRepair,
elapsed = elapsed,
wall = wallClock,
)
}
@@ -0,0 +1,128 @@
package de.jeanlucmakiola.clockula.testing
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.clockula.data.prefs.BootStateStore
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerMapper
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepositoryImpl
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.system.RebootRepair
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.Job
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import java.nio.file.Path
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* The **real** [TimerEngine] over the fake DAO, the three fake seams and a real
* DataStore under a `@TempDir` — `AlarmEngineHarness`'s arrangement, for the
* other engine. The reboot gate is the real [RebootRepair] over the real
* repository, so "the second call of a boot does not repair again" is the
* production gate and not a fake's counter.
*/
class TimerEngineHarness(
tempDir: Path,
storeScope: CoroutineScope,
now: Instant = T0,
uptime: Duration = 1_000.seconds,
val timerDao: FakeTimerDao = FakeTimerDao(),
) {
val wallClock: FakeWallClock = FakeWallClock(now)
val elapsed: FakeElapsedRealtimeClock = FakeElapsedRealtimeClock(uptime)
val scheduler: FakeTimerScheduler = FakeTimerScheduler()
val service: FakeTimerServiceHandle = FakeTimerServiceHandle()
val alarmRing: FakeAlarmRingStatus = FakeAlarmRingStatus()
val bootIds: FakeBootIdProvider = FakeBootIdProvider()
val ticker: FakeTicker = FakeTicker()
val stateDao: FakeAlarmStateDao = FakeAlarmStateDao()
val alarmScheduler: FakeAlarmScheduler = FakeAlarmScheduler()
val ringCoordinator: FakeRingCoordinator = FakeRingCoordinator()
/**
* Counted, so "the session anchor was persisted **once** across three reads"
* is assertable without reaching into the store (D15). Nothing else in an
* engine pass writes a preference, so a count here is the anchor's.
*/
val dataStore: CountingPreferencesDataStore = CountingPreferencesDataStore(
PreferenceDataStoreFactory.create(
scope = storeScope,
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
),
)
val store: PrefStore = PrefStore(dataStore)
val settings: SettingsPrefs = SettingsPrefs(store)
val ringState: TimerRingStateStore = TimerRingStateStore(store)
val timers: TimerRepository = TimerRepositoryImpl(timerDao, elapsed, wallClock)
val stopwatch: RecordingStopwatchRepository = RecordingStopwatchRepository()
val rebootRepair: RebootRepair = RebootRepair(
bootIds = bootIds,
bootState = BootStateStore(store),
timers = timers,
stopwatch = stopwatch,
)
val engine: TimerEngine = TimerEngine(
timers = timers,
settings = settings,
ringState = ringState,
scheduler = scheduler,
service = service,
alarmRing = alarmRing,
rebootRepair = rebootRepair,
elapsed = elapsed,
wall = wallClock,
)
/** Inserts a timer straight into storage, bypassing the engine. */
suspend fun given(timer: Timer): Timer {
timerDao.insert(TimerMapper.toEntity(timer))
return timer
}
/** What the table holds for [id] right now, read through the mapper. */
suspend fun stored(id: Long): Timer? = timers.find(id)
}
/** A [DataStore] that counts the transactions taken against it. */
class CountingPreferencesDataStore(
private val delegate: DataStore<Preferences>,
) : DataStore<Preferences> {
var writes: Int = 0
private set
override val data: Flow<Preferences> get() = delegate.data
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
writes++
return delegate.updateData(transform)
}
}
/** The harness on `runTest`'s own scheduler, so nothing waits on a real clock. */
fun TestScope.timerEngineHarness(
tempDir: Path,
now: Instant = T0,
uptime: Duration = 1_000.seconds,
timerDao: FakeTimerDao = FakeTimerDao(),
): TimerEngineHarness = TimerEngineHarness(
tempDir = tempDir,
storeScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
now = now,
uptime = uptime,
timerDao = timerDao,
)
@@ -0,0 +1,387 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.lifecycle.SavedStateHandle
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
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.testing.T0
import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.idleTimer
import de.jeanlucmakiola.clockula.testing.pausedTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import de.jeanlucmakiola.clockula.testing.timerEngineHarness
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* §5.13 — 19 cases. The timer editor writes as the user goes, exactly as the
* alarm editor does (M5 D22/D24), and it owns exactly one override: the
* ringtone, through M5's picker (D27). Deleting goes through the engine, so a
* *ringing* timer cannot be deleted into a stuck ring (D2).
*/
class TimerEditorViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
private val catalog = FakeRingtoneCatalog()
private val previewer = FakeRingtonePreviewer()
private val uptime: Duration = 1_000.seconds
private fun TestScope.editor(harness: TimerEngineHarness, timerId: Long = 1L) =
TimerEditorViewModel(
savedStateHandle = SavedStateHandle(mapOf(TimerRoutes.ARG_TIMER_ID to timerId)),
timers = harness.timers,
engine = harness.engine,
settings = harness.settings,
ringtones = catalog,
preview = previewer,
appScope = this,
)
private fun TimerEditorViewModel.editing(): TimerEditorUiState.Editing =
state.value as TimerEditorUiState.Editing
/** §5.13 #1 */
@Test
fun `an id no timer has is already gone`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
val viewModel = editor(harness, timerId = 404L)
advanceUntilIdle()
assertThat(viewModel.state.value).isEqualTo(TimerEditorUiState.Gone)
}
/** §5.13 #2 */
@Test
fun `rapid typing stores the last keystroke, in order`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, label = ""))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onLabelTyped("P")
viewModel.onLabelTyped("Pa")
viewModel.onLabelTyped("Pas")
viewModel.onLabelTyped("Pasta")
advanceUntilIdle()
assertThat(harness.timerDao.stored.single().label).isEqualTo("Pasta")
}
/** §5.13 #3 */
@Test
fun `leaving the editor does not lose the last keystroke`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, label = ""))
val viewModel = editor(harness)
viewModel.onLabelTyped("Pasta")
viewModel.onCleared()
advanceUntilIdle()
assertThat(harness.timerDao.stored.single().label).isEqualTo("Pasta")
}
/** §5.13 #4 */
@Test
fun `only an idle timer's length may be edited`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val rows = listOf(
idleTimer(id = 1L),
runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0),
pausedTimer(id = 1L, remaining = 2.minutes),
expiredTimer(id = 1L),
)
val answers = rows.map { row ->
val harness = timerEngineHarness(tempDir.resolve(row.state.name).also { it.toFile().mkdirs() })
harness.given(row)
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.editing().mode to viewModel.editing().canEditDuration
}
assertThat(answers).containsExactly(
TimerMode.IDLE to true,
TimerMode.RUNNING to false,
TimerMode.PAUSED to false,
TimerMode.EXPIRED to false,
).inOrder()
}
/** §5.13 #5 */
@Test
fun `committing a duration on an idle timer moves its remaining with it`(
@TempDir tempDir: Path,
) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onDurationCommitted(90.seconds)
advanceUntilIdle()
val stored = harness.stored(1L)!!
assertThat(stored.duration to stored.remaining).isEqualTo(90.seconds to 90.seconds)
}
/** §5.13 #6 */
@Test
fun `committing a duration on a running timer changes nothing`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
val viewModel = editor(harness)
advanceUntilIdle()
val before = harness.timerDao.stored
viewModel.onDurationCommitted(90.seconds)
advanceUntilIdle()
assertThat(harness.timerDao.stored).isEqualTo(before)
}
/** §5.13 #7 */
@Test
fun `a timer with no sound of its own inherits the app default`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.settings.setTimerRingtoneUri("content://media/2")
harness.given(idleTimer(id = 1L, ringtoneUri = null))
val viewModel = editor(harness)
advanceUntilIdle()
val ringtone = viewModel.editing().ringtone
assertThat(ringtone.inherited to ringtone.effectiveUri)
.isEqualTo(true to "content://media/2")
}
/** §5.13 #8 */
@Test
fun `a sound, then the app default, then silence all store what they say`(
@TempDir tempDir: Path,
) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L))
val viewModel = editor(harness)
advanceUntilIdle()
val stored = listOf("content://media/1", null, Ringtones.SILENT_URI).map { choice ->
viewModel.onRingtoneChosen(choice)
advanceUntilIdle()
harness.stored(1L)!!.ringtoneUri
}
assertThat(stored).containsExactly("content://media/1", null, Ringtones.SILENT_URI).inOrder()
}
/** §5.13 #9 */
@Test
fun `an unreadable sound is reported, never rewritten`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
catalog.unplayable = setOf("content://media/1")
harness.given(idleTimer(id = 1L, ringtoneUri = "content://media/1"))
val viewModel = editor(harness)
advanceUntilIdle()
assertThat(
viewModel.editing().ringtone.unavailable to harness.stored(1L)!!.ringtoneUri,
).isEqualTo(true to "content://media/1")
}
/** §5.13 #10 */
@Test
fun `typing waits on no provider round-trip`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, label = "P", ringtoneUri = "content://media/1"))
val viewModel = editor(harness)
advanceUntilIdle()
check(viewModel.editing().ringtone.title == "Argon")
val queriesAfterLoad = catalog.metadataCalls
repeat(12) { viewModel.onLabelTyped("P".repeat(it + 1)) }
advanceUntilIdle()
assertThat(catalog.metadataCalls).isEqualTo(queriesAfterLoad)
}
/** §5.13 #11 */
@Test
fun `opening the picker loads the device's tones once`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onOpenRingtonePicker()
advanceUntilIdle()
assertThat(catalog.alarmTonesCalls).isEqualTo(1)
}
/** §5.13 #12 */
@Test
fun `closing the picker stops the preview`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onPreview("content://media/1")
advanceUntilIdle()
viewModel.onCloseRingtonePicker()
advanceUntilIdle()
assertThat(previewer.events.last()).isEqualTo(PreviewEvent.Stopped)
}
/** §5.13 #13 */
@Test
fun `leaving the editor stops the preview`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onPreview("content://media/1")
advanceUntilIdle()
viewModel.onCleared()
advanceUntilIdle()
assertThat(previewer.events.last()).isEqualTo(PreviewEvent.Stopped)
}
/** §5.13 #14 */
@Test
fun `a second preview stops the first before it plays`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onPreview("content://media/1")
advanceUntilIdle()
viewModel.onPreview("content://media/2")
advanceUntilIdle()
assertThat(previewer.events).containsExactly(
PreviewEvent.Played("content://media/1"),
PreviewEvent.Stopped,
PreviewEvent.Played("content://media/2"),
).inOrder()
}
/** §5.13 #15 */
@Test
fun `previewing nothing or silence only stops`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onPreview(null)
viewModel.onPreview(Ringtones.SILENT_URI)
advanceUntilIdle()
assertThat(previewer.events.filterIsInstance<PreviewEvent.Played>()).isEmpty()
assertThat(previewer.events).isNotEmpty()
}
/** §5.13 #16 */
@Test
fun `deleting takes the timer, the state and the ring session with it`(
@TempDir tempDir: Path,
) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.ringState.set(T0 - 30.seconds)
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onDelete()
advanceUntilIdle()
assertThat(harness.timerDao.stored).isEmpty()
assertThat(viewModel.state.value to harness.ringState.current())
.isEqualTo(TimerEditorUiState.Gone to null)
}
/** §5.13 #17 */
@Test
fun `deleting a running timer cancels its expiry slot`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onDelete()
advanceUntilIdle()
assertThat(harness.scheduler.expiryAt).isNull()
}
/** §5.13 #18 */
@Test
fun `a grant that cannot be made persistent is disclosed, not refused`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
catalog.canPersist = false
harness.given(idleTimer(id = 1L))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onDocumentPicked("content://docs/a")
advanceUntilIdle()
assertThat(
harness.stored(1L)!!.ringtoneUri to viewModel.editing().ringtone.accessMayLapse,
).isEqualTo("content://docs/a" to true)
}
/** §5.13 #19 */
@Test
fun `a keystroke racing the expiry write loses neither`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.seconds, elapsedNow = uptime - 1.minutes))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onLabelTyped("Pasta")
harness.engine.onExpiryDue()
advanceUntilIdle()
val stored = harness.stored(1L)!!
assertThat(stored.label to stored.state).isEqualTo("Pasta" to TimerState.EXPIRED)
}
}
@@ -0,0 +1,138 @@
package de.jeanlucmakiola.clockula.ui.timers
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.idleTimer
import de.jeanlucmakiola.clockula.testing.pausedTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* §5.11 — 11 cases. The tab is a list in **storage order**, never in subject
* order (D22): an expired timer does not jump to the top, because a row that
* moves under a thumb is worse than a row the user has to look for.
*/
class TimerListRowsTest {
private val now: Duration = 1_000.seconds
private val wall: Instant = T0
private fun rows(
timers: List<Timer>,
elapsedRealtime: Duration = now,
wallClock: Instant = wall,
): List<TimerRowState> = TimerListRows.from(timers, elapsedRealtime, wallClock)
/** §5.11 #1 */
@Test
fun `no timers is no rows`() {
assertThat(rows(emptyList())).isEmpty()
}
/** §5.11 #2 */
@Test
fun `the rows come back in storage order`() {
val result = rows(
listOf(
pausedTimer(id = 1L, sortOrder = 2),
runningTimerWith(id = 2L, left = 1.minutes, elapsedNow = now, sortOrder = 0),
idleTimer(id = 3L, sortOrder = 1),
),
)
assertThat(result.map { it.id }).containsExactly(2L, 3L, 1L).inOrder()
}
/** §5.11 #3 */
@Test
fun `an expired timer does not jump to the top`() {
val result = rows(
listOf(
runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = now, sortOrder = 0),
expiredTimer(id = 2L, sortOrder = 2),
),
)
assertThat(result.map { it.id to it.mode })
.containsExactly(1L to TimerMode.RUNNING, 2L to TimerMode.EXPIRED)
.inOrder()
}
/** §5.11 #4 */
@Test
fun `an idle timer reads its configured length, full`() {
val row = rows(listOf(idleTimer(duration = 5.minutes))).single()
assertThat(row.remaining to row.progress).isEqualTo(5.minutes to 1f)
}
/** §5.11 #5 */
@Test
fun `a running timer halfway through is half full`() {
val timer = runningTimerWith(left = 150.seconds, elapsedNow = now, duration = 5.minutes)
assertThat(rows(listOf(timer)).single().progress).isEqualTo(0.5f)
}
/** §5.11 #6 */
@Test
fun `a paused timer reads its banked remainder`() {
val timer = pausedTimer(remaining = 60.seconds, duration = 5.minutes)
val row = rows(listOf(timer)).single()
assertThat(row.remaining to row.progress).isEqualTo(60.seconds to 0.2f)
}
/** §5.11 #7 */
@Test
fun `an expired timer reads empty`() {
val row = rows(listOf(expiredTimer(duration = 5.minutes))).single()
assertThat(row.remaining to row.progress).isEqualTo(Duration.ZERO to 0f)
}
/** §5.11 #8 */
@Test
fun `a zero-length timer divides by nothing`() {
val row = rows(listOf(idleTimer(duration = Duration.ZERO))).single()
assertThat(row.progress).isEqualTo(0f)
}
/** §5.11 #9 */
@Test
fun `a timer with more left than its length is clamped full`() {
val timer = runningTimerWith(left = 10.minutes, elapsedNow = now, duration = 5.minutes)
assertThat(rows(listOf(timer)).single().progress).isEqualTo(1f)
}
/** §5.11 #10 */
@Test
fun `a blank label stays blank, because the composable owns the fallback`() {
val row = rows(listOf(idleTimer(label = ""))).single()
assertThat(row.label).isEmpty()
}
/** §5.11 #11 */
@Test
fun `three hours of clock jump each way changes no readout`() {
val timers = listOf(runningTimerWith(left = 150.seconds, elapsedNow = now, wallNow = wall))
val before = rows(timers)
val forward = rows(timers, wallClock = wall + 3.hours)
val backward = rows(timers, wallClock = wall - 3.hours)
assertThat(listOf(forward, backward)).containsExactly(before, before)
}
}
@@ -0,0 +1,32 @@
package de.jeanlucmakiola.clockula.ui.timers
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* §5.16 — 3 cases. The editor is a sibling route in the same flat `NavHost`, so
* M4's navigation policy already answers what the tab bar shows and who owns
* back; those two properties are §5.15's.
*/
class TimerRoutesTest {
/** §5.16 #1 */
@Test
fun `the editor route carries the timer's id`() {
assertThat(TimerRoutes.editor(7L)).isEqualTo("timers/edit/7")
}
/** §5.16 #2 */
@Test
fun `the pattern's placeholder is the declared argument name`() {
assertThat(TimerRoutes.EDITOR_PATTERN).contains("{" + TimerRoutes.ARG_TIMER_ID + "}")
}
/** §5.16 #3 */
@Test
fun `a negative id is a route, not a crash`() {
val prefix = TimerRoutes.EDITOR_PATTERN.substringBefore("{")
assertThat(TimerRoutes.editor(-1L)).isEqualTo(prefix + "-1")
}
}
@@ -0,0 +1,246 @@
package de.jeanlucmakiola.clockula.ui.timers
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.timers.TimerEntity
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.testing.FakeTimerDao
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.idleTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import de.jeanlucmakiola.clockula.testing.timerEngineHarness
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* §5.12 — 12 cases over the real repository and a real `TimerEngine`. The six
* verbs go through the engine, not the repository, which is what keeps the
* expiry slot and the service moving with the state (D2) — and the expiry slot
* is how a test proves the call took that route.
*/
class TimersViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
private val uptime: Duration = 1_000.seconds
private val dao = SubscriptionCountingTimerDao()
private fun TestScope.viewModel(harness: TimerEngineHarness) = TimersViewModel(
timers = harness.timers,
engine = harness.engine,
settings = harness.settings,
source = TimerListSource(harness.timers, harness.elapsed, harness.wallClock, harness.ticker),
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
private fun TimersUiState.loaded(): TimersUiState.Loaded = this as TimersUiState.Loaded
/** §5.12 #1 */
@Test
fun `the list starts out loading, not empty`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val harness = timerEngineHarness(tempDir, timerDao = dao)
val viewModel = viewModel(harness)
assertThat(viewModel.state.value).isEqualTo(TimersUiState.Loading)
}
/** §5.12 #2 */
@Test
fun `a timer stored underneath the list appears in it`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir, timerDao = dao)
val viewModel = viewModel(harness)
advanceUntilIdle()
harness.given(idleTimer(id = 1L, label = "Pasta"))
advanceUntilIdle()
assertThat(viewModel.state.value.loaded().rows.map { it.id }).containsExactly(1L)
}
/** §5.12 #3 */
@Test
fun `starting a new timer creates it, runs it and registers its deadline`(
@TempDir tempDir: Path,
) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir, timerDao = dao)
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onStartNew(90.seconds)
advanceUntilIdle()
val stored = harness.stored(dao.stored.single().id)!!
assertThat(listOf(stored.state, stored.duration, harness.scheduler.expiryAt))
.containsExactly(TimerState.RUNNING, 90.seconds, uptime + 90.seconds)
.inOrder()
}
/** §5.12 #4 */
@Test
fun `a double-tapped start creates one timer`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val harness = timerEngineHarness(tempDir, timerDao = dao)
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onStartNew(90.seconds)
viewModel.onStartNew(90.seconds)
advanceUntilIdle()
assertThat(dao.stored).hasSize(1)
}
/** §5.12 #5 */
@Test
fun `a duration outside the allowed range creates nothing`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir, timerDao = dao)
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onStartNew(500.milliseconds)
advanceUntilIdle()
assertThat(dao.stored).isEmpty()
}
/** §5.12 #6 */
@Test
fun `pausing from the list cancels the expiry slot`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir, timerDao = dao)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onPause(1L)
advanceUntilIdle()
assertThat(harness.scheduler.expiryAt).isNull()
}
/** §5.12 #7 */
@Test
fun `deleting a sounding timer closes the ring session`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir, timerDao = dao)
harness.given(expiredTimer(id = 1L))
harness.ringState.set(T0 - 30.seconds)
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onDelete(1L)
advanceUntilIdle()
assertThat(harness.ringState.current()).isNull()
}
/** §5.12 #8 */
@Test
fun `the stored presets are part of the loaded state`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir, timerDao = dao)
harness.settings.setTimerPresets(listOf(2.minutes, 20.minutes))
val viewModel = viewModel(harness)
advanceUntilIdle()
assertThat(viewModel.state.value.loaded().presets)
.containsExactly(2.minutes, 20.minutes)
.inOrder()
}
/** §5.12 #9 */
@Test
fun `saving a preset keeps the set ascending`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir, timerDao = dao)
harness.settings.setTimerPresets(listOf(1.minutes, 5.minutes))
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onSavePreset(3.minutes)
advanceUntilIdle()
assertThat(viewModel.state.value.loaded().presets)
.containsExactly(1.minutes, 3.minutes, 5.minutes)
.inOrder()
}
/** §5.12 #10 */
@Test
fun `saving a preset that is already there changes nothing`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir, timerDao = dao)
harness.settings.setTimerPresets(listOf(1.minutes, 5.minutes))
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onSavePreset(5.minutes)
advanceUntilIdle()
assertThat(viewModel.state.value.loaded().presets)
.containsExactly(1.minutes, 5.minutes)
.inOrder()
}
/** §5.12 #11 */
@Test
fun `removing a preset leaves the rest`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir, timerDao = dao)
harness.settings.setTimerPresets(listOf(1.minutes, 5.minutes, 10.minutes))
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onRemovePreset(5.minutes)
advanceUntilIdle()
assertThat(viewModel.state.value.loaded().presets)
.containsExactly(1.minutes, 10.minutes)
.inOrder()
}
/** §5.12 #12 */
@Test
fun `two collectors subscribe the repository once`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir, timerDao = dao)
val viewModel = viewModel(harness)
advanceUntilIdle()
backgroundScope.launch { viewModel.state.collect {} }
advanceUntilIdle()
assertThat(dao.subscriptions).isEqualTo(1)
}
/** Counts the collectors the observing flow actually acquires. */
private class SubscriptionCountingTimerDao : FakeTimerDao() {
var subscriptions: Int = 0
private set
override fun observeAll(): Flow<List<TimerEntity>> =
super.observeAll().onStart { subscriptions++ }
}
}