diff --git a/README.md b/README.md index e088d63..e475e13 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,32 @@ Each client is then sent a state **redacted for its own seat**, so opponents' hands and the deck order are never transmitted to devices that should not see 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. + +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. + +Rounds are not written in ink. Tapping one reopens it for correction or lets it +be struck out, and the totals follow. + +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 +settings, because it is a game in progress rather than a preference. + +`score/ScoreSheet.kt` holds the whole model and, like `game/`, has no Android in +it: every rule above is a pure function and unit-tested on the JVM. + ## Settings - **Sound** on/off. @@ -247,6 +273,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested chess/ Chess model, FEN, move generation, search tayu/ Ta Yü tile geometry, river rules, scoring, AI net/ wire protocol, LAN transport, lobby, match driver +score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store settings/ DataStore-backed preferences update/ GitHub Releases OTA updater ui/ Compose screens diff --git a/app/src/main/java/org/prolibertate/games/MainActivity.kt b/app/src/main/java/org/prolibertate/games/MainActivity.kt index d8e2cb1..b4a7059 100644 --- a/app/src/main/java/org/prolibertate/games/MainActivity.kt +++ b/app/src/main/java/org/prolibertate/games/MainActivity.kt @@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.lifecycle.lifecycleScope import org.prolibertate.games.net.LobbyController +import org.prolibertate.games.score.ScorekeeperRepository import org.prolibertate.games.settings.SettingsRepository import org.prolibertate.games.ui.AppEnv import org.prolibertate.games.ui.AppRoot @@ -24,6 +25,7 @@ class MainActivity : ComponentActivity() { settingsRepository = SettingsRepository(applicationContext), updater = Updater(this), lobby = LobbyController(applicationContext, lifecycleScope), + scorekeeper = ScorekeeperRepository(applicationContext), peerId = stablePeerId(), ) diff --git a/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt b/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt new file mode 100644 index 0000000..812f484 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt @@ -0,0 +1,139 @@ +package org.prolibertate.games.score + +import kotlinx.serialization.Serializable + +/** + * One person on the score sheet. + * + * Points are recorded against [id] rather than against a position, which is + * what lets the players be dragged into a different order without their scores + * moving with them. Ids come from [ScoreSheet.nextPlayerId] and are never + * reused: somebody added where somebody else was removed must not inherit the + * rounds that were scored against them. + * + * [name] is stored exactly as typed, blank included, so clearing the field + * while renaming does not fight back. What to show for a blank name is a + * display concern and belongs to [ScoreSheet.displayName]. + */ +@Serializable +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. + */ +@Serializable +data class ScoreRound(val deltas: Map = emptyMap()) { + fun delta(playerId: Int): Int = deltas[playerId] ?: 0 +} + +/** + * A pencil-and-paper score sheet: some players, and a row of points per round. + * + * Every change returns a new sheet rather than mutating this one, so the screen + * can hand the result straight to state and persistence without worrying about + * which of them holds the live copy. Nothing here touches Android, for the same + * reason `game/` does not: it can then be unit-tested on the JVM. + */ +@Serializable +data class ScoreSheet( + val players: List = emptyList(), + val rounds: List = emptyList(), + /** The next id to hand out. Only ever counts up; see [ScorePlayer.id]. */ + val nextPlayerId: Int = 1, +) { + /** A sheet with nobody on it has not been set up yet. */ + val started: Boolean get() = players.isNotEmpty() + + /** What to call a player who has not been named: their seat at the table. */ + fun displayName(player: ScorePlayer): String { + val typed = player.name.trim() + if (typed.isNotEmpty()) return typed + val position = players.indexOfFirst { it.id == player.id } + return "Player ${if (position >= 0) position + 1 else player.id}" + } + + fun total(playerId: Int): Int = rounds.sumOf { it.delta(playerId) } + + fun totals(): Map = players.associate { it.id to total(it.id) } + + /** + * Grows or shrinks the table to [count], clamped to what a sheet can hold. + * + * Shrinking drops players from the end and takes their scores with them, + * because points recorded against somebody nobody can see are points that + * can never be corrected. + */ + fun withPlayerCount(count: Int): ScoreSheet { + val target = count.coerceIn(MIN_PLAYERS, MAX_PLAYERS) + if (target == players.size) return this + if (target < players.size) return copy(players = players.take(target)).pruned() + + var next = nextPlayerId + val added = List(target - players.size) { ScorePlayer(id = next++) } + return copy(players = players + added, nextPlayerId = next) + } + + fun withPlayerAdded(): ScoreSheet = withPlayerCount(players.size + 1) + + /** Removes one player by id. The last two cannot be removed. */ + fun withPlayerRemoved(playerId: Int): ScoreSheet { + if (players.size <= MIN_PLAYERS) return this + if (players.none { it.id == playerId }) return this + return copy(players = players.filterNot { it.id == playerId }).pruned() + } + + fun renamed(playerId: Int, name: String): ScoreSheet = copy( + players = players.map { if (it.id == playerId) it.copy(name = name) else it } + ) + + /** Moves the player at [from] to [to], which is what a drag amounts to. */ + fun moved(from: Int, to: Int): ScoreSheet { + if (from !in players.indices) return this + val target = to.coerceIn(0, players.size - 1) + if (from == target) return this + val reordered = players.toMutableList() + reordered.add(target, reordered.removeAt(from)) + return copy(players = reordered) + } + + fun withRound(deltas: Map): ScoreSheet = copy(rounds = rounds + roundOf(deltas)) + + /** Corrects a round already written down. Out-of-range indices do nothing. */ + fun withRoundAt(index: Int, deltas: Map): ScoreSheet { + if (index !in rounds.indices) return this + return copy(rounds = rounds.toMutableList().also { it[index] = roundOf(deltas) }) + } + + fun withoutRound(index: Int): ScoreSheet { + if (index !in rounds.indices) return this + return copy(rounds = rounds.filterIndexed { at, _ -> at != index }) + } + + /** + * A round holding only what is worth storing: entries for players who are + * still at the table, and only where somebody actually scored. + */ + private fun roundOf(deltas: Map): ScoreRound { + val ids = players.map { it.id }.toSet() + return ScoreRound(deltas.filterKeys { it in ids }.filterValues { it != 0 }) + } + + /** Drops points recorded against anybody no longer at the table. */ + private fun pruned(): ScoreSheet { + val ids = players.map { it.id }.toSet() + return copy(rounds = rounds.map { round -> ScoreRound(round.deltas.filterKeys { it in ids }) }) + } + + companion object { + const val MIN_PLAYERS = 2 + const val MAX_PLAYERS = 12 + 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) + } +} diff --git a/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt b/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt new file mode 100644 index 0000000..0394199 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt @@ -0,0 +1,51 @@ +package org.prolibertate.games.score + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +// Its own store rather than a corner of the settings one: a score sheet is +// session data with a shape that will keep changing, and it has no business +// being rewritten every time somebody moves the animation slider. +private val Context.scorekeeperDataStore: DataStore by preferencesDataStore( + name = "scorekeeper", +) + +/** + * Keeps the score sheet across the app being left, backgrounded or killed. + * + * A scorekeeper that forgets the tally because somebody took a phone call is + * worse than a pencil, so the sheet is written on every change and read back at + * the top of the screen. + */ +class ScorekeeperRepository(private val context: Context) { + + private object Keys { + val SHEET = stringPreferencesKey("sheet") + } + + // Unknown keys are ignored so a sheet written by a newer build can still be + // read by an older one — the alternative is losing a game in progress to a + // channel switch. + private val json = Json { + encodeDefaults = true + ignoreUnknownKeys = true + } + + val sheet: Flow = context.scorekeeperDataStore.data.map { prefs -> + prefs[Keys.SHEET] + ?.let { stored -> runCatching { json.decodeFromString(stored) }.getOrNull() } + ?: ScoreSheet() + } + + suspend fun save(sheet: ScoreSheet) { + context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index fd6f842..c82fa4e 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -5,7 +5,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -39,6 +40,7 @@ import org.prolibertate.games.game.GameCategory import org.prolibertate.games.game.GameDescriptor import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.net.LobbyController +import org.prolibertate.games.score.ScorekeeperRepository import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.SettingsRepository import org.prolibertate.games.update.UpdateChannel @@ -49,6 +51,7 @@ class AppEnv( val settingsRepository: SettingsRepository, val updater: Updater, val lobby: LobbyController, + val scorekeeper: ScorekeeperRepository, val peerId: String, ) @@ -62,6 +65,7 @@ class AppEnv( sealed interface Route { data object Menu : Route data object Settings : Route + data object Scorekeeper : Route data class Setup(val gameId: String) : Route data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route data class Play( @@ -145,6 +149,7 @@ private fun AppContent( showComingSoon = showComingSoon, onPickGame = { push(Route.Setup(it.id)) }, onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) }, + onScorekeeper = { push(Route.Scorekeeper) }, onSettings = { push(Route.Settings) }, ) @@ -154,6 +159,11 @@ private fun AppContent( onBack = { pop() }, ) + is Route.Scorekeeper -> ScorekeeperScreen( + repository = env.scorekeeper, + onBack = { pop() }, + ) + is Route.Setup -> GameSetupScreen( descriptor = GameCatalog.byId(route.gameId)!!, playerName = settings.displayName, @@ -187,12 +197,13 @@ private fun AppContent( // Main menu // --------------------------------------------------------------------------- -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun MainMenuScreen( showComingSoon: Boolean, onPickGame: (GameDescriptor) -> Unit, onJoinGame: () -> Unit, + onScorekeeper: () -> Unit, onSettings: () -> Unit, ) { Scaffold( @@ -211,11 +222,17 @@ fun MainMenuScreen( .padding(padding) .padding(horizontal = 12.dp), ) { - Row( + // Wraps rather than running off the edge: two buttons and a narrow + // phone would otherwise push the second one past the screen. + FlowRow( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") } + // Not a game, and deliberately not in the catalogue: it keeps + // score for whatever is being played on the actual table. + OutlinedButton(onClick = onScorekeeper) { Text("Scorekeeper") } } // The column count follows the available width, so a phone in diff --git a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt new file mode 100644 index 0000000..5f38295 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt @@ -0,0 +1,636 @@ +package org.prolibertate.games.ui + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.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.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.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 +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +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.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.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.prolibertate.games.score.ScorePlayer +import org.prolibertate.games.score.ScoreSheet +import org.prolibertate.games.score.ScorekeeperRepository +import kotlin.math.roundToInt + +/** + * A pencil for games the app does not play. + * + * 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. + */ +@Composable +fun ScorekeeperScreen( + repository: ScorekeeperRepository, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + + // 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. + var loaded by remember { mutableStateOf(false) } + var sheet by remember { mutableStateOf(ScoreSheet()) } + LaunchedEffect(Unit) { + sheet = repository.sheet.first() + loaded = true + } + + val update: (ScoreSheet) -> Unit = { next -> + sheet = next + scope.launch { repository.save(next) } + } + + var editingPlayers by remember { mutableStateOf(false) } + var confirmNewGame by remember { mutableStateOf(false) } + + ScreenScaffold( + title = "Scorekeeper", + onBack = onBack, + actions = { + if (sheet.started) { + TextButton(onClick = { confirmNewGame = true }) { Text("New game") } + } + }, + ) { modifier -> + // Nothing is drawn until the stored sheet has been read, so a game in + // progress does not flash the "how many are playing" pane first. + 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 + } + } else { + Scoreboard( + modifier = modifier, + sheet = sheet, + editingPlayers = editingPlayers, + onEditPlayers = { editingPlayers = it }, + onChange = update, + ) + } + } + + if (confirmNewGame) { + AlertDialog( + onDismissRequest = { confirmNewGame = false }, + title = { Text("Start a new game?") }, + text = { Text("The players and every round scored so far are rubbed out.") }, + confirmButton = { + TextButton(onClick = { + confirmNewGame = false + editingPlayers = false + update(ScoreSheet()) + }) { Text("Rub it out") } + }, + dismissButton = { + TextButton(onClick = { confirmNewGame = false }) { Text("Keep scoring") } + }, + ) + } +} + +// --------------------------------------------------------------------------- +// Setting the table +// --------------------------------------------------------------------------- + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) { + var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) } + + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + 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.", + style = MaterialTheme.typography.bodyMedium, + ) + Text("How many are playing?", style = MaterialTheme.typography.labelLarge) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + (ScoreSheet.MIN_PLAYERS..ScoreSheet.MAX_PLAYERS).forEach { value -> + FilterChip( + selected = value == count, + onClick = { count = value }, + label = { Text("$value") }, + ) + } + } + 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.", + style = MaterialTheme.typography.bodySmall, + ) + PrimaryAction(text = "Start scoring") { onStart(count) } + } +} + +// --------------------------------------------------------------------------- +// The sheet proper +// --------------------------------------------------------------------------- + +@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. + val pending = remember { mutableStateMapOf() } + var editingRound by remember { mutableStateOf(-1) } + + 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) }, + ) + + if (editingPlayers) { + PlayerEditor(sheet = sheet, onChange = onChange) + } else { + RoundEntry( + sheet = sheet, + amounts = pending, + onRecord = { + onChange(sheet.withRound(amountsAsPoints(pending))) + pending.clear() + }, + ) + 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 }, + ) + } + } + } + } + + TotalsBar(sheet = sheet) + } + + if (editingRound in sheet.rounds.indices) { + RoundEditorDialog( + sheet = sheet, + index = editingRound, + onDismiss = { editingRound = -1 }, + onSave = { amounts -> + onChange(sheet.withRoundAt(editingRound, amounts)) + editingRound = -1 + }, + onDelete = { + onChange(sheet.withoutRound(editingRound)) + editingRound = -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, + ) + TextButton(onClick = onAction) { Text(actionLabel) } + } +} + +// --------------------------------------------------------------------------- +// Players: naming, adding, removing, and dragging into order +// --------------------------------------------------------------------------- + +/** + * One row per player, dragged by the handle on the left. + * + * 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. + */ +@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. + val currentSheet by rememberUpdatedState(sheet) + val commit by rememberUpdatedState(onChange) + + // 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) } + + 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) + } + + val target = landingIndex() + + 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 + } + + 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 + }, + ) + } + + 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), + ) + } +} + +@Composable +private fun PlayerRow( + sheet: ScoreSheet, + player: ScorePlayer, + dragging: Boolean, + shift: Float, + onName: (String) -> Unit, + onRemove: () -> Unit, + onDragStart: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> 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, + ) { + 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), + ) { + sheet.players.forEach { player -> + Text( + text = "${sheet.displayName(player)} ${signed(round.delta(player.id))}", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +@Composable +private fun RoundEditorDialog( + sheet: ScoreSheet, + index: Int, + 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}") }, + 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") } + } + }, + confirmButton = { + TextButton(onClick = { onSave(amountsAsPoints(amounts)) }) { Text("Save") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +// --------------------------------------------------------------------------- +// 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 + +/** Keeps a points field to a number, optionally negative, as it is typed. */ +private fun sanitiseAmount(raw: String): String { + val negative = raw.startsWith("-") + val digits = raw.filter { it.isDigit() }.take(5) + return if (negative) "-$digits" else digits +} + +/** The typed fields as points. Blanks, a lone minus and zeroes 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() diff --git a/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt b/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt new file mode 100644 index 0000000..9454760 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt @@ -0,0 +1,185 @@ +package org.prolibertate.games.score + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScoreSheetTest { + + private fun sheetOf(vararg names: String): ScoreSheet = + names.foldIndexed(ScoreSheet.of(names.size)) { index, sheet, name -> + sheet.renamed(sheet.players[index].id, name) + } + + @Test + fun `a new sheet seats the players asked for, unnamed`() { + val sheet = ScoreSheet.of(5) + assertEquals(5, sheet.players.size) + assertTrue(sheet.started) + assertTrue(sheet.rounds.isEmpty()) + assertEquals("Player 3", sheet.displayName(sheet.players[2])) + } + + @Test + fun `a table cannot be smaller than two or larger than twelve`() { + assertEquals(ScoreSheet.MIN_PLAYERS, ScoreSheet.of(1).players.size) + assertEquals(ScoreSheet.MAX_PLAYERS, ScoreSheet.of(40).players.size) + } + + @Test + fun `points add up round on round, and can be taken away`() { + val sheet = sheetOf("Alice", "Bob") + .let { it.withRound(mapOf(it.players[0].id to 3, it.players[1].id to -1)) } + .let { it.withRound(mapOf(it.players[0].id to -5, it.players[1].id to 10)) } + + assertEquals(-2, sheet.total(sheet.players[0].id)) + assertEquals(9, sheet.total(sheet.players[1].id)) + assertEquals(2, sheet.rounds.size) + } + + @Test + fun `a player with no entry that round scored nothing`() { + val sheet = sheetOf("Alice", "Bob").let { it.withRound(mapOf(it.players[0].id to 4)) } + assertEquals(0, sheet.total(sheet.players[1].id)) + // Nothing scored is not worth storing. + assertEquals(1, sheet.rounds.first().deltas.size) + } + + @Test + fun `dragging a player into a different order leaves the scores where they were`() { + val start = sheetOf("Alice", "Bob", "Carol") + .let { + it.withRound( + mapOf( + it.players[0].id to 1, + it.players[1].id to 2, + it.players[2].id to 3, + ) + ) + } + val alice = start.players[0].id + val carol = start.players[2].id + + // Carol dragged to the front. + val moved = start.moved(from = 2, to = 0) + + assertEquals(listOf("Carol", "Alice", "Bob"), moved.players.map { it.name }) + assertEquals(3, moved.total(carol)) + assertEquals(1, moved.total(alice)) + } + + @Test + fun `a drag that lands where it started changes nothing`() { + val sheet = sheetOf("Alice", "Bob", "Carol") + assertEquals(sheet, sheet.moved(from = 1, to = 1)) + // Off the end of the list is clamped rather than rejected: a finger can + // always be dragged further than the list is long. + assertEquals(listOf("Bob", "Carol", "Alice"), sheet.moved(from = 0, to = 9).players.map { it.name }) + } + + @Test + fun `renaming touches nobody else`() { + val sheet = sheetOf("Alice", "Bob") + val renamed = sheet.renamed(sheet.players[0].id, "Alasdair") + assertEquals(listOf("Alasdair", "Bob"), renamed.players.map { it.name }) + } + + @Test + fun `a blank name shows as the seat, and is stored as typed`() { + val sheet = sheetOf("Alice", "Bob").let { it.renamed(it.players[0].id, "") } + assertEquals("", sheet.players[0].name) + assertEquals("Player 1", sheet.displayName(sheet.players[0])) + // Whitespace is not a name either. + assertEquals("Player 1", sheet.displayName(sheet.players[0].copy(name = " "))) + } + + @Test + fun `adding a player mid-game gives them a fresh id and no history`() { + val start = sheetOf("Alice", "Bob", "Carol") + .let { it.withRound(mapOf(it.players[1].id to 7)) } + val bob = start.players[1].id + + // Bob leaves and somebody else sits down in his place. The id he was + // scored against must not come round again with him. + val grown = start.withPlayerRemoved(bob).withPlayerAdded() + val newcomer = grown.players.last() + + assertNotEquals(bob, newcomer.id) + assertEquals(0, grown.total(newcomer.id)) + assertEquals(3, grown.players.size) + } + + @Test + fun `removing a player takes their score with them`() { + val start = sheetOf("Alice", "Bob", "Carol") + .let { it.withRound(mapOf(it.players[0].id to 5, it.players[1].id to 6)) } + val bob = start.players[1].id + + val without = start.withPlayerRemoved(bob) + + assertEquals(listOf("Alice", "Carol"), without.players.map { it.name }) + assertEquals(5, without.total(without.players[0].id)) + assertTrue("Bob's points went with him", without.rounds.all { bob !in it.deltas }) + // The round itself stays: it was still played. + assertEquals(1, without.rounds.size) + } + + @Test + fun `shrinking the table drops from the end and prunes those scores`() { + val start = sheetOf("Alice", "Bob", "Carol") + .let { it.withRound(mapOf(it.players[2].id to 9)) } + val carol = start.players[2].id + + val smaller = start.withPlayerCount(2) + + assertEquals(listOf("Alice", "Bob"), smaller.players.map { it.name }) + assertTrue(smaller.rounds.all { carol !in it.deltas }) + } + + @Test + fun `the last two players cannot be removed`() { + val sheet = sheetOf("Alice", "Bob") + assertEquals(sheet, sheet.withPlayerRemoved(sheet.players[0].id)) + } + + @Test + fun `a round can be corrected or struck out`() { + val start = sheetOf("Alice", "Bob") + .let { it.withRound(mapOf(it.players[0].id to 1)) } + .let { it.withRound(mapOf(it.players[0].id to 100)) } + val alice = start.players[0].id + + val corrected = start.withRoundAt(1, mapOf(alice to 10)) + assertEquals(11, corrected.total(alice)) + + val struck = corrected.withoutRound(0) + assertEquals(1, struck.rounds.size) + assertEquals(10, struck.total(alice)) + } + + @Test + fun `points for somebody not at the table are ignored`() { + val sheet = sheetOf("Alice", "Bob") + val stranger = 9999 + val recorded = sheet.withRound(mapOf(sheet.players[0].id to 2, stranger to 50)) + assertEquals(mapOf(sheet.players[0].id to 2), recorded.rounds.first().deltas) + } + + @Test + fun `an out of range round index is left alone rather than crashing`() { + val sheet = sheetOf("Alice", "Bob").withRound(mapOf()) + assertEquals(sheet, sheet.withRoundAt(7, mapOf())) + assertEquals(sheet, sheet.withoutRound(-1)) + assertEquals(sheet, sheet.moved(from = 5, to = 0)) + } + + @Test + fun `totals cover everybody at the table`() { + val sheet = sheetOf("Alice", "Bob", "Carol") + .let { it.withRound(mapOf(it.players[1].id to 4)) } + assertEquals(sheet.players.map { it.id }.toSet(), sheet.totals().keys) + assertEquals(0, sheet.totals()[sheet.players[0].id]) + assertEquals(4, sheet.totals()[sheet.players[1].id]) + } +}