Scorekeeper: show an entered 0, tap a past score to edit it

Zeros are now stored so a typed 0 shows as 0 while a blank cell stays blank.
Tapping a score in an earlier round opens that round for correction with the
cursor in the tapped cell, instead of needing the row number first.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
James Wallace
2026-10-02 21:19:58 +00:00
co-authored by Claude Sonnet 5.5
parent 26ae3fecaa
commit b98d464954
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].
*
* A player absent from the map scored nothing, which is the same as zero — so
* a round where only one person scored carries one entry rather than a column
* of noughts.
* A player absent from the map had nothing entered, which counts as zero in the
* totals — so a round where only one person scored carries one entry rather
* than a column of noughts. A 0 that was typed is kept, so it can be shown.
*/
@Serializable
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
* still at the table, and only where somebody actually scored.
* A round holding only entries for players who are still at the table.
* Zeros stay: a typed 0 is different from a cell left blank.
*/
private fun roundOf(deltas: Map<Int, Int>): ScoreRound {
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. */
@@ -42,6 +42,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.input.pointer.pointerInput
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) }
// Which cell the ± button flips: the last one typed into.
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
// 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.
LaunchedEffect(sheet.rounds.size) { rows.animateScrollTo(rows.maxValue) }
@@ -264,15 +278,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
if (underCorrection) {
correcting = -1
} else {
correcting = index
correction.clear()
sheet.players.forEach { player ->
val points = round.delta(player.id)
if (points != 0) {
correction[player.id] = points.toString()
}
}
lastTyped = -1
startCorrection(index, -1)
}
},
)
@@ -282,13 +288,17 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
if (underCorrection) {
PointsField(
text = correction[player.id].orEmpty(),
autoFocus = focusPlayer == player.id,
onText = {
correction[player.id] = it
lastTyped = player.id
},
)
} 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()
}
lastTyped = -1
focusPlayer = -1
},
onDelete = {
onChange(sheet.withoutRound(correcting))
correcting = -1
correction.clear()
lastTyped = -1
focusPlayer = -1
},
onCancel = {
correcting = -1
correction.clear()
lastTyped = -1
focusPlayer = -1
},
)
}
@@ -583,11 +596,12 @@ private fun RoundLabel(text: String, selected: Boolean, onClick: () -> Unit) {
}
@Composable
private fun PointsCell(text: String, muted: Boolean = false) {
private fun PointsCell(text: String, muted: Boolean = false, onClick: (() -> Unit)? = null) {
Box(
modifier = Modifier
.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,
) {
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
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(
value = text,
onValueChange = { onText(sanitiseAmount(it)) },
@@ -616,7 +632,7 @@ private fun PointsField(text: String, onText: (String) -> Unit) {
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().focusRequester(focus),
decorationBox = { field ->
Box(
modifier = Modifier
@@ -758,14 +774,13 @@ private fun flipSign(text: String): String {
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> =
amounts.mapNotNull { (id, text) -> text.toIntOrNull()?.takeIf { it != 0 }?.let { id to it } }
.toMap()
amounts.mapNotNull { (id, text) -> text.toIntOrNull()?.let { id to it } }.toMap()
/** Nothing scored shows as an empty cell rather than a nought in every column. */
private fun signed(points: Int): String = when {
/** Not entered shows as an empty cell; an entered 0 shows as 0. */
private fun signed(points: Int?): String = when {
points == null -> ""
points > 0 -> "+$points"
points < 0 -> points.toString()
else -> ""
else -> points.toString()
}
@@ -182,4 +182,13 @@ class ScoreSheetTest {
assertEquals(0, sheet.totals()[sheet.players[0].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))
}
}