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:
makiolaj committed 2026-09-23 11:02:20 +02:00
1 parent 02f3794ee8
commit 46a049f988
9 files changed
+420 -1

No files matched your search

@@ -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>