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 -> "" +}