diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index f9a55bd..0880765 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -19,6 +19,7 @@ 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.requiredHeight import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width @@ -76,6 +77,25 @@ private val Muted = Color(0xFF9FB0A6) private val Danger = Color(0xFF8C242A) private val Panel = Color(0xE604120D) +/** + * Minimum accessible touch target. Controls keep this even when their painted + * area is smaller — see [SLIDER_VISIBLE_HEIGHT]. + */ +private val TOUCH_TARGET = 48.dp + +/** + * How much vertical space the slider actually paints. Material reserves a 48dp + * touch target around a ~16dp track; laying it out at the full 48dp injects + * invisible padding that makes uniform gaps render unevenly. + */ +private val SLIDER_VISIBLE_HEIGHT = 16.dp + +/** One consistent rhythm for stacked controls. */ +private val CONTROL_GAP = 8.dp + +/** Separation between a group of controls and the action that commits them. */ +private val GROUP_GAP = 18.dp + internal fun visibleChipCount(amount: Int): Int = when { amount <= 0 -> 0 amount <= 2 -> 1 @@ -87,26 +107,51 @@ internal fun visibleChipCount(amount: Int): Int = when { else -> 7 } -internal data class NormalizedSeatPosition( - val centerX: Float, - val topY: Float, +internal data class SeatPlacement( + /** Distance in dp from the table's left edge to this seat's left edge. */ + val leftDp: Float, + /** Vertical lane as a fraction of table height. */ + val topFraction: Float, ) +/** Space kept clear at the table's left and right edges. */ +private const val SEAT_EDGE_MARGIN_DP = 6f + +/** Smallest horizontal gap permitted between two neighbouring seats. */ +private const val SEAT_MIN_GAP_DP = 6f + /** - * Five seats around the outside of a circular felt. + * Width one opponent seat may occupy, derived from the table's real width. * - * Keeping this geometry separate from Compose makes the table relationship - * testable. The three top seats have independent horizontal lanes; the two side - * seats straddle the rail instead of consuming the board's middle. + * Seat width MUST come from the measured container. A fixed width combined with + * fractional positions silently overlaps as the screen narrows: an 88dp seat at + * centre fractions 0.13/0.28 overlaps by 30dp at 384dp wide (Galaxy S24+) while + * merely looking tight at 427dp (Pixel emulator). Sizing from the container + * makes five seats fit at any width by construction. */ -internal fun opponentSeatOrbit(): List = - listOf( - NormalizedSeatPosition(centerX = 0.13f, topY = 0.18f), - NormalizedSeatPosition(centerX = 0.28f, topY = 0.01f), - NormalizedSeatPosition(centerX = 0.50f, topY = 0f), - NormalizedSeatPosition(centerX = 0.72f, topY = 0.01f), - NormalizedSeatPosition(centerX = 0.87f, topY = 0.18f), - ) +internal fun opponentSeatWidthDp(availableWidthDp: Float): Float { + val usable = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - 4f * SEAT_MIN_GAP_DP + return (usable / 5f).coerceIn(52f, 96f) +} + +/** + * Five seats spread edge to edge with equal gaps, staggered into three lanes so + * the row still reads as an arc around the felt rather than a straight line. + */ +internal fun opponentSeatPlacements( + availableWidthDp: Float, + seatWidthDp: Float = opponentSeatWidthDp(availableWidthDp), +): List { + val lanes = listOf(0.30f, 0.06f, 0f, 0.06f, 0.30f) + val span = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - seatWidthDp + val step = span / 4f + return List(5) { index -> + SeatPlacement( + leftDp = SEAT_EDGE_MARGIN_DP + step * index, + topFraction = lanes[index], + ) + } +} @Composable fun TableScreen(vm: PokerViewModel) { @@ -131,11 +176,12 @@ fun TableScreen(vm: PokerViewModel) { status = status, tableTalk = tableTalk, handSummary = handSummary, + // Fill the space the two-stage raise control freed instead of locking + // an aspect ratio and leaving ~178dp of dead felt below the hero. modifier = Modifier .fillMaxWidth() - .aspectRatio(0.80f), + .weight(1f), ) - Spacer(Modifier.weight(1f)) TableControls( offer = offer, handSummary = handSummary, @@ -223,18 +269,21 @@ private fun PokerTable( ) { TableFelt(Modifier.fillMaxSize()) - val orbit = opponentSeatOrbit() + // Seat width and position both come from the measured table, so five + // seats fit with real gaps on a 384dp phone as well as a 427dp emulator. + val seatWidth = opponentSeatWidthDp(maxWidth.value) + val placements = opponentSeatPlacements(maxWidth.value, seatWidth) seats.filter { it.index != HERO_SEAT } .forEachIndexed { index, seat -> - val position = orbit.getOrElse(index) { orbit.last() } - val seatWidth = if (seat.revealed && seat.hole != null) 108.dp else 88.dp + val placement = placements.getOrElse(index) { placements.last() } OpponentSeat( seat = seat, isTurn = snapshot?.toAct == seat.index, isWinner = seat.index in winners, + width = seatWidth.dp, modifier = Modifier.offset( - x = maxWidth * position.centerX - seatWidth / 2, - y = maxWidth * position.topY, + x = placement.leftDp.dp, + y = maxHeight * placement.topFraction, ), ) } @@ -243,9 +292,11 @@ private fun PokerTable( snapshot = snapshot, status = status, tableTalk = tableTalk, + // Below the side-seat lane (0.30 of height plus the seat itself), + // measured against height so it tracks the taller felt. modifier = Modifier .align(Alignment.TopCenter) - .offset(y = maxWidth * 0.40f), + .offset(y = maxHeight * 0.46f), ) HeroSeat( @@ -274,33 +325,47 @@ private fun PokerTable( @Composable private fun TableFelt(modifier: Modifier = Modifier) { Canvas(modifier) { - val center = Offset(size.width * 0.5f, size.width * 0.5f) - val railRadius = size.minDimension * 0.405f - drawCircle( + // An oval sized to the container, so the felt grows into whatever height + // the table is given rather than staying a width-sized circle. + val inset = 9.dp.toPx() + val railTop = size.height * 0.055f + val railRect = androidx.compose.ui.geometry.Rect( + left = size.width * 0.012f, + top = railTop, + right = size.width * 0.988f, + bottom = size.height * 0.965f, + ) + drawOval( brush = Brush.linearGradient( colors = listOf(RailLight, RailDark), - start = Offset(center.x - railRadius, center.y - railRadius), - end = Offset(size.width, size.height), + start = Offset(railRect.left, railRect.top), + end = Offset(railRect.right, railRect.bottom), ), - radius = railRadius, - center = center, + topLeft = Offset(railRect.left, railRect.top), + size = androidx.compose.ui.geometry.Size(railRect.width, railRect.height), ) - val inset = 9.dp.toPx() - val feltRadius = railRadius - inset - drawCircle( + val feltTopLeft = Offset(railRect.left + inset, railRect.top + inset) + val feltSize = androidx.compose.ui.geometry.Size( + railRect.width - inset * 2, + railRect.height - inset * 2, + ) + drawOval( brush = Brush.radialGradient( colors = listOf(FeltLight, FeltMid, FeltDark), - center = Offset(center.x, center.y - feltRadius * 0.18f), - radius = feltRadius * 1.15f, + center = Offset( + feltTopLeft.x + feltSize.width * 0.5f, + feltTopLeft.y + feltSize.height * 0.34f, + ), + radius = feltSize.minDimension * 1.15f, ), - radius = feltRadius, - center = center, + topLeft = feltTopLeft, + size = feltSize, ) - drawCircle( - color = TableGold.copy(alpha = 0.34f), - radius = feltRadius, - center = center, + drawOval( + color = TableGold.copy(alpha = 0.30f), + topLeft = feltTopLeft, + size = feltSize, style = Stroke(width = 1.dp.toPx()), ) } @@ -311,15 +376,18 @@ private fun OpponentSeat( seat: SeatSnapshot, isTurn: Boolean, isWinner: Boolean, + width: Dp, modifier: Modifier = Modifier, ) { val faded = seat.folded && !isWinner val revealed = seat.revealed && seat.hole != null - val faceWidth = 50.dp - val faceHeight = 75.dp + // Revealed cards scale with the slot instead of widening it. Growing the seat + // would reintroduce the overlap the measured layout exists to prevent. + val faceWidth = ((width - 14.dp) / 2).coerceIn(30.dp, 54.dp) + val faceHeight = faceWidth * 1.5f Column( modifier = modifier - .width(if (revealed) 108.dp else 88.dp) + .width(width) .alpha(if (faded) 0.38f else 1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), @@ -773,8 +841,8 @@ private fun DecisionControls( listOf(Color.Transparent, Night.copy(alpha = 0.95f), Night), ), ) - .padding(horizontal = 14.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(CONTROL_GAP), ) { if (!sizingRaise) { PrimaryDecisionButtons( @@ -787,7 +855,9 @@ private fun DecisionControls( } else { val amount = raiseConfirmationAmount(buttons, raiseTo) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(TOUCH_TARGET), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -795,7 +865,7 @@ private fun DecisionControls( Column(horizontalAlignment = Alignment.End) { Text( "RAISE TO", - color = Muted.copy(alpha = 0.82f), + color = Muted, fontWeight = FontWeight.Bold, fontSize = 12.sp, letterSpacing = 1.4.sp, @@ -811,26 +881,39 @@ private fun DecisionControls( if (buttons.showSlider) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(TOUCH_TARGET), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { FineTuneButton("−") { raiseTo = adjustRaiseAmount(raiseTo, -1, buttons) } - Slider( - value = amount.toFloat(), - onValueChange = { raiseTo = it.roundToInt() }, - valueRange = buttons.sliderMin.toFloat()..buttons.sliderMax.toFloat(), - colors = SliderDefaults.colors( - thumbColor = GoldLight, - activeTrackColor = TableGold, - inactiveTrackColor = Color.White.copy(alpha = 0.12f), - ), + // A Slider draws a ~16dp track inside a 48dp accessibility touch + // target, so 32dp of it is invisible. Laying it out at 48dp adds + // 16dp of phantom space above and below, which is why a uniform + // gap rendered as ~20dp around the slider and ~4dp between the + // filled buttons. Reserve only the visible height and let the + // touch target overflow, so declared gaps are the real gaps. + Box( modifier = Modifier .weight(1f) - .height(48.dp), - ) + .height(SLIDER_VISIBLE_HEIGHT), + contentAlignment = Alignment.Center, + ) { + Slider( + value = amount.toFloat(), + onValueChange = { raiseTo = it.roundToInt() }, + valueRange = buttons.sliderMin.toFloat()..buttons.sliderMax.toFloat(), + colors = SliderDefaults.colors( + thumbColor = GoldLight, + activeTrackColor = TableGold, + inactiveTrackColor = Color.White.copy(alpha = 0.12f), + ), + modifier = Modifier.requiredHeight(TOUCH_TARGET), + ) + } FineTuneButton("+") { raiseTo = adjustRaiseAmount(raiseTo, 1, buttons) } @@ -851,7 +934,7 @@ private fun DecisionControls( containerColor = if (amount == preset.amount) { TableGold.copy(alpha = 0.2f) } else { - Color.White.copy(alpha = 0.06f) + Color.White.copy(alpha = 0.12f) }, contentColor = if (amount == preset.amount) GoldLight else Muted, ), @@ -882,6 +965,7 @@ private fun DecisionControls( ) } + Spacer(Modifier.height(GROUP_GAP - CONTROL_GAP)) ActionButton( title = if (amount >= buttons.sliderMax) "Confirm all in" else "Confirm raise", detail = if (amount >= buttons.sliderMax) "$amount" else "to $amount", @@ -948,7 +1032,7 @@ private fun BackToActionsButton(onClick: () -> Unit) { modifier = Modifier.height(48.dp), contentPadding = PaddingValues(horizontal = 14.dp), colors = ButtonDefaults.buttonColors( - containerColor = Color.White.copy(alpha = 0.09f), + containerColor = Color.White.copy(alpha = 0.16f), contentColor = Cream, ), shape = RoundedCornerShape(12.dp), @@ -967,7 +1051,7 @@ private fun FineTuneButton( modifier = Modifier.size(48.dp), contentPadding = PaddingValues(0.dp), colors = ButtonDefaults.buttonColors( - containerColor = Color.White.copy(alpha = 0.09f), + containerColor = Color.White.copy(alpha = 0.16f), contentColor = GoldLight, ), shape = RoundedCornerShape(12.dp), diff --git a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt index fd278b0..1f19092 100644 --- a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt @@ -4,23 +4,76 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +/** + * Seat geometry must hold on real phones, not just the development emulator. + * + * The previous layout paired a fixed 88dp seat with fractional centres. That + * overlapped by 30dp at 384dp wide (Galaxy S24+) while looking merely tight at + * 427dp (Pixel emulator) — which is why the crowding stayed invisible in + * testing. These widths are asserted explicitly so the narrow case cannot + * regress unnoticed again. + */ class TableOrbitTest { - @Test - fun `five opponents form a symmetric perimeter around the circular felt`() { - val positions = opponentSeatOrbit() + /** Small phone, common phone, Galaxy S24+, Pixel, emulator, large phone. */ + private val widths = listOf(320f, 360f, 384f, 411f, 427f, 480f) - assertEquals(5, positions.size) - assertTrue(positions.zipWithNext().all { (left, right) -> left.centerX < right.centerX }) - assertTrue(positions.all { it.centerX in 0f..1f }) - assertTrue( - "all opponents stay around the upper perimeter", - positions.all { it.topY < 0.30f }, - ) - assertEquals(1f, positions.first().centerX + positions.last().centerX, 0.001f) - assertEquals(positions.first().topY, positions.last().topY, 0.001f) - assertEquals(1f, positions[1].centerX + positions[3].centerX, 0.001f) - assertEquals(positions[1].topY, positions[3].topY, 0.001f) + @Test + fun `five seats never overlap at any supported width`() { + for (width in widths) { + val seatWidth = opponentSeatWidthDp(width) + val placements = opponentSeatPlacements(width, seatWidth) + assertEquals(5, placements.size) + + placements.zipWithNext { left, right -> + val gap = right.leftDp - (left.leftDp + seatWidth) + assertTrue( + "at ${width}dp seats overlap by ${-gap}dp (seat width $seatWidth)", + gap >= -0.01f, + ) + } + } + } + + @Test + fun `seats stay inside the table at any supported width`() { + for (width in widths) { + val seatWidth = opponentSeatWidthDp(width) + val placements = opponentSeatPlacements(width, seatWidth) + assertTrue("at ${width}dp the first seat is off-screen", placements.first().leftDp >= 0f) + assertTrue( + "at ${width}dp the last seat overflows by " + + "${placements.last().leftDp + seatWidth - width}dp", + placements.last().leftDp + seatWidth <= width + 0.01f, + ) + } + } + + @Test + fun `seat width grows with the table but stays legible`() { + val narrow = opponentSeatWidthDp(320f) + val phone = opponentSeatWidthDp(384f) + val wide = opponentSeatWidthDp(480f) + assertTrue("a wider table should never shrink seats", narrow <= phone && phone <= wide) + assertTrue("a seat must stay wide enough to read a name", narrow >= 52f) + } + + @Test + fun `seats are staggered into an arc rather than one straight row`() { + val lanes = opponentSeatPlacements(384f).map { it.topFraction } + assertTrue("outer seats should sit lower than the crown", lanes[0] > lanes[2]) + assertTrue("the arc should be symmetric", lanes[0] == lanes[4] && lanes[1] == lanes[3]) + assertEquals("the middle seat crowns the arc", 0f, lanes[2], 0.001f) + } + + @Test + fun `spacing is even so the row reads as a deliberate arrangement`() { + val width = 384f + val seatWidth = opponentSeatWidthDp(width) + val gaps = opponentSeatPlacements(width, seatWidth) + .zipWithNext { a, b -> b.leftDp - (a.leftDp + seatWidth) } + val first = gaps.first() + assertTrue("uneven seat gaps: $gaps", gaps.all { kotlin.math.abs(it - first) < 0.01f }) } @Test