From 8cbb8b311c716a09a7153b8916b2451db3dd41d7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 5 Oct 2026 19:31:10 +0200 Subject: [PATCH] feat(widgets): let the quick timer start a custom duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick-timer config screen now ends its list of presets with a "Custom…" row that opens the timer keypad. Anything from 1 second to 59:59 is accepted; past that the keypad shows a range hint and OK stays disabled, since the widget's readout is sized for MM:SS. A stored value that isn't a preset marks the Custom row, with the value as its summary. The keypad dialog is lifted out of the timer editor into a shared TimerDurationDialog, so both use the same one; the editor's now also shows the range hint the setup screen shows. Closes #7 --- .../domain/widget/QuickTimerWidgetSettings.kt | 16 +++- .../clockula/ui/timers/TimerDurationDialog.kt | 76 ++++++++++++++++++ .../clockula/ui/timers/TimerEditorScreen.kt | 46 ++--------- .../widget/config/QuickTimerConfigActivity.kt | 77 ++++++++++++++++--- app/src/main/res/values/strings.xml | 3 + .../domain/widget/ClockWidgetSettingsTest.kt | 35 +++++++++ 6 files changed, 202 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerDurationDialog.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt index bede8c3..ca2e7bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt @@ -18,14 +18,24 @@ object QuickTimerWidgetSettings { */ val MAX: Duration = 59.minutes + 59.seconds - /** What the config screen offers: the Timers screen's own built-in quick starts, up to [MAX]. */ + /** + * What the config screen lists: the Timers screen's own built-in quick + * starts, up to [MAX]. Anything else in range is typed in as a custom + * duration. + */ val OPTIONS: List = TimerPresets.BUILT_IN.filter { it <= MAX } + + /** Whether the widget can start [duration]: [TimerPresets.MIN]..[MAX], whether listed or typed. */ + fun accepts(duration: Duration): Boolean = duration in TimerPresets.MIN..MAX + + /** A stored [duration] that isn't one of [OPTIONS] — the config screen's "Custom" row. */ + fun isCustom(duration: Duration): Boolean = duration !in OPTIONS } -/** Anything unreadable, or outside [TimerPresets.MIN]..[QuickTimerWidgetSettings.MAX], falls back to [QuickTimerWidgetSettings.DEFAULT]. */ +/** Anything unreadable, or one [QuickTimerWidgetSettings.accepts] refuses, falls back to [QuickTimerWidgetSettings.DEFAULT]. */ fun parseQuickTimerDuration(raw: Long?): Duration { val duration = raw?.milliseconds - return duration?.takeIf { it in TimerPresets.MIN..QuickTimerWidgetSettings.MAX } ?: QuickTimerWidgetSettings.DEFAULT + return duration?.takeIf(QuickTimerWidgetSettings::accepts) ?: QuickTimerWidgetSettings.DEFAULT } /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerDurationDialog.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerDurationDialog.kt new file mode 100644 index 0000000..164b954 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerDurationDialog.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.clockula.ui.timers + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +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.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.unit.dp +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry +import de.jeanlucmakiola.clockula.domain.timer.TimerPresets +import kotlin.time.Duration + +/** + * "Type a duration" as a dialog: the setup panel's keypad over the same pure + * state machine (D23), for the timer editor's length and the quick-timer + * widget's custom duration. Starts empty each time it opens; the typed digits + * are saveable, so an open dialog keeps them across a rotation. + * + * OK stays disabled until the entry is in [accepts]'s range, and [rangeHint] + * says why once something has been typed — as the setup screen's readout does. + */ +@Composable +internal fun TimerDurationDialog( + title: String, + onConfirm: (Duration) -> Unit, + onDismiss: () -> Unit, + accepts: (Duration) -> Boolean = { it in TimerPresets.MIN..TimerPresets.MAX }, + rangeHint: String = stringResource(R.string.timers_setup_range_hint), +) { + var digits by rememberSaveable { mutableStateOf("") } + val entry = TimerDurationEntry(digits) + val valid = accepts(entry.duration) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + TimerDurationReadout(entry = entry) + if (!entry.isEmpty && !valid) { + Text( + text = rangeHint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(16.dp)) + TimerKeypad( + onDigit = { digits = entry.plusDigit(it).digits }, + onDoubleZero = { digits = entry.plusDoubleZero().digits }, + onBackspace = { digits = entry.backspace().digits }, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(entry.duration) }, enabled = valid) { + Text(stringResource(R.string.action_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} 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 index 1f1e22a..b9f6d0c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorScreen.kt @@ -20,7 +20,6 @@ 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.font.FontWeight @@ -29,7 +28,6 @@ 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 @@ -128,18 +126,11 @@ private fun TimerEditorContent( // Saveable, not remembered: a dialog open across a rotation must still be // open afterwards (M5's lesson). var editingLength by rememberSaveable { mutableStateOf(false) } - var digits by rememberSaveable { mutableStateOf("") } - val entry = TimerDurationEntry(digits) LengthCard( editing = editing, position = Position.Top, - onClick = { - if (editing.canEditDuration) { - digits = "" - editingLength = true - } - }, + onClick = { if (editing.canEditDuration) editingLength = true }, ) LabelField(label = editing.label, onLabelTyped = viewModel::onLabelTyped, position = Position.Middle) @@ -150,36 +141,13 @@ private fun TimerEditorContent( 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) { - TimerDurationReadout(entry = entry) - Spacer(Modifier.height(16.dp)) - TimerKeypad( - onDigit = { digits = entry.plusDigit(it).digits }, - onDoubleZero = { digits = entry.plusDoubleZero().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)) - } + TimerDurationDialog( + title = stringResource(R.string.timer_editor_duration), + onConfirm = { + viewModel.onDurationCommitted(it) + editingLength = false }, + onDismiss = { editingLength = false }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt index 4bc0238..a8a8ac0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import androidx.glance.appwidget.state.getAppWidgetState @@ -15,16 +16,21 @@ import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.format.ClockFormat import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration +import de.jeanlucmakiola.clockula.ui.timers.TimerDurationDialog import de.jeanlucmakiola.clockula.widget.QuickTimerWidget import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY -import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf +import kotlin.time.Duration /** * The quick-timer widget's entire configuration: which duration it starts on - * tap — [QuickTimerWidgetSettings.OPTIONS], the Timers screen's own built-in - * quick-start durations up to the widget's 59:59 cap. Like - * [AnalogClockConfigActivity], tapping an option saves and finishes - * immediately. + * tap — one of [QuickTimerWidgetSettings.OPTIONS], the Timers screen's own + * built-in quick-start durations up to the widget's 59:59 cap, or a custom + * one typed on the timer keypad. Like [AnalogClockConfigActivity], picking a + * duration saves and finishes immediately. */ @AndroidEntryPoint class QuickTimerConfigActivity : WidgetConfigActivity() { @@ -36,12 +42,10 @@ class QuickTimerConfigActivity : WidgetConfigActivity() { val state = getAppWidgetState(this@QuickTimerConfigActivity, PreferencesGlanceStateDefinition, glanceId) duration = parseQuickTimerDuration(state[WIDGET_QUICK_TIMER_DURATION_KEY]) } - OptionPicker( - title = stringResource(R.string.widget_quick_timer_config_title), - options = QuickTimerWidgetSettings.OPTIONS, + QuickTimerPicker( selected = duration, - label = { ClockFormat.countdown(it) }, onSelect = { picked -> + duration = picked save { updateAppWidgetState(this@QuickTimerConfigActivity, glanceId) { prefs -> prefs[WIDGET_QUICK_TIMER_DURATION_KEY] = picked.inWholeMilliseconds @@ -53,3 +57,58 @@ class QuickTimerConfigActivity : WidgetConfigActivity() { ) } } + +/** + * The listed durations, then "Custom…", which opens the timer keypad. A + * stored duration that isn't listed marks the Custom row instead, with the + * value as its summary, so the picker always shows what the widget starts. + */ +@Composable +private fun QuickTimerPicker(selected: Duration, onSelect: (Duration) -> Unit, onDismiss: () -> Unit) { + var typing by rememberSaveable { mutableStateOf(false) } + val options = QuickTimerWidgetSettings.OPTIONS + val count = options.size + 1 + + FullScreenPicker(title = stringResource(R.string.widget_quick_timer_config_title), onDismiss = onDismiss) { + options.forEachIndexed { index, option -> + val isSelected = option == selected + GroupedRow( + title = ClockFormat.countdown(option), + position = positionOf(index, count), + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { onSelect(option) }, + ) + } + val custom = QuickTimerWidgetSettings.isCustom(selected) + GroupedRow( + title = stringResource(R.string.widget_quick_timer_custom), + summary = if (custom) ClockFormat.countdown(selected) else null, + position = positionOf(options.size, count), + selected = custom, + trailing = if (custom) { + { SelectedCheck() } + } else { + null + }, + onClick = { typing = true }, + ) + } + + if (typing) { + TimerDurationDialog( + title = stringResource(R.string.widget_quick_timer_custom_title), + accepts = QuickTimerWidgetSettings::accepts, + rangeHint = stringResource(R.string.widget_quick_timer_range_hint), + onConfirm = { + typing = false + onSelect(it) + }, + onDismiss = { typing = false }, + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7472e44..2953155 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -426,4 +426,7 @@ Starts a timer with one tap Quick timer Start %1$s + Custom… + Custom duration + Between 1 second and 59:59 diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettingsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettingsTest.kt index 6da80e2..6fa9029 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettingsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettingsTest.kt @@ -1,7 +1,12 @@ package de.jeanlucmakiola.clockula.domain.widget import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry 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 class ClockWidgetSettingsTest { @@ -73,4 +78,34 @@ class ClockWidgetSettingsTest { fun `an in-range quick-timer duration round-trips`() { assertThat(parseQuickTimerDuration(600_000L).inWholeMilliseconds).isEqualTo(600_000L) } + + @Test + fun `a custom quick-timer duration round-trips`() { + val custom = 7.minutes + 30.seconds + assertThat(QuickTimerWidgetSettings.isCustom(custom)).isTrue() + assertThat(parseQuickTimerDuration(custom.inWholeMilliseconds)).isEqualTo(custom) + } + + @Test + fun `the quick-timer range is one second to 59 59 inclusive`() { + assertThat(QuickTimerWidgetSettings.accepts(1.seconds)).isTrue() + assertThat(QuickTimerWidgetSettings.accepts(59.minutes + 59.seconds)).isTrue() + assertThat(QuickTimerWidgetSettings.accepts(Duration.ZERO)).isFalse() + assertThat(QuickTimerWidgetSettings.accepts(1.hours)).isFalse() + } + + @Test + fun `a listed quick-timer duration is not custom`() { + QuickTimerWidgetSettings.OPTIONS.forEach { assertThat(QuickTimerWidgetSettings.isCustom(it)).isFalse() } + } + + @Test + fun `typed minutes past 59 that overflow an hour are refused`() { + // The keypad doesn't clamp minutes: "9999" is 0:99:99, i.e. 1:40:39. + val entry = TimerDurationEntry("9999") + assertThat(QuickTimerWidgetSettings.accepts(entry.duration)).isFalse() + assertThat(QuickTimerWidgetSettings.accepts(TimerDurationEntry("5959").duration)).isTrue() + // "90" is 0:00:90 — seconds past 59 are fine while the total fits. + assertThat(QuickTimerWidgetSettings.accepts(TimerDurationEntry("90").duration)).isTrue() + } }