Add two-stage raise confirmation

This commit is contained in:
Jay
2026-07-27 10:50:41 -04:00
parent 33be35f970
commit b6ea330e07
4 changed files with 198 additions and 112 deletions
+5 -3
View File
@@ -154,9 +154,11 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
straddles the outside rail rather than occupying the community-card band.
The terminal modal repeats the board, every public hand turned up (including
losing hands), and exact pot awards so dimming the table never hides the
explanation. Raise sizing combines coarse slider/presets with one-chip minus
and plus controls; visible chip stacks grow with bets and the pot while numeric
labels remain authoritative.
explanation. The default action bar contains only Fold/Check-or-Call/Raise;
Raise opens a confirmation screen with coarse slider/presets, one-chip minus
and plus controls, and explicit UI/system Back handling. Merely opening or
leaving sizing never submits an action. Visible chip stacks grow with bets and
the pot while numeric labels remain authoritative.
- A cash-game session begins at an explicit take-a-seat screen. Opponents may
auto-reload below the big blind; the human is never silently topped up and must
explicitly choose "Reload to N & deal" from the completed-hand screen.
@@ -68,8 +68,18 @@ fun adjustRaiseAmount(
buttons: ActionButtons,
): Int = (current + change).coerceIn(buttons.sliderMin, buttons.sliderMax)
/** The exact amount submitted by the confirmation screen. */
fun raiseConfirmationAmount(
buttons: ActionButtons,
selected: Int,
): Int = if (buttons.showSlider) {
selected.coerceIn(buttons.sliderMin, buttons.sliderMax)
} else {
buttons.fixedRaiseTo
}
/**
* Honest, legal raise-to shortcuts for the always-visible sizing rail.
* Honest, legal raise-to shortcuts for the raise-sizing screen.
*
* Facing a bet, a fraction describes the extra raise after calling: the pot
* after a call is [DecisionOffer.pot] plus the affordable call, and the final
@@ -1,5 +1,6 @@
package com.jsjdesigns.poker
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -33,9 +34,9 @@ import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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
@@ -757,8 +758,12 @@ private fun DecisionControls(
onRaise: (Long, Int) -> Unit,
) {
val buttons = buttonsFor(offer)
var raiseTo by remember { mutableIntStateOf(buttons.fixedRaiseTo) }
LaunchedEffect(offer.token) { raiseTo = buttons.fixedRaiseTo }
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
@@ -771,129 +776,187 @@ private fun DecisionControls(
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (buttons.showRaise && buttons.showSlider) {
if (!sizingRaise) {
PrimaryDecisionButtons(
offer = offer,
buttons = buttons,
onFold = onFold,
onCheckCall = onCheckCall,
onOpenRaise = { sizingRaise = true },
)
} else {
val amount = raiseConfirmationAmount(buttons, raiseTo)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"RAISE TO",
color = Muted.copy(alpha = 0.72f),
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
letterSpacing = 1.4.sp,
)
Text(
"$raiseTo",
color = GoldLight,
fontWeight = FontWeight.ExtraBold,
fontSize = 22.sp,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FineTuneButton("") {
raiseTo = adjustRaiseAmount(raiseTo, -1, buttons)
}
Slider(
value = raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax).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
.weight(1f)
.height(44.dp),
)
FineTuneButton("+") {
raiseTo = adjustRaiseAmount(raiseTo, 1, buttons)
BackToActionsButton { sizingRaise = false }
Column(horizontalAlignment = Alignment.End) {
Text(
"RAISE TO",
color = Muted.copy(alpha = 0.82f),
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
letterSpacing = 1.4.sp,
)
Text(
"$amount",
color = GoldLight,
fontWeight = FontWeight.ExtraBold,
fontSize = 24.sp,
)
}
}
val presets = raisePresets(offer, buttons)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
presets.forEach { preset ->
Button(
onClick = { raiseTo = preset.amount },
if (buttons.showSlider) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FineTuneButton("") {
raiseTo = adjustRaiseAmount(raiseTo, -1, buttons)
}
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
.weight(1f)
.height(42.dp),
contentPadding = PaddingValues(horizontal = 4.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (raiseTo == preset.amount) {
TableGold.copy(alpha = 0.2f)
} else {
Color.White.copy(alpha = 0.06f)
},
contentColor = if (raiseTo == preset.amount) GoldLight else Muted,
),
shape = RoundedCornerShape(9.dp),
) {
Text(
"${preset.label} ${preset.amount}",
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
maxLines = 1,
)
.height(44.dp),
)
FineTuneButton("+") {
raiseTo = adjustRaiseAmount(raiseTo, 1, buttons)
}
}
}
}
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) {
val amount = if (buttons.showSlider) {
raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax)
} else {
buttons.fixedRaiseTo
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(42.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.06f)
},
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,
)
}
}
}
ActionButton(
title = if (amount >= buttons.sliderMax) "All in" else "Raise",
detail = if (amount >= buttons.sliderMax) "$amount" else "to $amount",
container = Brush.verticalGradient(listOf(GoldLight, Color(0xFFC79A38))),
content = Color(0xFF22190A),
modifier = Modifier.weight(1.12f),
onClick = { onRaise(offer.token, amount) },
} 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,
)
}
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(44.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.09f),
contentColor = Cream,
),
shape = RoundedCornerShape(12.dp),
) {
Text(" Back", fontWeight = FontWeight.Bold, fontSize = 14.sp)
}
}
@Composable
private fun FineTuneButton(
label: String,
@@ -152,6 +152,17 @@ class ActionButtonsTest {
assertEquals(20, adjustRaiseAmount(20, -1, b))
assertEquals(500, adjustRaiseAmount(500, 1, b))
}
@Test
fun `raise confirmation clamps a range but ignores selection for an exact raise`() {
val range = buttonsFor(offer(minRaiseTo = 20, maxRaiseTo = 500))
assertEquals(20, raiseConfirmationAmount(range, -10))
assertEquals(60, raiseConfirmationAmount(range, 60))
assertEquals(500, raiseConfirmationAmount(range, 900))
val exact = buttonsFor(offer(minRaiseTo = 100, maxRaiseTo = 100))
assertEquals(100, raiseConfirmationAmount(exact, 999))
}
}
class CallCostTest {