a11daddf6c
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>
1407 lines
49 KiB
Kotlin
1407 lines
49 KiB
Kotlin
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)),
|
||
)
|
||
}
|