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 adb109f..77371ee 100644 --- a/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt +++ b/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt @@ -30,6 +30,18 @@ data class ScoreRound(val deltas: Map = emptyMap()) { fun delta(playerId: Int): Int = deltas[playerId] ?: 0 } +/** + * How points get onto a sheet. + * + * [ROUNDS] fills in a row per round, with an entry for whoever scored in it. + * [TALLY] has no rounds: any player scores at any moment, one entry at a time. + * Both keep their entries in [ScoreSheet.rounds] — a tally entry is just a row + * with a single player in it — so totals, editing, undo and sharing work the + * same for either, and only the way the sheet is drawn differs. + */ +@Serializable +enum class ScoringMode { ROUNDS, TALLY } + /** * A pencil-and-paper score sheet: some players, and a row of points per round. * @@ -44,6 +56,8 @@ data class ScoreSheet( val rounds: List = emptyList(), /** The next id to hand out. Only ever counts up; see [ScorePlayer.id]. */ val nextPlayerId: Int = 1, + /** Chosen when the sheet is started and fixed until it is rubbed out. */ + val mode: ScoringMode = ScoringMode.ROUNDS, ) { /** A sheet with nobody on it has not been set up yet. */ val started: Boolean get() = players.isNotEmpty() @@ -100,6 +114,9 @@ data class ScoreSheet( return copy(players = reordered) } + /** One tally entry: [points] to a single player, whenever they scored. */ + fun withTally(playerId: Int, points: Int): ScoreSheet = withRound(mapOf(playerId to points)) + fun withRound(deltas: Map): ScoreSheet = copy(rounds = rounds + roundOf(deltas)) /** Corrects a round already written down. Out-of-range indices do nothing. */ @@ -134,6 +151,7 @@ data class ScoreSheet( const val DEFAULT_PLAYERS = 4 /** A fresh sheet seated for [count] players, none of them named yet. */ - fun of(count: Int): ScoreSheet = ScoreSheet().withPlayerCount(count) + fun of(count: Int, mode: ScoringMode = ScoringMode.ROUNDS): ScoreSheet = + ScoreSheet(mode = mode).withPlayerCount(count) } } diff --git a/app/src/main/java/org/prolibertate/games/score/SheetOp.kt b/app/src/main/java/org/prolibertate/games/score/SheetOp.kt index d746417..102e8d6 100644 --- a/app/src/main/java/org/prolibertate/games/score/SheetOp.kt +++ b/app/src/main/java/org/prolibertate/games/score/SheetOp.kt @@ -28,11 +28,11 @@ sealed interface SheetOp { fun applyTo(sheet: ScoreSheet): ScoreSheet } -/** Sets a fresh sheet up for [count] players. */ +/** Sets a fresh sheet up for [count] players, scored by round or by tally. */ @Serializable @SerialName("start") -data class StartSheet(val count: Int) : SheetOp { - override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count) +data class StartSheet(val count: Int, val mode: ScoringMode = ScoringMode.ROUNDS) : SheetOp { + override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count, mode) } /** Rubs everything out. */ 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 ee5a203..99d7fcd 100644 --- a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt @@ -74,6 +74,7 @@ import org.prolibertate.games.score.RemovePlayer import org.prolibertate.games.score.RenamePlayer import org.prolibertate.games.score.ScorePlayer import org.prolibertate.games.score.ScoreSheet +import org.prolibertate.games.score.ScoringMode import org.prolibertate.games.score.ScorekeeperRepository import org.prolibertate.games.score.SheetOp import org.prolibertate.games.score.StartSheet @@ -217,7 +218,7 @@ fun ScorekeeperScreen( role == Role.GUEST -> Column(modifier = modifier) { ConnectionBanner(shared = shared, onSync = { session.sync() }) - Sheet(modifier = Modifier.weight(1f), sheet = sheet, onOp = change) + Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change) } joining && role == Role.NONE -> @@ -225,11 +226,11 @@ fun ScorekeeperScreen( !sheet.started -> SetupPane( modifier = modifier, - onStart = { count -> change(StartSheet(count)) }, + onStart = { count, mode -> change(StartSheet(count, mode)) }, onJoin = { joining = true }, ) - else -> Sheet(modifier = modifier, sheet = sheet, onOp = change) + else -> Scoring(modifier = modifier, sheet = sheet, onOp = change) } } @@ -286,8 +287,9 @@ fun ScorekeeperScreen( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () -> Unit) { +private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, onJoin: () -> Unit) { var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) } + var mode by remember { mutableStateOf(ScoringMode.ROUNDS) } Column( modifier = modifier.verticalScroll(rememberScrollState()), @@ -299,6 +301,28 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () -> "tally kept for you at the foot.", style = MaterialTheme.typography.bodyMedium, ) + Text("How is it scored?", style = MaterialTheme.typography.labelLarge) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = mode == ScoringMode.ROUNDS, + onClick = { mode = ScoringMode.ROUNDS }, + label = { Text("By round") }, + ) + FilterChip( + selected = mode == ScoringMode.TALLY, + onClick = { mode = ScoringMode.TALLY }, + label = { Text("Tally") }, + ) + } + Text( + text = if (mode == ScoringMode.ROUNDS) { + "Everyone's points are written down together, a row per round." + } else { + "Anybody can score at any moment: tap +1, or enter any amount, " + + "with no rounds to finish." + }, + style = MaterialTheme.typography.bodySmall, + ) Text("How many are playing?", style = MaterialTheme.typography.labelLarge) FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -318,7 +342,7 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () -> "without losing anybody's score.", style = MaterialTheme.typography.bodySmall, ) - PrimaryAction(text = "Start scoring") { onStart(count) } + PrimaryAction(text = "Start scoring") { onStart(count, mode) } OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) { Text("Join a shared sheet nearby") } @@ -329,6 +353,15 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () -> // The sheet: a column per player, a row per round, totals ruled off at the foot // --------------------------------------------------------------------------- +@Composable +private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) { + if (sheet.mode == ScoringMode.TALLY) { + TallySheet(modifier = modifier, sheet = sheet, onOp = onOp) + } else { + Sheet(modifier = modifier, sheet = sheet, onOp = onOp) + } +} + @Composable private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) { // One scroll state shared by the head, the rows and the totals, so all three @@ -562,6 +595,127 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit } } +// --------------------------------------------------------------------------- +// Tally: points scored by anybody, whenever +// --------------------------------------------------------------------------- + +/** + * A card per player with their running total and quick buttons, and the entries + * so far listed underneath, newest first. Each entry is a one-player row on the + * sheet, so tapping one in the list takes it back off. + */ +@Composable +private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) { + var renaming by remember { mutableStateOf(-1) } + var custom by remember { mutableStateOf(-1) } + + Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Tap a name to rename it. Tap an entry below to take it back.", + style = MaterialTheme.typography.bodySmall, + ) + sheet.players.forEach { player -> + Row( + modifier = Modifier + .fillMaxWidth() + .border(Dp.Hairline, MaterialTheme.colorScheme.outline) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = sheet.displayName(player), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).clickable { renaming = player.id }, + ) + Text( + text = sheet.total(player.id).toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(64.dp), + textAlign = TextAlign.Center, + ) + OutlinedButton(onClick = { onOp(AddRound(mapOf(player.id to -1))) }) { Text("−1") } + Button(onClick = { onOp(AddRound(mapOf(player.id to 1))) }) { Text("+1") } + TextButton(onClick = { custom = player.id }) { Text("…") } + } + } + OutlinedButton( + onClick = { onOp(AddPlayer) }, + enabled = sheet.players.size < ScoreSheet.MAX_PLAYERS, + modifier = Modifier.fillMaxWidth(), + ) { Text("Add a player") } + + Text("Entries", style = MaterialTheme.typography.labelLarge) + if (sheet.rounds.isEmpty()) { + Text("Nothing scored yet.", style = MaterialTheme.typography.bodySmall) + } + sheet.rounds.indices.reversed().forEach { index -> + val entry = sheet.rounds[index].deltas.entries.firstOrNull() + val who = entry?.let { e -> sheet.players.firstOrNull { it.id == e.key } } + if (entry != null && who != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onOp(DeleteRound(index)) } + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("${index + 1}. ${sheet.displayName(who)}", style = MaterialTheme.typography.bodyMedium) + Text(signed(entry.value), style = MaterialTheme.typography.bodyMedium) + } + } + } + } + + val renamed = sheet.players.firstOrNull { it.id == renaming } + if (renamed != null) { + RenameDialog( + sheet = sheet, + player = renamed, + onName = { onOp(RenamePlayer(renamed.id, it)) }, + onRemove = { + onOp(RemovePlayer(renamed.id)) + renaming = -1 + }, + onDismiss = { renaming = -1 }, + ) + } + + val scorer = sheet.players.firstOrNull { it.id == custom } + if (scorer != null) { + var amount by remember(scorer.id) { mutableStateOf("") } + AlertDialog( + onDismissRequest = { custom = -1 }, + title = { Text("Points for ${sheet.displayName(scorer)}") }, + text = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = amount, + onValueChange = { amount = sanitiseAmount(it) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + OutlinedButton(onClick = { amount = flipSign(amount) }, enabled = amount.toIntOrNull() != null) { Text("±") } + } + }, + confirmButton = { + TextButton( + onClick = { + amount.toIntOrNull()?.let { onOp(AddRound(mapOf(scorer.id to it))) } + custom = -1 + }, + enabled = amount.toIntOrNull() != null, + ) { Text("Add") } + }, + dismissButton = { TextButton(onClick = { custom = -1 }) { Text("Cancel") } }, + ) + } +} + // --------------------------------------------------------------------------- // Dragging a column into a different place // --------------------------------------------------------------------------- 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 32e6e71..cd6502b 100644 --- a/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt +++ b/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt @@ -191,4 +191,22 @@ class ScoreSheetTest { assertEquals(null, round.deltas[sheet.players[1].id]) assertEquals(0, sheet.total(sheet.players[0].id)) } + + @Test + fun `a sheet defaults to round scoring and tally is chosen at start`() { + assertEquals(ScoringMode.ROUNDS, ScoreSheet.of(3).mode) + assertEquals(ScoringMode.TALLY, StartSheet(3, ScoringMode.TALLY).applyTo(ScoreSheet()).mode) + assertEquals(ScoringMode.ROUNDS, StartSheet(3).applyTo(ScoreSheet()).mode) + } + + @Test + fun `tally entries add up per player and can be taken back`() { + val sheet = ScoreSheet.of(2, ScoringMode.TALLY) + val (a, b) = sheet.players.map { it.id } + val scored = sheet.withTally(a, 1).withTally(b, 5).withTally(a, 2).withTally(a, -1) + assertEquals(2, scored.total(a)) + assertEquals(5, scored.total(b)) + assertEquals(3, scored.withoutRound(3).total(a)) + assertEquals(ScoringMode.TALLY, scored.mode) + } }