From eb63f02d38488b766f0f96df4aa3be9b05406524 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 5 Oct 2026 19:33:49 +0200 Subject: [PATCH] fix(timers): fit the add-timer keypad on one screen The keypad had fixed 92 dp keys, about 410 dp for its four rows. On a short screen, in landscape, in split screen or at a large display or font size, the readout, keypad and Start row no longer fit and Start was cut off. The keypad now takes whatever height the readout and Start row leave and sizes its keys to fit, between the 48 dp touch-target minimum and the old 92 dp, with the gap scaled along. A landscape screen puts the keypad beside the readout and Start. Normal phones keep the current look. The Timers tab's empty-state input shares the same panel and now sits outside the lazy list, so it has a real height to fit into. Closes #3 --- .../clockula/ui/timers/KeypadSizing.kt | 39 +++++ .../clockula/ui/timers/TimerSetupScreen.kt | 153 ++++++++++++++---- .../clockula/ui/timers/TimersDefaults.kt | 6 + .../clockula/ui/timers/TimersScreen.kt | 117 ++++++-------- .../clockula/ui/timers/KeypadSizingTest.kt | 73 +++++++++ 5 files changed, 291 insertions(+), 97 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/KeypadSizing.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/KeypadSizingTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/KeypadSizing.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/KeypadSizing.kt new file mode 100644 index 0000000..487c20c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/KeypadSizing.kt @@ -0,0 +1,39 @@ +package de.jeanlucmakiola.clockula.ui.timers + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.coerceIn +import androidx.compose.ui.unit.min + +/** + * The key size and gap a [TimerKeypad] uses inside a [maxWidth] × [maxHeight] + * box. [fits] is false only when even [TimersDefaults.KeypadKeySizeMin] keys + * overflow the box. + */ +internal data class KeypadSizing(val keySize: Dp, val keyGap: Dp, val fits: Boolean) + +/** + * The largest keypad that fits the box, between [minKey] and [maxKey]. The gap + * stays the same fraction of a key, so a shrunk grid keeps the large one's + * proportions rather than closing up. A box with room to spare gets [maxKey]: + * on a normal phone the keypad looks as it always did. + */ +internal fun keypadSizing( + maxWidth: Dp, + maxHeight: Dp, + minKey: Dp = TimersDefaults.KeypadKeySizeMin, + maxKey: Dp = TimersDefaults.KeypadKeySizeLarge, +): KeypadSizing { + val gapRatio = TimersDefaults.KeypadKeyGapLarge / TimersDefaults.KeypadKeySizeLarge + // n keys and n - 1 gaps along each axis, all in units of one key. + val byWidth = maxWidth / (KEYPAD_COLUMNS + (KEYPAD_COLUMNS - 1) * gapRatio) + val byHeight = maxHeight / (KEYPAD_ROWS + (KEYPAD_ROWS - 1) * gapRatio) + val ideal = min(byWidth, byHeight) + val key = ideal.coerceIn(minKey, maxKey) + // Scaled from the large gap rather than `key * gapRatio`, so the full-size + // keypad gets exactly its 14 dp rather than a float's 13.999999. + val gap = TimersDefaults.KeypadKeyGapLarge * (key / TimersDefaults.KeypadKeySizeLarge) + return KeypadSizing(keySize = key, keyGap = gap, fits = ideal >= minKey) +} + +private const val KEYPAD_COLUMNS = 3 +private const val KEYPAD_ROWS = 4 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerSetupScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerSetupScreen.kt index 92d4c10..ec76ea2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerSetupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerSetupScreen.kt @@ -6,14 +6,20 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.DeleteOutline import androidx.compose.material.icons.rounded.PlayArrow @@ -72,47 +78,129 @@ fun TimerSetupScreen( modifier = modifier, largeTopBar = false, predictiveBack = true, - // The content owns its own layout below: a thumb-zone keypad wants a - // flexible spacer above it, which a `verticalScroll` column — the - // scaffold's default — gives an unbounded height and no weight to - // divide (M5's lesson, applied here). + // The content owns its own layout below: a keypad sized to the + // height that is left needs a bounded height to measure, which a + // `verticalScroll` column — the scaffold's default — never gives + // (M5's lesson, applied here). scrollable = false, ) { - Column( + TimerInputPanel( + entry = entry, + onEntryChange = { digits = it.digits }, + onStart = ::start, + // The lower half of the screen is the thumb zone on a one-handed + // phone grip; centring the keypad would leave the heaviest, + // most-tapped surface at the hardest reach. + keypadInThumbZone = true, modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(24.dp)) - Readout(entry = entry) + .padding(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 32.dp), + ) + } +} - // Pushes the keypad into the lower half of the screen — the thumb - // zone on a one-handed phone grip — rather than centring it, which - // leaves the heaviest, most-tapped surface at the hardest reach. - Spacer(Modifier.weight(1f)) +/** + * The readout, keypad and Start row, laid out so all three always fit on one + * screen: the keypad takes whatever height the readout and Start row leave and + * shrinks its keys to fit ([keypadSizing]), down to the 48 dp touch-target + * minimum. A landscape box puts the keypad beside the other two instead, since + * stacking them would leave the keypad almost no height at all. + * + * [keypadInThumbZone] anchors the keypad to the bottom of its space rather than + * stacking it right under the readout. Shared with the Timers tab's empty-state + * inline input, which stacks. + */ +@Composable +internal fun TimerInputPanel( + entry: TimerDurationEntry, + onEntryChange: (TimerDurationEntry) -> Unit, + onStart: () -> Unit, + keypadInThumbZone: Boolean, + modifier: Modifier = Modifier, +) { + val keypad = @Composable { keypadModifier: Modifier, alignment: Alignment -> + FittedTimerKeypad( + onDigit = { onEntryChange(entry.plusDigit(it)) }, + onDoubleZero = { onEntryChange(entry.plusDoubleZero()) }, + onBackspace = { onEntryChange(entry.backspace()) }, + alignment = alignment, + modifier = keypadModifier, + ) + } + val startRow = @Composable { + StartRow( + entry = entry, + onClear = { onEntryChange(entry.cleared()) }, + onStart = onStart, + ) + } - TimerKeypad( - onDigit = { digits = entry.plusDigit(it).digits }, - onDoubleZero = { digits = entry.plusDoubleZero().digits }, - onBackspace = { digits = entry.backspace().digits }, - keySize = TimersDefaults.KeypadKeySizeLarge, - keyGap = TimersDefaults.KeypadKeyGapLarge, - ) - - Spacer(Modifier.height(24.dp)) - - StartRow( - entry = entry, - onClear = { digits = entry.cleared().digits }, - onStart = ::start, - ) - - Spacer(Modifier.height(32.dp)) + BoxWithConstraints(modifier = modifier) { + if (maxWidth > maxHeight) { + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Readout(entry = entry) + Spacer(Modifier.height(PanelGap)) + startRow() + } + Spacer(Modifier.width(PanelGap)) + keypad(Modifier.weight(1f).fillMaxHeight(), Alignment.Center) + } + } else { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Readout(entry = entry) + Spacer(Modifier.height(PanelGap)) + // Weighted, so it is measured last and gets exactly the + // height the readout and Start row leave over. + keypad( + Modifier + .weight(1f, fill = keypadInThumbZone) + .fillMaxWidth(), + if (keypadInThumbZone) Alignment.BottomCenter else Alignment.TopCenter, + ) + Spacer(Modifier.height(PanelGap)) + startRow() + } } } } +/** + * A [TimerKeypad] at the largest size its box allows. In the rare box too + * short for even minimum-size keys (split screen at a large display size), + * the keypad scrolls within its own slot rather than pushing the readout or + * Start off screen. + */ +@Composable +private fun FittedTimerKeypad( + onDigit: (Char) -> Unit, + onDoubleZero: () -> Unit, + onBackspace: () -> Unit, + alignment: Alignment, + modifier: Modifier = Modifier, +) { + BoxWithConstraints(modifier = modifier, contentAlignment = alignment) { + val sizing = keypadSizing(maxWidth = maxWidth, maxHeight = maxHeight) + TimerKeypad( + onDigit = onDigit, + onDoubleZero = onDoubleZero, + onBackspace = onBackspace, + keySize = sizing.keySize, + keyGap = sizing.keyGap, + modifier = if (sizing.fits) Modifier else Modifier.verticalScroll(rememberScrollState()), + ) + } +} + /** Shared with the Timers tab's own empty-state inline input. */ @Composable internal fun Readout(entry: TimerDurationEntry, modifier: Modifier = Modifier) { @@ -188,6 +276,9 @@ internal fun StartRow( } } +/** Between the readout, the keypad and the Start row. */ +private val PanelGap = 24.dp + /** Comfortably past a normal icon button: the one unambiguous action gets the emphasis. */ private val StartButtonSize = 96.dp private val ClearButtonSize = StartButtonSize * 0.6f diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersDefaults.kt index 8cf532f..ea34287 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersDefaults.kt @@ -27,4 +27,10 @@ internal object TimersDefaults { */ val KeypadKeySizeLarge: Dp = 92.dp val KeypadKeyGapLarge: Dp = 14.dp + + /** + * The smallest a large keypad shrinks to when the screen is short + * ([keypadSizing]): the 48 dp touch-target minimum. + */ + val KeypadKeySizeMin: Dp = 48.dp } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt index bb18dd2..0741321 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt @@ -1,12 +1,8 @@ package de.jeanlucmakiola.clockula.ui.timers import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -21,7 +17,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.unit.dp @@ -86,37 +81,45 @@ fun TimersScreen( } }, ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - // Disconnected cards, exactly as the Alarms tab's own list is: each - // timer is its own object, not a row of a table. - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues( - top = TimersDefaults.ListBottomPadding, - bottom = TimersDefaults.ListBottomPadding + TimersDefaults.FabBand + pillInset, - ), - ) { - if (rows != null && rows.isEmpty()) { - item { InlineTimerSetup(onStartNew = viewModel::onStartNew) } - } - items(rows.orEmpty(), key = { row -> row.id }) { row -> - TimerRow( - row = row, - onOpen = { onEditTimer(row.id) }, - onStart = { viewModel.onStart(row.id) }, - onPause = { viewModel.onPause(row.id) }, - onReset = { viewModel.onReset(row.id) }, - onAddMinute = { viewModel.onAddMinute(row.id) }, - onSubtractMinute = { viewModel.onSubtractMinute(row.id) }, - // No confirmation: re-creating a timer costs one tap and a - // Start, faster than reading a dialog would be (D2's delete - // still routes through the engine, so a ringing timer is - // stopped cleanly rather than deleted into a stuck ring). - onDelete = { viewModel.onDelete(row.id) }, - // A timer that finishes stays where it is (D22); the motion - // is for a row arriving or leaving. - modifier = animateItemMotion(), - ) + if (rows != null && rows.isEmpty()) { + // Outside the list, not an item of it: a lazy item has unbounded + // height, and the keypad needs the real height to fit itself into. + InlineTimerSetup( + onStartNew = viewModel::onStartNew, + modifier = Modifier + .fillMaxSize() + .padding(bottom = pillInset), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + // Disconnected cards, exactly as the Alarms tab's own list is: each + // timer is its own object, not a row of a table. + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues( + top = TimersDefaults.ListBottomPadding, + bottom = TimersDefaults.ListBottomPadding + TimersDefaults.FabBand + pillInset, + ), + ) { + items(rows.orEmpty(), key = { row -> row.id }) { row -> + TimerRow( + row = row, + onOpen = { onEditTimer(row.id) }, + onStart = { viewModel.onStart(row.id) }, + onPause = { viewModel.onPause(row.id) }, + onReset = { viewModel.onReset(row.id) }, + onAddMinute = { viewModel.onAddMinute(row.id) }, + onSubtractMinute = { viewModel.onSubtractMinute(row.id) }, + // No confirmation: re-creating a timer costs one tap and a + // Start, faster than reading a dialog would be (D2's delete + // still routes through the engine, so a ringing timer is + // stopped cleanly rather than deleted into a stuck ring). + onDelete = { viewModel.onDelete(row.id) }, + // A timer that finishes stays where it is (D22); the motion + // is for a row arriving or leaving. + modifier = animateItemMotion(), + ) + } } } } @@ -126,7 +129,8 @@ fun TimersScreen( * The empty state: the same readout, keypad and Start [TimerSetupScreen] * uses, stacked inline rather than anchored to the thumb zone of a dedicated * screen — there is no screen's worth of empty space above it to leave for - * reach, just the top of a list. + * reach, just the top of a list. The keypad still shrinks to fit the tab + * ([TimerInputPanel]). */ @Composable private fun InlineTimerSetup(onStartNew: (kotlin.time.Duration) -> Unit, modifier: Modifier = Modifier) { @@ -135,33 +139,14 @@ private fun InlineTimerSetup(onStartNew: (kotlin.time.Duration) -> Unit, modifie var digits by rememberSaveable { mutableStateOf("") } val entry = TimerDurationEntry(digits) - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Readout(entry = entry) - - Spacer(Modifier.height(24.dp)) - - TimerKeypad( - onDigit = { digits = entry.plusDigit(it).digits }, - onDoubleZero = { digits = entry.plusDoubleZero().digits }, - onBackspace = { digits = entry.backspace().digits }, - keySize = TimersDefaults.KeypadKeySizeLarge, - keyGap = TimersDefaults.KeypadKeyGapLarge, - ) - - Spacer(Modifier.height(24.dp)) - - StartRow( - entry = entry, - onClear = { digits = entry.cleared().digits }, - onStart = { - onStartNew(entry.duration) - digits = "" - }, - ) - } + TimerInputPanel( + entry = entry, + onEntryChange = { digits = it.digits }, + onStart = { + onStartNew(entry.duration) + digits = "" + }, + keypadInThumbZone = false, + modifier = modifier.padding(horizontal = 16.dp, vertical = 16.dp), + ) } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/KeypadSizingTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/KeypadSizingTest.kt new file mode 100644 index 0000000..fdbdba7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/KeypadSizingTest.kt @@ -0,0 +1,73 @@ +package de.jeanlucmakiola.clockula.ui.timers + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class KeypadSizingTest { + + /** What the grid actually takes up at [sizing]: three keys across, four down. */ + private fun width(sizing: KeypadSizing): Dp = sizing.keySize * 3 + sizing.keyGap * 2 + + private fun height(sizing: KeypadSizing): Dp = sizing.keySize * 4 + sizing.keyGap * 3 + + @Test + fun `a normal phone keeps the large keypad`() { + val sizing = keypadSizing(maxWidth = 379.dp, maxHeight = 520.dp) + + assertThat(sizing.keySize).isEqualTo(TimersDefaults.KeypadKeySizeLarge) + assertThat(sizing.keyGap).isEqualTo(TimersDefaults.KeypadKeyGapLarge) + assertThat(sizing.fits).isTrue() + } + + @Test + fun `a huge box never grows the keys past the large size`() { + val sizing = keypadSizing(maxWidth = 1200.dp, maxHeight = 1600.dp) + + assertThat(sizing.keySize).isEqualTo(TimersDefaults.KeypadKeySizeLarge) + } + + @Test + fun `a short box shrinks the keypad to fill its height exactly`() { + val sizing = keypadSizing(maxWidth = 379.dp, maxHeight = 300.dp) + + assertThat(sizing.keySize).isLessThan(TimersDefaults.KeypadKeySizeLarge) + assertThat(height(sizing).value).isWithin(0.01f).of(300f) + assertThat(sizing.fits).isTrue() + } + + @Test + fun `a narrow box shrinks the keypad to its width`() { + val sizing = keypadSizing(maxWidth = 200.dp, maxHeight = 800.dp) + + assertThat(width(sizing).value).isWithin(0.01f).of(200f) + assertThat(height(sizing)).isLessThan(800.dp) + } + + @Test + fun `a shrunk keypad keeps the large one's proportions`() { + val sizing = keypadSizing(maxWidth = 379.dp, maxHeight = 300.dp) + + val ratio = sizing.keyGap / sizing.keySize + val largeRatio = TimersDefaults.KeypadKeyGapLarge / TimersDefaults.KeypadKeySizeLarge + assertThat(ratio).isWithin(0.0001f).of(largeRatio) + } + + @Test + fun `a box too short for the minimum keeps minimum keys and says it does not fit`() { + val sizing = keypadSizing(maxWidth = 379.dp, maxHeight = 120.dp) + + assertThat(sizing.keySize).isEqualTo(TimersDefaults.KeypadKeySizeMin) + assertThat(sizing.fits).isFalse() + } + + @Test + fun `exactly the minimum still fits`() { + val min = TimersDefaults.KeypadKeySizeMin + val gap = min * (TimersDefaults.KeypadKeyGapLarge / TimersDefaults.KeypadKeySizeLarge) + val sizing = keypadSizing(maxWidth = 379.dp, maxHeight = min * 4 + gap * 3 + 0.01.dp) + + assertThat(sizing.fits).isTrue() + } +}