Spread the seat arc and fan the hole cards

Two issues visible on the real Galaxy S24+ screenshot.

Shoulder seats crowded the crown. Lanes were 0.30 / 0.06 / 0 / 0.06 / 0.30, so
Bruno and Dex sat within 0.06 of Cleo and the three read as one packed row while
Ada and Enzo were stranded far below. Shoulders now sit at 0.15, the midpoint
between the crown and the side seats, so the row reads as a curve. A test pins
the shoulder near that midpoint rather than merely below the crown, since
"below" was already true at 0.06.

Hole cards looked like one red slab. Two 18x27dp cards overlapped by 5dp with no
rotation merged into a single block. They are now a proper fan: opposing
rotation, real offset, and tucked behind the avatar as in the reference design.

Fanning first pushed the rightmost seats off-screen, because a fixed 48dp avatar
plus the fan exceeded the 69.6dp slot a 384dp screen allows. Avatar and fan now
scale with the slot and share one slot-width box, so the pair can never overflow
the seat it was allotted. Verified: zero bright pixels in the right margin.

260 tests, 0 failures. Lint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-27 22:19:44 -04:00
parent 10a2056f7f
commit a11daddf6c
2 changed files with 67 additions and 12 deletions
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -142,7 +143,7 @@ internal fun opponentSeatPlacements(
availableWidthDp: Float, availableWidthDp: Float,
seatWidthDp: Float = opponentSeatWidthDp(availableWidthDp), seatWidthDp: Float = opponentSeatWidthDp(availableWidthDp),
): List<SeatPlacement> { ): List<SeatPlacement> {
val lanes = listOf(0.30f, 0.06f, 0f, 0.06f, 0.30f) val lanes = listOf(0.30f, 0.15f, 0f, 0.15f, 0.30f)
val span = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - seatWidthDp val span = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - seatWidthDp
val step = span / 4f val step = span / 4f
return List(5) { index -> return List(5) { index ->
@@ -419,18 +420,19 @@ private fun OpponentSeat(
} }
} }
} else { } else {
Row( // One slot-width box: the fan is drawn first so the avatar
verticalAlignment = Alignment.CenterVertically, // sits on top of it, and neither can push past the seat.
horizontalArrangement = Arrangement.spacedBy(0.dp), Box(
modifier = Modifier.width(width - 6.dp),
contentAlignment = Alignment.CenterStart,
) { ) {
OpponentAvatar(seat)
if (!seat.folded) { if (!seat.folded) {
Row(horizontalArrangement = Arrangement.spacedBy((-5).dp)) { HoleCardFan(
repeat(2) { cardWidth = (width * 0.27f).coerceIn(18.dp, 27.dp),
CardImage(null, Modifier.size(18.dp, 27.dp)) modifier = Modifier.align(Alignment.CenterEnd),
} )
}
} }
OpponentAvatar(seat, size = (width * 0.58f).coerceIn(34.dp, 48.dp))
} }
} }
} }
@@ -486,11 +488,44 @@ private fun OpponentSeat(
} }
} }
/**
* Two face-down cards fanned like a real hand.
*
* Drawn flat and barely overlapped they merged into one red slab; opposing
* rotation and a real offset make the pair legible at seat scale. The left card
* tucks behind the avatar, which is why this is drawn after it in the row.
*/
@Composable @Composable
private fun OpponentAvatar(seat: SeatSnapshot) { private fun HoleCardFan(cardWidth: Dp, modifier: Modifier = Modifier) {
val cardHeight = cardWidth * 1.5f
Box(
modifier = modifier
.width(cardWidth * 1.40f)
.height(cardHeight * 1.16f),
contentAlignment = Alignment.Center,
) {
CardImage(
null,
Modifier
.size(cardWidth, cardHeight)
.offset(x = (-4).dp)
.rotate(-11f),
)
CardImage(
null,
Modifier
.size(cardWidth, cardHeight)
.offset(x = 5.dp)
.rotate(10f),
)
}
}
@Composable
private fun OpponentAvatar(seat: SeatSnapshot, size: Dp = 48.dp) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(size)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
Brush.linearGradient( Brush.linearGradient(
@@ -66,6 +66,26 @@ class TableOrbitTest {
assertEquals("the middle seat crowns the arc", 0f, lanes[2], 0.001f) assertEquals("the middle seat crowns the arc", 0f, lanes[2], 0.001f)
} }
/**
* The shoulder seats sat at 0.06 against a crown of 0.0, so three seats read
* as one crowded row instead of a curve. They belong between the crown and
* the side seats.
*/
@Test
fun `shoulder seats sit between the crown and the side seats`() {
val lanes = opponentSeatPlacements(384f).map { it.topFraction }
val crown = lanes[2]
val side = lanes[0]
val shoulder = lanes[1]
assertTrue("shoulder must be below the crown", shoulder > crown)
assertTrue("shoulder must be above the side seats", shoulder < side)
val midpoint = (crown + side) / 2f
assertTrue(
"shoulder $shoulder should be near the midpoint $midpoint, not hugging the crown",
kotlin.math.abs(shoulder - midpoint) <= 0.05f,
)
}
@Test @Test
fun `spacing is even so the row reads as a deliberate arrangement`() { fun `spacing is even so the row reads as a deliberate arrangement`() {
val width = 384f val width = 384f