|
|
|
@@ -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
|
|
|
|
@@ -230,7 +231,12 @@ 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 },
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -291,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()),
|
|
|
|
@@ -341,18 +384,71 @@ 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
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@@ -398,6 +494,9 @@ private fun Sheet(
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
@@ -527,7 +626,7 @@ private fun Sheet(
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
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.
|
|
|
|
@@ -535,6 +634,8 @@ private fun Sheet(
|
|
|
|
|
} else {
|
|
|
|
|
PointsField(
|
|
|
|
|
text = pending[player.id].orEmpty(),
|
|
|
|
|
autoFocus = index == 0 && nextRoundFocus > 0,
|
|
|
|
|
focusRequest = nextRoundFocus,
|
|
|
|
|
onText = {
|
|
|
|
|
pending[player.id] = it
|
|
|
|
|
lastTyped = player.id
|
|
|
|
@@ -577,6 +678,7 @@ private fun Sheet(
|
|
|
|
|
onOp(AddRound(amountsAsPoints(pending)))
|
|
|
|
|
pending.clear()
|
|
|
|
|
}
|
|
|
|
|
nextRoundFocus += 1
|
|
|
|
|
lastTyped = -1
|
|
|
|
|
focusPlayer = -1
|
|
|
|
|
},
|
|
|
|
@@ -985,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)) },
|
|
|
|
|