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
@@ -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 {