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() + } +}