From d7aa528d7e5d0393c1a13daa3147723e2bdf1164 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 19 Jun 2026 08:20:55 +0000 Subject: [PATCH] fix(android): settings dropdowns trapped D-pad/controller focus 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) --- .../io/unom/punktfunk/SettingsScreen.kt | 58 +++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 34a91a2..b42ba2c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -5,7 +5,9 @@ import android.content.pm.PackageManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope 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.rememberScrollState 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.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuAnchorType -import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -31,6 +33,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp 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 private fun SettingDropdown( label: String, @@ -181,20 +188,35 @@ private fun SettingDropdown( onSelect: (T) -> Unit, ) { var expanded by remember { mutableStateOf(false) } + var focused by remember { mutableStateOf(false) } val selectedLabel = options.firstOrNull { it.first == selected }?.second ?: options.firstOrNull()?.second.orEmpty() - ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { - OutlinedTextField( - value = selectedLabel, - onValueChange = {}, - readOnly = true, - label = { Text(label) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + Box(modifier = Modifier.fillMaxWidth()) { + Surface( + onClick = { expanded = true }, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant, + border = if (focused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, modifier = Modifier - .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) - .fillMaxWidth(), - ) - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused }, + ) { + 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) -> DropdownMenuItem( text = { Text(lbl) },