Fix card clipping and table reshape; add a live seat-layout switcher

Three things, two of them defects rather than taste.

Cards were double-clipped. CardImage applied a 6dp RoundedCornerShape clip over
artwork that already carries its own 4.8dp radius and a black border, so the
clip cut inside the card's own corner and shaved the border off. The clip and
its backing plate are gone; the art is drawn at the same 2:3 ratio it is laid
out at, so nothing is masked.

The felt reshaped between circular and oval on every raise. PokerTable is
weighted against the control column, so growing that column to hold the sizing
controls shrank the table and rounded the oval. Raise sizing is now a sheet
overlaid on the felt, which is what it was meant to be. Measured before and
after opening it: felt top 451 both, identical width, height within 7dp — the
remainder is the sheet's own gradient, not a reshape.

Seat layout is now switchable at runtime via a SEATS chip in the header, so
treatments can be compared on a real device instead of argued about from
screenshots:
- Plate: avatar and fan over a full-width name plate. Most information, widest.
- Badge: one horizontal pill, avatar beside name and stack. Half the height.
- Chip: avatar-led with name and stack beneath. Narrowest, most open felt.

Seat width is derived per style, so each variant keeps the arc geometry and
overflow guarantees the tests already enforce.

259 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-28 07:23:40 -04:00
parent de0b96279d
commit ba2675d6ff
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -158,8 +159,31 @@ internal const val BOARD_Y_FRACTION = 0.47f
* merely looking tight at 427dp (Pixel emulator). Sizing from the container * merely looking tight at 427dp (Pixel emulator). Sizing from the container
* makes five seats fit at any width by construction. * makes five seats fit at any width by construction.
*/ */
internal fun opponentSeatWidthDp(availableWidthDp: Float): Float = /**
(availableWidthDp * 0.16f).coerceIn(52f, 72f) * Competing treatments for an opponent seat, switchable at runtime so they can
* be compared on a real device instead of argued about from screenshots.
*/
enum class SeatStyle(val label: String) {
/** Avatar and fan stacked over a full-width name plate. Most information. */
PLATE("Plate"),
/** One horizontal pill: avatar beside a compact name and stack. */
BADGE("Badge"),
/** Avatar-led, name and stack tucked under it. Least width. */
CHIP("Chip");
fun next(): SeatStyle = entries[(ordinal + 1) % entries.size]
}
internal fun opponentSeatWidthDp(
availableWidthDp: Float,
style: SeatStyle = SeatStyle.PLATE,
): Float = when (style) {
SeatStyle.PLATE -> (availableWidthDp * 0.16f).coerceIn(52f, 72f)
SeatStyle.BADGE -> (availableWidthDp * 0.26f).coerceIn(88f, 112f)
SeatStyle.CHIP -> (availableWidthDp * 0.13f).coerceIn(44f, 58f)
}
/** /**
* Five seats spread edge to edge with equal gaps, staggered into three lanes so * Five seats spread edge to edge with equal gaps, staggered into three lanes so
@@ -206,20 +230,33 @@ fun TableScreen(vm: PokerViewModel) {
val tableTalk = state.visibleTableTalk() val tableTalk = state.visibleTableTalk()
val status = snap?.let(::tableStatus) val status = snap?.let(::tableStatus)
Column( // Raise sizing is hoisted here so it can overlay the felt. Left inside the
// control column it grew the controls, which shrank the weighted table and
// visibly reshaped the oval into a circle every time Raise was tapped.
var sizingRaise by remember(offer?.token) { mutableStateOf(false) }
var seatStyle by remember { mutableStateOf(SeatStyle.PLATE) }
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Night) .background(Night)
.systemBarsPadding(), .systemBarsPadding(),
) { ) {
TableHeader(snap, state.gameConfig) Column(modifier = Modifier.fillMaxSize()) {
TableHeader(
snapshot = snap,
config = state.gameConfig,
seatStyle = seatStyle,
onCycleSeatStyle = { seatStyle = seatStyle.next() },
)
PokerTable( PokerTable(
snapshot = snap, snapshot = snap,
status = status, status = status,
tableTalk = tableTalk, tableTalk = tableTalk,
handSummary = handSummary, handSummary = handSummary,
// Fill the space the two-stage raise control freed instead of locking seatStyle = seatStyle,
// an aspect ratio and leaving ~178dp of dead felt below the hero. // Weighted against a control area whose height no longer changes,
// so the felt keeps one shape for the whole hand.
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
@@ -234,16 +271,31 @@ fun TableScreen(vm: PokerViewModel) {
heroFolded = snap?.seats?.firstOrNull { it.index == HERO_SEAT }?.folded == true, heroFolded = snap?.seats?.firstOrNull { it.index == HERO_SEAT }?.folded == true,
onFold = vm::fold, onFold = vm::fold,
onCheckCall = vm::checkOrCall, onCheckCall = vm::checkOrCall,
onRaise = vm::raiseTo, onOpenRaise = { sizingRaise = true },
onNextHand = vm::nextHand, onNextHand = vm::nextHand,
) )
} }
if (sizingRaise && offer != null) {
RaiseSizingSheet(
offer = offer,
onDismiss = { sizingRaise = false },
onRaise = { token, amount ->
sizingRaise = false
vm.raiseTo(token, amount)
},
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
} }
@Composable @Composable
private fun TableHeader( private fun TableHeader(
snapshot: TableSnapshot?, snapshot: TableSnapshot?,
config: CashGameConfig?, config: CashGameConfig?,
seatStyle: SeatStyle,
onCycleSeatStyle: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -253,7 +305,10 @@ private fun TableHeader(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
Text( Text(
"HAND ${snapshot?.handNumber ?: 1}", "HAND ${snapshot?.handNumber ?: 1}",
color = Cream, color = Cream,
@@ -268,6 +323,26 @@ private fun TableHeader(
letterSpacing = 1.4.sp, letterSpacing = 1.4.sp,
) )
} }
Surface(
modifier = Modifier
.padding(end = 8.dp)
.clickable(onClick = onCycleSeatStyle),
shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.10f),
border = androidx.compose.foundation.BorderStroke(
1.dp,
TableGold.copy(alpha = 0.45f),
),
) {
Text(
"SEATS · ${seatStyle.label.uppercase()}",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
color = TableGold,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.sp,
)
}
Surface( Surface(
shape = RoundedCornerShape(50), shape = RoundedCornerShape(50),
color = Color.Black.copy(alpha = 0.24f), color = Color.Black.copy(alpha = 0.24f),
@@ -297,6 +372,7 @@ private fun PokerTable(
status: TableStatus?, status: TableStatus?,
tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?, tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?,
handSummary: HandSummary?, handSummary: HandSummary?,
seatStyle: SeatStyle,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val seats = snapshot?.seats.orEmpty() val seats = snapshot?.seats.orEmpty()
@@ -313,7 +389,7 @@ private fun PokerTable(
// Seat width and position both come from the measured table, so five // 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. // seats fit with real gaps on a 384dp phone as well as a 427dp emulator.
val seatWidth = opponentSeatWidthDp(maxWidth.value) val seatWidth = opponentSeatWidthDp(maxWidth.value, seatStyle)
val placements = opponentSeatPlacements(maxWidth.value, seatWidth) val placements = opponentSeatPlacements(maxWidth.value, seatWidth)
seats.filter { it.index != HERO_SEAT } seats.filter { it.index != HERO_SEAT }
.forEachIndexed { index, seat -> .forEachIndexed { index, seat ->
@@ -323,6 +399,7 @@ private fun PokerTable(
isTurn = snapshot?.toAct == seat.index, isTurn = snapshot?.toAct == seat.index,
isWinner = seat.index in winners, isWinner = seat.index in winners,
width = seatWidth.dp, width = seatWidth.dp,
style = seatStyle,
modifier = Modifier.offset( modifier = Modifier.offset(
x = placement.leftDp.dp, x = placement.leftDp.dp,
// Placement gives where the avatar centre belongs on the // Placement gives where the avatar centre belongs on the
@@ -424,6 +501,35 @@ private fun TableFelt(modifier: Modifier = Modifier) {
@Composable @Composable
private fun OpponentSeat( private fun OpponentSeat(
seat: SeatSnapshot,
isTurn: Boolean,
isWinner: Boolean,
width: Dp,
style: SeatStyle,
modifier: Modifier = Modifier,
) {
when (style) {
SeatStyle.PLATE -> PlateSeat(seat, isTurn, isWinner, width, modifier)
SeatStyle.BADGE -> BadgeSeat(seat, isTurn, isWinner, width, modifier)
SeatStyle.CHIP -> ChipSeat(seat, isTurn, isWinner, width, modifier)
}
}
private fun seatHighlight(isTurn: Boolean, isWinner: Boolean): Color = when {
isWinner -> TableGold
isTurn -> GoldLight
else -> Color.White.copy(alpha = 0.12f)
}
@Composable
private fun seatStackLabel(seat: SeatSnapshot): String = when {
seat.folded -> "folded"
seat.allIn -> "ALL IN"
else -> "${seat.stack}"
}
@Composable
private fun PlateSeat(
seat: SeatSnapshot, seat: SeatSnapshot,
isTurn: Boolean, isTurn: Boolean,
isWinner: Boolean, isWinner: Boolean,
@@ -444,11 +550,7 @@ private fun OpponentSeat(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
val highlight = when { val highlight = seatHighlight(isTurn, isWinner)
isWinner -> TableGold
isTurn -> GoldLight
else -> Color.White.copy(alpha = 0.12f)
}
Box( Box(
modifier = Modifier modifier = Modifier
.border( .border(
@@ -538,6 +640,152 @@ private fun OpponentSeat(
} }
} }
/**
* Badge: one horizontal pill, avatar beside a compact name and stack.
*
* Trades vertical height for width. The seat occupies about one row instead of
* three, which leaves more open felt between the arc and the pot.
*/
@Composable
private fun BadgeSeat(
seat: SeatSnapshot,
isTurn: Boolean,
isWinner: Boolean,
width: Dp,
modifier: Modifier = Modifier,
) {
val faded = seat.folded && !isWinner
val avatar = (width * 0.42f).coerceIn(30.dp, 42.dp)
Column(
modifier = modifier.width(width).alpha(if (faded) 0.4f else 1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Box(contentAlignment = Alignment.CenterStart) {
if (!seat.folded && !(seat.revealed && seat.hole != null)) {
HoleCardFan(
cardWidth = (width * 0.21f).coerceIn(16.dp, 24.dp),
modifier = Modifier.align(Alignment.CenterEnd).offset(x = 4.dp),
)
}
Surface(
shape = RoundedCornerShape(50),
color = Panel,
border = androidx.compose.foundation.BorderStroke(
if (isTurn || isWinner) 2.dp else 1.dp,
seatHighlight(isTurn, isWinner),
),
) {
Row(
modifier = Modifier.padding(end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
OpponentAvatar(seat, size = avatar)
Column(modifier = Modifier.weight(1f, fill = false)) {
Text(
seat.name,
color = Cream,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
seatStackLabel(seat),
color = if (seat.folded) Muted.copy(alpha = 0.7f) else TableGold,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
)
}
}
}
if (seat.isButton) {
DealerButton(Modifier.align(Alignment.BottomStart).offset(y = 6.dp))
}
}
if (seat.revealed && seat.hole != null) {
Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) {
repeat(2) { i ->
CardImage(
seat.hole?.getOrNull(i),
Modifier.size((width * 0.34f).coerceIn(26.dp, 40.dp), (width * 0.51f).coerceIn(39.dp, 60.dp)),
)
}
}
}
if (seat.committedThisRound > 0 && !seat.folded) BetMarker(seat.committedThisRound)
}
}
/**
* Chip: avatar-led with the name and stack tucked beneath.
*
* The narrowest option, which buys the most separation between neighbours on a
* small screen at the cost of showing less at a glance.
*/
@Composable
private fun ChipSeat(
seat: SeatSnapshot,
isTurn: Boolean,
isWinner: Boolean,
width: Dp,
modifier: Modifier = Modifier,
) {
val faded = seat.folded && !isWinner
Column(
modifier = modifier.width(width).alpha(if (faded) 0.4f else 1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Box(contentAlignment = Alignment.Center) {
if (!seat.folded && !(seat.revealed && seat.hole != null)) {
HoleCardFan(
cardWidth = (width * 0.30f).coerceIn(16.dp, 22.dp),
modifier = Modifier.align(Alignment.TopEnd).offset(x = 10.dp, y = (-6).dp),
)
}
Box(
modifier = Modifier.border(
width = if (isTurn || isWinner) 2.dp else 1.dp,
color = seatHighlight(isTurn, isWinner),
shape = CircleShape,
).padding(2.dp),
) {
OpponentAvatar(seat, size = (width * 0.78f).coerceIn(34.dp, 46.dp))
}
if (seat.isButton) {
DealerButton(Modifier.align(Alignment.BottomStart).offset(x = (-6).dp, y = 4.dp))
}
}
if (seat.revealed && seat.hole != null) {
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
repeat(2) { i ->
CardImage(
seat.hole?.getOrNull(i),
Modifier.size((width * 0.44f).coerceIn(22.dp, 32.dp), (width * 0.66f).coerceIn(33.dp, 48.dp)),
)
}
}
}
Text(
seat.name,
color = Cream,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
seatStackLabel(seat),
color = if (seat.folded) Muted.copy(alpha = 0.7f) else TableGold,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
)
if (seat.committedThisRound > 0 && !seat.folded) BetMarker(seat.committedThisRound)
}
}
/** /**
* Two face-down cards fanned like a real hand. * Two face-down cards fanned like a real hand.
* *
@@ -870,7 +1118,7 @@ private fun TableControls(
heroFolded: Boolean, heroFolded: Boolean,
onFold: (Long) -> Unit, onFold: (Long) -> Unit,
onCheckCall: (Long) -> Unit, onCheckCall: (Long) -> Unit,
onRaise: (Long, Int) -> Unit, onOpenRaise: () -> Unit,
onNextHand: (Int) -> Unit, onNextHand: (Int) -> Unit,
) { ) {
Surface( Surface(
@@ -878,7 +1126,7 @@ private fun TableControls(
color = Night, color = Night,
) { ) {
when { when {
offer != null -> DecisionControls(offer, onFold, onCheckCall, onRaise) offer != null -> DecisionControls(offer, onFold, onCheckCall, onOpenRaise)
handSummary != null -> CompletionControls( handSummary != null -> CompletionControls(
summary = handSummary, summary = handSummary,
nextHandRequested = nextHandRequested, nextHandRequested = nextHandRequested,
@@ -908,36 +1156,53 @@ private fun DecisionControls(
offer: DecisionOffer, offer: DecisionOffer,
onFold: (Long) -> Unit, onFold: (Long) -> Unit,
onCheckCall: (Long) -> Unit, onCheckCall: (Long) -> Unit,
onRaise: (Long, Int) -> Unit, onOpenRaise: () -> Unit,
) { ) {
val buttons = buttonsFor(offer) val buttons = buttonsFor(offer)
var sizingRaise by remember(offer.token) { mutableStateOf(false) }
var raiseTo by remember(offer.token) { mutableIntStateOf(buttons.fixedRaiseTo) }
BackHandler(enabled = sizingRaise) {
sizingRaise = false
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Night.copy(alpha = 0.95f), Night),
),
)
.padding(horizontal = 14.dp, vertical = 10.dp), .padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(CONTROL_GAP),
) { ) {
if (!sizingRaise) {
PrimaryDecisionButtons( PrimaryDecisionButtons(
offer = offer, offer = offer,
buttons = buttons, buttons = buttons,
onFold = onFold, onFold = onFold,
onCheckCall = onCheckCall, onCheckCall = onCheckCall,
onOpenRaise = { sizingRaise = true }, onOpenRaise = onOpenRaise,
) )
} else { }
}
/**
* Raise sizing, presented as a sheet over the felt.
*
* It must not live in the control column: growing that column shrinks the
* weighted table above it, which visibly reshapes the oval every time Raise is
* tapped. Overlaying leaves the table untouched.
*/
@Composable
private fun RaiseSizingSheet(
offer: DecisionOffer,
onDismiss: () -> Unit,
onRaise: (Long, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val buttons = buttonsFor(offer)
var raiseTo by remember(offer.token) { mutableIntStateOf(buttons.fixedRaiseTo) }
BackHandler(enabled = true) { onDismiss() }
Column(
modifier = modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Night.copy(alpha = 0.97f), Night),
),
)
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(CONTROL_GAP),
) {
val amount = raiseConfirmationAmount(buttons, raiseTo) val amount = raiseConfirmationAmount(buttons, raiseTo)
Row( Row(
modifier = Modifier modifier = Modifier
@@ -946,7 +1211,7 @@ private fun DecisionControls(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
BackToActionsButton { sizingRaise = false } BackToActionsButton { onDismiss() }
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Text( Text(
"RAISE TO", "RAISE TO",
@@ -1061,7 +1326,6 @@ private fun DecisionControls(
) )
} }
} }
}
@Composable @Composable
private fun PrimaryDecisionButtons( private fun PrimaryDecisionButtons(
@@ -1448,9 +1712,10 @@ private fun CardImage(index: Int?, modifier: Modifier) {
Image( Image(
painter = painterResource(if (index != null) cardFace(index) else cardBack()), painter = painterResource(if (index != null) cardFace(index) else cardBack()),
contentDescription = index?.let { Card(it).toString() } ?: "Face-down card", contentDescription = index?.let { Card(it).toString() } ?: "Face-down card",
// No clip and no backing plate: the artwork is already a rounded card
// with its own border, drawn at the same 2:3 ratio it is laid out at.
// Clipping again at a different radius cut into that border.
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = modifier modifier = modifier,
.clip(RoundedCornerShape(6.dp))
.background(Color(0xFFFFFDF8)),
) )
} }