From 083ecf426603d0273a82d60691e9feb47c3c175d Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 26 Sep 2026 10:01:13 -0500 Subject: [PATCH] fix(ui): even out the collapsed controls drawer's spacing The collapsed peek looked cramped and lopsided. The 120.dp peek height was a flat constant that happened to sit 6.dp past the bottom of the 72.dp Call CQ / Hunt row, while the row itself butted straight against the 42.dp handle strip: 0.dp of padding above the buttons, 6.dp below, and the buttons effectively jammed onto the tab bar. The status line above had no gap either, so the sheet's rounded top edge read as clipping it. - Derive OperateDrawerPeekHeight from its parts (handle + top gap + the shared OperatePrimaryRowHeight + bottom padding) instead of hard-coding it, so the peek can neither clip the primary row nor reveal the row below it when any of those change. Row spacing is now larger than the bottom padding, which is what keeps the next row hidden; both invariants are unit-tested. - 10.dp above the primary row, 14.dp below it (was 0 / 6), and a 10.dp gap between the docked status line and the sheet's top edge. - Shrink the handle strip 42 -> 36.dp and stop the "More controls" hint bobbing. A caption drifting up and down directly above fixed buttons reads as uneven spacing, not as an affordance; the up-chevron already says "pulls up". Measured on an emulator (1080x2400, xhdpi): clearance under the Call CQ row goes from 6.1.dp to 14.1.dp. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Lbd9brnZDMCzipgsu9n8Xg --- .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 9 ++- .../ui/components/OperateControlsDrawer.kt | 69 ++++++++++++------- .../ks3ckc/ft8af/ui/components/TxStrip.kt | 11 ++- .../components/OperateControlsDrawerTest.kt | 38 +++++++++- 4 files changed, 98 insertions(+), 29 deletions(-) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index 877b736d1..4a144f206 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -602,7 +602,14 @@ fun FT8AFApp(mainViewModel: MainViewModel) { showCatChip = showCatChip, catState = catState, onReconnectCat = onReconnectCatAction, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), + // Bottom padding separates the status line from the sheet's rounded top edge + // below it — butted together, the rounding read as if the line were clipped. + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 10.dp, + ), ) Spacer( modifier = Modifier diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt index 3117e27fd..450f6d975 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt @@ -1,10 +1,6 @@ package radio.ks3ckc.ft8af.ui.components import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -73,11 +69,37 @@ internal fun drawerTargetExpanded( /** The vertical drag distance on the handle that commits to toggling the drawer. */ internal val DrawerDragThreshold = 48.dp +/** Height of the grab handle strip (grabber bar + the collapsed "More controls" hint). */ +internal val DrawerHandleHeight = 36.dp + +/** Gap between the handle strip and the first control row. */ +internal val DrawerContentTopGap = 10.dp + +/** + * Padding below the last visible control row. In the collapsed peek this is the breathing room + * between the Call CQ / Hunt row and the tab bar the sheet sits on, so it must be comfortably + * bigger than nothing and comfortably smaller than [DrawerRowSpacing] (see below). + */ +internal val DrawerContentBottomPad = 14.dp + /** - * The collapsed height of the drawer — the handle (with its "More controls" hint) plus the primary - * Call CQ + Hunt row, stopping before the next row so nothing peeks through when collapsed. + * Vertical gap between control rows. Deliberately larger than [DrawerContentBottomPad]: the + * collapsed sheet is clipped [DrawerContentBottomPad] past the primary row's bottom edge, so the + * next row only stays fully hidden while the gap exceeds that padding. */ -internal val OperateDrawerPeekHeight = 120.dp +internal val DrawerRowSpacing = 18.dp + +/** + * The collapsed height of the drawer — the handle (with its "More controls" hint), the primary + * Call CQ + Hunt row, and equal-feeling padding above and below it, stopping short of the next row + * so nothing peeks through when collapsed. + * + * Derived from the parts rather than hard-coded (it was a flat 120.dp, which left the 72.dp button + * row butted against the handle with 6.dp under it — the row read as jammed onto the tab bar) so + * the peek can never clip the primary row or reveal the row below it. + */ +internal val OperateDrawerPeekHeight = + DrawerHandleHeight + DrawerContentTopGap + OperatePrimaryRowHeight + DrawerContentBottomPad /** * The operate-controls drawer (design 3a/3b) as a *single* bottom sheet that grows in place. @@ -199,8 +221,13 @@ fun OperateControlsDrawer( Column( modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding( + start = 16.dp, + end = 16.dp, + top = DrawerContentTopGap, + bottom = DrawerContentBottomPad, + ), + verticalArrangement = Arrangement.spacedBy(DrawerRowSpacing), ) { // ---- Primary row (visible in the peek): Call CQ / Stop + Hunt ---- CqHuntRow( @@ -246,9 +273,13 @@ fun OperateControlsDrawer( /** * The drawer's grab handle: a centered grabber bar plus — while collapsed — an up-chevron and - * "More controls" caption that gently bob to advertise that the sheet pulls up. A tap toggles the - * drawer; a vertical drag is fed live to [onDrag] (dy, negative = up) and settled in [onDragEnd] - * with the total drag distance. + * "More controls" caption advertising that the sheet pulls up. A tap toggles the drawer; a + * vertical drag is fed live to [onDrag] (dy, negative = up) and settled in [onDragEnd] with the + * total drag distance. + * + * The hint used to bob up and down on an infinite animation. It sat directly above the primary + * action row, where a caption drifting against fixed buttons read as uneven spacing rather than as + * an affordance, so it is static now — the chevron carries the message. */ @Composable private fun DrawerHandle( @@ -263,19 +294,10 @@ private fun DrawerHandle( val collapseLabel = stringResource(R.string.controls_drawer_collapse) val label = if (expanded) collapseLabel else expandLabel - // Gentle up-and-down bob on the hint, only while collapsed, to draw the eye to the affordance. - val bobTransition = rememberInfiniteTransition(label = "drawer-hint-bob") - val bob by bobTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), - label = "drawer-hint-bob-offset", - ) - Column( modifier = modifier .fillMaxWidth() - .height(42.dp) + .height(DrawerHandleHeight) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -293,7 +315,7 @@ private fun DrawerHandle( ) }, horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(3.dp, Alignment.CenterVertically), + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), ) { Box( modifier = Modifier @@ -307,7 +329,6 @@ private fun DrawerHandle( FT8AFIcons.ChevronDown(size = 14.dp, color = TextFaint, strokeWidth = 2.2f) } else { Row( - modifier = Modifier.offset(y = (bob * -3f).dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp), ) { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt index 5d67e1b1a..c88862b6a 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt @@ -63,6 +63,13 @@ internal data class TxStripActionState( val cqIsStop: Boolean, ) +/** + * Height of the primary action row — the Call CQ button and the Hunt tile beside it. Shared so + * the collapsing drawer can derive its peek height from the real button height instead of + * repeating the number (see OperateDrawerPeekHeight). + */ +internal val OperatePrimaryRowHeight = 72.dp + /** * Clamp a volume value after a +/- step to the 0–100 range. * Extracted so it can be unit-tested without Compose. @@ -528,7 +535,7 @@ internal fun CallCqButton( Row( modifier = modifier - .height(72.dp) + .height(OperatePrimaryRowHeight) .clip(RoundedCornerShape(14.dp)) .background(background) .combinedClickable( @@ -629,7 +636,7 @@ internal fun HuntTile( val contentAlpha = if (huntDisabled) 0.4f else 1f Column( modifier = modifier - .height(72.dp) + .height(OperatePrimaryRowHeight) .clip(RoundedCornerShape(14.dp)) .background(if (huntDisabled) BgSurface3.copy(alpha = 0.4f) else BgSurface3) .border(1.dp, Border, RoundedCornerShape(14.dp)) diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt index 39a036eb8..91e1fafea 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt @@ -1,12 +1,14 @@ package radio.ks3ckc.ft8af.ui.components +import androidx.compose.ui.unit.dp import com.google.common.truth.Truth.assertThat import org.junit.Test /** * Unit tests for [drawerTargetExpanded] — the pure gesture rule deciding whether the - * operate-controls drawer (design 3a/3b) settles open or collapsed after a drag on its handle. - * No Android/Compose runtime needed. Sign convention: negative dy = upward drag. + * operate-controls drawer (design 3a/3b) settles open or collapsed after a drag on its handle — + * plus the collapsed-peek geometry invariants. No Android/Compose runtime needed. Sign convention: + * negative dy = upward drag. */ class OperateControlsDrawerTest { @@ -67,4 +69,36 @@ class OperateControlsDrawerTest { assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = 0f, thresholdPx = threshold)) .isTrue() } + + // ---- Collapsed-peek geometry (see OperateDrawerPeekHeight) ---- + + @Test + fun `peek height is the handle, the primary row and its padding`() { + assertThat(OperateDrawerPeekHeight).isEqualTo( + DrawerHandleHeight + DrawerContentTopGap + OperatePrimaryRowHeight + + DrawerContentBottomPad + ) + } + + @Test + fun `peek height clears the whole primary row`() { + // Anything less and the collapsed sheet clips the Call CQ / Hunt buttons. + assertThat(OperateDrawerPeekHeight) + .isGreaterThan(DrawerHandleHeight + DrawerContentTopGap + OperatePrimaryRowHeight) + } + + @Test + fun `row gap exceeds the bottom padding so the next row cannot peek through`() { + // The sheet is clipped DrawerContentBottomPad past the primary row's bottom edge; the + // following row starts DrawerRowSpacing past it, so the gap has to be the larger of the two. + assertThat(DrawerRowSpacing).isGreaterThan(DrawerContentBottomPad) + } + + @Test + fun `primary row has breathing room above and below it`() { + // The 120.dp peek this replaced left 0.dp above the buttons and 6.dp below, which is what + // made the collapsed drawer look jammed onto the tab bar. + assertThat(DrawerContentTopGap).isAtLeast(8.dp) + assertThat(DrawerContentBottomPad).isAtLeast(8.dp) + } }