feat(alarms): ask which alarm, when the request names more than one

"Dismiss my alarm" with two alarms set is a question, not an instruction. When
the search matches more than one the app asks, in a dialog listing the
candidates; when it matches exactly one it acts without asking.

The Timers tab's setup effect no longer re-keys on a list that changes with
every readout tick.
This commit is contained in:
2026-09-23 11:02:20 +02:00
parent 02f3794ee8
commit 46a049f988
9 changed files with 420 additions and 1 deletions
@@ -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<Long>()
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<AlarmClockActivity>(intent).use { scenario ->
assertThat(scenario.state).isEqualTo(Lifecycle.State.DESTROYED)
}
}
}
@@ -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. */
@@ -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 {
@@ -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<AlarmRowState>,
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 },
)
}
@@ -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<AlarmRowState>, ids: List<Long>): List<AlarmRowState> {
// 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 }
}
}
@@ -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 = ""
+5
View File
@@ -230,4 +230,9 @@
<item quantity="one">%d minute</item>
<item quantity="other">%d minutes</item>
</plurals>
<!-- M9: the one question the AlarmClock contract has to ask -->
<string name="interop_dismiss_title">Which alarm?</string>
<!-- %1$s is the time, followed by the label when the alarm has one. -->
<string name="interop_dismiss_row_description">Dismiss the alarm at %1$s</string>
</resources>
@@ -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)
}
}
@@ -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()
}
}