From b9e50faa403ae751886bc9efbe51aa01f6b3dea8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 18 Jun 2026 22:58:35 +0000 Subject: [PATCH] polish(android): grouped Settings cards + ConnectScreen error banner & search indicator - Settings: flat list -> Display / Host / Audio / Overlay sections in outlined cards (SettingsGroup + ToggleRow helpers) with section headers. - ConnectScreen: connection errors now show in a filled errorContainer banner (was plain red text lost in the layout), and a "Searching the local network..." spinner appears while discovery is active but nothing's turned up yet. Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 43 ++++- .../io/unom/punktfunk/SettingsScreen.kt | 151 +++++++++++------- 2 files changed, 133 insertions(+), 61 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index ca080d1..031ec23 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -40,6 +41,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState @@ -228,12 +230,21 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { ) } } else { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - ) + // Result/error: a filled error container reads as a real failure banner, + // not just red text lost in the layout. + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } } Spacer(Modifier.height(16.dp)) } @@ -282,6 +293,26 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { } } + // Active-discovery hint: when we're scanning but nothing's turned up yet, show it's + // working rather than looking idle/empty. + if (nearbyGranted && discovered.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text( + "Searching the local network…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + item(span = { GridItemSpan(maxLineSpan) }) { Spacer(Modifier.height(96.dp)) } 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 ef9503c..34a91a2 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 @@ -7,6 +7,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -19,6 +20,7 @@ import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -34,8 +36,9 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat /** - * Stream settings. Edits are persisted immediately via [onChange]; [onBack] returns to the connect - * screen. Resolution/refresh "Native" resolve from the device display at connect time. + * Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted + * immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native" + * resolve from the device display at connect time. */ @Composable fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) { @@ -48,58 +51,62 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () - BackHandler(onBack = onBack) + // Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off. + val micLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> update(s.copy(micEnabled = granted)) } + Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { Text("Settings", style = MaterialTheme.typography.headlineMedium) val (nw, nh, nhz) = nativeDisplayMode(context) - SettingDropdown( - label = "Resolution", - options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> - (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) - }, - selected = s.width to s.height, - ) { (w, h) -> update(s.copy(width = w, height = h)) } - SettingDropdown( - label = "Refresh rate", - options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl (${nhz} Hz)" else lbl) }, - selected = s.hz, - ) { hz -> update(s.copy(hz = hz)) } + SettingsGroup("Display") { + SettingDropdown( + label = "Resolution", + options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> + (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) + }, + selected = s.width to s.height, + ) { (w, h) -> update(s.copy(width = w, height = h)) } - SettingDropdown( - label = "Bitrate", - options = BITRATE_OPTIONS, - selected = s.bitrateKbps, - ) { kbps -> update(s.copy(bitrateKbps = kbps)) } + SettingDropdown( + label = "Refresh rate", + options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) }, + selected = s.hz, + ) { hz -> update(s.copy(hz = hz)) } - SettingDropdown( - label = "Compositor (virtual-display host backend)", - options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, - selected = s.compositor, - ) { c -> update(s.copy(compositor = c)) } + SettingDropdown( + label = "Bitrate", + options = BITRATE_OPTIONS, + selected = s.bitrateKbps, + ) { kbps -> update(s.copy(bitrateKbps = kbps)) } + } - SettingDropdown( - label = "Controller type", - options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, - selected = s.gamepad, - ) { g -> update(s.copy(gamepad = g)) } + SettingsGroup("Host") { + SettingDropdown( + label = "Compositor", + options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, + selected = s.compositor, + ) { c -> update(s.copy(compositor = c)) } - // Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off. - val micLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { granted -> update(s.copy(micEnabled = granted)) } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text("Microphone", style = MaterialTheme.typography.bodyLarge) - Text( - "Send your mic to the host's virtual microphone", - style = MaterialTheme.typography.bodySmall, - ) - } - Switch( + SettingDropdown( + label = "Controller type", + options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, + selected = s.gamepad, + ) { g -> update(s.copy(gamepad = g)) } + } + + SettingsGroup("Audio") { + ToggleRow( + title = "Microphone", + subtitle = "Send your mic to the host's virtual microphone", checked = s.micEnabled, onCheckedChange = { on -> when { @@ -112,17 +119,10 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () - ) } - // Live stats overlay (FPS / throughput / capture→client latency). A 3-finger tap in-stream - // toggles it without coming back here. - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text("Stats overlay", style = MaterialTheme.typography.bodyLarge) - Text( - "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)", - style = MaterialTheme.typography.bodySmall, - ) - } - Switch( + SettingsGroup("Overlay") { + ToggleRow( + title = "Stats overlay", + subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)", checked = s.statsHudEnabled, onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) }, ) @@ -130,6 +130,47 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () - } } +/** A titled group of settings rendered inside an outlined card. */ +@Composable +private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 4.dp), + ) + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = content, + ) + } + } +} + +/** A title + subtitle on the left, a Switch on the right. */ +@Composable +private fun ToggleRow( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + /** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */ @OptIn(ExperimentalMaterial3Api::class) @Composable