Compare commits

...
Author SHA1 Message Date
Claude 77cc6c5a05 Scorekeeper: ask for each player's name at setup; refocus first cell after a round
CI / Build and check (pull_request) Successful in 9m40s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 04:24:25 +00:00
devadmin 166f85734b Merge pull request 'Scorekeeper: remember the last 10 player names' (#16) from feature/scorekeeper-memory into dev
CI / Build and check (push) Successful in 8m44s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-25 21:34:23 -06:00
Claude 25e38c4bf8 Scorekeeper: remember the last 10 player names and offer them when naming a column
CI / Build and check (pull_request) Successful in 8m50s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 03:16:53 +00:00
devadmin 6bf7522042 Merge pull request 'Meowdoku: rules, AI and screen refinements' (#15) from meow2 into dev
CI / Build and check (push) Successful in 20m52s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-10-25 20:29:24 -06:00
6 changed files with 261 additions and 20 deletions
@@ -0,0 +1,17 @@
package org.prolibertate.games.score
/** The short list of names offered when a column is being named. */
object RecentNames {
const val LIMIT = 10
/**
* [name] moved to the front of [names], trimmed, with any earlier copy
* (compared ignoring case) dropped and the list cut to [LIMIT]. A blank name
* leaves the list as it was.
*/
fun with(names: List<String>, name: String): List<String> {
val typed = name.trim()
if (typed.isEmpty()) return names
return (listOf(typed) + names.filterNot { it.equals(typed, ignoreCase = true) }).take(LIMIT)
}
}
@@ -29,6 +29,7 @@ class ScorekeeperRepository(private val context: Context) {
private object Keys {
val SHEET = stringPreferencesKey("sheet")
val RECENT_NAMES = stringPreferencesKey("recent_names")
}
// Unknown keys are ignored so a sheet written by a newer build can still be
@@ -45,6 +46,24 @@ class ScorekeeperRepository(private val context: Context) {
?: ScoreSheet()
}
/** The names last used on a sheet, most recent first, for the rename dialog. */
val recentNames: Flow<List<String>> = context.scorekeeperDataStore.data.map { prefs ->
prefs[Keys.RECENT_NAMES]
?.let { stored ->
runCatching { json.decodeFromString<List<String>>(stored) }.getOrNull()
}
?: emptyList()
}
suspend fun rememberName(name: String) {
context.scorekeeperDataStore.edit { prefs ->
val current = prefs[Keys.RECENT_NAMES]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
.orEmpty()
prefs[Keys.RECENT_NAMES] = json.encodeToString(RecentNames.with(current, name))
}
}
suspend fun save(sheet: ScoreSheet) {
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
}
@@ -31,8 +31,18 @@ sealed interface SheetOp {
/** Sets a fresh sheet up for [count] players, scored by round or by tally. */
@Serializable
@SerialName("start")
data class StartSheet(val count: Int, val mode: ScoringMode = ScoringMode.ROUNDS) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count, mode)
data class StartSheet(
val count: Int,
val mode: ScoringMode = ScoringMode.ROUNDS,
/** Names for the first seats, in order; a seat past the end stays unnamed. */
val names: List<String> = emptyList(),
) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet {
val seated = ScoreSheet.of(count, mode)
return seated.players.foldIndexed(seated) { index, acc, player ->
names.getOrNull(index)?.let { acc.renamed(player.id, it) } ?: acc
}
}
}
/** Rubs everything out. */
@@ -36,6 +36,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -114,6 +115,8 @@ fun ScorekeeperScreen(
local = repository.sheet.first()
loaded = true
}
val recentNames by repository.recentNames.collectAsState(initial = emptyList())
val nameUsed: (String) -> Unit = { name -> scope.launch { repository.rememberName(name) } }
val shared by session.state.collectAsState()
val role = shared.role
@@ -220,7 +223,7 @@ fun ScorekeeperScreen(
role == Role.GUEST -> Column(modifier = modifier) {
ConnectionBanner(shared = shared, onSync = { session.sync() })
Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
Scoring(Modifier.weight(1f), sheet, change, recentNames, nameUsed)
}
joining && role == Role.NONE ->
@@ -228,11 +231,16 @@ fun ScorekeeperScreen(
!sheet.started -> SetupPane(
modifier = modifier,
onStart = { count, mode -> change(StartSheet(count, mode)) },
recentNames = recentNames,
onStart = { count, mode, names ->
change(StartSheet(count, mode, names))
// Last first, so the list reads in seating order.
names.reversed().forEach(nameUsed)
},
onJoin = { joining = true },
)
else -> Scoring(modifier = modifier, sheet = sheet, onOp = change)
else -> Scoring(modifier, sheet, change, recentNames, nameUsed)
}
}
@@ -289,9 +297,46 @@ fun ScorekeeperScreen(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, onJoin: () -> Unit) {
private fun SetupPane(
modifier: Modifier,
recentNames: List<String>,
onStart: (Int, ScoringMode, List<String>) -> Unit,
onJoin: () -> Unit,
) {
var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
var mode by remember { mutableStateOf(ScoringMode.ROUNDS) }
// -1 while the options are showing; otherwise the seat being named.
var naming by remember { mutableStateOf(-1) }
val names = remember { mutableStateListOf<String>() }
if (naming >= 0) {
NamePane(
modifier = modifier,
seat = naming,
count = count,
name = names.getOrElse(naming) { "" },
// Offered once only: two players on one sheet with the same name
// are indistinguishable.
suggestions = recentNames.filter { recent ->
names.withIndex().none { (seat, taken) ->
seat != naming && taken.trim().equals(recent.trim(), ignoreCase = true)
}
},
onName = { typed ->
while (names.size <= naming) names.add("")
names[naming] = typed
},
onBack = { naming -= 1 },
onNext = {
if (naming + 1 < count) {
naming += 1
} else {
onStart(count, mode, List(count) { names.getOrElse(it) { "" }.trim() })
}
},
)
return
}
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
@@ -339,33 +384,98 @@ private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, o
}
}
Text(
text = "Names come next: tap a column head to name it. Players can be " +
"added, removed or dragged into a different order at any point " +
"without losing anybody's score.",
text = "You will be asked for each name next, or skip them and tap a " +
"column head later. 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, mode) }
PrimaryAction(text = "Next: name the players") { naming = 0 }
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
Text("Join a shared sheet nearby")
}
}
}
/** One seat's name, asked for on its own; a blank leaves the seat as "Player N". */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun NamePane(
modifier: Modifier,
seat: Int,
count: Int,
name: String,
suggestions: List<String>,
onName: (String) -> Unit,
onBack: () -> Unit,
onNext: () -> Unit,
) {
val focus = remember { FocusRequester() }
LaunchedEffect(seat) { focus.requestFocus() }
val last = seat + 1 >= count
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Player ${seat + 1} of $count", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = name,
onValueChange = onName,
placeholder = { Text("Player ${seat + 1}") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = if (last) ImeAction.Done else ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { onNext() }, onDone = { onNext() }),
modifier = Modifier.fillMaxWidth().focusRequester(focus),
)
if (suggestions.isNotEmpty()) {
Text("Recent names", style = MaterialTheme.typography.labelMedium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
suggestions.forEach { recent ->
FilterChip(
selected = recent.equals(name.trim(), ignoreCase = true),
onClick = { onName(recent) },
label = { Text(recent) },
)
}
}
}
PrimaryAction(text = if (last) "Start scoring" else "Next") { onNext() }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onBack) { Text("Back") }
TextButton(onClick = { onName(""); onNext() }) { Text(if (last) "Skip and start" else "Skip") }
}
}
}
// ---------------------------------------------------------------------------
// The sheet: a column per player, a row per round, totals ruled off at the foot
// ---------------------------------------------------------------------------
@Composable
private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun Scoring(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
if (sheet.mode == ScoringMode.TALLY) {
TallySheet(modifier = modifier, sheet = sheet, onOp = onOp)
TallySheet(modifier, sheet, onOp, recentNames, onNameUsed)
} else {
Sheet(modifier = modifier, sheet = sheet, onOp = onOp)
Sheet(modifier, sheet, onOp, recentNames, onNameUsed)
}
}
@Composable
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun Sheet(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
// One scroll state shared by the head, the rows and the totals, so all three
// move together: a column and the name over it must never come apart. Every
// row is built the same way and is therefore exactly the same width, which
@@ -384,6 +494,9 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
var lastTyped by remember { mutableStateOf(-1) }
// The cell to put the cursor in when a round is opened by tapping it.
var focusPlayer by remember { mutableStateOf(-1) }
// Bumped when a round is finished, to put the cursor back in the first cell
// of the row that is now being filled in.
var nextRoundFocus by remember { mutableStateOf(0) }
val editing = if (correcting >= 0) correction else pending
@@ -513,7 +626,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
)
},
trailing = { EmptyCell(width = ADD_COLUMN, height = ROW_HEIGHT) },
) { _, player ->
) { index, player ->
if (correcting >= 0) {
// A round is being corrected above; what is typed here
// is kept, but only one row is edited at a time.
@@ -521,6 +634,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
} else {
PointsField(
text = pending[player.id].orEmpty(),
autoFocus = index == 0 && nextRoundFocus > 0,
focusRequest = nextRoundFocus,
onText = {
pending[player.id] = it
lastTyped = player.id
@@ -563,6 +678,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
onOp(AddRound(amountsAsPoints(pending)))
pending.clear()
}
nextRoundFocus += 1
lastTyped = -1
focusPlayer = -1
},
@@ -587,6 +703,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
RenameDialog(
sheet = sheet,
player = renamed,
recentNames = recentNames,
onNameUsed = onNameUsed,
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onOp(RemovePlayer(renamed.id))
@@ -607,7 +725,13 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
* sheet, so tapping one in the list takes it back off.
*/
@Composable
private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun TallySheet(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
var renaming by remember { mutableStateOf(-1) }
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -676,6 +800,8 @@ private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) ->
RenameDialog(
sheet = sheet,
player = renamed,
recentNames = recentNames,
onNameUsed = onNameUsed,
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onOp(RemovePlayer(renamed.id))
@@ -961,9 +1087,14 @@ private fun PointsCell(text: String, muted: Boolean = false, onClick: (() -> Uni
/** A cell being typed into. Blank means nothing entered; a typed 0 is kept and shown. */
@Composable
private fun PointsField(text: String, onText: (String) -> Unit, autoFocus: Boolean = false) {
private fun PointsField(
text: String,
onText: (String) -> Unit,
autoFocus: Boolean = false,
focusRequest: Int = 0,
) {
val focus = remember { FocusRequester() }
LaunchedEffect(autoFocus) { if (autoFocus) focus.requestFocus() }
LaunchedEffect(autoFocus, focusRequest) { if (autoFocus) focus.requestFocus() }
BasicTextField(
value = text,
onValueChange = { onText(sanitiseAmount(it)) },
@@ -1064,16 +1195,27 @@ private fun ActionBar(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenameDialog(
sheet: ScoreSheet,
player: ScorePlayer,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
onName: (String) -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
) {
// Closing the dialog is what files the name away, so a name is remembered
// once rather than once per keystroke.
val finish = {
onNameUsed(player.name)
onDismiss()
}
val taken = sheet.players.filter { it.id != player.id }.map { it.name.trim().lowercase() }
val suggestions = recentNames.filter { it.trim().lowercase() !in taken }
AlertDialog(
onDismissRequest = onDismiss,
onDismissRequest = finish,
title = { Text("Name this column") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -1084,6 +1226,21 @@ private fun RenameDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
Text("Recent names", style = MaterialTheme.typography.labelMedium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
suggestions.forEach { name ->
FilterChip(
selected = name.equals(player.name.trim(), ignoreCase = true),
onClick = { onName(name) },
label = { Text(name) },
)
}
}
}
TextButton(
onClick = onRemove,
// The last two columns stay: a sheet needs somebody on it.
@@ -1091,7 +1248,7 @@ private fun RenameDialog(
) { Text("Remove this player and their score") }
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
confirmButton = { TextButton(onClick = finish) { Text("Done") } },
)
}
@@ -0,0 +1,30 @@
package org.prolibertate.games.score
import org.junit.Test
import org.junit.Assert.assertEquals
class RecentNamesTest {
@Test
fun newestComesFirst() {
assertEquals(listOf("Bo", "Al"), RecentNames.with(listOf("Al"), "Bo"))
}
@Test
fun repeatMovesToFrontIgnoringCase() {
assertEquals(listOf("al", "Bo"), RecentNames.with(listOf("Bo", "Al"), " al "))
}
@Test
fun blankChangesNothing() {
assertEquals(listOf("Al"), RecentNames.with(listOf("Al"), " "))
}
@Test
fun keepsOnlyTen() {
val names = (1..10).map { "n$it" }
val result = RecentNames.with(names, "new")
assertEquals(10, result.size)
assertEquals("new", result.first())
assertEquals("n9", result.last())
}
}
@@ -199,6 +199,14 @@ class ScoreSheetTest {
assertEquals(ScoringMode.ROUNDS, StartSheet(3).applyTo(ScoreSheet()).mode)
}
@Test
fun startingWithNamesSeatsThemInOrderAndLeavesTheRestUnnamed() {
val sheet = StartSheet(3, names = listOf("Ann", "")).applyTo(ScoreSheet())
assertEquals(listOf("Ann", "", ""), sheet.players.map { it.name })
assertEquals("Ann", sheet.displayName(sheet.players[0]))
assertEquals("Player 2", sheet.displayName(sheet.players[1]))
}
@Test
fun `tally entries add up per player and can be taken back`() {
val sheet = ScoreSheet.of(2, ScoringMode.TALLY)