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:
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 = ""
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user