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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user