From 97386aa94259b54885d291a5382574966014a67b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 03:53:00 +0000 Subject: [PATCH] Lay the scorekeeper out as a score sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same sheet, drawn as the paper it replaces: a column per player with their name at the head, a row per round running down the page, and the running total ruled off at the foot. The bottom row is always the one being filled in. Finishing it writes it down and opens a fresh empty row above the totals, so the place to type is always in the same spot. The head and the totals sit outside the scrolling rounds and share one horizontal scroll state with them, which is what keeps a column and the name over it from ever coming apart — every row is built by the same composable and is therefore exactly the same width, which is what lets them share it. Reordering moves to where a spreadsheet puts it: drag a name sideways and the whole column travels with it. Tapping a name renames it or removes that player, tapping a round number down the left edge opens that row for correction, and a + column at the right adds a player. The mode toggle and the round dialog are gone with them. Points still go in as typed text until the row is finished, so a half-typed minus is not a number yet. The ± button flips the last number typed: not every keyboard offers a minus on its number pad, and taking points away has to work on all of them. adjustResize is now stated in the manifest rather than left to the system to resolve. This screen types into the bottom row with the totals and the finish button below it, and they have to stay above the keyboard rather than being panned off the screen. The model is untouched: ScoreSheet and its tests did not need to change for any of this, which is the point of it having no Android in it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud --- README.md | 42 +- app/src/main/AndroidManifest.xml | 9 +- .../games/ui/ScorekeeperScreen.kt | 937 ++++++++++-------- 3 files changed, 573 insertions(+), 415 deletions(-) diff --git a/README.md b/README.md index e475e13..4d185f8 100644 --- a/README.md +++ b/README.md @@ -44,21 +44,37 @@ them. ## Scorekeeper **Scorekeeper** on the main menu is a pencil for games the app does not deal — -darts, cribbage, whatever is actually on the table. Say how many are playing, -name them, and write down what each of them scored every round; the running -tally sits at the foot of the screen, where the one number anybody looks up is -always visible. +darts, cribbage, whatever is actually on the table. It is laid out as the paper +it replaces: a column per player with their name at the head, a row per round +running down the page, and the running total ruled off at the foot. -Players can be renamed, added, removed or **dragged into a different order** at -any point in a game. That works because points are recorded against a player's -id rather than their position, so reordering the sheet moves the columns and -nothing else. Ids are handed out once and never reused: somebody sitting down -where somebody else got up starts at nought instead of inheriting a stranger's -score. Removing a player does take their points with them — a score nobody can -see is a score nobody can correct. +``` + Alice Bob Carol + 1 +3 −1 +2 + 2 +5 +7 • + 3 [ ] [ ] [ ] ← the row being filled in + Σ 8 6 2 +``` -Rounds are not written in ink. Tapping one reopens it for correction or lets it -be struck out, and the totals follow. +The bottom row is always the one being filled in. Finish it and it is written +down, and a fresh empty row opens above the totals for the next round. The +totals and that button stay put while the rounds scroll under them, so the +tally is never off-screen. + +Tap a column head to name it or remove that player; **drag it sideways to move +the column**, and the whole column travels with the name. That works because +points are recorded against a player's id rather than their position, so +reordering the sheet moves the columns and nothing else. Ids are handed out +once and never reused: somebody sitting down where somebody else got up starts +at nought instead of inheriting a stranger's score. Removing a player does take +their points with them — a score nobody can see is a score nobody can correct. + +Rounds are not written in ink. Tapping a round number down the left edge opens +that row for correction or lets it be struck out, and the totals follow. + +Points are taken away by typing a minus, or with the **±** button, which flips +the last number typed. Not every keyboard offers a minus on its number pad, and +subtracting has to work on all of them. The sheet is saved as it is edited, so leaving the app or taking a phone call does not lose the game. It is kept in its own DataStore rather than with the diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 74eb70f..1e167ba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,11 +22,18 @@ + instead of tearing the activity down mid-hand. + + adjustResize is stated rather than left to the system to resolve: + the scorekeeper types into the bottom row of its sheet, and the + totals and the button that finishes the round sit below that. The + window has to shrink so they stay above the keyboard rather than + being panned off the screen. --> 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 5f38295..ef756bc 100644 --- a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt @@ -1,5 +1,9 @@ package org.prolibertate.games.ui +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -8,24 +12,21 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -37,15 +38,18 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateMap 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.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import kotlinx.coroutines.flow.first @@ -56,12 +60,12 @@ import org.prolibertate.games.score.ScorekeeperRepository import kotlin.math.roundToInt /** - * A pencil for games the app does not play. + * A pencil for games the app does not deal, laid out as the paper it replaces: + * a column per player with their name at the head, a row per round running down + * the page, and the running total ruled off at the foot. * - * Name the people at the table, drag them into the order they are sitting in, - * then write down what each of them scored every round. The running tally sits - * at the foot of the screen where it can always be seen, because that is the - * one number anybody actually looks up. + * The bottom row is always the one being filled in. Finish it and it is written + * down, and a fresh empty row opens above the totals for the next round. */ @Composable fun ScorekeeperScreen( @@ -72,8 +76,7 @@ fun ScorekeeperScreen( // Read once, then held here and written through. Reading the sheet back out // of storage on every change would put a DataStore round trip between a - // keystroke and the letter appearing, which is how a name field loses - // characters. + // keystroke and the digit appearing, which is how a field loses characters. var loaded by remember { mutableStateOf(false) } var sheet by remember { mutableStateOf(ScoreSheet()) } LaunchedEffect(Unit) { @@ -86,7 +89,6 @@ fun ScorekeeperScreen( scope.launch { repository.save(next) } } - var editingPlayers by remember { mutableStateOf(false) } var confirmNewGame by remember { mutableStateOf(false) } ScreenScaffold( @@ -103,19 +105,9 @@ fun ScorekeeperScreen( if (!loaded) { Box(modifier = modifier) } else if (!sheet.started) { - SetupPane(modifier = modifier) { count -> - update(ScoreSheet.of(count)) - // Straight into naming them: a fresh sheet is a row of blanks. - editingPlayers = true - } + SetupPane(modifier = modifier) { count -> update(ScoreSheet.of(count)) } } else { - Scoreboard( - modifier = modifier, - sheet = sheet, - editingPlayers = editingPlayers, - onEditPlayers = { editingPlayers = it }, - onChange = update, - ) + Sheet(modifier = modifier, sheet = sheet, onChange = update) } } @@ -127,7 +119,6 @@ fun ScorekeeperScreen( confirmButton = { TextButton(onClick = { confirmNewGame = false - editingPlayers = false update(ScoreSheet()) }) { Text("Rub it out") } }, @@ -153,8 +144,8 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) { ) { Text( text = "Keep score for a game the app does not deal — darts, cribbage, " + - "whatever is on the table. Add or take away points each round and the " + - "tally is kept for you.", + "whatever is on the table. A column each, a row per round, and the " + + "tally kept for you at the foot.", style = MaterialTheme.typography.bodyMedium, ) Text("How many are playing?", style = MaterialTheme.typography.labelLarge) @@ -171,8 +162,9 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) { } } Text( - text = "Names come next, and players can be added, removed or dragged into " + - "a different order at any point without losing anybody's score.", + text = "Names come next: tap a column head to name it. Players can be " + + "added, removed or dragged into a different order at any point " + + "without losing anybody's score.", style = MaterialTheme.typography.bodySmall, ) PrimaryAction(text = "Start scoring") { onStart(count) } @@ -180,446 +172,579 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) { } // --------------------------------------------------------------------------- -// The sheet proper +// The sheet: a column per player, a row per round, totals ruled off at the foot // --------------------------------------------------------------------------- @Composable -private fun Scoreboard( - modifier: Modifier, - sheet: ScoreSheet, - editingPlayers: Boolean, - onEditPlayers: (Boolean) -> Unit, - onChange: (ScoreSheet) -> Unit, -) { - // What is being typed into the round that has not been written down yet, - // keyed by player id so it survives the players being reordered. +private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet) -> Unit) { + // One scroll state shared by the head, the rows and the totals, so all three + // move together: a column and the name over it must never come apart. Every + // row is built the same way and is therefore exactly the same width, which + // is what lets them share it. + val columns = rememberScrollState() + val rows = rememberScrollState() + + // The row being filled in, and — when a round is being corrected instead — + // the one under correction. Points are held as the text that was typed + // until the row is finished, so a half-typed "-" is not a number yet. val pending = remember { mutableStateMapOf() } - var editingRound by remember { mutableStateOf(-1) } + val correction = remember { mutableStateMapOf() } + var correcting by remember { mutableStateOf(-1) } + var renaming by remember { mutableStateOf(-1) } + // Which cell the ± button flips: the last one typed into. + var lastTyped by remember { mutableStateOf(-1) } + + val editing = if (correcting >= 0) correction else pending + + // Keep the row being filled in in view when a round is written down. + LaunchedEffect(sheet.rounds.size) { rows.animateScrollTo(rows.maxValue) } + + val drag = rememberColumnDrag(sheet = sheet, onChange = onChange) Column(modifier = modifier) { - Column( - modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - SectionHeader( - title = if (editingPlayers) "Players" else "Round ${sheet.rounds.size + 1}", - actionLabel = if (editingPlayers) "Done" else "Players", - onAction = { onEditPlayers(!editingPlayers) }, - ) + Text( + text = "Tap a name to rename it, or drag it to move the column.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 6.dp), + ) - if (editingPlayers) { - PlayerEditor(sheet = sheet, onChange = onChange) - } else { - RoundEntry( - sheet = sheet, - amounts = pending, - onRecord = { - onChange(sheet.withRound(amountsAsPoints(pending))) - pending.clear() - }, + // The head: names, and a column to add another player. + GridRow( + sheet = sheet, + scroll = columns, + height = HEAD_HEIGHT, + drag = drag, + leading = { + HeadCell(text = "", modifier = Modifier.width(ROUND_COLUMN).height(HEAD_HEIGHT)) + }, + trailing = { + AddColumnCell( + enabled = sheet.players.size < ScoreSheet.MAX_PLAYERS, + onClick = { onChange(sheet.withPlayerAdded()) }, ) - if (sheet.rounds.isNotEmpty()) { - Text( - text = "Rounds so far", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 8.dp), - ) - // Newest first: the round just written down is the one most - // likely to need correcting. - for (index in sheet.rounds.indices.reversed()) { - RoundRow( - sheet = sheet, - index = index, - onClick = { editingRound = index }, + }, + ) { index, player -> + HeadCell( + text = sheet.displayName(player), + dragging = index == drag.index, + modifier = Modifier + .fillMaxSize() + .clickable { renaming = player.id } + .pointerInput(player.id) { + detectDragGestures( + onDragStart = { drag.start(player.id) }, + onDragEnd = { drag.end() }, + onDragCancel = { drag.cancel() }, + onDrag = { change, delta -> + // Consumed here so the sheet does not scroll + // sideways under the finger as well. + change.consume() + drag.move(delta.x) + }, + ) + }, + ) + } + + // The rounds, and the row being filled in at the bottom of them. + Box(modifier = Modifier.weight(1f).verticalScroll(rows)) { + Column(modifier = Modifier.horizontalScroll(columns)) { + sheet.rounds.forEachIndexed { index, round -> + val underCorrection = index == correcting + GridRow( + sheet = sheet, + scroll = null, + height = ROW_HEIGHT, + drag = drag, + leading = { + RoundLabel( + text = "${index + 1}", + selected = underCorrection, + onClick = { + 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 + } + }, + ) + }, + trailing = { EmptyCell(width = ADD_COLUMN, height = ROW_HEIGHT) }, + ) { _, player -> + if (underCorrection) { + PointsField( + text = correction[player.id].orEmpty(), + onText = { + correction[player.id] = it + lastTyped = player.id + }, + ) + } else { + PointsCell(text = signed(round.delta(player.id))) + } + } + } + + // Always the last row: what has not been written down yet. + GridRow( + sheet = sheet, + scroll = null, + height = ROW_HEIGHT, + drag = drag, + leading = { + RoundLabel( + text = "${sheet.rounds.size + 1}", + selected = correcting < 0, + onClick = { correcting = -1 }, + ) + }, + trailing = { EmptyCell(width = ADD_COLUMN, height = ROW_HEIGHT) }, + ) { _, player -> + if (correcting >= 0) { + // A round is being corrected above; what is typed here + // is kept, but only one row is edited at a time. + PointsCell(text = pending[player.id].orEmpty(), muted = true) + } else { + PointsField( + text = pending[player.id].orEmpty(), + onText = { + pending[player.id] = it + lastTyped = player.id + }, ) } } } } - TotalsBar(sheet = sheet) - } - - if (editingRound in sheet.rounds.indices) { - RoundEditorDialog( + // Ruled off at the foot, where the one number anybody looks up lives. + GridRow( sheet = sheet, - index = editingRound, - onDismiss = { editingRound = -1 }, - onSave = { amounts -> - onChange(sheet.withRoundAt(editingRound, amounts)) - editingRound = -1 + scroll = columns, + height = TOTAL_HEIGHT, + drag = drag, + leading = { + HeadCell( + text = "Σ", + modifier = Modifier.width(ROUND_COLUMN).height(TOTAL_HEIGHT), + ) + }, + trailing = { EmptyCell(width = ADD_COLUMN, height = TOTAL_HEIGHT) }, + ) { _, player -> + TotalCell(total = sheet.total(player.id)) + } + + ActionBar( + roundNumber = if (correcting >= 0) correcting + 1 else sheet.rounds.size + 1, + correcting = correcting >= 0, + canFinish = amountsAsPoints(editing).isNotEmpty(), + canFlip = editing[lastTyped].orEmpty().toIntOrNull() != null, + onFlip = { editing[lastTyped] = flipSign(editing[lastTyped].orEmpty()) }, + onFinish = { + if (correcting >= 0) { + onChange(sheet.withRoundAt(correcting, amountsAsPoints(correction))) + correcting = -1 + correction.clear() + } else { + onChange(sheet.withRound(amountsAsPoints(pending))) + pending.clear() + } + lastTyped = -1 }, onDelete = { - onChange(sheet.withoutRound(editingRound)) - editingRound = -1 + onChange(sheet.withoutRound(correcting)) + correcting = -1 + correction.clear() + lastTyped = -1 + }, + onCancel = { + correcting = -1 + correction.clear() + lastTyped = -1 }, ) } -} -@Composable -private fun SectionHeader(title: String, actionLabel: String, onAction: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + val renamed = sheet.players.firstOrNull { it.id == renaming } + if (renamed != null) { + RenameDialog( + sheet = sheet, + player = renamed, + onName = { onChange(sheet.renamed(renamed.id, it)) }, + onRemove = { + onChange(sheet.withPlayerRemoved(renamed.id)) + renaming = -1 + }, + onDismiss = { renaming = -1 }, ) - TextButton(onClick = onAction) { Text(actionLabel) } } } // --------------------------------------------------------------------------- -// Players: naming, adding, removing, and dragging into order +// Dragging a column into a different place // --------------------------------------------------------------------------- /** - * One row per player, dragged by the handle on the left. + * A column being dragged along the head of the sheet. * - * Every row is exactly [PLAYER_ROW_HEIGHT] tall and they are stacked with no - * gap, so a drag of one row height is a move of exactly one place and the - * arithmetic needs no measurement of individual items. + * The sheet is deliberately left alone until the finger lifts: the columns move + * on screen as the drag passes them, but the positions the gesture is working + * from cannot shift underneath it. The sheet and the callback are read through + * [rememberUpdatedState] because the gesture handlers outlive the composition + * that created them — without that, a drag would be applied to whatever the + * sheet looked like when the head was first drawn, throwing away a name typed + * since. */ @Composable -private fun PlayerEditor(sheet: ScoreSheet, onChange: (ScoreSheet) -> Unit) { - val rowHeightPx = with(LocalDensity.current) { PLAYER_ROW_HEIGHT.toPx() } - - // Read inside the gesture handlers, which outlive the composition that - // created them: without this a drag would be applied to whatever the sheet - // looked like when the row was first drawn, throwing away anything typed - // since. +private fun rememberColumnDrag(sheet: ScoreSheet, onChange: (ScoreSheet) -> Unit): ColumnDrag { + val widthPx = with(LocalDensity.current) { PLAYER_COLUMN.toPx() } val currentSheet by rememberUpdatedState(sheet) val commit by rememberUpdatedState(onChange) + return remember { ColumnDrag(widthPx, { currentSheet }, { commit(it) }) } +} - // The sheet is deliberately left alone until the finger lifts. Rows move on - // screen as the drag passes them, but the positions the gesture is working - // from cannot shift underneath it. - var draggingIndex by remember { mutableStateOf(-1) } - var dragOffset by remember { mutableStateOf(0f) } +private class ColumnDrag( + private val widthPx: Float, + private val sheet: () -> ScoreSheet, + private val commit: (ScoreSheet) -> Unit, +) { + /** Which column is under the finger, or -1 when nothing is being dragged. */ + var index by mutableStateOf(-1) + private set + private var offset by mutableStateOf(0f) - fun landingIndex(): Int { - val last = currentSheet.players.lastIndex - if (draggingIndex < 0 || last < 0) return -1 - val moved = draggingIndex + (dragOffset / rowHeightPx).roundToInt() - return moved.coerceIn(0, last) + private fun landing(): Int { + val last = sheet().players.lastIndex + if (index < 0 || last < 0) return -1 + return (index + (offset / widthPx).roundToInt()).coerceIn(0, last) } - val target = landingIndex() + fun start(playerId: Int) { + index = sheet().players.indexOfFirst { it.id == playerId } + offset = 0f + } - Column(modifier = Modifier.fillMaxWidth()) { - sheet.players.forEachIndexed { index, player -> - val dragging = index == draggingIndex - // The dragged row follows the finger; the rows it has passed step - // aside by one place to show where it would land. - val shift = when { - draggingIndex < 0 -> 0f - dragging -> dragOffset - draggingIndex < target && index in (draggingIndex + 1)..target -> -rowHeightPx - draggingIndex > target && index in target until draggingIndex -> rowHeightPx - else -> 0f - } + fun move(dx: Float) { + offset += dx + } - PlayerRow( - sheet = sheet, - player = player, - dragging = dragging, - shift = shift, - onName = { commit(currentSheet.renamed(player.id, it)) }, - onRemove = { commit(currentSheet.withPlayerRemoved(player.id)) }, - onDragStart = { - draggingIndex = currentSheet.players.indexOfFirst { it.id == player.id } - dragOffset = 0f - }, - onDrag = { dy -> dragOffset += dy }, - onDragEnd = { - val from = draggingIndex - val to = landingIndex() - draggingIndex = -1 - dragOffset = 0f - if (from >= 0 && to >= 0) commit(currentSheet.moved(from, to)) - }, - onDragCancel = { - draggingIndex = -1 - dragOffset = 0f - }, - ) + fun end() { + val from = index + val to = landing() + index = -1 + offset = 0f + if (from >= 0 && to >= 0) commit(sheet().moved(from, to)) + } + + fun cancel() { + index = -1 + offset = 0f + } + + /** + * How far a column sits from where it belongs. The dragged one follows the + * finger; the ones it has passed step aside to show where it would land. + */ + fun shiftOf(column: Int): Float { + if (index < 0) return 0f + if (column == index) return offset + val to = landing() + return when { + index < to && column in (index + 1)..to -> -widthPx + index > to && column in to until index -> widthPx + else -> 0f } - - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedButton( - onClick = { onChange(sheet.withPlayerAdded()) }, - enabled = sheet.players.size < ScoreSheet.MAX_PLAYERS, - ) { Text("Add a player") } - } - Text( - text = "Drag the ≡ handle to change the order. Removing a player takes " + - "their score with them.", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 4.dp), - ) } } +// --------------------------------------------------------------------------- +// The grid itself +// --------------------------------------------------------------------------- + +/** + * One line of the sheet: a leading cell, a cell per player, a trailing cell. + * + * Head, rounds and totals all come through here, which is what guarantees they + * are the same width — they share a scroll state, and a row that measured + * differently would slide out of step with the rest. + * + * [scroll] is null for rows that already sit inside a scrolling column. + */ @Composable -private fun PlayerRow( +private fun GridRow( sheet: ScoreSheet, - player: ScorePlayer, - dragging: Boolean, - shift: Float, - onName: (String) -> Unit, - onRemove: () -> Unit, - onDragStart: () -> Unit, - onDrag: (Float) -> Unit, - onDragEnd: () -> Unit, - onDragCancel: () -> Unit, + scroll: ScrollState?, + height: Dp, + drag: ColumnDrag, + leading: @Composable () -> Unit, + trailing: @Composable () -> Unit, + cell: @Composable (index: Int, player: ScorePlayer) -> Unit, ) { Row( modifier = Modifier - .fillMaxWidth() - .height(PLAYER_ROW_HEIGHT) - // Above its neighbours while it is being dragged, so it passes over - // them rather than under. - .zIndex(if (dragging) 1f else 0f) - .graphicsLayer { translationY = shift }, - verticalAlignment = Alignment.CenterVertically, + .height(height) + .then(if (scroll != null) Modifier.horizontalScroll(scroll) else Modifier), ) { - Box( - modifier = Modifier - .width(40.dp) - .fillMaxHeight() - .pointerInput(player.id) { - detectDragGestures( - onDragStart = { onDragStart() }, - onDragEnd = { onDragEnd() }, - onDragCancel = { onDragCancel() }, - onDrag = { change, delta -> - // Consumed here so the surrounding scroll does not - // also take the gesture and run away with the page. - change.consume() - onDrag(delta.y) - }, - ) - }, - contentAlignment = Alignment.Center, - ) { - Text( - text = "≡", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - OutlinedTextField( - value = player.name, - onValueChange = onName, - placeholder = { Text(sheet.displayName(player)) }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - - TextButton( - onClick = onRemove, - enabled = sheet.players.size > ScoreSheet.MIN_PLAYERS, - ) { Text("✕") } - } -} - -// --------------------------------------------------------------------------- -// Scoring a round -// --------------------------------------------------------------------------- - -@Composable -private fun RoundEntry( - sheet: ScoreSheet, - amounts: SnapshotStateMap, - onRecord: () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = "What did each player score? Take points away with the minus, or type " + - "a negative number.", - style = MaterialTheme.typography.bodySmall, - ) - sheet.players.forEach { player -> - AmountRow(name = sheet.displayName(player), id = player.id, amounts = amounts) - } - PrimaryAction( - text = "Record round ${sheet.rounds.size + 1}", - // A round where nobody scored anything is not worth a row. - enabled = amountsAsPoints(amounts).isNotEmpty(), - onClick = onRecord, - ) - } -} - -/** Name, a minus, a number, a plus. The same row scores a round and edits one. */ -@Composable -private fun AmountRow(name: String, id: Int, amounts: SnapshotStateMap) { - val typed = amounts[id].orEmpty() - - fun step(by: Int) { - val next = (typed.toIntOrNull() ?: 0) + by - amounts[id] = if (next == 0) "" else next.toString() - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = name, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = { step(-1) }) { Text("−") } - OutlinedTextField( - value = typed, - onValueChange = { amounts[id] = sanitiseAmount(it) }, - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - placeholder = { Text("0", textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) }, - modifier = Modifier.width(96.dp), - ) - TextButton(onClick = { step(1) }) { Text("+") } - } -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -private fun RoundRow(sheet: ScoreSheet, index: Int, onClick: () -> Unit) { - val round = sheet.rounds[index] - Card( - onClick = onClick, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.padding(10.dp)) { - Text( - text = "Round ${index + 1}", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.padding(top = 2.dp), + leading() + sheet.players.forEachIndexed { index, player -> + Box( + modifier = Modifier + .width(PLAYER_COLUMN) + .height(height) + // Over its neighbours while it is being dragged, so it + // passes across them rather than under. + .zIndex(if (index == drag.index) 1f else 0f) + .graphicsLayer { translationX = drag.shiftOf(index) }, ) { - sheet.players.forEach { player -> + cell(index, player) + } + } + trailing() + } +} + +@Composable +private fun HeadCell(text: String, modifier: Modifier = Modifier, dragging: Boolean = false) { + Box( + modifier = modifier + .background( + if (dragging) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ) + .border(Dp.Hairline, MaterialTheme.colorScheme.outline) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun AddColumnCell(enabled: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .width(ADD_COLUMN) + .height(HEAD_HEIGHT) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border(Dp.Hairline, MaterialTheme.colorScheme.outline) + .then(if (enabled) Modifier.clickable { onClick() } else Modifier), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+", + style = MaterialTheme.typography.titleLarge, + color = if (enabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + }, + ) + } +} + +/** The round number down the left edge. Tapping it opens that round for correction. */ +@Composable +private fun RoundLabel(text: String, selected: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .width(ROUND_COLUMN) + .height(ROW_HEIGHT) + .background( + if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ) + .border(Dp.Hairline, MaterialTheme.colorScheme.outline) + .clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + Text(text = text, style = MaterialTheme.typography.labelMedium) + } +} + +@Composable +private fun PointsCell(text: String, muted: Boolean = false) { + Box( + modifier = Modifier + .fillMaxSize() + .border(Dp.Hairline, MaterialTheme.colorScheme.outlineVariant), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = if (muted) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + ) + } +} + +/** A cell being typed into. Blank means nothing scored, which is the same as nought. */ +@Composable +private fun PointsField(text: String, onText: (String) -> Unit) { + BasicTextField( + value = text, + onValueChange = { onText(sanitiseAmount(it)) }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxSize(), + decorationBox = { field -> + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.primary) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center, + ) { + if (text.isEmpty()) { Text( - text = "${sheet.displayName(player)} ${signed(round.delta(player.id))}", - style = MaterialTheme.typography.bodyMedium, + text = "0", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), ) } + field() + } + }, + ) +} + +@Composable +private fun TotalCell(total: Int) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant) + .border(Dp.Hairline, MaterialTheme.colorScheme.outline), + contentAlignment = Alignment.Center, + ) { + Text( + text = total.toString(), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } +} + +@Composable +private fun EmptyCell(width: Dp, height: Dp) { + Box(modifier = Modifier.width(width).height(height).background(Color.Transparent)) +} + +// --------------------------------------------------------------------------- +// Below the sheet +// --------------------------------------------------------------------------- + +@Composable +private fun ActionBar( + roundNumber: Int, + correcting: Boolean, + canFinish: Boolean, + canFlip: Boolean, + onFlip: () -> Unit, + onFinish: () -> Unit, + onDelete: () -> Unit, + onCancel: () -> Unit, +) { + Column(modifier = Modifier.padding(top = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Not every keyboard offers a minus on its number pad, and taking + // points away has to work on all of them. + OutlinedButton(onClick = onFlip, enabled = canFlip) { Text("±") } + Button( + onClick = onFinish, + enabled = canFinish, + modifier = Modifier.weight(1f), + ) { + Text(if (correcting) "Save round $roundNumber" else "Finish round $roundNumber") + } + } + if (correcting) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onCancel) { Text("Cancel") } + TextButton(onClick = onDelete) { Text("Delete round $roundNumber") } } } } } @Composable -private fun RoundEditorDialog( +private fun RenameDialog( sheet: ScoreSheet, - index: Int, + player: ScorePlayer, + onName: (String) -> Unit, + onRemove: () -> Unit, onDismiss: () -> Unit, - onSave: (Map) -> Unit, - onDelete: () -> Unit, ) { - val round = sheet.rounds[index] - val amounts = remember(index) { - mutableStateMapOf().apply { - sheet.players.forEach { player -> - val delta = round.delta(player.id) - if (delta != 0) put(player.id, delta.toString()) - } - } - } - AlertDialog( onDismissRequest = onDismiss, - title = { Text("Round ${index + 1}") }, + title = { Text("Name this column") }, text = { - Column( - // Twelve players will not fit on a phone otherwise. - modifier = Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - sheet.players.forEach { player -> - AmountRow( - name = sheet.displayName(player), - id = player.id, - amounts = amounts, - ) - } - TextButton(onClick = onDelete) { Text("Delete this round") } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = player.name, + onValueChange = onName, + placeholder = { Text(sheet.displayName(player)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + TextButton( + onClick = onRemove, + // The last two columns stay: a sheet needs somebody on it. + enabled = sheet.players.size > ScoreSheet.MIN_PLAYERS, + ) { Text("Remove this player and their score") } } }, - confirmButton = { - TextButton(onClick = { onSave(amountsAsPoints(amounts)) }) { Text("Save") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } }, ) } -// --------------------------------------------------------------------------- -// The tally // --------------------------------------------------------------------------- -@Composable -private fun TotalsBar(sheet: ScoreSheet) { - Surface( - tonalElevation = 3.dp, - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.padding(vertical = 8.dp)) { - Text( - text = when (sheet.rounds.size) { - 0 -> "No rounds scored yet" - 1 -> "After 1 round" - else -> "After ${sheet.rounds.size} rounds" - }, - style = MaterialTheme.typography.labelSmall, - ) - // Scrolls sideways rather than squeezing: a twelve-handed game has - // more columns than a phone has width. - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(top = 4.dp), - horizontalArrangement = Arrangement.spacedBy(20.dp), - ) { - sheet.players.forEach { player -> - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = sheet.displayName(player), - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - ) - Text( - text = sheet.total(player.id).toString(), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) - } - } - } - } - } -} - -// --------------------------------------------------------------------------- - -private val PLAYER_ROW_HEIGHT = 64.dp +private val ROUND_COLUMN = 40.dp +private val PLAYER_COLUMN = 88.dp +private val ADD_COLUMN = 44.dp +private val HEAD_HEIGHT = 48.dp +private val ROW_HEIGHT = 44.dp +private val TOTAL_HEIGHT = 56.dp /** Keeps a points field to a number, optionally negative, as it is typed. */ private fun sanitiseAmount(raw: String): String { @@ -628,9 +753,19 @@ private fun sanitiseAmount(raw: String): String { return if (negative) "-$digits" else digits } -/** The typed fields as points. Blanks, a lone minus and zeroes all drop out. */ +private fun flipSign(text: String): String { + val value = text.toIntOrNull() ?: return text + return (-value).toString() +} + +/** The typed cells as points. Blanks, a lone minus and noughts all drop out. */ private fun amountsAsPoints(amounts: Map): Map = amounts.mapNotNull { (id, text) -> text.toIntOrNull()?.takeIf { it != 0 }?.let { id to it } } .toMap() -private fun signed(points: Int): String = if (points > 0) "+$points" else points.toString() +/** Nothing scored shows as an empty cell rather than a nought in every column. */ +private fun signed(points: Int): String = when { + points > 0 -> "+$points" + points < 0 -> points.toString() + else -> "" +}