Let the player name field be cleared

Clearing the name put "Player" straight back before a real one could be
typed. Three things combined to cause it:

- setPlayerName coerced a blank value to "Player" on write, so deleting
  the last character immediately persisted the default.
- The settings flow then emitted that default, and the text field's
  remember was keyed on the stored name, so it re-seeded itself from the
  value that had just been written.
- The default also lived in the stored value, so a fresh install had
  "Player" pre-filled and had to be cleared to begin with.

The stored name is now whatever the user typed, blank included. The
default moved to Settings.displayName, which is what the lobby and seat
labels use, so it applies where a name is shown rather than to what is
being edited. The field keeps its own edit state once touched, and shows
"Player" as placeholder text with a note explaining the fallback.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
Claude
2026-08-06 01:30:36 +00:00
parent 0e387ad6d4
commit 76c136718d
3 changed files with 35 additions and 8 deletions
@@ -25,10 +25,23 @@ data class Settings(
val soundEnabled: Boolean = true, val soundEnabled: Boolean = true,
val animationSpeed: Float = 1.0f, val animationSpeed: Float = 1.0f,
val checkForUpdatesOnLaunch: Boolean = true, val checkForUpdatesOnLaunch: Boolean = true,
val playerName: String = "Player", /**
* Exactly what the user has typed, which may be blank while they are
* mid-edit. Nothing substitutes a default into this value: doing so meant
* that clearing the field wrote "Player" straight back and the text
* snapped back before a real name could be typed.
*/
val playerName: String = "",
/** Which builds the updater offers. Production unless deliberately changed. */ /** Which builds the updater offers. Production unless deliberately changed. */
val updateChannel: UpdateChannel = UpdateChannel.PRODUCTION, val updateChannel: UpdateChannel = UpdateChannel.PRODUCTION,
) { ) {
/**
* The name other players actually see. This is where the default lives, so
* an empty field is a display concern rather than something written back
* into what the user is typing.
*/
val displayName: String get() = playerName.trim().ifBlank { DEFAULT_PLAYER_NAME }
/** Scales a nominal duration by the chosen speed. */ /** Scales a nominal duration by the chosen speed. */
fun scaled(millis: Long): Long = fun scaled(millis: Long): Long =
(millis / animationSpeed.coerceIn(MIN_SPEED, MAX_SPEED)).toLong() (millis / animationSpeed.coerceIn(MIN_SPEED, MAX_SPEED)).toLong()
@@ -36,6 +49,7 @@ data class Settings(
companion object { companion object {
const val MIN_SPEED = 0.5f const val MIN_SPEED = 0.5f
const val MAX_SPEED = 2.0f const val MAX_SPEED = 2.0f
const val DEFAULT_PLAYER_NAME = "Player"
} }
} }
@@ -54,7 +68,7 @@ class SettingsRepository(private val context: Context) {
soundEnabled = prefs[Keys.SOUND] ?: true, soundEnabled = prefs[Keys.SOUND] ?: true,
animationSpeed = prefs[Keys.SPEED] ?: 1.0f, animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true, checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
playerName = prefs[Keys.NAME] ?: "Player", playerName = prefs[Keys.NAME] ?: "",
updateChannel = prefs[Keys.CHANNEL] updateChannel = prefs[Keys.CHANNEL]
?.let { stored -> runCatching { UpdateChannel.valueOf(stored) }.getOrNull() } ?.let { stored -> runCatching { UpdateChannel.valueOf(stored) }.getOrNull() }
?: UpdateChannel.PRODUCTION, ?: UpdateChannel.PRODUCTION,
@@ -79,7 +93,8 @@ class SettingsRepository(private val context: Context) {
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled } context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
} }
/** Stores the name verbatim. The fallback belongs to [Settings.displayName]. */
suspend fun setPlayerName(name: String) { suspend fun setPlayerName(name: String) {
context.dataStore.edit { it[Keys.NAME] = name.trim().ifBlank { "Player" } } context.dataStore.edit { it[Keys.NAME] = name }
} }
} }
@@ -107,7 +107,7 @@ fun AppRoot(env: AppEnv) {
is Route.Setup -> GameSetupScreen( is Route.Setup -> GameSetupScreen(
descriptor = GameCatalog.byId(route.gameId)!!, descriptor = GameCatalog.byId(route.gameId)!!,
playerName = settings.playerName, playerName = settings.displayName,
onBack = { pop() }, onBack = { pop() },
onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) }, onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) },
onHostOnline = { optionsJson -> onHostOnline = { optionsJson ->
@@ -118,7 +118,7 @@ fun AppRoot(env: AppEnv) {
is Route.Lobby -> LobbyScreen( is Route.Lobby -> LobbyScreen(
env = env, env = env,
route = route, route = route,
playerName = settings.playerName, playerName = settings.displayName,
onBack = { pop() }, onBack = { pop() },
onStart = { config, hosting, seat -> onStart = { config, hosting, seat ->
push(Route.Play(config.gameId, config, hosting, seat)) push(Route.Play(config.gameId, config, hosting, seat))
@@ -36,7 +36,11 @@ fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var name by remember(settings.playerName) { mutableStateOf(settings.playerName) } // Null until the user touches the field, at which point their text takes
// over for good. Keying this off the stored name instead meant every
// keystroke re-seeded the field from what had just been written, so the
// caret jumped and a cleared field refilled itself.
var editedName by remember { mutableStateOf<String?>(null) }
var updateStatus by remember { mutableStateOf<String?>(null) } var updateStatus by remember { mutableStateOf<String?>(null) }
var pendingRelease by remember { mutableStateOf<Updater.Release?>(null) } var pendingRelease by remember { mutableStateOf<Updater.Release?>(null) }
@@ -86,14 +90,22 @@ fun SettingsScreen(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
OutlinedTextField( OutlinedTextField(
value = name, value = editedName ?: settings.playerName,
onValueChange = { entered -> onValueChange = { entered ->
name = entered editedName = entered
scope.launch { env.settingsRepository.setPlayerName(entered) } scope.launch { env.settingsRepository.setPlayerName(entered) }
}, },
placeholder = { Text(Settings.DEFAULT_PLAYER_NAME) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) )
if ((editedName ?: settings.playerName).isBlank()) {
Text(
text = "Leave it empty and you'll show up as " +
"${Settings.DEFAULT_PLAYER_NAME}.",
style = MaterialTheme.typography.bodySmall,
)
}
} }
Divider() Divider()