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:
@@ -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.
|
||||
@@ -41,6 +41,32 @@ Each client is then sent a state **redacted for its own seat**, so opponents'
|
||||
hands and the deck order are never transmitted to devices that should not see
|
||||
them.
|
||||
|
||||
## Scorekeeper
|
||||
|
||||
**Scorekeeper** on the main menu is a pencil for games the app does not deal —
|
||||
darts, cribbage, whatever is actually on the table. Say how many are playing,
|
||||
name them, and write down what each of them scored every round; the running
|
||||
tally sits at the foot of the screen, where the one number anybody looks up is
|
||||
always visible.
|
||||
|
||||
Players can be renamed, added, removed or **dragged into a different order** at
|
||||
any point in a game. That works because points are recorded against a player's
|
||||
id rather than their position, so reordering the sheet moves the columns and
|
||||
nothing else. Ids are handed out once and never reused: somebody sitting down
|
||||
where somebody else got up starts at nought instead of inheriting a stranger's
|
||||
score. Removing a player does take their points with them — a score nobody can
|
||||
see is a score nobody can correct.
|
||||
|
||||
Rounds are not written in ink. Tapping one reopens it for correction or lets it
|
||||
be struck out, and the totals follow.
|
||||
|
||||
The sheet is saved as it is edited, so leaving the app or taking a phone call
|
||||
does not lose the game. It is kept in its own DataStore rather than with the
|
||||
settings, because it is a game in progress rather than a preference.
|
||||
|
||||
`score/ScoreSheet.kt` holds the whole model and, like `game/`, has no Android in
|
||||
it: every rule above is a pure function and unit-tested on the JVM.
|
||||
|
||||
## Settings
|
||||
|
||||
- **Sound** on/off.
|
||||
@@ -51,12 +77,20 @@ them.
|
||||
- **Production** — stable builds, published from `main`.
|
||||
- **Dev** — preview builds, published from `dev` on every green CI run.
|
||||
|
||||
The two install as separate apps, so both can be kept on one device — see
|
||||
[Both channels on one device](#both-channels-on-one-device).
|
||||
|
||||
## Branding
|
||||
|
||||
The launcher icon is a single square of the Wallace tartan set on the bias, and
|
||||
the app opens on the same cloth carrying the name in a Celtic uncial hand for two
|
||||
seconds before fading into the menu.
|
||||
|
||||
A dev build wears **Wallace Hunting** instead — the same threadcount with green
|
||||
where the clan sett has red — and installs as *Pro Libertate Games DEV*. That is
|
||||
what tells the two copies apart when both are on the phone; see
|
||||
[Both channels on one device](#both-channels-on-one-device).
|
||||
|
||||
The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as
|
||||
warp and then as weft at half opacity, which is what produces the blended
|
||||
squares instead of a flat grid. The icon is the same construction as a vector
|
||||
@@ -72,7 +106,9 @@ Two honest caveats:
|
||||
and the narrow black guard sits *between two red blocks*. The icon is
|
||||
generated from the same numbers, so the icon and the splash cannot drift.
|
||||
Weave scale is derived from the sett rather than fixed, so changing the
|
||||
threadcount rescales both instead of silently zooming in.
|
||||
threadcount rescales both instead of silently zooming in. The hunting
|
||||
colourway is the same threadcount passed a different field colour, not a
|
||||
second copy of the numbers, so it cannot fall out of step either.
|
||||
- **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J.
|
||||
Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under
|
||||
the SIL Open Font License 1.1. The full licence is at
|
||||
@@ -101,6 +137,11 @@ smaller than a fingertip.
|
||||
|
||||
Requires JDK 17 and the Android SDK (compileSdk 34).
|
||||
|
||||
A local build is a dev build: it installs as `org.prolibertate.games.dev` under
|
||||
the name *Pro Libertate Games DEV* in the hunting sett, so it goes on beside an
|
||||
installed release rather than over it. Set `PLG_CHANNEL=production` to build the
|
||||
production package instead.
|
||||
|
||||
## CI
|
||||
|
||||
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull
|
||||
@@ -161,9 +202,39 @@ is the run number of the workflow that produced it, and the two workflows count
|
||||
separately. Versions are therefore only ever compared *within* a channel; the app
|
||||
reads the `-dev` suffix in its own `versionName` to know which channel it is on,
|
||||
and treats a change of channel as an explicit switch rather than an upgrade.
|
||||
One consequence: switching channels can be a downgrade as far as Android is
|
||||
concerned, in which case the install is refused until the current copy is
|
||||
uninstalled. The settings screen says so when it applies.
|
||||
|
||||
### Both channels on one device
|
||||
|
||||
A dev build installs under its own `applicationId`, `org.prolibertate.games.dev`,
|
||||
so it is a separate app as far as Android is concerned and can sit beside the
|
||||
production copy rather than replacing it. Each channel still updates itself
|
||||
normally, because an update within a channel keeps the same package.
|
||||
|
||||
Three things move together, all off `isDevBuild` in `app/build.gradle.kts`:
|
||||
|
||||
| | Production | Dev |
|
||||
| --- | --- | --- |
|
||||
| `applicationId` | `org.prolibertate.games` | `org.prolibertate.games.dev` |
|
||||
| Launcher name | Pro Libertate Games | Pro Libertate Games DEV |
|
||||
| Sett | Wallace (red) | Wallace Hunting (green) |
|
||||
|
||||
Anything that is not an explicit `PLG_CHANNEL=production` build is a dev build,
|
||||
so a local `assembleDebug` is a dev build too and installs alongside a release
|
||||
without uninstalling anything.
|
||||
|
||||
They have to move together. A dev package wearing the production name and
|
||||
colours is precisely the mix-up that having both installed is meant to prevent,
|
||||
which is why one flag drives all three rather than each being set by hand. The
|
||||
name and the colours reach the resources through `resValue`, so `app_name`,
|
||||
`tartan_field`, `splash_background` and `ic_launcher_background` are generated
|
||||
by the build and are deliberately *not* in `res/values/` — the icon vectors
|
||||
reference `@color/tartan_field` rather than a literal, which is how one set of
|
||||
vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag
|
||||
into Kotlin, where `AppSett` picks the matching Compose sett for the splash.
|
||||
|
||||
Switching channels in Settings therefore installs the other channel's app
|
||||
alongside this one and leaves this one in place; the settings screen says so
|
||||
when the selected channel is not the installed one.
|
||||
|
||||
The debug keystore is committed on purpose: every build is signed with the same
|
||||
key, so an OTA update installs over the previous one instead of being rejected
|
||||
@@ -202,6 +273,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
||||
chess/ Chess model, FEN, move generation, search
|
||||
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
||||
net/ wire protocol, LAN transport, lobby, match driver
|
||||
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||
settings/ DataStore-backed preferences
|
||||
update/ GitHub Releases OTA updater
|
||||
ui/ Compose screens
|
||||
|
||||
+46
-3
@@ -16,22 +16,63 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty()
|
||||
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
|
||||
val stampVersion = buildChannel.isNotEmpty() && runNumber > 0
|
||||
|
||||
// Anything that is not an explicit production build is a dev build: ci.yml
|
||||
// stamps "dev", and a local build with nothing set counts as dev too.
|
||||
//
|
||||
// This one flag drives everything that makes a dev build a separate app rather
|
||||
// than a replacement for the production one — its own applicationId, its own
|
||||
// launcher name, and the hunting sett in place of the clan sett. They have to
|
||||
// move together: a dev package wearing the production name and colours is
|
||||
// exactly the confusion having both installed is meant to avoid.
|
||||
val isDevBuild = buildChannel != "production"
|
||||
|
||||
android {
|
||||
namespace = "org.prolibertate.games"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.prolibertate.games"
|
||||
// A dev build installs under its own package, so a dev copy and a
|
||||
// production copy can sit side by side on one device instead of one
|
||||
// replacing the other. The namespace above is deliberately left alone:
|
||||
// R and BuildConfig stay in org.prolibertate.games for both, so no
|
||||
// source has to care which package it ended up installed as.
|
||||
applicationId = if (isDevBuild) "org.prolibertate.games.dev" else "org.prolibertate.games"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
// The -dev suffix in versionName is what the installed app reads to
|
||||
// know its own channel, so it must survive into the shipped APK.
|
||||
// know its own channel, so it must survive into the shipped APK. It
|
||||
// tracks isDevBuild rather than the stamp alone, so an unstamped local
|
||||
// build cannot end up claiming a channel its applicationId contradicts.
|
||||
versionCode = if (stampVersion) runNumber else 1
|
||||
versionName = when {
|
||||
stampVersion && buildChannel == "production" -> "1.0.$runNumber"
|
||||
stampVersion -> "1.0.$runNumber-dev"
|
||||
else -> "1.0.0-dev"
|
||||
isDevBuild -> "1.0.0-dev"
|
||||
else -> "1.0.0"
|
||||
}
|
||||
|
||||
// The launcher label. With both copies on the phone the app drawer shows
|
||||
// two entries, and this is what names them apart.
|
||||
resValue(
|
||||
"string",
|
||||
"app_name",
|
||||
if (isDevBuild) "Pro Libertate Games DEV" else "Pro Libertate Games",
|
||||
)
|
||||
|
||||
// The one colour that separates the two setts: Wallace red for
|
||||
// production, Wallace Hunting green for dev. The icon vectors reference
|
||||
// this rather than a literal, and Tartan.kt picks the matching Compose
|
||||
// colour off DEV_BUILD below — the two have to stay in step or the
|
||||
// launcher icon and the splash it opens on would be different cloth.
|
||||
resValue("color", "tartan_field", if (isDevBuild) "#1E7A3C" else "#E01B24")
|
||||
// Shades of the same field, so a launch does not flash the wrong colour
|
||||
// before the tartan is drawn: splash_background is held behind the
|
||||
// window for that instant, ic_launcher_background is the icon's flat
|
||||
// fallback.
|
||||
resValue("color", "splash_background", if (isDevBuild) "#0D2E17" else "#4A0B10")
|
||||
resValue("color", "ic_launcher_background", if (isDevBuild) "#145C2D" else "#B3121B")
|
||||
|
||||
buildConfigField("boolean", "DEV_BUILD", isDevBuild.toString())
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -59,6 +100,8 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
// For DEV_BUILD, which is how the Compose tartan picks its sett.
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import org.prolibertate.games.net.LobbyController
|
||||
import org.prolibertate.games.score.ScorekeeperRepository
|
||||
import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.ui.AppEnv
|
||||
import org.prolibertate.games.ui.AppRoot
|
||||
@@ -24,6 +25,7 @@ class MainActivity : ComponentActivity() {
|
||||
settingsRepository = SettingsRepository(applicationContext),
|
||||
updater = Updater(this),
|
||||
lobby = LobbyController(applicationContext, lifecycleScope),
|
||||
scorekeeper = ScorekeeperRepository(applicationContext),
|
||||
peerId = stablePeerId(),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package org.prolibertate.games.score
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One person on the score sheet.
|
||||
*
|
||||
* Points are recorded against [id] rather than against a position, which is
|
||||
* what lets the players be dragged into a different order without their scores
|
||||
* moving with them. Ids come from [ScoreSheet.nextPlayerId] and are never
|
||||
* reused: somebody added where somebody else was removed must not inherit the
|
||||
* rounds that were scored against them.
|
||||
*
|
||||
* [name] is stored exactly as typed, blank included, so clearing the field
|
||||
* while renaming does not fight back. What to show for a blank name is a
|
||||
* display concern and belongs to [ScoreSheet.displayName].
|
||||
*/
|
||||
@Serializable
|
||||
data class ScorePlayer(val id: Int, val name: String = "")
|
||||
|
||||
/**
|
||||
* What each player scored in one round, keyed by [ScorePlayer.id].
|
||||
*
|
||||
* A player absent from the map scored nothing, which is the same as zero — so
|
||||
* a round where only one person scored carries one entry rather than a column
|
||||
* of noughts.
|
||||
*/
|
||||
@Serializable
|
||||
data class ScoreRound(val deltas: Map<Int, Int> = emptyMap()) {
|
||||
fun delta(playerId: Int): Int = deltas[playerId] ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A pencil-and-paper score sheet: some players, and a row of points per round.
|
||||
*
|
||||
* Every change returns a new sheet rather than mutating this one, so the screen
|
||||
* can hand the result straight to state and persistence without worrying about
|
||||
* which of them holds the live copy. Nothing here touches Android, for the same
|
||||
* reason `game/` does not: it can then be unit-tested on the JVM.
|
||||
*/
|
||||
@Serializable
|
||||
data class ScoreSheet(
|
||||
val players: List<ScorePlayer> = emptyList(),
|
||||
val rounds: List<ScoreRound> = emptyList(),
|
||||
/** The next id to hand out. Only ever counts up; see [ScorePlayer.id]. */
|
||||
val nextPlayerId: Int = 1,
|
||||
) {
|
||||
/** A sheet with nobody on it has not been set up yet. */
|
||||
val started: Boolean get() = players.isNotEmpty()
|
||||
|
||||
/** What to call a player who has not been named: their seat at the table. */
|
||||
fun displayName(player: ScorePlayer): String {
|
||||
val typed = player.name.trim()
|
||||
if (typed.isNotEmpty()) return typed
|
||||
val position = players.indexOfFirst { it.id == player.id }
|
||||
return "Player ${if (position >= 0) position + 1 else player.id}"
|
||||
}
|
||||
|
||||
fun total(playerId: Int): Int = rounds.sumOf { it.delta(playerId) }
|
||||
|
||||
fun totals(): Map<Int, Int> = players.associate { it.id to total(it.id) }
|
||||
|
||||
/**
|
||||
* Grows or shrinks the table to [count], clamped to what a sheet can hold.
|
||||
*
|
||||
* Shrinking drops players from the end and takes their scores with them,
|
||||
* because points recorded against somebody nobody can see are points that
|
||||
* can never be corrected.
|
||||
*/
|
||||
fun withPlayerCount(count: Int): ScoreSheet {
|
||||
val target = count.coerceIn(MIN_PLAYERS, MAX_PLAYERS)
|
||||
if (target == players.size) return this
|
||||
if (target < players.size) return copy(players = players.take(target)).pruned()
|
||||
|
||||
var next = nextPlayerId
|
||||
val added = List(target - players.size) { ScorePlayer(id = next++) }
|
||||
return copy(players = players + added, nextPlayerId = next)
|
||||
}
|
||||
|
||||
fun withPlayerAdded(): ScoreSheet = withPlayerCount(players.size + 1)
|
||||
|
||||
/** Removes one player by id. The last two cannot be removed. */
|
||||
fun withPlayerRemoved(playerId: Int): ScoreSheet {
|
||||
if (players.size <= MIN_PLAYERS) return this
|
||||
if (players.none { it.id == playerId }) return this
|
||||
return copy(players = players.filterNot { it.id == playerId }).pruned()
|
||||
}
|
||||
|
||||
fun renamed(playerId: Int, name: String): ScoreSheet = copy(
|
||||
players = players.map { if (it.id == playerId) it.copy(name = name) else it }
|
||||
)
|
||||
|
||||
/** Moves the player at [from] to [to], which is what a drag amounts to. */
|
||||
fun moved(from: Int, to: Int): ScoreSheet {
|
||||
if (from !in players.indices) return this
|
||||
val target = to.coerceIn(0, players.size - 1)
|
||||
if (from == target) return this
|
||||
val reordered = players.toMutableList()
|
||||
reordered.add(target, reordered.removeAt(from))
|
||||
return copy(players = reordered)
|
||||
}
|
||||
|
||||
fun withRound(deltas: Map<Int, Int>): ScoreSheet = copy(rounds = rounds + roundOf(deltas))
|
||||
|
||||
/** Corrects a round already written down. Out-of-range indices do nothing. */
|
||||
fun withRoundAt(index: Int, deltas: Map<Int, Int>): ScoreSheet {
|
||||
if (index !in rounds.indices) return this
|
||||
return copy(rounds = rounds.toMutableList().also { it[index] = roundOf(deltas) })
|
||||
}
|
||||
|
||||
fun withoutRound(index: Int): ScoreSheet {
|
||||
if (index !in rounds.indices) return this
|
||||
return copy(rounds = rounds.filterIndexed { at, _ -> at != index })
|
||||
}
|
||||
|
||||
/**
|
||||
* A round holding only what is worth storing: entries for players who are
|
||||
* still at the table, and only where somebody actually scored.
|
||||
*/
|
||||
private fun roundOf(deltas: Map<Int, Int>): ScoreRound {
|
||||
val ids = players.map { it.id }.toSet()
|
||||
return ScoreRound(deltas.filterKeys { it in ids }.filterValues { it != 0 })
|
||||
}
|
||||
|
||||
/** Drops points recorded against anybody no longer at the table. */
|
||||
private fun pruned(): ScoreSheet {
|
||||
val ids = players.map { it.id }.toSet()
|
||||
return copy(rounds = rounds.map { round -> ScoreRound(round.deltas.filterKeys { it in ids }) })
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MIN_PLAYERS = 2
|
||||
const val MAX_PLAYERS = 12
|
||||
const val DEFAULT_PLAYERS = 4
|
||||
|
||||
/** A fresh sheet seated for [count] players, none of them named yet. */
|
||||
fun of(count: Int): ScoreSheet = ScoreSheet().withPlayerCount(count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.prolibertate.games.score
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// Its own store rather than a corner of the settings one: a score sheet is
|
||||
// session data with a shape that will keep changing, and it has no business
|
||||
// being rewritten every time somebody moves the animation slider.
|
||||
private val Context.scorekeeperDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "scorekeeper",
|
||||
)
|
||||
|
||||
/**
|
||||
* Keeps the score sheet across the app being left, backgrounded or killed.
|
||||
*
|
||||
* A scorekeeper that forgets the tally because somebody took a phone call is
|
||||
* worse than a pencil, so the sheet is written on every change and read back at
|
||||
* the top of the screen.
|
||||
*/
|
||||
class ScorekeeperRepository(private val context: Context) {
|
||||
|
||||
private object Keys {
|
||||
val SHEET = stringPreferencesKey("sheet")
|
||||
}
|
||||
|
||||
// Unknown keys are ignored so a sheet written by a newer build can still be
|
||||
// read by an older one — the alternative is losing a game in progress to a
|
||||
// channel switch.
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
val sheet: Flow<ScoreSheet> = context.scorekeeperDataStore.data.map { prefs ->
|
||||
prefs[Keys.SHEET]
|
||||
?.let { stored -> runCatching { json.decodeFromString<ScoreSheet>(stored) }.getOrNull() }
|
||||
?: ScoreSheet()
|
||||
}
|
||||
|
||||
suspend fun save(sheet: ScoreSheet) {
|
||||
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -30,13 +31,16 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.R
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.GameCategory
|
||||
import org.prolibertate.games.game.GameDescriptor
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.net.LobbyController
|
||||
import org.prolibertate.games.score.ScorekeeperRepository
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.update.UpdateChannel
|
||||
@@ -47,6 +51,7 @@ class AppEnv(
|
||||
val settingsRepository: SettingsRepository,
|
||||
val updater: Updater,
|
||||
val lobby: LobbyController,
|
||||
val scorekeeper: ScorekeeperRepository,
|
||||
val peerId: String,
|
||||
)
|
||||
|
||||
@@ -60,6 +65,7 @@ class AppEnv(
|
||||
sealed interface Route {
|
||||
data object Menu : Route
|
||||
data object Settings : Route
|
||||
data object Scorekeeper : Route
|
||||
data class Setup(val gameId: String) : Route
|
||||
data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route
|
||||
data class Play(
|
||||
@@ -143,6 +149,7 @@ private fun AppContent(
|
||||
showComingSoon = showComingSoon,
|
||||
onPickGame = { push(Route.Setup(it.id)) },
|
||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||
onScorekeeper = { push(Route.Scorekeeper) },
|
||||
onSettings = { push(Route.Settings) },
|
||||
)
|
||||
|
||||
@@ -152,6 +159,11 @@ private fun AppContent(
|
||||
onBack = { pop() },
|
||||
)
|
||||
|
||||
is Route.Scorekeeper -> ScorekeeperScreen(
|
||||
repository = env.scorekeeper,
|
||||
onBack = { pop() },
|
||||
)
|
||||
|
||||
is Route.Setup -> GameSetupScreen(
|
||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||
playerName = settings.displayName,
|
||||
@@ -185,18 +197,21 @@ private fun AppContent(
|
||||
// Main menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MainMenuScreen(
|
||||
showComingSoon: Boolean,
|
||||
onPickGame: (GameDescriptor) -> Unit,
|
||||
onJoinGame: () -> Unit,
|
||||
onScorekeeper: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Pro Libertate Games") },
|
||||
// The installed name rather than a literal, so a dev build says
|
||||
// so in its own title bar as well as in the app drawer.
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
actions = { TextButton(onClick = onSettings) { Text("Settings") } },
|
||||
)
|
||||
}
|
||||
@@ -207,11 +222,17 @@ fun MainMenuScreen(
|
||||
.padding(padding)
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
Row(
|
||||
// Wraps rather than running off the edge: two buttons and a narrow
|
||||
// phone would otherwise push the second one past the screen.
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") }
|
||||
// Not a game, and deliberately not in the catalogue: it keeps
|
||||
// score for whatever is being played on the actual table.
|
||||
OutlinedButton(onClick = onScorekeeper) { Text("Scorekeeper") }
|
||||
}
|
||||
|
||||
// The column count follows the available width, so a phone in
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
if (settings.updateChannel != env.updater.installedChannel()) {
|
||||
// The channels are numbered independently, so a switch can
|
||||
// be a downgrade as far as Android is concerned.
|
||||
// Each channel installs under its own package, so the other
|
||||
// one arrives as a second app rather than replacing this
|
||||
// one — no downgrade for Android to refuse.
|
||||
Text(
|
||||
text = "You're on a ${env.updater.installedChannel().label} build. " +
|
||||
"Switching channels installs a different build rather than a " +
|
||||
"newer one — if Android refuses it, uninstall this copy first.",
|
||||
"The two channels install as separate apps, so this adds the " +
|
||||
"${settings.updateChannel.label} build alongside this one and " +
|
||||
"leaves it in place.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.BuildConfig
|
||||
|
||||
/** One band of a tartan sett, measured in threads. */
|
||||
data class TartanStripe(val color: Color, val threads: Int)
|
||||
@@ -18,6 +19,15 @@ val WallaceRed = Color(0xFFE01B24)
|
||||
val WallaceBlack = Color(0xFF0C0C0C)
|
||||
val WallaceGold = Color(0xFFF2C518)
|
||||
|
||||
/**
|
||||
* The green the hunting sett puts where the clan sett puts red.
|
||||
*
|
||||
* Must match `tartan_field` for a dev build in app/build.gradle.kts, which is
|
||||
* what the launcher icon is woven from — otherwise the icon and the splash it
|
||||
* opens on would be visibly different cloth.
|
||||
*/
|
||||
val WallaceHuntingGreen = Color(0xFF1E7A3C)
|
||||
|
||||
/**
|
||||
* Expands a half-sett into a full repeat by reflecting it.
|
||||
*
|
||||
@@ -42,14 +52,42 @@ fun List<TartanStripe>.pivoted(): List<TartanStripe> =
|
||||
* between red and black;
|
||||
* - the narrow black guard sits **between two red blocks**, splitting the red
|
||||
* into pairs.
|
||||
*
|
||||
* The threadcount itself lives in [wallaceSett], because the hunting colourway
|
||||
* below is the same cloth in a different colour and must not drift from it.
|
||||
*/
|
||||
val WallaceSett: List<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(WallaceRed, 32),
|
||||
TartanStripe(field, 32),
|
||||
TartanStripe(WallaceBlack, 32),
|
||||
TartanStripe(WallaceGold, 4),
|
||||
).pivoted()
|
||||
|
||||
/**
|
||||
* The cloth this build wears: the clan sett on production, the hunting sett on
|
||||
* dev.
|
||||
*
|
||||
* A dev build installs as its own app beside the production one, so it has to
|
||||
* be recognisable at a glance in the app drawer and on the splash — the green
|
||||
* says which of the two copies just opened without anything having to be read.
|
||||
*/
|
||||
val AppSett: List<TartanStripe> =
|
||||
if (BuildConfig.DEV_BUILD) WallaceHuntingSett else WallaceSett
|
||||
|
||||
/** Threads in one full repeat of a sett. */
|
||||
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.
|
||||
*/
|
||||
fun threadWidthFor(
|
||||
sett: List<TartanStripe> = WallaceSett,
|
||||
sett: List<TartanStripe> = AppSett,
|
||||
across: Dp,
|
||||
repeats: Float = 2f,
|
||||
): Dp = across / (sett.threadsPerRepeat * repeats)
|
||||
@@ -77,7 +115,7 @@ fun threadWidthFor(
|
||||
* past the edges of the canvas so the corners stay covered once it is turned.
|
||||
*/
|
||||
fun DrawScope.drawTartan(
|
||||
sett: List<TartanStripe> = WallaceSett,
|
||||
sett: List<TartanStripe> = AppSett,
|
||||
threadSize: Float,
|
||||
rotationDegrees: Float = 0f,
|
||||
) {
|
||||
@@ -135,7 +173,7 @@ fun DrawScope.drawTartan(
|
||||
@Composable
|
||||
fun TartanBackground(
|
||||
modifier: Modifier = Modifier,
|
||||
sett: List<TartanStripe> = WallaceSett,
|
||||
sett: List<TartanStripe> = AppSett,
|
||||
threadWidth: Dp = 2.dp,
|
||||
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.
|
||||
* Versions are therefore only ever compared *within* a channel; moving between
|
||||
* them is treated as an explicit switch rather than an upgrade.
|
||||
*
|
||||
* The two also carry different applicationIds — a dev build installs as
|
||||
* `…games.dev` — so a switch installs the other channel beside this one instead
|
||||
* of over it, and both can be kept on the same device.
|
||||
*/
|
||||
enum class UpdateChannel(val label: String, val blurb: String) {
|
||||
PRODUCTION("Production", "Stable builds released from main"),
|
||||
@@ -53,7 +57,8 @@ class Updater(private val activity: Activity) {
|
||||
/**
|
||||
* [isChannelSwitch] means this build is from a different channel than
|
||||
* the one installed, so it is an explicit move rather than a newer
|
||||
* version of the same line.
|
||||
* version of the same line — and, because the channels have separate
|
||||
* applicationIds, it lands as a second app rather than an upgrade.
|
||||
*/
|
||||
data class Available(val release: Release, val isChannelSwitch: Boolean) : Result
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
centre of a wide black band, and the narrow black guard splits the red into
|
||||
pairs.
|
||||
|
||||
The stripes reference @color/tartan_field rather than a literal, because the
|
||||
same threadcount is woven in two colourways: Wallace red on a production
|
||||
build, Wallace Hunting green on a dev one, so the two installs are told apart
|
||||
in the app drawer by the icon alone. The build generates that colour; see
|
||||
resValue in app/build.gradle.kts.
|
||||
|
||||
Warp is opaque, weft laid over it at half opacity, which is what gives the
|
||||
blended squares rather than a flat grid. Paths run well past the viewport
|
||||
because the group is rotated 45 degrees; the overspill keeps the corners
|
||||
@@ -22,43 +28,43 @@
|
||||
android:rotation="45"
|
||||
android:pivotX="54.0"
|
||||
android:pivotY="54.0">
|
||||
<path android:fillColor="#0C0C0C" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-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="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-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="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-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="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-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="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
centre of a wide black band, and the narrow black guard splits the red into
|
||||
pairs.
|
||||
|
||||
The stripes reference @color/tartan_field rather than a literal, because the
|
||||
same threadcount is woven in two colourways: Wallace red on a production
|
||||
build, Wallace Hunting green on a dev one, so the two installs are told apart
|
||||
in the app drawer by the icon alone. The build generates that colour; see
|
||||
resValue in app/build.gradle.kts.
|
||||
|
||||
Warp is opaque, weft laid over it at half opacity, which is what gives the
|
||||
blended squares rather than a flat grid. Paths run well past the viewport
|
||||
because the group is rotated 45 degrees; the overspill keeps the corners
|
||||
@@ -22,43 +28,43 @@
|
||||
android:rotation="45"
|
||||
android:pivotX="54.0"
|
||||
android:pivotY="54.0">
|
||||
<path android:fillColor="#0C0C0C" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-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="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-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="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-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="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-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="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
centre of a wide black band, and the narrow black guard splits the red into
|
||||
pairs.
|
||||
|
||||
The stripes reference @color/tartan_field rather than a literal, because the
|
||||
same threadcount is woven in two colourways: Wallace red on a production
|
||||
build, Wallace Hunting green on a dev one, so the two installs are told apart
|
||||
in the app drawer by the icon alone. The build generates that colour; see
|
||||
resValue in app/build.gradle.kts.
|
||||
|
||||
Warp is opaque, weft laid over it at half opacity, which is what gives the
|
||||
blended squares rather than a flat grid. Paths run well past the viewport
|
||||
because the group is rotated 45 degrees; the overspill keeps the corners
|
||||
@@ -22,43 +28,43 @@
|
||||
android:rotation="45"
|
||||
android:pivotX="54.0"
|
||||
android:pivotY="54.0">
|
||||
<path android:fillColor="#0C0C0C" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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="#E01B24" 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="#E01B24" 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="#F2C518" 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:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-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="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-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="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-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="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-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="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-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="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<?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>
|
||||
<color name="ic_launcher_background">#B3121B</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<!-- Shown for the instant before Compose draws, so launch does not flash
|
||||
white before the tartan splash appears. -->
|
||||
<color name="splash_background">#4A0B10</color>
|
||||
|
||||
<!-- The ground the sett is woven on, and the overstripe down its centre.
|
||||
Same numbers as WallaceBlack and WallaceGold in Tartan.kt. -->
|
||||
<color name="tartan_ground">#0C0C0C</color>
|
||||
<color name="tartan_over">#F2C518</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,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>
|
||||
<string name="app_name">Pro Libertate Games</string>
|
||||
</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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user