Add a scorekeeper for games the app does not deal

A pencil, reachable from the main menu: say how many are playing, name
them, and write down what each scored every round. The tally sits at the
foot of the screen because that is the one number anybody looks up.

Points are recorded against a player's id rather than their position,
which is what lets the players be dragged into a different order without
their scores moving with them. Ids are never reused, so 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.

The drag leaves the sheet 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, and the handlers read the sheet through
rememberUpdatedState so a drag cannot be applied to a stale copy and
throw away a name typed since.

Rounds are not written in ink — tapping one reopens it for correction or
strikes it out — and the sheet is saved as it is edited, so a phone call
does not lose the game. It lives in its own DataStore rather than with
the settings: a game in progress is not a preference.

score/ScoreSheet.kt has no Android in it, for the same reason game/ does
not, and every rule above is unit-tested on the JVM.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud
This commit is contained in:
Claude
2026-08-06 23:33:11 +00:00
parent 8ca877c416
commit b532749093
7 changed files with 1060 additions and 3 deletions
@@ -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(),
)
@@ -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<Int, Int> = 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<ScorePlayer> = emptyList(),
val rounds: List<ScoreRound> = 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<Int, Int> = 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<Int, Int>): ScoreSheet = copy(rounds = rounds + roundOf(deltas))
/** Corrects a round already written down. Out-of-range indices do nothing. */
fun withRoundAt(index: Int, deltas: Map<Int, Int>): 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<Int, Int>): 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)
}
}
@@ -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<Preferences> 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<ScoreSheet> = context.scorekeeperDataStore.data.map { prefs ->
prefs[Keys.SHEET]
?.let { stored -> runCatching { json.decodeFromString<ScoreSheet>(stored) }.getOrNull() }
?: ScoreSheet()
}
suspend fun save(sheet: ScoreSheet) {
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
}
}
@@ -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
@@ -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<Int, String>() }
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<Int, String>,
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<Int, String>) {
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<Int, Int>) -> Unit,
onDelete: () -> Unit,
) {
val round = sheet.rounds[index]
val amounts = remember(index) {
mutableStateMapOf<Int, String>().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<Int, String>): Map<Int, Int> =
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()