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:
@@ -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
|
hands and the deck order are never transmitted to devices that should not see
|
||||||
them.
|
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
|
## Settings
|
||||||
|
|
||||||
- **Sound** on/off.
|
- **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
|
chess/ Chess model, FEN, move generation, search
|
||||||
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
||||||
net/ wire protocol, LAN transport, lobby, match driver
|
net/ wire protocol, LAN transport, lobby, match driver
|
||||||
|
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||||
settings/ DataStore-backed preferences
|
settings/ DataStore-backed preferences
|
||||||
update/ GitHub Releases OTA updater
|
update/ GitHub Releases OTA updater
|
||||||
ui/ Compose screens
|
ui/ Compose screens
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import org.prolibertate.games.net.LobbyController
|
import org.prolibertate.games.net.LobbyController
|
||||||
|
import org.prolibertate.games.score.ScorekeeperRepository
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
import org.prolibertate.games.ui.AppEnv
|
import org.prolibertate.games.ui.AppEnv
|
||||||
import org.prolibertate.games.ui.AppRoot
|
import org.prolibertate.games.ui.AppRoot
|
||||||
@@ -24,6 +25,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
settingsRepository = SettingsRepository(applicationContext),
|
settingsRepository = SettingsRepository(applicationContext),
|
||||||
updater = Updater(this),
|
updater = Updater(this),
|
||||||
lobby = LobbyController(applicationContext, lifecycleScope),
|
lobby = LobbyController(applicationContext, lifecycleScope),
|
||||||
|
scorekeeper = ScorekeeperRepository(applicationContext),
|
||||||
peerId = stablePeerId(),
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.GameDescriptor
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
import org.prolibertate.games.net.LobbyController
|
import org.prolibertate.games.net.LobbyController
|
||||||
|
import org.prolibertate.games.score.ScorekeeperRepository
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
import org.prolibertate.games.update.UpdateChannel
|
import org.prolibertate.games.update.UpdateChannel
|
||||||
@@ -49,6 +51,7 @@ class AppEnv(
|
|||||||
val settingsRepository: SettingsRepository,
|
val settingsRepository: SettingsRepository,
|
||||||
val updater: Updater,
|
val updater: Updater,
|
||||||
val lobby: LobbyController,
|
val lobby: LobbyController,
|
||||||
|
val scorekeeper: ScorekeeperRepository,
|
||||||
val peerId: String,
|
val peerId: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,6 +65,7 @@ class AppEnv(
|
|||||||
sealed interface Route {
|
sealed interface Route {
|
||||||
data object Menu : Route
|
data object Menu : Route
|
||||||
data object Settings : Route
|
data object Settings : Route
|
||||||
|
data object Scorekeeper : Route
|
||||||
data class Setup(val gameId: String) : Route
|
data class Setup(val gameId: String) : Route
|
||||||
data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route
|
data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route
|
||||||
data class Play(
|
data class Play(
|
||||||
@@ -145,6 +149,7 @@ private fun AppContent(
|
|||||||
showComingSoon = showComingSoon,
|
showComingSoon = showComingSoon,
|
||||||
onPickGame = { push(Route.Setup(it.id)) },
|
onPickGame = { push(Route.Setup(it.id)) },
|
||||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||||
|
onScorekeeper = { push(Route.Scorekeeper) },
|
||||||
onSettings = { push(Route.Settings) },
|
onSettings = { push(Route.Settings) },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,6 +159,11 @@ private fun AppContent(
|
|||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is Route.Scorekeeper -> ScorekeeperScreen(
|
||||||
|
repository = env.scorekeeper,
|
||||||
|
onBack = { pop() },
|
||||||
|
)
|
||||||
|
|
||||||
is Route.Setup -> GameSetupScreen(
|
is Route.Setup -> GameSetupScreen(
|
||||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||||
playerName = settings.displayName,
|
playerName = settings.displayName,
|
||||||
@@ -187,12 +197,13 @@ private fun AppContent(
|
|||||||
// Main menu
|
// Main menu
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainMenuScreen(
|
fun MainMenuScreen(
|
||||||
showComingSoon: Boolean,
|
showComingSoon: Boolean,
|
||||||
onPickGame: (GameDescriptor) -> Unit,
|
onPickGame: (GameDescriptor) -> Unit,
|
||||||
onJoinGame: () -> Unit,
|
onJoinGame: () -> Unit,
|
||||||
|
onScorekeeper: () -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -211,11 +222,17 @@ fun MainMenuScreen(
|
|||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 12.dp),
|
.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),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") }
|
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
|
// 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()
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user