feat(widgets): let the quick timer start a custom duration

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
This commit is contained in:
2026-10-05 19:45:31 +02:00
parent 462f9f8c9f
commit 8cbb8b311c
6 changed files with 202 additions and 51 deletions
@@ -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<Duration> = 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
}
/**
@@ -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))
}
},
)
}
@@ -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 },
)
}
}
@@ -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 },
)
}
}
+3
View File
@@ -426,4 +426,7 @@
<string name="widget_quick_timer_description">Starts a timer with one tap</string>
<string name="widget_quick_timer_config_title">Quick timer</string>
<string name="widget_quick_timer_start">Start %1$s</string>
<string name="widget_quick_timer_custom">Custom…</string>
<string name="widget_quick_timer_custom_title">Custom duration</string>
<string name="widget_quick_timer_range_hint">Between 1 second and 59:59</string>
</resources>
@@ -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()
}
}