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