diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/TimersInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/TimersInstrumentedTest.kt new file mode 100644 index 0000000..9490f99 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/TimersInstrumentedTest.kt @@ -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? = 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 = 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 { + val launched = ActivityScenario.launch( + 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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt index 842e2f4..ff6226b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt @@ -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(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(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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt index 924e85d..986a7f4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt @@ -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() } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt index ed9b611..77dc8a2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt index adfd4fe..2e5636d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt @@ -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 = 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() } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorScreen.kt new file mode 100644 index 0000000..611dd6d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorScreen.kt @@ -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)) } + }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorUiState.kt new file mode 100644 index 0000000..a8b21a3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorUiState.kt @@ -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, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorViewModel.kt new file mode 100644 index 0000000..17532ca --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorViewModel.kt @@ -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(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(null) + + /** A picked document whose read grant could not be made persistent (M5 D20). */ + private val accessMayLapse = MutableStateFlow(false) + + private val _state = MutableStateFlow(TimerEditorUiState.Loading) + + val state: StateFlow = _state.asStateFlow() + + private val _ringtonePicker = MutableStateFlow(null) + + /** Null while closed. M5's type. */ + val ringtonePicker: StateFlow = _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() + + /** 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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerKeypad.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerKeypad.kt new file mode 100644 index 0000000..bacbed2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerKeypad.kt @@ -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'), +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerListSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerListSource.kt new file mode 100644 index 0000000..76b8735 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerListSource.kt @@ -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> = + 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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRoutes.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRoutes.kt new file mode 100644 index 0000000..66733b1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRoutes.kt @@ -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" +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRow.kt new file mode 100644 index 0000000..841b025 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRow.kt @@ -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 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 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRowState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRowState.kt new file mode 100644 index 0000000..dc76679 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRowState.kt @@ -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, val presets: List) : 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, + elapsedRealtime: Duration, + wallClock: Instant, + ): List = 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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerSetupPanel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerSetupPanel.kt new file mode 100644 index 0000000..27ed2df --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerSetupPanel.kt @@ -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, + 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, + 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)) }, + ) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersDefaults.kt new file mode 100644 index 0000000..05e4f46 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersDefaults.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt index a4e6bf8..1b453c4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt @@ -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') } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModel.kt new file mode 100644 index 0000000..20eca8c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModel.kt @@ -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 = + 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 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f46e48..d211d41 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,9 +24,47 @@ No alarms yet - No timers yet No world clocks yet + + Set timer + Start + Between 1 second and 24 hours + Presets + Save + Remove preset + Delete a digit + Type %1$s + Start + Pause + Resume + Reset + Stop + +1 min + Finished + Paused \u00b7 %1$s + Timer + %1$s left + Timer settings + + + Timer + Label + Length + Reset the timer to change its length + Sound + Delete timer + Delete this timer? + + + Timers + The running countdown and the sound when a timer finishes. Turning this off does not silence a timer. + Timer + + +%1$d more timer + +%1$d more timers + + Pause Resume diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTimerSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTimerSeams.kt new file mode 100644 index 0000000..4230342 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTimerSeams.kt @@ -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() + + /** Every transition, in order — null for a cancel. */ + val scheduled: List 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() + + val transitions: List 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 = 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, + ) +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/TimerEngineHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/TimerEngineHarness.kt new file mode 100644 index 0000000..2fac8cc --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/TimerEngineHarness.kt @@ -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, +) : DataStore { + + var writes: Int = 0 + private set + + override val data: Flow 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, +) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorViewModelTest.kt new file mode 100644 index 0000000..e5217e0 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorViewModelTest.kt @@ -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()).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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerListRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerListRowsTest.kt new file mode 100644 index 0000000..0b606f5 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerListRowsTest.kt @@ -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, + elapsedRealtime: Duration = now, + wallClock: Instant = wall, + ): List = 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerRoutesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerRoutesTest.kt new file mode 100644 index 0000000..3f65d97 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerRoutesTest.kt @@ -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") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModelTest.kt new file mode 100644 index 0000000..3dc0fe1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModelTest.kt @@ -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> = + super.observeAll().onStart { subscriptions++ } + } +}