Merge pull request #3: a scorekeeper for games the app does not deal
Verified green on the feature branch before merging: build, unit tests and Android lint (ci.yml run 36).
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