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
This commit is contained in:
2026-10-05 19:33:49 +02:00
parent cac5b2dfca
commit eb63f02d38
5 changed files with 291 additions and 97 deletions
@@ -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
@@ -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
@@ -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
}
@@ -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),
)
}
@@ -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()
}
}