Android: make the manager acknowledge dialogs pad-navigable

Seven sites across six manager screens, all the same shape: something
went wrong (or finished), here is the text, press OK. Every one was an
AlertDialog, so every one was its own focused Android window and killed
the pad for as long as it was up — on the screens where a pad user is
most likely to be stuck, since these are what an import failure or a
bad path actually surfaces.

They collapse to one call each against a new NotifyOverlay: the same
card as the confirmation, one button instead of two, and a scrolling
height-capped body so a long error can be read to the end. The window
dialogs simply clipped it.

The settings info panel folds onto the same thing, deleting the copy
the previous commit had to make of the card. A setting description and
an error notice are the same object; keeping them as one is what stops
the next fix landing on only one of them, which is the exact failure
that commit had to repair between the two info-hint copies.

Worth reading carefully in the memory-card diff: its message dialog
registered controllerFocusable ids for its OK button. That looks like
working controller code and never was — it registered from inside a
dialog window, so the ids landed in the registry, reported positions the
pad behind could navigate onto, and answered nothing. Removing them is
the fix, not a loss of function.

These are only reachable by causing the error they report, so most are
verified by inspection rather than exercised.
This commit is contained in:
Brian Degenhardt
2026-08-02 20:49:36 -07:00
parent dfbc490c2c
commit 6d6ef31a98
8 changed files with 111 additions and 100 deletions
@@ -128,11 +128,11 @@ fun AchievementsScreen(onBack: () -> Unit, viewModel: AchievementsViewModel = vi
}
state.error?.let { error ->
AlertDialog(
onDismissRequest = viewModel::dismissError,
title = { Text("RetroAchievements") },
text = { Text(error) },
confirmButton = { TextButton(onClick = viewModel::dismissError) { Text(str("action.ok")) } },
com.armsx2.ui.common.NotifyOverlay(
title = "RetroAchievements",
message = error,
onDismiss = viewModel::dismissError,
idPrefix = "ra.error",
)
}
@@ -147,11 +147,11 @@ fun BiosManagerScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Bio
)
}
state.error?.let { error ->
AlertDialog(
onDismissRequest = viewModel::dismissError,
title = { Text(str("setup.page.bios.title")) },
text = { Text(error) },
confirmButton = { TextButton(onClick = viewModel::dismissError) { Text(str("action.ok")) } },
com.armsx2.ui.common.NotifyOverlay(
title = str("setup.page.bios.title"),
message = error,
onDismiss = viewModel::dismissError,
idPrefix = "bios.error",
)
}
}
@@ -1,12 +1,15 @@
package com.armsx2.ui.common
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.widthIn
@@ -102,6 +105,71 @@ fun ConfirmOverlay(
}
}
/**
* A one-button acknowledgement — an error, a result, an explanation. The other half of the pair
* with [ConfirmOverlay]: same card, no choice to make.
*
* The body scrolls and is height-capped, and the modal declares that scroll state, so a message
* longer than the panel can be read with the pad's Up/Down once the selection has nowhere left to
* go. Every window dialog this replaces simply clipped long text with no way to reach the rest.
*
* @param idPrefix distinguishes concurrent notices' nav ids and layer.
*/
@Composable
fun NotifyOverlay(
title: String,
message: String,
onDismiss: () -> Unit,
buttonLabel: String = str("action.ok"),
idPrefix: String = "notify",
) {
val layer = "notify-overlay:$idPrefix"
val bodyScroll = rememberScrollState()
PadModal(
key = layer,
onDismiss = onDismiss,
initialFocusId = "$layer.ok",
scrollState = bodyScroll,
) {
Surface(
modifier = Modifier
.padding(24.dp)
.widthIn(max = 420.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)),
tonalElevation = 6.dp,
) {
Column(Modifier.padding(20.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
Text(
message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.heightIn(max = 340.dp)
.verticalScroll(bodyScroll),
)
Spacer(Modifier.height(18.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
ConfirmButton(
label = buttonLabel,
id = "$layer.ok",
onClick = onDismiss,
container = MaterialTheme.colorScheme.primaryContainer,
content = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
}
}
/**
* App-wide confirmation host, for prompts raised from code that has no composition of its own to
* put a [ConfirmOverlay] in — a click handler deep in a view model, say.
@@ -131,11 +131,11 @@ fun MemoryCardScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Memo
)
}
(state.error ?: state.message)?.let { message ->
AlertDialog(
onDismissRequest = viewModel::dismissMessage,
title = { Text(if (state.error != null) str("memcard.title") else str("action.ok")) },
text = { Text(message) },
confirmButton = { TextButton(onClick = viewModel::dismissMessage, modifier = Modifier.controllerFocusable("memcard.message.ok", onConfirm = viewModel::dismissMessage)) { Text(str("action.ok")) } },
com.armsx2.ui.common.NotifyOverlay(
title = if (state.error != null) str("memcard.title") else str("action.ok"),
message = message,
onDismiss = viewModel::dismissMessage,
idPrefix = "memcard.message",
)
}
}
@@ -24,7 +24,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
@@ -114,11 +113,11 @@ fun PatchManagerScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Pa
com.armsx2.MenuSfx.play(com.armsx2.MenuSfx.Event.POPUP_OPEN)
onDispose { com.armsx2.MenuSfx.play(com.armsx2.MenuSfx.Event.POPUP_CLOSE) }
}
AlertDialog(
onDismissRequest = viewModel::dismissMessage,
title = { Text(if (state.error == null) str("action.ok") else str("patches.dialog.patchesAndCheats")) },
text = { Text(message) },
confirmButton = { TextButton(onClick = viewModel::dismissMessage) { Text(str("action.ok")) } },
com.armsx2.ui.common.NotifyOverlay(
title = if (state.error == null) str("action.ok") else str("patches.dialog.patchesAndCheats"),
message = message,
onDismiss = viewModel::dismissMessage,
idPrefix = "patches.message",
)
}
}
@@ -269,11 +268,11 @@ fun PatchesSettingsTab(game: GameInfo? = null, viewModel: PatchManagerViewModel
com.armsx2.MenuSfx.play(com.armsx2.MenuSfx.Event.POPUP_OPEN)
onDispose { com.armsx2.MenuSfx.play(com.armsx2.MenuSfx.Event.POPUP_CLOSE) }
}
AlertDialog(
onDismissRequest = viewModel::dismissMessage,
title = { Text(if (state.error == null) str("action.ok") else str("patches.dialog.patchesAndCheats")) },
text = { Text(message) },
confirmButton = { TextButton(onClick = viewModel::dismissMessage) { Text(str("action.ok")) } },
com.armsx2.ui.common.NotifyOverlay(
title = if (state.error == null) str("action.ok") else str("patches.dialog.patchesAndCheats"),
message = message,
onDismiss = viewModel::dismissMessage,
idPrefix = "patches.message",
)
}
}
@@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -99,13 +98,11 @@ fun SaveManagerScreen(onBack: () -> Unit, viewModel: SaveManagerViewModel = view
}
(state.error ?: state.message)?.let { message ->
AlertDialog(
onDismissRequest = viewModel::dismissMessage,
title = { Text(str("savestate.title.loadManage")) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = viewModel::dismissMessage) { Text(str("action.ok")) }
},
com.armsx2.ui.common.NotifyOverlay(
title = str("savestate.title.loadManage"),
message = message,
onDismiss = viewModel::dismissMessage,
idPrefix = "saves.message",
)
}
}
@@ -1272,67 +1272,15 @@ internal fun InfoHint(title: String, message: String) {
com.armsx2.MenuSfx.play(com.armsx2.MenuSfx.Event.POPUP_OPEN)
onDispose { com.armsx2.MenuSfx.play(com.armsx2.MenuSfx.Event.POPUP_CLOSE) }
}
val layer = "info-hint:$title"
val bodyScroll = rememberScrollState()
com.armsx2.ui.common.PadModal(
key = layer,
// The shared acknowledgement panel: same card, same scrolling body, so a setting
// description behaves exactly like an error notice and neither can drift from the other.
com.armsx2.ui.common.NotifyOverlay(
title = title,
message = message,
onDismiss = { open = false },
// Every setting description is a candidate for being longer than the panel, so the
// pad needs Up/Down to read it — Close is the only thing here to select.
scrollState = bodyScroll,
) {
Surface(
modifier = Modifier
.padding(24.dp)
.widthIn(max = 420.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)),
tonalElevation = 6.dp,
) {
Column(Modifier.padding(20.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
// Cap the height and scroll INSIDE it. AlertDialog did not scroll its text
// slot at all, so a description longer than the slot was CLIPPED mid-sentence
// with no way to reach the rest — which is most of the longer setting
// explanations (reported against Low Latency Mode, cut off at "...turning
// back off if the frame pacing").
Text(
message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.heightIn(max = 340.dp)
.verticalScroll(bodyScroll),
)
Spacer(Modifier.height(18.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Surface(
onClick = { open = false },
modifier = Modifier.controllerFocusable(
controllerId = "$layer.close",
shape = RoundedCornerShape(14.dp),
onConfirm = { open = false },
),
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Text(
str("action.close"),
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
}
}
buttonLabel = str("action.close"),
idPrefix = "info:$title",
)
}
}
@@ -20,7 +20,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -96,11 +95,11 @@ fun TextureManagerScreen(onBack: () -> Unit, viewModel: TextureManagerViewModel
}
}
(state.error ?: state.message)?.let { message ->
AlertDialog(
onDismissRequest = viewModel::dismissMessage,
title = { Text(if (state.error == null) str("action.ok") else str("renderer.section.texturePacks")) },
text = { Text(message) },
confirmButton = { TextButton(onClick = viewModel::dismissMessage) { Text(str("action.ok")) } },
com.armsx2.ui.common.NotifyOverlay(
title = if (state.error == null) str("action.ok") else str("renderer.section.texturePacks"),
message = message,
onDismiss = viewModel::dismissMessage,
idPrefix = "textures.message",
)
}
}