Merge dev: scorekeeper, and dev beside production

Verified on dev at 956ee4d, the same tree: build, unit tests and Android
lint green (ci.yml run 39).
This commit is contained in:
adrianoftyriel
2026-08-06 18:34:28 -06:00
committed by GitHub
17 changed files with 1432 additions and 140 deletions
+69
View File
@@ -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.
+76 -4
View File
@@ -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.
@@ -51,12 +77,20 @@ them.
- **Production** — stable builds, published from `main`. - **Production** — stable builds, published from `main`.
- **Dev** — preview builds, published from `dev` on every green CI run. - **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 ## Branding
The launcher icon is a single square of the Wallace tartan set on the bias, and 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 the app opens on the same cloth carrying the name in a Celtic uncial hand for two
seconds before fading into the menu. 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 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 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 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 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. 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 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. - **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J.
Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under
the SIL Open Font License 1.1. The full licence is at 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). 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 ## CI
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull - `.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 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, 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. 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 ### Both channels on one device
uninstalled. The settings screen says so when it applies.
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 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 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 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
+46 -3
View File
@@ -16,22 +16,63 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty()
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
val stampVersion = buildChannel.isNotEmpty() && runNumber > 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 { android {
namespace = "org.prolibertate.games" namespace = "org.prolibertate.games"
compileSdk = 34 compileSdk = 34
defaultConfig { 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 minSdk = 24
targetSdk = 34 targetSdk = 34
// The -dev suffix in versionName is what the installed app reads to // 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 versionCode = if (stampVersion) runNumber else 1
versionName = when { versionName = when {
stampVersion && buildChannel == "production" -> "1.0.$runNumber" stampVersion && buildChannel == "production" -> "1.0.$runNumber"
stampVersion -> "1.0.$runNumber-dev" 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 { signingConfigs {
@@ -59,6 +100,8 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
// For DEV_BUILD, which is how the Compose tartan picks its sett.
buildConfig = true
} }
composeOptions { composeOptions {
@@ -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
@@ -30,13 +31,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.prolibertate.games.R
import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.GameCategory 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
@@ -47,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,
) )
@@ -60,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(
@@ -143,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) },
) )
@@ -152,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,
@@ -185,18 +197,21 @@ 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(
topBar = { topBar = {
TopAppBar( 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") } }, actions = { TextButton(onClick = onSettings) { Text("Settings") } },
) )
} }
@@ -207,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()
@@ -139,12 +139,14 @@ fun SettingsScreen(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
if (settings.updateChannel != env.updater.installedChannel()) { if (settings.updateChannel != env.updater.installedChannel()) {
// The channels are numbered independently, so a switch can // Each channel installs under its own package, so the other
// be a downgrade as far as Android is concerned. // one arrives as a second app rather than replacing this
// one — no downgrade for Android to refuse.
Text( Text(
text = "You're on a ${env.updater.installedChannel().label} build. " + text = "You're on a ${env.updater.installedChannel().label} build. " +
"Switching channels installs a different build rather than a " + "The two channels install as separate apps, so this adds the " +
"newer one — if Android refuses it, uninstall this copy first.", "${settings.updateChannel.label} build alongside this one and " +
"leaves it in place.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
@@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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. */ /** One band of a tartan sett, measured in threads. */
data class TartanStripe(val color: Color, val threads: Int) data class TartanStripe(val color: Color, val threads: Int)
@@ -18,6 +19,15 @@ val WallaceRed = Color(0xFFE01B24)
val WallaceBlack = Color(0xFF0C0C0C) val WallaceBlack = Color(0xFF0C0C0C)
val WallaceGold = Color(0xFFF2C518) 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. * Expands a half-sett into a full repeat by reflecting it.
* *
@@ -42,14 +52,42 @@ fun List<TartanStripe>.pivoted(): List<TartanStripe> =
* between red and black; * between red and black;
* - the narrow black guard sits **between two red blocks**, splitting the red * - the narrow black guard sits **between two red blocks**, splitting the red
* into pairs. * 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<TartanStripe> = listOf( val WallaceSett: List<TartanStripe> = 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<TartanStripe> = wallaceSett(WallaceHuntingGreen)
/** The Wallace threadcount woven with [field] in place of the red. */
private fun wallaceSett(field: Color): List<TartanStripe> = listOf(
TartanStripe(WallaceBlack, 4), TartanStripe(WallaceBlack, 4),
TartanStripe(WallaceRed, 32), TartanStripe(field, 32),
TartanStripe(WallaceBlack, 32), TartanStripe(WallaceBlack, 32),
TartanStripe(WallaceGold, 4), TartanStripe(WallaceGold, 4),
).pivoted() ).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<TartanStripe> =
if (BuildConfig.DEV_BUILD) WallaceHuntingSett else WallaceSett
/** Threads in one full repeat of a sett. */ /** Threads in one full repeat of a sett. */
val List<TartanStripe>.threadsPerRepeat: Int get() = sumOf { it.threads } val List<TartanStripe>.threadsPerRepeat: Int get() = sumOf { it.threads }
@@ -62,7 +100,7 @@ val List<TartanStripe>.threadsPerRepeat: Int get() = sumOf { it.threads }
* and stops reading as tartan at all. * and stops reading as tartan at all.
*/ */
fun threadWidthFor( fun threadWidthFor(
sett: List<TartanStripe> = WallaceSett, sett: List<TartanStripe> = AppSett,
across: Dp, across: Dp,
repeats: Float = 2f, repeats: Float = 2f,
): Dp = across / (sett.threadsPerRepeat * repeats) ): 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. * past the edges of the canvas so the corners stay covered once it is turned.
*/ */
fun DrawScope.drawTartan( fun DrawScope.drawTartan(
sett: List<TartanStripe> = WallaceSett, sett: List<TartanStripe> = AppSett,
threadSize: Float, threadSize: Float,
rotationDegrees: Float = 0f, rotationDegrees: Float = 0f,
) { ) {
@@ -135,7 +173,7 @@ fun DrawScope.drawTartan(
@Composable @Composable
fun TartanBackground( fun TartanBackground(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sett: List<TartanStripe> = WallaceSett, sett: List<TartanStripe> = AppSett,
threadWidth: Dp = 2.dp, threadWidth: Dp = 2.dp,
rotationDegrees: Float = 0f, rotationDegrees: Float = 0f,
) { ) {
@@ -22,6 +22,10 @@ import java.net.URL
* a production build's is its release run number, and the two are unrelated. * 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 * Versions are therefore only ever compared *within* a channel; moving between
* them is treated as an explicit switch rather than an upgrade. * 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) { enum class UpdateChannel(val label: String, val blurb: String) {
PRODUCTION("Production", "Stable builds released from main"), 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 * [isChannelSwitch] means this build is from a different channel than
* the one installed, so it is an explicit move rather than a newer * 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 data class Available(val release: Release, val isChannelSwitch: Boolean) : Result
@@ -8,6 +8,12 @@
centre of a wide black band, and the narrow black guard splits the red into centre of a wide black band, and the narrow black guard splits the red into
pairs. 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 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 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 because the group is rotated 45 degrees; the overspill keeps the corners
@@ -22,43 +28,43 @@
android:rotation="45" android:rotation="45"
android:pivotX="54.0" android:pivotX="54.0"
android:pivotY="54.0"> android:pivotY="54.0">
<path android:fillColor="#0C0C0C" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
<path android:fillColor="#E01B24" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group> </group>
</vector> </vector>
+44 -38
View File
@@ -8,6 +8,12 @@
centre of a wide black band, and the narrow black guard splits the red into centre of a wide black band, and the narrow black guard splits the red into
pairs. 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 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 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 because the group is rotated 45 degrees; the overspill keeps the corners
@@ -22,43 +28,43 @@
android:rotation="45" android:rotation="45"
android:pivotX="54.0" android:pivotX="54.0"
android:pivotY="54.0"> android:pivotY="54.0">
<path android:fillColor="#0C0C0C" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
<path android:fillColor="#E01B24" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group> </group>
</vector> </vector>
+44 -38
View File
@@ -8,6 +8,12 @@
centre of a wide black band, and the narrow black guard splits the red into centre of a wide black band, and the narrow black guard splits the red into
pairs. 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 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 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 because the group is rotated 45 degrees; the overspill keeps the corners
@@ -22,43 +28,43 @@
android:rotation="45" android:rotation="45"
android:pivotX="54.0" android:pivotX="54.0"
android:pivotY="54.0"> android:pivotY="54.0">
<path android:fillColor="#0C0C0C" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
<path android:fillColor="#E01B24" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" /> <path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" /> <path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" /> <path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" /> <path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group> </group>
</vector> </vector>
+11 -4
View File
@@ -1,9 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
The colours the sett shares between channels. The one that differs —
tartan_field, red on production and hunting green on dev — is generated by the
build, along with splash_background and ic_launcher_background, which are
shades of it. See resValue in app/build.gradle.kts.
-->
<resources> <resources>
<color name="ic_launcher_background">#B3121B</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- Shown for the instant before Compose draws, so launch does not flash
white before the tartan splash appears. --> <!-- The ground the sett is woven on, and the overstripe down its centre.
<color name="splash_background">#4A0B10</color> Same numbers as WallaceBlack and WallaceGold in Tartan.kt. -->
<color name="tartan_ground">#0C0C0C</color>
<color name="tartan_over">#F2C518</color>
</resources> </resources>
+5 -1
View File
@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
app_name is generated by the build rather than declared here, because it
differs by channel: a dev build installs as "Pro Libertate Games DEV"
alongside the production copy. See resValue in app/build.gradle.kts.
-->
<resources> <resources>
<string name="app_name">Pro Libertate Games</string>
</resources> </resources>
@@ -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])
}
}