diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/InteropInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/InteropInstrumentedTest.kt new file mode 100644 index 0000000..9e93ec5 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/InteropInstrumentedTest.kt @@ -0,0 +1,163 @@ +package de.jeanlucmakiola.clockula.ui + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.AlarmClock +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.RepeatDays +import de.jeanlucmakiola.clockula.domain.TimeOfDay +import de.jeanlucmakiola.clockula.domain.format.NextFireLabel +import de.jeanlucmakiola.clockula.domain.interop.AlarmClockContract +import de.jeanlucmakiola.clockula.interop.AlarmClockActivity +import de.jeanlucmakiola.clockula.ui.alarms.AlarmRowState +import de.jeanlucmakiola.clockula.ui.alarms.DismissAlarmDialog +import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * What only a device can answer about the `AlarmClock` contract: that our + * literals really are the platform's constants, that the manifest's two + * intent-filters really resolve — with and without a data URI — and that the + * door really opens no window. + * + * Compiled in the gate; **not run here** — no device is attached. + */ +@RunWith(AndroidJUnit4::class) +class InteropInstrumentedTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + private fun row(id: Long, hour: Int, label: String) = AlarmRowState( + id = id, + time = TimeOfDay(hour, 0), + label = label, + enabled = true, + repeatDays = RepeatDays.NONE, + nextFire = NextFireLabel.NotScheduled, + snoozed = false, + ringing = false, + skippingNext = false, + isNext = false, + ) + + @Test + fun ourLiteralsAreThePlatformsOwnConstants() { + val ours = listOf( + AlarmClockContract.ACTION_SET_ALARM, + AlarmClockContract.ACTION_SET_TIMER, + AlarmClockContract.ACTION_SHOW_ALARMS, + AlarmClockContract.ACTION_SHOW_TIMERS, + AlarmClockContract.ACTION_DISMISS_ALARM, + AlarmClockContract.ACTION_DISMISS_TIMER, + AlarmClockContract.ACTION_SNOOZE_ALARM, + AlarmClockContract.EXTRA_HOUR, + AlarmClockContract.EXTRA_MINUTES, + AlarmClockContract.EXTRA_MESSAGE, + AlarmClockContract.EXTRA_DAYS, + AlarmClockContract.EXTRA_RINGTONE, + AlarmClockContract.EXTRA_VIBRATE, + AlarmClockContract.EXTRA_SKIP_UI, + AlarmClockContract.EXTRA_LENGTH, + AlarmClockContract.EXTRA_IS_PM, + AlarmClockContract.EXTRA_ALARM_SEARCH_MODE, + AlarmClockContract.EXTRA_ALARM_SNOOZE_DURATION, + AlarmClockContract.SEARCH_MODE_TIME, + AlarmClockContract.SEARCH_MODE_NEXT, + AlarmClockContract.SEARCH_MODE_ALL, + AlarmClockContract.SEARCH_MODE_LABEL, + AlarmClockContract.VALUE_RINGTONE_SILENT, + ) + + assertThat(ours).containsExactly( + AlarmClock.ACTION_SET_ALARM, + AlarmClock.ACTION_SET_TIMER, + AlarmClock.ACTION_SHOW_ALARMS, + AlarmClock.ACTION_SHOW_TIMERS, + AlarmClock.ACTION_DISMISS_ALARM, + AlarmClock.ACTION_DISMISS_TIMER, + AlarmClock.ACTION_SNOOZE_ALARM, + AlarmClock.EXTRA_HOUR, + AlarmClock.EXTRA_MINUTES, + AlarmClock.EXTRA_MESSAGE, + AlarmClock.EXTRA_DAYS, + AlarmClock.EXTRA_RINGTONE, + AlarmClock.EXTRA_VIBRATE, + AlarmClock.EXTRA_SKIP_UI, + AlarmClock.EXTRA_LENGTH, + AlarmClock.EXTRA_IS_PM, + AlarmClock.EXTRA_ALARM_SEARCH_MODE, + AlarmClock.EXTRA_ALARM_SNOOZE_DURATION, + AlarmClock.ALARM_SEARCH_MODE_TIME, + AlarmClock.ALARM_SEARCH_MODE_NEXT, + AlarmClock.ALARM_SEARCH_MODE_ALL, + AlarmClock.ALARM_SEARCH_MODE_LABEL, + AlarmClock.VALUE_RINGTONE_SILENT, + ).inOrder() + } + + @Test + fun everyContractActionResolvesToTheDoor() { + val unresolved = AlarmClockContract.ACTIONS.filterNot { action -> + context.packageManager + .queryIntentActivities(Intent(action).setPackage(context.packageName), 0) + .any { it.activityInfo.name == AlarmClockActivity::class.java.name } + } + + assertThat(unresolved).isEmpty() + } + + @Test + fun aDeeplinkedDismissResolvesToTheDoor() { + val intent = Intent(AlarmClockContract.ACTION_DISMISS_ALARM) + .setPackage(context.packageName) + .setData(Uri.parse("clockula://alarm/1")) + + val resolved = context.packageManager.queryIntentActivities(intent, 0) + + assertThat(resolved.map { it.activityInfo.name }) + .contains(AlarmClockActivity::class.java.name) + } + + @Test + fun tappingARowInTheChooserDismissesThatAlarm() { + val dismissed = mutableListOf() + composeTestRule.setContent { + ClockulaTheme { + DismissAlarmDialog( + rows = listOf(row(1L, 6, "Morning gym"), row(2L, 7, "Yoga")), + onDismissAlarm = { dismissed += it }, + onCancel = {}, + ) + } + } + + composeTestRule.onNodeWithText("Yoga").assertExists() + composeTestRule.onNodeWithText("Morning gym").performClick() + + assertThat(dismissed).containsExactly(1L) + } + + @Test + fun theDoorFinishesWithoutEverShowingAWindow() { + val intent = Intent(context, AlarmClockActivity::class.java) + .setAction(AlarmClockContract.ACTION_SHOW_ALARMS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + ActivityScenario.launch(intent).use { scenario -> + assertThat(scenario.state).isEqualTo(Lifecycle.State.DESTROYED) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt index 7360789..7eeb01e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt @@ -43,9 +43,12 @@ import kotlinx.coroutines.flow.collectLatest fun AlarmsScreen( onEditAlarm: (Long) -> Unit, modifier: Modifier = Modifier, + dismissCandidates: LongArray = LongArray(0), + onDismissCandidatesConsumed: () -> Unit = {}, viewModel: AlarmsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val rows = (state as? AlarmsUiState.Loaded)?.rows val pillInset = LocalLivePillInset.current val addAlarm = stringResource(R.string.alarms_add) @@ -70,7 +73,6 @@ fun AlarmsScreen( } }, ) { - val rows = (state as? AlarmsUiState.Loaded)?.rows LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( @@ -94,6 +96,25 @@ fun AlarmsScreen( } } } + + // "Which alarm should I dismiss?" — asked only when a search named more + // than one, and answered out of the rows already on screen (M9 D13). + if (dismissCandidates.isNotEmpty() && rows != null) { + val chosen = DismissChooser.rowsFor(rows, dismissCandidates.toList()) + if (chosen.isEmpty()) { + // Every candidate has since gone: there is nothing to ask about. + LaunchedEffect(dismissCandidates) { onDismissCandidatesConsumed() } + } else { + DismissAlarmDialog( + rows = chosen, + onDismissAlarm = { alarmId -> + viewModel.onDismissUpcoming(alarmId) + onDismissCandidatesConsumed() + }, + onCancel = onDismissCandidatesConsumed, + ) + } + } } /** A real empty state, not a flash: the list has loaded and there is nothing in it. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt index a0eb02d..d17e9fe 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt @@ -68,6 +68,14 @@ class AlarmsViewModel @Inject internal constructor( } } + /** The `AlarmClock` contract's dismissal, and the chooser dialog's, through the engine (M9 D14). */ + fun onDismissUpcoming(alarmId: Long) { + // One engine verb, so the contract's dismissal and the user's are the + // same code: it closes a ring, arms a repeating alarm's skip, or + // disables a one-shot — each of which moves state only the engine owns. + viewModelScope.launch { engine.dismissUpcoming(alarmId) } + } + /** Dismisses a ring first when switching off (M5 D11), then re-resolves. */ fun onToggleEnabled(alarmId: Long, enabled: Boolean) { viewModelScope.launch { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/DismissAlarmDialog.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/DismissAlarmDialog.kt new file mode 100644 index 0000000..7ad5dd6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/DismissAlarmDialog.kt @@ -0,0 +1,87 @@ +package de.jeanlucmakiola.clockula.ui.alarms + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.semantics +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter + +/** + * "Which alarm?" — an M3 basic dialog with a list. Tapping a row **is** the + * answer; the one action button is Cancel (M9 D13). + * + * The canonical component for "pick one of a short list, or cancel": not a + * full-screen dialog, which is for a whole task, and not a bottom sheet, which + * is a surface rather than a question. It takes `AlertDialog`'s M3 defaults — + * `surfaceContainerHigh`, the extra-large corner, tonal elevation 3 — with no + * overrides and no literal colour. + */ +@Composable +internal fun DismissAlarmDialog( + rows: List, + onDismissAlarm: (Long) -> Unit, + onCancel: () -> Unit, +) { + val formatTime = rememberAlarmTimeFormatter() + AlertDialog( + onDismissRequest = onCancel, + title = { Text(stringResource(R.string.interop_dismiss_title)) }, + text = { + // A dialog's own body does not scroll, and a search can name more + // alarms than fit on a short screen. + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + rows.forEach { row -> + DismissAlarmRow( + row = row, + time = formatTime(row.time), + onClick = { onDismissAlarm(row.id) }, + ) + } + } + }, + // M3 puts a lone action in the confirm slot. There is no "select then + // confirm": a tap on a row is the answer, the same rule M4 applied to + // the dismiss challenge. + confirmButton = { + TextButton(onClick = onCancel) { Text(stringResource(R.string.action_cancel)) } + }, + ) +} + +@Composable +private fun DismissAlarmRow( + row: AlarmRowState, + time: String, + onClick: () -> Unit, +) { + val repeat = repeatSummaryText(row.repeatDays) + val spoken = if (row.label.isBlank()) time else "$time, ${row.label}" + val description = stringResource(R.string.interop_dismiss_row_description, spoken) + ListItem( + headlineContent = { Text(time) }, + overlineContent = { Text(repeat) }, + supportingContent = row.label.takeIf { it.isNotBlank() }?.let { label -> { Text(label) } }, + // A `ListItem`'s default container is `surface`, which sits *lower* than + // the dialog's own `surfaceContainerHigh` and would draw a visible + // lighter block per row. `Color.Transparent` is the absence of a colour, + // not a literal one. + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier + .clickable(onClick = onClick) + // One node per row, saying what it is and what tapping it does; + // the 48dp target comes free with `ListItem`. + .semantics(mergeDescendants = true) { contentDescription = description }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/DismissChooser.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/DismissChooser.kt new file mode 100644 index 0000000..d854a37 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/DismissChooser.kt @@ -0,0 +1,13 @@ +package de.jeanlucmakiola.clockula.ui.alarms + +/** Which rows the disambiguation dialog shows, out of the list already on screen (M9 D13). */ +internal object DismissChooser { + /** The rows named by [ids], in [rows]' own order, de-duplicated. */ + fun rowsFor(rows: List, ids: List): List { + // Filtered out of the list already on screen, in *its* order: the + // dialog and the list behind it then agree, with no second source and + // no second ticker. A duplicated candidate cannot duplicate a row. + val wanted = ids.toSet() + return rows.filter { it.id in wanted } + } +} 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 1b453c4..67ea1f4 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 @@ -13,6 +13,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState 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 @@ -46,6 +47,8 @@ import kotlin.time.Duration fun TimersScreen( onEditTimer: (Long) -> Unit, modifier: Modifier = Modifier, + requestSetup: Boolean = false, + onSetupRequestConsumed: () -> Unit = {}, viewModel: TimersViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -64,6 +67,19 @@ fun TimersScreen( val rows = loaded?.rows val presets = loaded?.presets.orEmpty() + // `SET_TIMER` with no usable length asks for the setup panel (M9 D7). On an + // empty list that panel is already inline and *is* the empty state, so the + // sheet must not also open there. + // Keyed on whether the list has *arrived and is empty*, never on the list + // itself: `rows` is recomputed on every readout tick, so keying on it would + // cancel and relaunch this effect once a second for the life of the tab. + LaunchedEffect(requestSetup, rows?.isEmpty()) { + val loadedRows = rows ?: return@LaunchedEffect + if (!requestSetup) return@LaunchedEffect + if (loadedRows.isNotEmpty()) sheetOpen = true + onSetupRequestConsumed() + } + fun start() { viewModel.onStartNew(entry.duration) digits = "" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 02cb16f..322e39b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -230,4 +230,9 @@ %d minute %d minutes + + + Which alarm? + + Dismiss the alarm at %1$s diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt index a8f3b21..1317b97 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt @@ -345,4 +345,55 @@ class AlarmsViewModelTest { assertThat(viewModel.state.value.rows().map { it.id }).containsExactly(2L) } + + // --- M9: the AlarmClock contract's dismissal, and the chooser dialog's --- + + @Test + fun `dismissing a ringing alarm from the dialog closes the cycle`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 7) + harness.engine.reschedule() + harness.givenRinging(alarmId = 1L, at = sevenToday) + val viewModel = viewModel(harness) + advanceUntilIdle() + + viewModel.onDismissUpcoming(1L) + advanceUntilIdle() + + assertThat(harness.states.state(1L).ringingSince).isNull() + assertThat(harness.ring.events.last()).isEqualTo(RingEvent.Stopped(1L)) + } + + @Test + fun `dismissing a repeating alarm only skips its next occurrence`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY) + harness.engine.reschedule() + val viewModel = viewModel(harness) + advanceUntilIdle() + + viewModel.onDismissUpcoming(1L) + advanceUntilIdle() + + val alarm = harness.alarms.find(1L)!! + assertThat(alarm.skipNextOccurrence to alarm.enabled).isEqualTo(true to true) + } + + @Test + fun `dismissing an id no alarm has writes nothing`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 7) + harness.engine.reschedule() + val viewModel = viewModel(harness) + advanceUntilIdle() + val before = harness.alarmDao.stored + + viewModel.onDismissUpcoming(404L) + advanceUntilIdle() + + assertThat(harness.alarmDao.stored).isEqualTo(before) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/DismissChooserTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/DismissChooserTest.kt new file mode 100644 index 0000000..396046a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/DismissChooserTest.kt @@ -0,0 +1,55 @@ +package de.jeanlucmakiola.clockula.ui.alarms + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.RepeatDays +import de.jeanlucmakiola.clockula.domain.TimeOfDay +import de.jeanlucmakiola.clockula.domain.format.NextFireLabel +import org.junit.jupiter.api.Test + +/** + * The chooser dialog filters the list already on screen, so the question and + * the list behind it agree — one source, one order, no second ticker (M9 D13). + */ +class DismissChooserTest { + + private fun row(id: Long, hour: Int) = AlarmRowState( + id = id, + time = TimeOfDay(hour, 0), + label = "", + enabled = true, + repeatDays = RepeatDays.NONE, + nextFire = NextFireLabel.NotScheduled, + snoozed = false, + ringing = false, + skippingNext = false, + isNext = false, + ) + + private val rows = listOf(row(1L, 6), row(2L, 7), row(3L, 8)) + + @Test + fun `the chosen rows keep the list's own order, not the candidates'`() { + assertThat(DismissChooser.rowsFor(rows, listOf(3L, 1L)).map { it.id }) + .containsExactly(1L, 3L).inOrder() + } + + @Test + fun `a candidate the list does not hold is skipped`() { + assertThat(DismissChooser.rowsFor(rows, listOf(2L, 99L)).map { it.id }).containsExactly(2L) + } + + @Test + fun `no candidates means no rows`() { + assertThat(DismissChooser.rowsFor(rows, emptyList())).isEmpty() + } + + @Test + fun `a duplicated candidate appears once`() { + assertThat(DismissChooser.rowsFor(rows, listOf(2L, 2L, 2L)).map { it.id }).containsExactly(2L) + } + + @Test + fun `an empty list answers with nothing and throws nothing`() { + assertThat(DismissChooser.rowsFor(emptyList(), listOf(1L, 2L))).isEmpty() + } +}