Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77cc6c5a05 | ||
|
|
166f85734b | ||
|
|
25e38c4bf8 | ||
|
|
6bf7522042 |
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user