diff --git a/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt b/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt index 812f484..adb109f 100644 --- a/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt +++ b/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt @@ -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 = 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): 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. */ diff --git a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt index ef756bc..e9e276f 100644 --- a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt @@ -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): Map = - 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() } diff --git a/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt b/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt index 9454760..32e6e71 100644 --- a/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt +++ b/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt @@ -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)) + } }