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:
+13
-3
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+68
-9
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user