Files
holdem_poker/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt
T
thejayman77 a11daddf6c 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>
2026-07-27 22:19:44 -04:00

1407 lines
49 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.jsjdesigns.poker
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jsjdesigns.poker.core.Card
import com.jsjdesigns.poker.game.DecisionOffer
import com.jsjdesigns.poker.game.SeatSnapshot
import com.jsjdesigns.poker.game.TableSnapshot
import kotlin.math.roundToInt
private val Night = Color(0xFF04100C)
private val TableBackground = Color(0xFF071D16)
private val FeltLight = Color(0xFF1E6E51)
private val FeltMid = Color(0xFF155440)
private val FeltDark = Color(0xFF0C3A2C)
private val RailLight = Color(0xFF3B2A17)
private val RailDark = Color(0xFF211407)
private val TableGold = Color(0xFFE3C27E)
private val GoldLight = Color(0xFFF0D9A5)
private val Cream = Color(0xFFF3EFE6)
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
amount <= 5 -> 2
amount <= 10 -> 3
amount <= 25 -> 4
amount <= 60 -> 5
amount <= 150 -> 6
else -> 7
}
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
/**
* Width one opponent seat may occupy, derived from the table's real width.
*
* 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 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<SeatPlacement> {
val lanes = listOf(0.30f, 0.15f, 0f, 0.15f, 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) {
val state by vm.state.collectAsStateWithLifecycle()
val rawOffer by vm.offer.collectAsStateWithLifecycle()
val snap = state.snapshot
val offer = state.liveOffer(rawOffer)
val handSummary = state.visibleHandSummary()
val coachReview = state.visibleCoachReview()
val tableTalk = state.visibleTableTalk()
val status = snap?.let(::tableStatus)
Column(
modifier = Modifier
.fillMaxSize()
.background(Night)
.systemBarsPadding(),
) {
TableHeader(snap, state.gameConfig)
PokerTable(
snapshot = snap,
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()
.weight(1f),
)
TableControls(
offer = offer,
handSummary = handSummary,
nextHandRequested = state.nextHandRequested,
rebuyRequired = state.heroNeedsRebuy,
buyIn = state.gameConfig?.buyIn ?: DEFAULT_BUY_IN,
coachReview = coachReview,
heroFolded = snap?.seats?.firstOrNull { it.index == HERO_SEAT }?.folded == true,
onFold = vm::fold,
onCheckCall = vm::checkOrCall,
onRaise = vm::raiseTo,
onNextHand = vm::nextHand,
)
}
}
@Composable
private fun TableHeader(
snapshot: TableSnapshot?,
config: CashGameConfig?,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(horizontal = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(
"HAND ${snapshot?.handNumber ?: 1}",
color = Cream,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
Text(
snapshot?.street?.name.orEmpty(),
color = Muted.copy(alpha = 0.8f),
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
letterSpacing = 1.4.sp,
)
}
Surface(
shape = RoundedCornerShape(50),
color = Color.Black.copy(alpha = 0.24f),
border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.24f)),
) {
Row(
modifier = Modifier.padding(horizontal = 11.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ChipDot(TableGold, 11.dp)
Text(
"${config?.smallBlind ?: DEFAULT_SMALL_BLIND} / " +
"${config?.bigBlind ?: DEFAULT_BIG_BLIND}",
color = TableGold,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
}
}
}
}
@Composable
private fun PokerTable(
snapshot: TableSnapshot?,
status: TableStatus?,
tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?,
handSummary: HandSummary?,
modifier: Modifier = Modifier,
) {
val seats = snapshot?.seats.orEmpty()
val winners = handSummary?.awards
.orEmpty()
.flatMap { award -> award.winners.map(AwardWinner::seat) }
.toSet()
BoxWithConstraints(
modifier = modifier
.background(TableBackground),
) {
TableFelt(Modifier.fillMaxSize())
// 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 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 = placement.leftDp.dp,
y = maxHeight * placement.topFraction,
),
)
}
BoardAndStatus(
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 = maxHeight * 0.46f),
)
HeroSeat(
seat = seats.firstOrNull { it.index == HERO_SEAT },
isTurn = snapshot?.toAct == HERO_SEAT,
isWinner = HERO_SEAT in winners,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 10.dp),
)
handSummary?.let { summary ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Night.copy(alpha = 0.82f))
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
ShowdownModal(summary)
}
}
}
}
@Composable
private fun TableFelt(modifier: Modifier = Modifier) {
Canvas(modifier) {
// 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(railRect.left, railRect.top),
end = Offset(railRect.right, railRect.bottom),
),
topLeft = Offset(railRect.left, railRect.top),
size = androidx.compose.ui.geometry.Size(railRect.width, railRect.height),
)
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(
feltTopLeft.x + feltSize.width * 0.5f,
feltTopLeft.y + feltSize.height * 0.34f,
),
radius = feltSize.minDimension * 1.15f,
),
topLeft = feltTopLeft,
size = feltSize,
)
drawOval(
color = TableGold.copy(alpha = 0.30f),
topLeft = feltTopLeft,
size = feltSize,
style = Stroke(width = 1.dp.toPx()),
)
}
}
@Composable
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
// 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(width)
.alpha(if (faded) 0.38f else 1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(contentAlignment = Alignment.Center) {
val highlight = when {
isWinner -> TableGold
isTurn -> GoldLight
else -> Color.White.copy(alpha = 0.12f)
}
Box(
modifier = Modifier
.border(
width = if (isWinner || isTurn) 2.dp else 1.dp,
color = highlight,
shape = RoundedCornerShape(12.dp),
)
.padding(2.dp),
contentAlignment = Alignment.Center,
) {
val hole = seat.hole
if (revealed && hole != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(2) { index ->
CardImage(
hole.getOrNull(index),
Modifier.size(faceWidth, faceHeight),
)
}
}
} else {
// One slot-width box: the fan is drawn first so the avatar
// sits on top of it, and neither can push past the seat.
Box(
modifier = Modifier.width(width - 6.dp),
contentAlignment = Alignment.CenterStart,
) {
if (!seat.folded) {
HoleCardFan(
cardWidth = (width * 0.27f).coerceIn(18.dp, 27.dp),
modifier = Modifier.align(Alignment.CenterEnd),
)
}
OpponentAvatar(seat, size = (width * 0.58f).coerceIn(34.dp, 48.dp))
}
}
}
if (seat.isButton) {
DealerButton(Modifier.align(Alignment.BottomStart).offset(x = (-5).dp, y = 4.dp))
}
}
Surface(
shape = RoundedCornerShape(8.dp),
color = Panel,
border = androidx.compose.foundation.BorderStroke(
1.dp,
if (isWinner) TableGold.copy(alpha = 0.65f) else Color.White.copy(alpha = 0.06f),
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 3.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
seat.name,
color = Cream,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
when {
seat.folded -> "folded"
seat.allIn -> "ALL IN"
else -> "${seat.stack}"
},
color = when {
seat.folded -> Muted.copy(alpha = 0.65f)
seat.allIn || isWinner -> TableGold
else -> TableGold.copy(alpha = 0.9f)
},
fontWeight = if (seat.allIn || isWinner) FontWeight.Bold else FontWeight.Medium,
fontSize = 12.sp,
)
}
}
if (seat.committedThisRound > 0 && !seat.folded) {
BetMarker(seat.committedThisRound)
} else {
Spacer(Modifier.height(16.dp))
}
}
}
/**
* 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
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(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(Color(0xFF3A5C4B), Color(0xFF183226)),
),
),
contentAlignment = Alignment.Center,
) {
Text(
seat.name.take(1).uppercase(),
color = GoldLight,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
}
}
@Composable
private fun HeroSeat(
seat: SeatSnapshot?,
isTurn: Boolean,
isWinner: Boolean,
modifier: Modifier = Modifier,
) {
val folded = seat?.folded == true
val cardWidth = 76.dp
val cardHeight = 114.dp
Surface(
modifier = modifier.alpha(if (folded) 0.42f else 1f),
shape = RoundedCornerShape(16.dp),
color = Panel,
border = androidx.compose.foundation.BorderStroke(
if (isTurn || isWinner) 2.dp else 1.dp,
when {
isWinner -> TableGold
isTurn -> GoldLight
else -> Color.White.copy(alpha = 0.09f)
},
),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(2) { index ->
CardImage(
seat?.hole?.getOrNull(index),
Modifier.size(cardWidth, cardHeight),
)
}
}
Column(
modifier = Modifier.padding(bottom = 4.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
seat?.name ?: "You",
color = Cream,
fontWeight = FontWeight.ExtraBold,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"${seat?.stack ?: 0}",
color = TableGold,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
)
when {
folded -> StatusTag("FOLDED", Danger)
seat?.allIn == true -> StatusTag("ALL IN", TableGold)
isWinner -> StatusTag("WINNER", TableGold)
}
if ((seat?.committedThisRound ?: 0) > 0) {
BetMarker(seat?.committedThisRound ?: 0)
}
}
}
}
}
@Composable
private fun BoardAndStatus(
snapshot: TableSnapshot?,
status: TableStatus?,
tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?,
modifier: Modifier = Modifier,
) {
val board = snapshot?.board.orEmpty()
val cardWidth = 58.dp
val cardHeight = 87.dp
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
PotPill(snapshot?.pot ?: 0)
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
repeat(5) { index ->
if (index < board.size) {
CardImage(board[index], Modifier.size(cardWidth, cardHeight))
} else {
Box(
Modifier
.size(cardWidth, cardHeight)
.clip(RoundedCornerShape(6.dp))
.border(
1.dp,
TableGold.copy(alpha = 0.18f),
RoundedCornerShape(6.dp),
)
.background(Color.Black.copy(alpha = 0.08f)),
)
}
}
}
status?.let {
StatusPill(
text = it.text,
accent = it.tone == TableStatusTone.ACCENT,
)
}
tableTalk?.let {
Text(
"${it.speakerName}: “${it.text}",
color = Cream.copy(alpha = 0.9f),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(292.dp),
)
}
}
}
@Composable
private fun PotPill(pot: Int) {
Surface(
shape = RoundedCornerShape(50),
color = Color(0x9904140E),
border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.32f)),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ChipStack(pot, chipSize = 18.dp)
Text(
"POT",
color = TableGold.copy(alpha = 0.85f),
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.8.sp,
)
Text(
"$pot",
color = GoldLight,
fontWeight = FontWeight.ExtraBold,
fontSize = 17.sp,
)
}
}
}
@Composable
private fun StatusPill(text: String, accent: Boolean) {
Surface(
shape = RoundedCornerShape(50),
color = if (accent) TableGold.copy(alpha = 0.17f) else Color.Black.copy(alpha = 0.24f),
border = androidx.compose.foundation.BorderStroke(
1.dp,
if (accent) TableGold.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.08f),
),
) {
Text(
text,
color = if (accent) GoldLight else Cream.copy(alpha = 0.8f),
fontWeight = if (accent) FontWeight.Bold else FontWeight.Medium,
fontSize = 12.sp,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.width(286.dp)
.padding(horizontal = 11.dp, vertical = 7.dp),
)
}
}
@Composable
private fun BetMarker(amount: Int) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
ChipStack(amount, chipSize = 14.dp)
Text(
"$amount",
color = Cream.copy(alpha = 0.9f),
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
}
}
@Composable
private fun DealerButton(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(18.dp)
.clip(CircleShape)
.background(Color(0xFFF3EBD8))
.border(1.dp, Color(0xFFA79463), CircleShape),
contentAlignment = Alignment.Center,
) {
Text("D", color = Color(0xFF5A4A22), fontWeight = FontWeight.ExtraBold, fontSize = 8.sp)
}
}
@Composable
private fun StatusTag(text: String, color: Color) {
Surface(
shape = RoundedCornerShape(6.dp),
color = color.copy(alpha = 0.16f),
border = androidx.compose.foundation.BorderStroke(1.dp, color.copy(alpha = 0.5f)),
) {
Text(
text,
color = color,
fontWeight = FontWeight.ExtraBold,
fontSize = 10.sp,
letterSpacing = 0.5.sp,
modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp),
)
}
}
@Composable
private fun ChipStack(amount: Int, chipSize: Dp) {
val colors = listOf(
Color(0xFFE7E4D9),
Color(0xFF2A9E6B),
Color(0xFF2F72C4),
Color(0xFFE2B85B),
Color(0xFFB94B52),
)
Row(
horizontalArrangement = Arrangement.spacedBy((-4).dp),
verticalAlignment = Alignment.Bottom,
) {
repeat(visibleChipCount(amount)) { index ->
Box(Modifier.padding(bottom = ((index % 3) * 3).dp)) {
ChipDot(colors[index % colors.size], chipSize)
}
}
}
}
@Composable
private fun ChipDot(color: Color, size: Dp) {
Canvas(Modifier.size(size)) {
val radius = this.size.minDimension * 0.48f
drawCircle(color = Color.Black.copy(alpha = 0.34f), radius = radius)
drawCircle(color = color, radius = radius * 0.91f)
drawCircle(
color = Color.White.copy(alpha = 0.62f),
radius = radius * 0.63f,
style = Stroke(width = 1.2.dp.toPx()),
)
val mark = radius * 0.63f
drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x, center.y - radius), Offset(center.x, center.y - mark), 1.5.dp.toPx())
drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x, center.y + mark), Offset(center.x, center.y + radius), 1.5.dp.toPx())
drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x - radius, center.y), Offset(center.x - mark, center.y), 1.5.dp.toPx())
drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x + mark, center.y), Offset(center.x + radius, center.y), 1.5.dp.toPx())
}
}
@Composable
private fun TableControls(
offer: DecisionOffer?,
handSummary: HandSummary?,
nextHandRequested: Boolean,
rebuyRequired: Boolean,
buyIn: Int,
coachReview: com.jsjdesigns.poker.bot.CoachingReview?,
heroFolded: Boolean,
onFold: (Long) -> Unit,
onCheckCall: (Long) -> Unit,
onRaise: (Long, Int) -> Unit,
onNextHand: (Int) -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = Night,
) {
when {
offer != null -> DecisionControls(offer, onFold, onCheckCall, onRaise)
handSummary != null -> CompletionControls(
summary = handSummary,
nextHandRequested = nextHandRequested,
rebuyRequired = rebuyRequired,
buyIn = buyIn,
onNextHand = onNextHand,
)
coachReview != null -> CoachBar(coachPresentation(coachReview))
else -> Box(
Modifier
.fillMaxWidth()
.height(80.dp),
contentAlignment = Alignment.Center,
) {
Text(
if (heroFolded) "You folded — watching the hand finish" else "Waiting…",
color = Muted.copy(alpha = 0.55f),
fontSize = 13.sp,
)
}
}
}
}
@Composable
private fun DecisionControls(
offer: DecisionOffer,
onFold: (Long) -> Unit,
onCheckCall: (Long) -> Unit,
onRaise: (Long, Int) -> Unit,
) {
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(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Night.copy(alpha = 0.95f), Night),
),
)
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(CONTROL_GAP),
) {
if (!sizingRaise) {
PrimaryDecisionButtons(
offer = offer,
buttons = buttons,
onFold = onFold,
onCheckCall = onCheckCall,
onOpenRaise = { sizingRaise = true },
)
} else {
val amount = raiseConfirmationAmount(buttons, raiseTo)
Row(
modifier = Modifier
.fillMaxWidth()
.height(TOUCH_TARGET),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
BackToActionsButton { sizingRaise = false }
Column(horizontalAlignment = Alignment.End) {
Text(
"RAISE TO",
color = Muted,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
letterSpacing = 1.4.sp,
)
Text(
"$amount",
color = GoldLight,
fontWeight = FontWeight.ExtraBold,
fontSize = 24.sp,
)
}
}
if (buttons.showSlider) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(TOUCH_TARGET),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FineTuneButton("") {
raiseTo = adjustRaiseAmount(raiseTo, -1, buttons)
}
// 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(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)
}
}
val presets = raisePresets(offer, buttons)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
presets.forEach { preset ->
Button(
onClick = { raiseTo = preset.amount },
modifier = Modifier
.weight(1f)
.height(48.dp),
contentPadding = PaddingValues(horizontal = 4.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (amount == preset.amount) {
TableGold.copy(alpha = 0.2f)
} else {
Color.White.copy(alpha = 0.12f)
},
contentColor = if (amount == preset.amount) GoldLight else Muted,
),
shape = RoundedCornerShape(9.dp),
) {
Text(
"${preset.label} ${preset.amount}",
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
maxLines = 1,
)
}
}
}
} else {
Text(
if (amount >= buttons.sliderMax) {
"Your stack leaves one legal raise: all in for $amount."
} else {
"There is one legal raise size: $amount."
},
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
color = Cream.copy(alpha = 0.82f),
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
}
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",
container = Brush.verticalGradient(listOf(GoldLight, Color(0xFFC79A38))),
content = Color(0xFF22190A),
modifier = Modifier.fillMaxWidth(),
onClick = { onRaise(offer.token, amount) },
)
}
}
}
@Composable
private fun PrimaryDecisionButtons(
offer: DecisionOffer,
buttons: ActionButtons,
onFold: (Long) -> Unit,
onCheckCall: (Long) -> Unit,
onOpenRaise: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (buttons.showFold) {
ActionButton(
title = "Fold",
detail = null,
container = Danger.copy(alpha = 0.58f),
content = Color(0xFFF0BDBD),
modifier = Modifier.weight(1f),
onClick = { onFold(offer.token) },
)
}
ActionButton(
title = when {
offer.canCheck -> "Check"
offer.callIsAllIn -> "All in"
else -> "Call"
},
detail = offer.callAmount.takeUnless { offer.canCheck }?.toString(),
container = Color.White.copy(alpha = 0.09f),
content = Cream,
modifier = Modifier.weight(1f),
onClick = { onCheckCall(offer.token) },
)
if (buttons.showRaise) {
ActionButton(
title = "Raise",
detail = null,
container = Brush.verticalGradient(listOf(GoldLight, Color(0xFFC79A38))),
content = Color(0xFF22190A),
modifier = Modifier.weight(1.12f),
onClick = onOpenRaise,
)
}
}
}
@Composable
private fun BackToActionsButton(onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.height(48.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.16f),
contentColor = Cream,
),
shape = RoundedCornerShape(12.dp),
) {
Text(" Back", fontWeight = FontWeight.Bold, fontSize = 14.sp)
}
}
@Composable
private fun FineTuneButton(
label: String,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
modifier = Modifier.size(48.dp),
contentPadding = PaddingValues(0.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.16f),
contentColor = GoldLight,
),
shape = RoundedCornerShape(12.dp),
) {
Text(label, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp)
}
}
@Composable
private fun ActionButton(
title: String,
detail: String?,
container: Color,
content: Color,
modifier: Modifier,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
modifier = modifier.height(60.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = content),
) {
ActionButtonText(title, detail, content)
}
}
@Composable
private fun ActionButton(
title: String,
detail: String?,
container: Brush,
content: Color,
modifier: Modifier,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
modifier = modifier
.height(60.dp)
.background(container, RoundedCornerShape(14.dp)),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = content,
),
) {
ActionButtonText(title, detail, content)
}
}
@Composable
private fun ActionButtonText(title: String, detail: String?, color: Color) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, color = color, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp)
detail?.let {
Text(it, color = color.copy(alpha = 0.78f), fontWeight = FontWeight.Bold, fontSize = 12.sp)
}
}
}
@Composable
private fun ShowdownModal(summary: HandSummary) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = Color(0xFA07130F),
border = androidx.compose.foundation.BorderStroke(2.dp, TableGold.copy(alpha = 0.72f)),
shadowElevation = 14.dp,
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(7.dp),
) {
Text(
if (summary.wentToShowdown) "SHOWDOWN" else "HAND COMPLETE",
color = TableGold,
fontWeight = FontWeight.ExtraBold,
fontSize = 10.sp,
letterSpacing = 1.8.sp,
)
Text(
summary.title,
color = GoldLight,
fontWeight = FontWeight.ExtraBold,
fontSize = 24.sp,
)
Text(
summary.detail,
color = Cream.copy(alpha = 0.94f),
fontWeight = FontWeight.Medium,
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
if (summary.wentToShowdown && summary.board.isNotEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"BOARD",
color = Muted.copy(alpha = 0.9f),
fontWeight = FontWeight.Bold,
fontSize = 9.sp,
letterSpacing = 1.3.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
summary.board.forEach { card ->
CardImage(card, Modifier.size(50.dp, 75.dp))
}
}
}
}
if (summary.showdownPlayers.isNotEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"ALL SHOWDOWN HANDS",
color = Muted.copy(alpha = 0.9f),
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.2.sp,
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
summary.showdownPlayers.forEach { player ->
ShowdownPlayerCard(player)
}
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
summary.awards.forEach { award -> PotAwardCard(award) }
}
}
}
}
@Composable
private fun CompletionControls(
summary: HandSummary,
nextHandRequested: Boolean,
rebuyRequired: Boolean,
buyIn: Int,
onNextHand: (Int) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 9.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (rebuyRequired) {
Text(
"Your stack cannot cover the big blind.",
color = Color(0xFFC9545B),
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
)
}
Button(
onClick = { onNextHand(summary.handNumber) },
enabled = !nextHandRequested,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(13.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF1C6848),
contentColor = Cream,
disabledContainerColor = Color(0xFF1C6848).copy(alpha = 0.45f),
disabledContentColor = Cream.copy(alpha = 0.65f),
),
) {
Text(
continuationButtonLabel(nextHandRequested, rebuyRequired, buyIn),
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable
private fun ShowdownPlayerCard(player: ShowdownPlayerSummary) {
Card(
modifier = Modifier.width(118.dp),
colors = CardDefaults.cardColors(
containerColor = if (player.amountWon > 0) {
TableGold.copy(alpha = 0.13f)
} else {
Color.White.copy(alpha = 0.06f)
},
),
border = androidx.compose.foundation.BorderStroke(
1.dp,
if (player.amountWon > 0) TableGold.copy(alpha = 0.62f) else Color.White.copy(alpha = 0.1f),
),
shape = RoundedCornerShape(12.dp),
) {
Column(
modifier = Modifier.padding(7.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
player.hole.take(2).forEach { card ->
CardImage(card, Modifier.size(48.dp, 72.dp))
}
}
Text(
player.label,
color = Cream,
fontWeight = FontWeight.ExtraBold,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
player.handDescription,
color = if (player.amountWon > 0) GoldLight else Cream.copy(alpha = 0.84f),
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
if (player.amountWon > 0) "WON ${player.amountWon}" else "SHOWED",
color = if (player.amountWon > 0) TableGold else Muted,
fontWeight = FontWeight.ExtraBold,
fontSize = 10.sp,
)
}
}
}
@Composable
private fun PotAwardCard(award: PotAwardSummary) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.07f)),
border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.38f)),
shape = RoundedCornerShape(10.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"${award.label.uppercase()} ${award.amount}",
color = TableGold,
fontWeight = FontWeight.ExtraBold,
fontSize = 11.sp,
letterSpacing = 0.4.sp,
)
Text(
award.winners.joinToString("") { "${it.label} ${it.amountWon}" },
modifier = Modifier.weight(1f),
color = Cream,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun CoachBar(presentation: CoachPresentation) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF14357A).copy(alpha = 0.48f)),
) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text(
"COACH • ${presentation.title}",
color = TableGold,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
Text(
presentation.detail,
color = Cream.copy(alpha = 0.78f),
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun CardImage(index: Int?, modifier: Modifier) {
Image(
painter = painterResource(if (index != null) cardFace(index) else cardBack()),
contentDescription = index?.let { Card(it).toString() } ?: "Face-down card",
contentScale = ContentScale.Fit,
modifier = modifier
.clip(RoundedCornerShape(6.dp))
.background(Color(0xFFFFFDF8)),
)
}