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 { 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)), ) }