Merge branch 'fix/scorekeeper-zero-and-cell-edit' into dev

This commit is contained in:
James Wallace
2026-10-02 21:19:59 +00:00
3 changed files with 52 additions and 28 deletions
@@ -21,9 +21,9 @@ data class ScorePlayer(val id: Int, val name: String = "")
/** /**
* What each player scored in one round, keyed by [ScorePlayer.id]. * What each player scored in one round, keyed by [ScorePlayer.id].
* *
* A player absent from the map scored nothing, which is the same as zero — so * A player absent from the map had nothing entered, which counts as zero in the
* a round where only one person scored carries one entry rather than a column * totals — so a round where only one person scored carries one entry rather
* of noughts. * than a column of noughts. A 0 that was typed is kept, so it can be shown.
*/ */
@Serializable @Serializable
data class ScoreRound(val deltas: Map<Int, Int> = emptyMap()) { data class ScoreRound(val deltas: Map<Int, Int> = emptyMap()) {
@@ -114,12 +114,12 @@ data class ScoreSheet(
} }
/** /**
* A round holding only what is worth storing: entries for players who are * A round holding only entries for players who are still at the table.
* still at the table, and only where somebody actually scored. * Zeros stay: a typed 0 is different from a cell left blank.
*/ */
private fun roundOf(deltas: Map<Int, Int>): ScoreRound { private fun roundOf(deltas: Map<Int, Int>): ScoreRound {
val ids = players.map { it.id }.toSet() val ids = players.map { it.id }.toSet()
return ScoreRound(deltas.filterKeys { it in ids }.filterValues { it != 0 }) return ScoreRound(deltas.filterKeys { it in ids })
} }
/** Drops points recorded against anybody no longer at the table. */ /** Drops points recorded against anybody no longer at the table. */
@@ -42,6 +42,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -193,9 +195,21 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
var renaming by remember { mutableStateOf(-1) } var renaming by remember { mutableStateOf(-1) }
// Which cell the ± button flips: the last one typed into. // Which cell the ± button flips: the last one typed into.
var lastTyped by remember { mutableStateOf(-1) } var lastTyped by remember { mutableStateOf(-1) }
// The cell to put the cursor in when a round is opened by tapping it.
var focusPlayer by remember { mutableStateOf(-1) }
val editing = if (correcting >= 0) correction else pending val editing = if (correcting >= 0) correction else pending
// Opens a round for correction. Both the round number and any of its cells
// come through here, so tapping a score edits that score directly.
val startCorrection: (Int, Int) -> Unit = { index, focus ->
correcting = index
correction.clear()
sheet.rounds[index].deltas.forEach { (id, points) -> correction[id] = points.toString() }
lastTyped = -1
focusPlayer = focus
}
// Keep the row being filled in in view when a round is written down. // Keep the row being filled in in view when a round is written down.
LaunchedEffect(sheet.rounds.size) { rows.animateScrollTo(rows.maxValue) } LaunchedEffect(sheet.rounds.size) { rows.animateScrollTo(rows.maxValue) }
@@ -264,15 +278,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
if (underCorrection) { if (underCorrection) {
correcting = -1 correcting = -1
} else { } else {
correcting = index startCorrection(index, -1)
correction.clear()
sheet.players.forEach { player ->
val points = round.delta(player.id)
if (points != 0) {
correction[player.id] = points.toString()
}
}
lastTyped = -1
} }
}, },
) )
@@ -282,13 +288,17 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
if (underCorrection) { if (underCorrection) {
PointsField( PointsField(
text = correction[player.id].orEmpty(), text = correction[player.id].orEmpty(),
autoFocus = focusPlayer == player.id,
onText = { onText = {
correction[player.id] = it correction[player.id] = it
lastTyped = player.id lastTyped = player.id
}, },
) )
} else { } else {
PointsCell(text = signed(round.delta(player.id))) PointsCell(
text = signed(round.deltas[player.id]),
onClick = { startCorrection(index, player.id) },
)
} }
} }
} }
@@ -358,17 +368,20 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
pending.clear() pending.clear()
} }
lastTyped = -1 lastTyped = -1
focusPlayer = -1
}, },
onDelete = { onDelete = {
onChange(sheet.withoutRound(correcting)) onChange(sheet.withoutRound(correcting))
correcting = -1 correcting = -1
correction.clear() correction.clear()
lastTyped = -1 lastTyped = -1
focusPlayer = -1
}, },
onCancel = { onCancel = {
correcting = -1 correcting = -1
correction.clear() correction.clear()
lastTyped = -1 lastTyped = -1
focusPlayer = -1
}, },
) )
} }
@@ -583,11 +596,12 @@ private fun RoundLabel(text: String, selected: Boolean, onClick: () -> Unit) {
} }
@Composable @Composable
private fun PointsCell(text: String, muted: Boolean = false) { private fun PointsCell(text: String, muted: Boolean = false, onClick: (() -> Unit)? = null) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.border(Dp.Hairline, MaterialTheme.colorScheme.outlineVariant), .border(Dp.Hairline, MaterialTheme.colorScheme.outlineVariant)
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
@@ -603,9 +617,11 @@ private fun PointsCell(text: String, muted: Boolean = false) {
} }
} }
/** A cell being typed into. Blank means nothing scored, which is the same as nought. */ /** A cell being typed into. Blank means nothing entered; a typed 0 is kept and shown. */
@Composable @Composable
private fun PointsField(text: String, onText: (String) -> Unit) { private fun PointsField(text: String, onText: (String) -> Unit, autoFocus: Boolean = false) {
val focus = remember { FocusRequester() }
LaunchedEffect(autoFocus) { if (autoFocus) focus.requestFocus() }
BasicTextField( BasicTextField(
value = text, value = text,
onValueChange = { onText(sanitiseAmount(it)) }, onValueChange = { onText(sanitiseAmount(it)) },
@@ -616,7 +632,7 @@ private fun PointsField(text: String, onText: (String) -> Unit) {
), ),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().focusRequester(focus),
decorationBox = { field -> decorationBox = { field ->
Box( Box(
modifier = Modifier modifier = Modifier
@@ -758,14 +774,13 @@ private fun flipSign(text: String): String {
return (-value).toString() return (-value).toString()
} }
/** The typed cells as points. Blanks, a lone minus and noughts all drop out. */ /** The typed cells as points. Blanks and a lone minus drop out; a typed 0 stays. */
private fun amountsAsPoints(amounts: Map<Int, String>): Map<Int, Int> = private fun amountsAsPoints(amounts: Map<Int, String>): Map<Int, Int> =
amounts.mapNotNull { (id, text) -> text.toIntOrNull()?.takeIf { it != 0 }?.let { id to it } } amounts.mapNotNull { (id, text) -> text.toIntOrNull()?.let { id to it } }.toMap()
.toMap()
/** Nothing scored shows as an empty cell rather than a nought in every column. */ /** Not entered shows as an empty cell; an entered 0 shows as 0. */
private fun signed(points: Int): String = when { private fun signed(points: Int?): String = when {
points == null -> ""
points > 0 -> "+$points" points > 0 -> "+$points"
points < 0 -> points.toString() else -> points.toString()
else -> ""
} }
@@ -182,4 +182,13 @@ class ScoreSheetTest {
assertEquals(0, sheet.totals()[sheet.players[0].id]) assertEquals(0, sheet.totals()[sheet.players[0].id])
assertEquals(4, sheet.totals()[sheet.players[1].id]) assertEquals(4, sheet.totals()[sheet.players[1].id])
} }
@Test
fun `a zero that was entered is kept, unlike a cell left blank`() {
val sheet = sheetOf("Alice", "Bob").let { it.withRound(mapOf(it.players[0].id to 0)) }
val round = sheet.rounds.first()
assertEquals(0, round.deltas[sheet.players[0].id])
assertEquals(null, round.deltas[sheet.players[1].id])
assertEquals(0, sheet.total(sheet.players[0].id))
}
} }