diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5365101 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# Working on this repo with Claude + +## Nothing a session does triggers CI. Dispatch it explicitly. + +GitHub creates **no workflow run** for anything this sandbox does — pushing a +branch, opening a pull request, or merging one. All of it goes through the same +integration, and GitHub suppresses event-triggered runs from it. The pull +request will say it was opened by the repo owner; that is only how the UI +attributes it, and it does not change the suppression. + +This was measured, not assumed: + +| Action | Result | +| --- | --- | +| Human pushes a Claude-authored commit (`d165e5ef`, `469c900e`) | CI runs | +| Session pushes `dev` / `main` (`43394b8`, `742add4`) | no run | +| Session opens a PR against `dev` (#1) | no run | +| Session calls `workflow_dispatch` | **runs** | + +`workflow_dispatch` works because it creates a run directly instead of relying +on an event. It is the only mechanism available from a session, so every build +has to be asked for by hand. + +## Verify before merging, without publishing + +`ci.yml` builds, tests and lints on any ref, but its staging and publishing +steps are gated on `github.ref == 'refs/heads/dev'`. Dispatching it on a +**feature branch** therefore gives the full check — build, unit tests, Android +lint — and publishes nothing. + +So the working loop is: + +1. Commit and push the feature branch. +2. Dispatch `ci.yml` on that branch. Wait for green. This is the real review + gate, and it is the only verification that exists (see below). +3. Open the PR through the API and merge it there, so the change is reviewable + and the merge commit is honest about what happened. Neither step builds + anything. +4. Dispatch the target branch's workflow to actually build and publish: + `ci.yml` on `dev`, `release.yml` on `main`. + +Skipping step 4 leaves a branch whose head was never built and no release. + +## Merging is a shipping action + +Both long-lived branches publish, so a merge is a release, not just an +integration: + +- **`dev`** → `ci.yml` publishes a `v1.0.N-dev` **prerelease**, offered by the + in-app updater to anyone on the Dev channel. +- **`main`** → `release.yml` publishes a **production** release, which the + updater installs for everyone on the Production channel. + +Confirm before shipping to `main`. Note that `release.yml` runs `assembleDebug` +only — no tests, no lint — so a `main` release is only as verified as the `dev` +build it replays. + +## Gradle does not run in the sandbox + +`./gradlew` cannot resolve the Android plugin here: the proxy blocks +`dl.google.com` with a 403. No local build, no local test run, no local lint. A +dispatched CI run is the only way to find out whether a change compiles. + +## A permanent fix, if you want one + +The suppression is a property of the sandbox's credential, so it cannot be +fixed from inside a session. Giving the environment a personal access token to +push with would make pushes and PRs behave normally for everyone. Until then, +the dispatch step above is the workaround. diff --git a/README.md b/README.md index db40f69..e475e13 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,32 @@ Each client is then sent a state **redacted for its own seat**, so opponents' hands and the deck order are never transmitted to devices that should not see them. +## Scorekeeper + +**Scorekeeper** on the main menu is a pencil for games the app does not deal — +darts, cribbage, whatever is actually on the table. Say how many are playing, +name them, and write down what each of them scored every round; the running +tally sits at the foot of the screen, where the one number anybody looks up is +always visible. + +Players can be renamed, added, removed or **dragged into a different order** at +any point in a game. That works because points are recorded against a player's +id rather than their position, so reordering the sheet moves the columns and +nothing else. Ids are handed out once and never reused: somebody sitting down +where somebody else got up starts at nought instead of inheriting a stranger's +score. Removing a player does take their points with them — a score nobody can +see is a score nobody can correct. + +Rounds are not written in ink. Tapping one reopens it for correction or lets it +be struck out, and the totals follow. + +The sheet is saved as it is edited, so leaving the app or taking a phone call +does not lose the game. It is kept in its own DataStore rather than with the +settings, because it is a game in progress rather than a preference. + +`score/ScoreSheet.kt` holds the whole model and, like `game/`, has no Android in +it: every rule above is a pure function and unit-tested on the JVM. + ## Settings - **Sound** on/off. @@ -51,12 +77,20 @@ them. - **Production** — stable builds, published from `main`. - **Dev** — preview builds, published from `dev` on every green CI run. + The two install as separate apps, so both can be kept on one device — see + [Both channels on one device](#both-channels-on-one-device). + ## Branding The launcher icon is a single square of the Wallace tartan set on the bias, and the app opens on the same cloth carrying the name in a Celtic uncial hand for two seconds before fading into the menu. +A dev build wears **Wallace Hunting** instead — the same threadcount with green +where the clan sett has red — and installs as *Pro Libertate Games DEV*. That is +what tells the two copies apart when both are on the phone; see +[Both channels on one device](#both-channels-on-one-device). + The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as warp and then as weft at half opacity, which is what produces the blended squares instead of a flat grid. The icon is the same construction as a vector @@ -72,7 +106,9 @@ Two honest caveats: and the narrow black guard sits *between two red blocks*. The icon is generated from the same numbers, so the icon and the splash cannot drift. Weave scale is derived from the sett rather than fixed, so changing the - threadcount rescales both instead of silently zooming in. + threadcount rescales both instead of silently zooming in. The hunting + colourway is the same threadcount passed a different field colour, not a + second copy of the numbers, so it cannot fall out of step either. - **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J. Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under the SIL Open Font License 1.1. The full licence is at @@ -101,6 +137,11 @@ smaller than a fingertip. Requires JDK 17 and the Android SDK (compileSdk 34). +A local build is a dev build: it installs as `org.prolibertate.games.dev` under +the name *Pro Libertate Games DEV* in the hunting sett, so it goes on beside an +installed release rather than over it. Set `PLG_CHANNEL=production` to build the +production package instead. + ## CI - `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull @@ -161,9 +202,39 @@ is the run number of the workflow that produced it, and the two workflows count separately. Versions are therefore only ever compared *within* a channel; the app reads the `-dev` suffix in its own `versionName` to know which channel it is on, and treats a change of channel as an explicit switch rather than an upgrade. -One consequence: switching channels can be a downgrade as far as Android is -concerned, in which case the install is refused until the current copy is -uninstalled. The settings screen says so when it applies. + +### Both channels on one device + +A dev build installs under its own `applicationId`, `org.prolibertate.games.dev`, +so it is a separate app as far as Android is concerned and can sit beside the +production copy rather than replacing it. Each channel still updates itself +normally, because an update within a channel keeps the same package. + +Three things move together, all off `isDevBuild` in `app/build.gradle.kts`: + +| | Production | Dev | +| --- | --- | --- | +| `applicationId` | `org.prolibertate.games` | `org.prolibertate.games.dev` | +| Launcher name | Pro Libertate Games | Pro Libertate Games DEV | +| Sett | Wallace (red) | Wallace Hunting (green) | + +Anything that is not an explicit `PLG_CHANNEL=production` build is a dev build, +so a local `assembleDebug` is a dev build too and installs alongside a release +without uninstalling anything. + +They have to move together. A dev package wearing the production name and +colours is precisely the mix-up that having both installed is meant to prevent, +which is why one flag drives all three rather than each being set by hand. The +name and the colours reach the resources through `resValue`, so `app_name`, +`tartan_field`, `splash_background` and `ic_launcher_background` are generated +by the build and are deliberately *not* in `res/values/` — the icon vectors +reference `@color/tartan_field` rather than a literal, which is how one set of +vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag +into Kotlin, where `AppSett` picks the matching Compose sett for the splash. + +Switching channels in Settings therefore installs the other channel's app +alongside this one and leaves this one in place; the settings screen says so +when the selected channel is not the installed one. The debug keystore is committed on purpose: every build is signed with the same key, so an OTA update installs over the previous one instead of being rejected @@ -202,6 +273,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested chess/ Chess model, FEN, move generation, search tayu/ Ta Yü tile geometry, river rules, scoring, AI net/ wire protocol, LAN transport, lobby, match driver +score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store settings/ DataStore-backed preferences update/ GitHub Releases OTA updater ui/ Compose screens diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 94c7eeb..94b98c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,22 +16,63 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty() val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 val stampVersion = buildChannel.isNotEmpty() && runNumber > 0 +// Anything that is not an explicit production build is a dev build: ci.yml +// stamps "dev", and a local build with nothing set counts as dev too. +// +// This one flag drives everything that makes a dev build a separate app rather +// than a replacement for the production one — its own applicationId, its own +// launcher name, and the hunting sett in place of the clan sett. They have to +// move together: a dev package wearing the production name and colours is +// exactly the confusion having both installed is meant to avoid. +val isDevBuild = buildChannel != "production" + android { namespace = "org.prolibertate.games" compileSdk = 34 defaultConfig { - applicationId = "org.prolibertate.games" + // A dev build installs under its own package, so a dev copy and a + // production copy can sit side by side on one device instead of one + // replacing the other. The namespace above is deliberately left alone: + // R and BuildConfig stay in org.prolibertate.games for both, so no + // source has to care which package it ended up installed as. + applicationId = if (isDevBuild) "org.prolibertate.games.dev" else "org.prolibertate.games" minSdk = 24 targetSdk = 34 // The -dev suffix in versionName is what the installed app reads to - // know its own channel, so it must survive into the shipped APK. + // know its own channel, so it must survive into the shipped APK. It + // tracks isDevBuild rather than the stamp alone, so an unstamped local + // build cannot end up claiming a channel its applicationId contradicts. versionCode = if (stampVersion) runNumber else 1 versionName = when { stampVersion && buildChannel == "production" -> "1.0.$runNumber" stampVersion -> "1.0.$runNumber-dev" - else -> "1.0.0-dev" + isDevBuild -> "1.0.0-dev" + else -> "1.0.0" } + + // The launcher label. With both copies on the phone the app drawer shows + // two entries, and this is what names them apart. + resValue( + "string", + "app_name", + if (isDevBuild) "Pro Libertate Games DEV" else "Pro Libertate Games", + ) + + // The one colour that separates the two setts: Wallace red for + // production, Wallace Hunting green for dev. The icon vectors reference + // this rather than a literal, and Tartan.kt picks the matching Compose + // colour off DEV_BUILD below — the two have to stay in step or the + // launcher icon and the splash it opens on would be different cloth. + resValue("color", "tartan_field", if (isDevBuild) "#1E7A3C" else "#E01B24") + // Shades of the same field, so a launch does not flash the wrong colour + // before the tartan is drawn: splash_background is held behind the + // window for that instant, ic_launcher_background is the icon's flat + // fallback. + resValue("color", "splash_background", if (isDevBuild) "#0D2E17" else "#4A0B10") + resValue("color", "ic_launcher_background", if (isDevBuild) "#145C2D" else "#B3121B") + + buildConfigField("boolean", "DEV_BUILD", isDevBuild.toString()) } signingConfigs { @@ -59,6 +100,8 @@ android { buildFeatures { compose = true + // For DEV_BUILD, which is how the Compose tartan picks its sett. + buildConfig = true } composeOptions { diff --git a/app/src/main/java/org/prolibertate/games/MainActivity.kt b/app/src/main/java/org/prolibertate/games/MainActivity.kt index d8e2cb1..b4a7059 100644 --- a/app/src/main/java/org/prolibertate/games/MainActivity.kt +++ b/app/src/main/java/org/prolibertate/games/MainActivity.kt @@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.lifecycle.lifecycleScope import org.prolibertate.games.net.LobbyController +import org.prolibertate.games.score.ScorekeeperRepository import org.prolibertate.games.settings.SettingsRepository import org.prolibertate.games.ui.AppEnv import org.prolibertate.games.ui.AppRoot @@ -24,6 +25,7 @@ class MainActivity : ComponentActivity() { settingsRepository = SettingsRepository(applicationContext), updater = Updater(this), lobby = LobbyController(applicationContext, lifecycleScope), + scorekeeper = ScorekeeperRepository(applicationContext), peerId = stablePeerId(), ) diff --git a/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt b/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt new file mode 100644 index 0000000..812f484 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt @@ -0,0 +1,139 @@ +package org.prolibertate.games.score + +import kotlinx.serialization.Serializable + +/** + * One person on the score sheet. + * + * Points are recorded against [id] rather than against a position, which is + * what lets the players be dragged into a different order without their scores + * moving with them. Ids come from [ScoreSheet.nextPlayerId] and are never + * reused: somebody added where somebody else was removed must not inherit the + * rounds that were scored against them. + * + * [name] is stored exactly as typed, blank included, so clearing the field + * while renaming does not fight back. What to show for a blank name is a + * display concern and belongs to [ScoreSheet.displayName]. + */ +@Serializable +data class ScorePlayer(val id: Int, val name: String = "") + +/** + * What each player scored in one round, keyed by [ScorePlayer.id]. + * + * A player absent from the map scored nothing, which is the same as zero — so + * a round where only one person scored carries one entry rather than a column + * of noughts. + */ +@Serializable +data class ScoreRound(val deltas: Map = emptyMap()) { + fun delta(playerId: Int): Int = deltas[playerId] ?: 0 +} + +/** + * A pencil-and-paper score sheet: some players, and a row of points per round. + * + * Every change returns a new sheet rather than mutating this one, so the screen + * can hand the result straight to state and persistence without worrying about + * which of them holds the live copy. Nothing here touches Android, for the same + * reason `game/` does not: it can then be unit-tested on the JVM. + */ +@Serializable +data class ScoreSheet( + val players: List = emptyList(), + val rounds: List = emptyList(), + /** The next id to hand out. Only ever counts up; see [ScorePlayer.id]. */ + val nextPlayerId: Int = 1, +) { + /** A sheet with nobody on it has not been set up yet. */ + val started: Boolean get() = players.isNotEmpty() + + /** What to call a player who has not been named: their seat at the table. */ + fun displayName(player: ScorePlayer): String { + val typed = player.name.trim() + if (typed.isNotEmpty()) return typed + val position = players.indexOfFirst { it.id == player.id } + return "Player ${if (position >= 0) position + 1 else player.id}" + } + + fun total(playerId: Int): Int = rounds.sumOf { it.delta(playerId) } + + fun totals(): Map = players.associate { it.id to total(it.id) } + + /** + * Grows or shrinks the table to [count], clamped to what a sheet can hold. + * + * Shrinking drops players from the end and takes their scores with them, + * because points recorded against somebody nobody can see are points that + * can never be corrected. + */ + fun withPlayerCount(count: Int): ScoreSheet { + val target = count.coerceIn(MIN_PLAYERS, MAX_PLAYERS) + if (target == players.size) return this + if (target < players.size) return copy(players = players.take(target)).pruned() + + var next = nextPlayerId + val added = List(target - players.size) { ScorePlayer(id = next++) } + return copy(players = players + added, nextPlayerId = next) + } + + fun withPlayerAdded(): ScoreSheet = withPlayerCount(players.size + 1) + + /** Removes one player by id. The last two cannot be removed. */ + fun withPlayerRemoved(playerId: Int): ScoreSheet { + if (players.size <= MIN_PLAYERS) return this + if (players.none { it.id == playerId }) return this + return copy(players = players.filterNot { it.id == playerId }).pruned() + } + + fun renamed(playerId: Int, name: String): ScoreSheet = copy( + players = players.map { if (it.id == playerId) it.copy(name = name) else it } + ) + + /** Moves the player at [from] to [to], which is what a drag amounts to. */ + fun moved(from: Int, to: Int): ScoreSheet { + if (from !in players.indices) return this + val target = to.coerceIn(0, players.size - 1) + if (from == target) return this + val reordered = players.toMutableList() + reordered.add(target, reordered.removeAt(from)) + return copy(players = reordered) + } + + fun withRound(deltas: Map): ScoreSheet = copy(rounds = rounds + roundOf(deltas)) + + /** Corrects a round already written down. Out-of-range indices do nothing. */ + fun withRoundAt(index: Int, deltas: Map): ScoreSheet { + if (index !in rounds.indices) return this + return copy(rounds = rounds.toMutableList().also { it[index] = roundOf(deltas) }) + } + + fun withoutRound(index: Int): ScoreSheet { + if (index !in rounds.indices) return this + return copy(rounds = rounds.filterIndexed { at, _ -> at != index }) + } + + /** + * A round holding only what is worth storing: entries for players who are + * still at the table, and only where somebody actually scored. + */ + private fun roundOf(deltas: Map): ScoreRound { + val ids = players.map { it.id }.toSet() + return ScoreRound(deltas.filterKeys { it in ids }.filterValues { it != 0 }) + } + + /** Drops points recorded against anybody no longer at the table. */ + private fun pruned(): ScoreSheet { + val ids = players.map { it.id }.toSet() + return copy(rounds = rounds.map { round -> ScoreRound(round.deltas.filterKeys { it in ids }) }) + } + + companion object { + const val MIN_PLAYERS = 2 + const val MAX_PLAYERS = 12 + const val DEFAULT_PLAYERS = 4 + + /** A fresh sheet seated for [count] players, none of them named yet. */ + fun of(count: Int): ScoreSheet = ScoreSheet().withPlayerCount(count) + } +} diff --git a/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt b/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt new file mode 100644 index 0000000..0394199 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt @@ -0,0 +1,51 @@ +package org.prolibertate.games.score + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +// Its own store rather than a corner of the settings one: a score sheet is +// session data with a shape that will keep changing, and it has no business +// being rewritten every time somebody moves the animation slider. +private val Context.scorekeeperDataStore: DataStore by preferencesDataStore( + name = "scorekeeper", +) + +/** + * Keeps the score sheet across the app being left, backgrounded or killed. + * + * A scorekeeper that forgets the tally because somebody took a phone call is + * worse than a pencil, so the sheet is written on every change and read back at + * the top of the screen. + */ +class ScorekeeperRepository(private val context: Context) { + + private object Keys { + val SHEET = stringPreferencesKey("sheet") + } + + // Unknown keys are ignored so a sheet written by a newer build can still be + // read by an older one — the alternative is losing a game in progress to a + // channel switch. + private val json = Json { + encodeDefaults = true + ignoreUnknownKeys = true + } + + val sheet: Flow = context.scorekeeperDataStore.data.map { prefs -> + prefs[Keys.SHEET] + ?.let { stored -> runCatching { json.decodeFromString(stored) }.getOrNull() } + ?: ScoreSheet() + } + + suspend fun save(sheet: ScoreSheet) { + context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index acde3b5..c82fa4e 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -5,7 +5,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -30,13 +31,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.prolibertate.games.R import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.GameCategory import org.prolibertate.games.game.GameDescriptor import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.net.LobbyController +import org.prolibertate.games.score.ScorekeeperRepository import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.SettingsRepository import org.prolibertate.games.update.UpdateChannel @@ -47,6 +51,7 @@ class AppEnv( val settingsRepository: SettingsRepository, val updater: Updater, val lobby: LobbyController, + val scorekeeper: ScorekeeperRepository, val peerId: String, ) @@ -60,6 +65,7 @@ class AppEnv( sealed interface Route { data object Menu : Route data object Settings : Route + data object Scorekeeper : Route data class Setup(val gameId: String) : Route data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route data class Play( @@ -143,6 +149,7 @@ private fun AppContent( showComingSoon = showComingSoon, onPickGame = { push(Route.Setup(it.id)) }, onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) }, + onScorekeeper = { push(Route.Scorekeeper) }, onSettings = { push(Route.Settings) }, ) @@ -152,6 +159,11 @@ private fun AppContent( onBack = { pop() }, ) + is Route.Scorekeeper -> ScorekeeperScreen( + repository = env.scorekeeper, + onBack = { pop() }, + ) + is Route.Setup -> GameSetupScreen( descriptor = GameCatalog.byId(route.gameId)!!, playerName = settings.displayName, @@ -185,18 +197,21 @@ private fun AppContent( // Main menu // --------------------------------------------------------------------------- -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun MainMenuScreen( showComingSoon: Boolean, onPickGame: (GameDescriptor) -> Unit, onJoinGame: () -> Unit, + onScorekeeper: () -> Unit, onSettings: () -> Unit, ) { Scaffold( topBar = { TopAppBar( - title = { Text("Pro Libertate Games") }, + // The installed name rather than a literal, so a dev build says + // so in its own title bar as well as in the app drawer. + title = { Text(stringResource(R.string.app_name)) }, actions = { TextButton(onClick = onSettings) { Text("Settings") } }, ) } @@ -207,11 +222,17 @@ fun MainMenuScreen( .padding(padding) .padding(horizontal = 12.dp), ) { - Row( + // Wraps rather than running off the edge: two buttons and a narrow + // phone would otherwise push the second one past the screen. + FlowRow( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") } + // Not a game, and deliberately not in the catalogue: it keeps + // score for whatever is being played on the actual table. + OutlinedButton(onClick = onScorekeeper) { Text("Scorekeeper") } } // The column count follows the available width, so a phone in diff --git a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt new file mode 100644 index 0000000..5f38295 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt @@ -0,0 +1,636 @@ +package org.prolibertate.games.ui + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.prolibertate.games.score.ScorePlayer +import org.prolibertate.games.score.ScoreSheet +import org.prolibertate.games.score.ScorekeeperRepository +import kotlin.math.roundToInt + +/** + * A pencil for games the app does not play. + * + * Name the people at the table, drag them into the order they are sitting in, + * then write down what each of them scored every round. The running tally sits + * at the foot of the screen where it can always be seen, because that is the + * one number anybody actually looks up. + */ +@Composable +fun ScorekeeperScreen( + repository: ScorekeeperRepository, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + + // Read once, then held here and written through. Reading the sheet back out + // of storage on every change would put a DataStore round trip between a + // keystroke and the letter appearing, which is how a name field loses + // characters. + var loaded by remember { mutableStateOf(false) } + var sheet by remember { mutableStateOf(ScoreSheet()) } + LaunchedEffect(Unit) { + sheet = repository.sheet.first() + loaded = true + } + + val update: (ScoreSheet) -> Unit = { next -> + sheet = next + scope.launch { repository.save(next) } + } + + var editingPlayers by remember { mutableStateOf(false) } + var confirmNewGame by remember { mutableStateOf(false) } + + ScreenScaffold( + title = "Scorekeeper", + onBack = onBack, + actions = { + if (sheet.started) { + TextButton(onClick = { confirmNewGame = true }) { Text("New game") } + } + }, + ) { modifier -> + // Nothing is drawn until the stored sheet has been read, so a game in + // progress does not flash the "how many are playing" pane first. + if (!loaded) { + Box(modifier = modifier) + } else if (!sheet.started) { + SetupPane(modifier = modifier) { count -> + update(ScoreSheet.of(count)) + // Straight into naming them: a fresh sheet is a row of blanks. + editingPlayers = true + } + } else { + Scoreboard( + modifier = modifier, + sheet = sheet, + editingPlayers = editingPlayers, + onEditPlayers = { editingPlayers = it }, + onChange = update, + ) + } + } + + if (confirmNewGame) { + AlertDialog( + onDismissRequest = { confirmNewGame = false }, + title = { Text("Start a new game?") }, + text = { Text("The players and every round scored so far are rubbed out.") }, + confirmButton = { + TextButton(onClick = { + confirmNewGame = false + editingPlayers = false + update(ScoreSheet()) + }) { Text("Rub it out") } + }, + dismissButton = { + TextButton(onClick = { confirmNewGame = false }) { Text("Keep scoring") } + }, + ) + } +} + +// --------------------------------------------------------------------------- +// Setting the table +// --------------------------------------------------------------------------- + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) { + var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) } + + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Keep score for a game the app does not deal — darts, cribbage, " + + "whatever is on the table. Add or take away points each round and the " + + "tally is kept for you.", + style = MaterialTheme.typography.bodyMedium, + ) + Text("How many are playing?", style = MaterialTheme.typography.labelLarge) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + (ScoreSheet.MIN_PLAYERS..ScoreSheet.MAX_PLAYERS).forEach { value -> + FilterChip( + selected = value == count, + onClick = { count = value }, + label = { Text("$value") }, + ) + } + } + Text( + text = "Names come next, and players can be added, removed or dragged into " + + "a different order at any point without losing anybody's score.", + style = MaterialTheme.typography.bodySmall, + ) + PrimaryAction(text = "Start scoring") { onStart(count) } + } +} + +// --------------------------------------------------------------------------- +// The sheet proper +// --------------------------------------------------------------------------- + +@Composable +private fun Scoreboard( + modifier: Modifier, + sheet: ScoreSheet, + editingPlayers: Boolean, + onEditPlayers: (Boolean) -> Unit, + onChange: (ScoreSheet) -> Unit, +) { + // What is being typed into the round that has not been written down yet, + // keyed by player id so it survives the players being reordered. + val pending = remember { mutableStateMapOf() } + var editingRound by remember { mutableStateOf(-1) } + + Column(modifier = modifier) { + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SectionHeader( + title = if (editingPlayers) "Players" else "Round ${sheet.rounds.size + 1}", + actionLabel = if (editingPlayers) "Done" else "Players", + onAction = { onEditPlayers(!editingPlayers) }, + ) + + if (editingPlayers) { + PlayerEditor(sheet = sheet, onChange = onChange) + } else { + RoundEntry( + sheet = sheet, + amounts = pending, + onRecord = { + onChange(sheet.withRound(amountsAsPoints(pending))) + pending.clear() + }, + ) + if (sheet.rounds.isNotEmpty()) { + Text( + text = "Rounds so far", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 8.dp), + ) + // Newest first: the round just written down is the one most + // likely to need correcting. + for (index in sheet.rounds.indices.reversed()) { + RoundRow( + sheet = sheet, + index = index, + onClick = { editingRound = index }, + ) + } + } + } + } + + TotalsBar(sheet = sheet) + } + + if (editingRound in sheet.rounds.indices) { + RoundEditorDialog( + sheet = sheet, + index = editingRound, + onDismiss = { editingRound = -1 }, + onSave = { amounts -> + onChange(sheet.withRoundAt(editingRound, amounts)) + editingRound = -1 + }, + onDelete = { + onChange(sheet.withoutRound(editingRound)) + editingRound = -1 + }, + ) + } +} + +@Composable +private fun SectionHeader(title: String, actionLabel: String, onAction: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + TextButton(onClick = onAction) { Text(actionLabel) } + } +} + +// --------------------------------------------------------------------------- +// Players: naming, adding, removing, and dragging into order +// --------------------------------------------------------------------------- + +/** + * One row per player, dragged by the handle on the left. + * + * Every row is exactly [PLAYER_ROW_HEIGHT] tall and they are stacked with no + * gap, so a drag of one row height is a move of exactly one place and the + * arithmetic needs no measurement of individual items. + */ +@Composable +private fun PlayerEditor(sheet: ScoreSheet, onChange: (ScoreSheet) -> Unit) { + val rowHeightPx = with(LocalDensity.current) { PLAYER_ROW_HEIGHT.toPx() } + + // Read inside the gesture handlers, which outlive the composition that + // created them: without this a drag would be applied to whatever the sheet + // looked like when the row was first drawn, throwing away anything typed + // since. + val currentSheet by rememberUpdatedState(sheet) + val commit by rememberUpdatedState(onChange) + + // The sheet is deliberately left alone until the finger lifts. Rows move on + // screen as the drag passes them, but the positions the gesture is working + // from cannot shift underneath it. + var draggingIndex by remember { mutableStateOf(-1) } + var dragOffset by remember { mutableStateOf(0f) } + + fun landingIndex(): Int { + val last = currentSheet.players.lastIndex + if (draggingIndex < 0 || last < 0) return -1 + val moved = draggingIndex + (dragOffset / rowHeightPx).roundToInt() + return moved.coerceIn(0, last) + } + + val target = landingIndex() + + Column(modifier = Modifier.fillMaxWidth()) { + sheet.players.forEachIndexed { index, player -> + val dragging = index == draggingIndex + // The dragged row follows the finger; the rows it has passed step + // aside by one place to show where it would land. + val shift = when { + draggingIndex < 0 -> 0f + dragging -> dragOffset + draggingIndex < target && index in (draggingIndex + 1)..target -> -rowHeightPx + draggingIndex > target && index in target until draggingIndex -> rowHeightPx + else -> 0f + } + + PlayerRow( + sheet = sheet, + player = player, + dragging = dragging, + shift = shift, + onName = { commit(currentSheet.renamed(player.id, it)) }, + onRemove = { commit(currentSheet.withPlayerRemoved(player.id)) }, + onDragStart = { + draggingIndex = currentSheet.players.indexOfFirst { it.id == player.id } + dragOffset = 0f + }, + onDrag = { dy -> dragOffset += dy }, + onDragEnd = { + val from = draggingIndex + val to = landingIndex() + draggingIndex = -1 + dragOffset = 0f + if (from >= 0 && to >= 0) commit(currentSheet.moved(from, to)) + }, + onDragCancel = { + draggingIndex = -1 + dragOffset = 0f + }, + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { onChange(sheet.withPlayerAdded()) }, + enabled = sheet.players.size < ScoreSheet.MAX_PLAYERS, + ) { Text("Add a player") } + } + Text( + text = "Drag the ≡ handle to change the order. Removing a player takes " + + "their score with them.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + } +} + +@Composable +private fun PlayerRow( + sheet: ScoreSheet, + player: ScorePlayer, + dragging: Boolean, + shift: Float, + onName: (String) -> Unit, + onRemove: () -> Unit, + onDragStart: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(PLAYER_ROW_HEIGHT) + // Above its neighbours while it is being dragged, so it passes over + // them rather than under. + .zIndex(if (dragging) 1f else 0f) + .graphicsLayer { translationY = shift }, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .width(40.dp) + .fillMaxHeight() + .pointerInput(player.id) { + detectDragGestures( + onDragStart = { onDragStart() }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() }, + onDrag = { change, delta -> + // Consumed here so the surrounding scroll does not + // also take the gesture and run away with the page. + change.consume() + onDrag(delta.y) + }, + ) + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = "≡", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedTextField( + value = player.name, + onValueChange = onName, + placeholder = { Text(sheet.displayName(player)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + + TextButton( + onClick = onRemove, + enabled = sheet.players.size > ScoreSheet.MIN_PLAYERS, + ) { Text("✕") } + } +} + +// --------------------------------------------------------------------------- +// Scoring a round +// --------------------------------------------------------------------------- + +@Composable +private fun RoundEntry( + sheet: ScoreSheet, + amounts: SnapshotStateMap, + onRecord: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "What did each player score? Take points away with the minus, or type " + + "a negative number.", + style = MaterialTheme.typography.bodySmall, + ) + sheet.players.forEach { player -> + AmountRow(name = sheet.displayName(player), id = player.id, amounts = amounts) + } + PrimaryAction( + text = "Record round ${sheet.rounds.size + 1}", + // A round where nobody scored anything is not worth a row. + enabled = amountsAsPoints(amounts).isNotEmpty(), + onClick = onRecord, + ) + } +} + +/** Name, a minus, a number, a plus. The same row scores a round and edits one. */ +@Composable +private fun AmountRow(name: String, id: Int, amounts: SnapshotStateMap) { + val typed = amounts[id].orEmpty() + + fun step(by: Int) { + val next = (typed.toIntOrNull() ?: 0) + by + amounts[id] = if (next == 0) "" else next.toString() + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { step(-1) }) { Text("−") } + OutlinedTextField( + value = typed, + onValueChange = { amounts[id] = sanitiseAmount(it) }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + placeholder = { Text("0", textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) }, + modifier = Modifier.width(96.dp), + ) + TextButton(onClick = { step(1) }) { Text("+") } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun RoundRow(sheet: ScoreSheet, index: Int, onClick: () -> Unit) { + val round = sheet.rounds[index] + Card( + onClick = onClick, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(10.dp)) { + Text( + text = "Round ${index + 1}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(top = 2.dp), + ) { + sheet.players.forEach { player -> + Text( + text = "${sheet.displayName(player)} ${signed(round.delta(player.id))}", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +@Composable +private fun RoundEditorDialog( + sheet: ScoreSheet, + index: Int, + onDismiss: () -> Unit, + onSave: (Map) -> Unit, + onDelete: () -> Unit, +) { + val round = sheet.rounds[index] + val amounts = remember(index) { + mutableStateMapOf().apply { + sheet.players.forEach { player -> + val delta = round.delta(player.id) + if (delta != 0) put(player.id, delta.toString()) + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Round ${index + 1}") }, + text = { + Column( + // Twelve players will not fit on a phone otherwise. + modifier = Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + sheet.players.forEach { player -> + AmountRow( + name = sheet.displayName(player), + id = player.id, + amounts = amounts, + ) + } + TextButton(onClick = onDelete) { Text("Delete this round") } + } + }, + confirmButton = { + TextButton(onClick = { onSave(amountsAsPoints(amounts)) }) { Text("Save") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +// --------------------------------------------------------------------------- +// The tally +// --------------------------------------------------------------------------- + +@Composable +private fun TotalsBar(sheet: ScoreSheet) { + Surface( + tonalElevation = 3.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + Text( + text = when (sheet.rounds.size) { + 0 -> "No rounds scored yet" + 1 -> "After 1 round" + else -> "After ${sheet.rounds.size} rounds" + }, + style = MaterialTheme.typography.labelSmall, + ) + // Scrolls sideways rather than squeezing: a twelve-handed game has + // more columns than a phone has width. + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + sheet.players.forEach { player -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = sheet.displayName(player), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + ) + Text( + text = sheet.total(player.id).toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } +} + +// --------------------------------------------------------------------------- + +private val PLAYER_ROW_HEIGHT = 64.dp + +/** Keeps a points field to a number, optionally negative, as it is typed. */ +private fun sanitiseAmount(raw: String): String { + val negative = raw.startsWith("-") + val digits = raw.filter { it.isDigit() }.take(5) + return if (negative) "-$digits" else digits +} + +/** The typed fields as points. Blanks, a lone minus and zeroes all drop out. */ +private fun amountsAsPoints(amounts: Map): Map = + amounts.mapNotNull { (id, text) -> text.toIntOrNull()?.takeIf { it != 0 }?.let { id to it } } + .toMap() + +private fun signed(points: Int): String = if (points > 0) "+$points" else points.toString() diff --git a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt index 5e072a7..ebfea7e 100644 --- a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt @@ -139,12 +139,14 @@ fun SettingsScreen( style = MaterialTheme.typography.bodySmall, ) if (settings.updateChannel != env.updater.installedChannel()) { - // The channels are numbered independently, so a switch can - // be a downgrade as far as Android is concerned. + // Each channel installs under its own package, so the other + // one arrives as a second app rather than replacing this + // one — no downgrade for Android to refuse. Text( text = "You're on a ${env.updater.installedChannel().label} build. " + - "Switching channels installs a different build rather than a " + - "newer one — if Android refuses it, uninstall this copy first.", + "The two channels install as separate apps, so this adds the " + + "${settings.updateChannel.label} build alongside this one and " + + "leaves it in place.", style = MaterialTheme.typography.bodySmall, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt index af4aaa2..21e96a1 100644 --- a/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt +++ b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.prolibertate.games.BuildConfig /** One band of a tartan sett, measured in threads. */ data class TartanStripe(val color: Color, val threads: Int) @@ -18,6 +19,15 @@ val WallaceRed = Color(0xFFE01B24) val WallaceBlack = Color(0xFF0C0C0C) val WallaceGold = Color(0xFFF2C518) +/** + * The green the hunting sett puts where the clan sett puts red. + * + * Must match `tartan_field` for a dev build in app/build.gradle.kts, which is + * what the launcher icon is woven from — otherwise the icon and the splash it + * opens on would be visibly different cloth. + */ +val WallaceHuntingGreen = Color(0xFF1E7A3C) + /** * Expands a half-sett into a full repeat by reflecting it. * @@ -42,14 +52,42 @@ fun List.pivoted(): List = * between red and black; * - the narrow black guard sits **between two red blocks**, splitting the red * into pairs. + * + * The threadcount itself lives in [wallaceSett], because the hunting colourway + * below is the same cloth in a different colour and must not drift from it. */ -val WallaceSett: List = listOf( +val WallaceSett: List = wallaceSett(WallaceRed) + +/** + * Wallace Hunting: the same threadcount woven in green. + * + * Hunting setts are the clan cloth in muted, outdoor colours, and for Wallace + * that is the one substitution — green for the red — with the black ground and + * the yellow overstripe untouched. Deriving it from the same builder rather + * than writing the numbers out twice is what keeps that true: a change to the + * threadcount cannot land in one colourway and miss the other. + */ +val WallaceHuntingSett: List = wallaceSett(WallaceHuntingGreen) + +/** The Wallace threadcount woven with [field] in place of the red. */ +private fun wallaceSett(field: Color): List = listOf( TartanStripe(WallaceBlack, 4), - TartanStripe(WallaceRed, 32), + TartanStripe(field, 32), TartanStripe(WallaceBlack, 32), TartanStripe(WallaceGold, 4), ).pivoted() +/** + * The cloth this build wears: the clan sett on production, the hunting sett on + * dev. + * + * A dev build installs as its own app beside the production one, so it has to + * be recognisable at a glance in the app drawer and on the splash — the green + * says which of the two copies just opened without anything having to be read. + */ +val AppSett: List = + if (BuildConfig.DEV_BUILD) WallaceHuntingSett else WallaceSett + /** Threads in one full repeat of a sett. */ val List.threadsPerRepeat: Int get() = sumOf { it.threads } @@ -62,7 +100,7 @@ val List.threadsPerRepeat: Int get() = sumOf { it.threads } * and stops reading as tartan at all. */ fun threadWidthFor( - sett: List = WallaceSett, + sett: List = AppSett, across: Dp, repeats: Float = 2f, ): Dp = across / (sett.threadsPerRepeat * repeats) @@ -77,7 +115,7 @@ fun threadWidthFor( * past the edges of the canvas so the corners stay covered once it is turned. */ fun DrawScope.drawTartan( - sett: List = WallaceSett, + sett: List = AppSett, threadSize: Float, rotationDegrees: Float = 0f, ) { @@ -135,7 +173,7 @@ fun DrawScope.drawTartan( @Composable fun TartanBackground( modifier: Modifier = Modifier, - sett: List = WallaceSett, + sett: List = AppSett, threadWidth: Dp = 2.dp, rotationDegrees: Float = 0f, ) { diff --git a/app/src/main/java/org/prolibertate/games/update/Updater.kt b/app/src/main/java/org/prolibertate/games/update/Updater.kt index fbfa3f4..4d4f2c5 100644 --- a/app/src/main/java/org/prolibertate/games/update/Updater.kt +++ b/app/src/main/java/org/prolibertate/games/update/Updater.kt @@ -22,6 +22,10 @@ import java.net.URL * a production build's is its release run number, and the two are unrelated. * Versions are therefore only ever compared *within* a channel; moving between * them is treated as an explicit switch rather than an upgrade. + * + * The two also carry different applicationIds — a dev build installs as + * `…games.dev` — so a switch installs the other channel beside this one instead + * of over it, and both can be kept on the same device. */ enum class UpdateChannel(val label: String, val blurb: String) { PRODUCTION("Production", "Stable builds released from main"), @@ -53,7 +57,8 @@ class Updater(private val activity: Activity) { /** * [isChannelSwitch] means this build is from a different channel than * the one installed, so it is an explicit move rather than a newer - * version of the same line. + * version of the same line — and, because the channels have separate + * applicationIds, it lands as a second app rather than an upgrade. */ data class Available(val release: Release, val isChannelSwitch: Boolean) : Result diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 0b06f99..f4ce5b0 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -8,6 +8,12 @@ centre of a wide black band, and the narrow black guard splits the red into pairs. + The stripes reference @color/tartan_field rather than a literal, because the + same threadcount is woven in two colourways: Wallace red on a production + build, Wallace Hunting green on a dev one, so the two installs are told apart + in the app drawer by the icon alone. The build generates that colour; see + resValue in app/build.gradle.kts. + Warp is opaque, weft laid over it at half opacity, which is what gives the blended squares rather than a flat grid. Paths run well past the viewport because the group is rotated 45 degrees; the overspill keeps the corners @@ -22,43 +28,43 @@ android:rotation="45" android:pivotX="54.0" android:pivotY="54.0"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher.xml b/app/src/main/res/mipmap/ic_launcher.xml index 22102a1..5dc5e35 100644 --- a/app/src/main/res/mipmap/ic_launcher.xml +++ b/app/src/main/res/mipmap/ic_launcher.xml @@ -8,6 +8,12 @@ centre of a wide black band, and the narrow black guard splits the red into pairs. + The stripes reference @color/tartan_field rather than a literal, because the + same threadcount is woven in two colourways: Wallace red on a production + build, Wallace Hunting green on a dev one, so the two installs are told apart + in the app drawer by the icon alone. The build generates that colour; see + resValue in app/build.gradle.kts. + Warp is opaque, weft laid over it at half opacity, which is what gives the blended squares rather than a flat grid. Paths run well past the viewport because the group is rotated 45 degrees; the overspill keeps the corners @@ -22,43 +28,43 @@ android:rotation="45" android:pivotX="54.0" android:pivotY="54.0"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher_round.xml b/app/src/main/res/mipmap/ic_launcher_round.xml index 22102a1..5dc5e35 100644 --- a/app/src/main/res/mipmap/ic_launcher_round.xml +++ b/app/src/main/res/mipmap/ic_launcher_round.xml @@ -8,6 +8,12 @@ centre of a wide black band, and the narrow black guard splits the red into pairs. + The stripes reference @color/tartan_field rather than a literal, because the + same threadcount is woven in two colourways: Wallace red on a production + build, Wallace Hunting green on a dev one, so the two installs are told apart + in the app drawer by the icon alone. The build generates that colour; see + resValue in app/build.gradle.kts. + Warp is opaque, weft laid over it at half opacity, which is what gives the blended squares rather than a flat grid. Paths run well past the viewport because the group is rotated 45 degrees; the overspill keeps the corners @@ -22,43 +28,43 @@ android:rotation="45" android:pivotX="54.0" android:pivotY="54.0"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 97c6599..338becd 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,9 +1,16 @@ + - #B3121B #FF000000 #FFFFFFFF - - #4A0B10 + + + #0C0C0C + #F2C518 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1eff924..41e3314 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,8 @@ + - Pro Libertate Games diff --git a/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt b/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt new file mode 100644 index 0000000..9454760 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt @@ -0,0 +1,185 @@ +package org.prolibertate.games.score + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScoreSheetTest { + + private fun sheetOf(vararg names: String): ScoreSheet = + names.foldIndexed(ScoreSheet.of(names.size)) { index, sheet, name -> + sheet.renamed(sheet.players[index].id, name) + } + + @Test + fun `a new sheet seats the players asked for, unnamed`() { + val sheet = ScoreSheet.of(5) + assertEquals(5, sheet.players.size) + assertTrue(sheet.started) + assertTrue(sheet.rounds.isEmpty()) + assertEquals("Player 3", sheet.displayName(sheet.players[2])) + } + + @Test + fun `a table cannot be smaller than two or larger than twelve`() { + assertEquals(ScoreSheet.MIN_PLAYERS, ScoreSheet.of(1).players.size) + assertEquals(ScoreSheet.MAX_PLAYERS, ScoreSheet.of(40).players.size) + } + + @Test + fun `points add up round on round, and can be taken away`() { + val sheet = sheetOf("Alice", "Bob") + .let { it.withRound(mapOf(it.players[0].id to 3, it.players[1].id to -1)) } + .let { it.withRound(mapOf(it.players[0].id to -5, it.players[1].id to 10)) } + + assertEquals(-2, sheet.total(sheet.players[0].id)) + assertEquals(9, sheet.total(sheet.players[1].id)) + assertEquals(2, sheet.rounds.size) + } + + @Test + fun `a player with no entry that round scored nothing`() { + val sheet = sheetOf("Alice", "Bob").let { it.withRound(mapOf(it.players[0].id to 4)) } + assertEquals(0, sheet.total(sheet.players[1].id)) + // Nothing scored is not worth storing. + assertEquals(1, sheet.rounds.first().deltas.size) + } + + @Test + fun `dragging a player into a different order leaves the scores where they were`() { + val start = sheetOf("Alice", "Bob", "Carol") + .let { + it.withRound( + mapOf( + it.players[0].id to 1, + it.players[1].id to 2, + it.players[2].id to 3, + ) + ) + } + val alice = start.players[0].id + val carol = start.players[2].id + + // Carol dragged to the front. + val moved = start.moved(from = 2, to = 0) + + assertEquals(listOf("Carol", "Alice", "Bob"), moved.players.map { it.name }) + assertEquals(3, moved.total(carol)) + assertEquals(1, moved.total(alice)) + } + + @Test + fun `a drag that lands where it started changes nothing`() { + val sheet = sheetOf("Alice", "Bob", "Carol") + assertEquals(sheet, sheet.moved(from = 1, to = 1)) + // Off the end of the list is clamped rather than rejected: a finger can + // always be dragged further than the list is long. + assertEquals(listOf("Bob", "Carol", "Alice"), sheet.moved(from = 0, to = 9).players.map { it.name }) + } + + @Test + fun `renaming touches nobody else`() { + val sheet = sheetOf("Alice", "Bob") + val renamed = sheet.renamed(sheet.players[0].id, "Alasdair") + assertEquals(listOf("Alasdair", "Bob"), renamed.players.map { it.name }) + } + + @Test + fun `a blank name shows as the seat, and is stored as typed`() { + val sheet = sheetOf("Alice", "Bob").let { it.renamed(it.players[0].id, "") } + assertEquals("", sheet.players[0].name) + assertEquals("Player 1", sheet.displayName(sheet.players[0])) + // Whitespace is not a name either. + assertEquals("Player 1", sheet.displayName(sheet.players[0].copy(name = " "))) + } + + @Test + fun `adding a player mid-game gives them a fresh id and no history`() { + val start = sheetOf("Alice", "Bob", "Carol") + .let { it.withRound(mapOf(it.players[1].id to 7)) } + val bob = start.players[1].id + + // Bob leaves and somebody else sits down in his place. The id he was + // scored against must not come round again with him. + val grown = start.withPlayerRemoved(bob).withPlayerAdded() + val newcomer = grown.players.last() + + assertNotEquals(bob, newcomer.id) + assertEquals(0, grown.total(newcomer.id)) + assertEquals(3, grown.players.size) + } + + @Test + fun `removing a player takes their score with them`() { + val start = sheetOf("Alice", "Bob", "Carol") + .let { it.withRound(mapOf(it.players[0].id to 5, it.players[1].id to 6)) } + val bob = start.players[1].id + + val without = start.withPlayerRemoved(bob) + + assertEquals(listOf("Alice", "Carol"), without.players.map { it.name }) + assertEquals(5, without.total(without.players[0].id)) + assertTrue("Bob's points went with him", without.rounds.all { bob !in it.deltas }) + // The round itself stays: it was still played. + assertEquals(1, without.rounds.size) + } + + @Test + fun `shrinking the table drops from the end and prunes those scores`() { + val start = sheetOf("Alice", "Bob", "Carol") + .let { it.withRound(mapOf(it.players[2].id to 9)) } + val carol = start.players[2].id + + val smaller = start.withPlayerCount(2) + + assertEquals(listOf("Alice", "Bob"), smaller.players.map { it.name }) + assertTrue(smaller.rounds.all { carol !in it.deltas }) + } + + @Test + fun `the last two players cannot be removed`() { + val sheet = sheetOf("Alice", "Bob") + assertEquals(sheet, sheet.withPlayerRemoved(sheet.players[0].id)) + } + + @Test + fun `a round can be corrected or struck out`() { + val start = sheetOf("Alice", "Bob") + .let { it.withRound(mapOf(it.players[0].id to 1)) } + .let { it.withRound(mapOf(it.players[0].id to 100)) } + val alice = start.players[0].id + + val corrected = start.withRoundAt(1, mapOf(alice to 10)) + assertEquals(11, corrected.total(alice)) + + val struck = corrected.withoutRound(0) + assertEquals(1, struck.rounds.size) + assertEquals(10, struck.total(alice)) + } + + @Test + fun `points for somebody not at the table are ignored`() { + val sheet = sheetOf("Alice", "Bob") + val stranger = 9999 + val recorded = sheet.withRound(mapOf(sheet.players[0].id to 2, stranger to 50)) + assertEquals(mapOf(sheet.players[0].id to 2), recorded.rounds.first().deltas) + } + + @Test + fun `an out of range round index is left alone rather than crashing`() { + val sheet = sheetOf("Alice", "Bob").withRound(mapOf()) + assertEquals(sheet, sheet.withRoundAt(7, mapOf())) + assertEquals(sheet, sheet.withoutRound(-1)) + assertEquals(sheet, sheet.moved(from = 5, to = 0)) + } + + @Test + fun `totals cover everybody at the table`() { + val sheet = sheetOf("Alice", "Bob", "Carol") + .let { it.withRound(mapOf(it.players[1].id to 4)) } + assertEquals(sheet.players.map { it.id }.toSet(), sheet.totals().keys) + assertEquals(0, sheet.totals()[sheet.players[0].id]) + assertEquals(4, sheet.totals()[sheet.players[1].id]) + } +}