fix(android): settings dropdowns trapped D-pad/controller focus
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m20s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 30s
deb / build-publish (push) Successful in 3m5s
decky / build-publish (push) Successful in 13s
android / android (push) Successful in 3m29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 7m20s
docker / deploy-docs (push) Successful in 6s

ExposedDropdownMenuBox anchors on a read-only OutlinedTextField, and a text field
captures D-pad focus -- directional keys never escape it, so on a TV/controller you
got stuck on the first select. Replace SettingDropdown with a clickable Surface +
DropdownMenu (no text field): D-pad moves between settings, A opens the menu, A
selects an item. Adds a primary-colour focus border so the focused setting reads
across a room.

Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 08:20:55 +00:00
parent 3074b30988
commit d7aa528d7e
@@ -5,7 +5,9 @@ import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -14,14 +16,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -31,6 +33,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -171,8 +174,12 @@ private fun ToggleRow(
} }
} }
/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */ /**
@OptIn(ExperimentalMaterial3Api::class) * A labelled value that opens a menu on click. Uses a clickable [Surface] + [DropdownMenu] rather
* than `ExposedDropdownMenuBox` — that component's read-only text field traps D-pad / controller
* focus (directional keys never leave it), so you can't navigate past it on a TV. Calls [onSelect]
* on a pick. A primary-colour border marks D-pad focus.
*/
@Composable @Composable
private fun <T> SettingDropdown( private fun <T> SettingDropdown(
label: String, label: String,
@@ -181,20 +188,35 @@ private fun <T> SettingDropdown(
onSelect: (T) -> Unit, onSelect: (T) -> Unit,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var focused by remember { mutableStateOf(false) }
val selectedLabel = options.firstOrNull { it.first == selected }?.second val selectedLabel = options.firstOrNull { it.first == selected }?.second
?: options.firstOrNull()?.second.orEmpty() ?: options.firstOrNull()?.second.orEmpty()
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField( Surface(
value = selectedLabel, onClick = { expanded = true },
onValueChange = {}, shape = MaterialTheme.shapes.small,
readOnly = true, color = MaterialTheme.colorScheme.surfaceVariant,
label = { Text(label) }, border = if (focused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) .fillMaxWidth()
.fillMaxWidth(), .onFocusChanged { focused = it.isFocused },
) ) {
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(selectedLabel, style = MaterialTheme.typography.bodyLarge)
}
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (value, lbl) -> options.forEach { (value, lbl) ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(lbl) }, text = { Text(lbl) },