From 857d7d7b6b5da389b8a0f968f256d65996acffa9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:41:04 +0200 Subject: [PATCH] feat(android/gamepad): Profiles section + pin-to-hosts dialog in Default settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GamepadSettingsScreen gains the trailing Profiles section (per-profile rows with live pin counts, touch-interface explainer) and a console-styled GamepadPinHostsDialog — controller- and TV-remote-navigable pin management writing KnownHost.pinnedProfileIds through the existing store path. Pin-add was previously touch-only; pinned-card rendering and unpin stay as they were. --- .../io/unom/punktfunk/GamepadDialogs.kt | 135 +++++++++++++++++ .../unom/punktfunk/GamepadSettingsScreen.kt | 137 ++++++++++++++++-- 2 files changed, 261 insertions(+), 11 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index 2aaf2bfe..a3f27ec2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -50,10 +50,12 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.security.ClientIdentity +import io.unom.punktfunk.kit.security.KnownHost import io.unom.punktfunk.models.PendingTrust import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -250,6 +252,139 @@ fun GamepadHostOptionsDialog( } } +/** + * The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the + * desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down + * moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes. + * A toggle is presentation only: it edits the host's pinned cards through the same store write the + * carousel's unpin uses, never the profile itself and never the host's default binding. + * + * Pin state is read live from [pinned] (backed by the host records), so what a switch shows is + * always what the store holds — the row can't disagree with the carousel it feeds. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GamepadPinHostsDialog( + profileName: String, + hosts: List, + pinned: (KnownHost) -> Boolean, + onToggle: (KnownHost) -> Unit, + onDismiss: () -> Unit, +) { + // 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS + // Done, so it starts focused). + var focus by remember { mutableIntStateOf(0) } + BackHandler(onBack = onDismiss) + GamepadNavEffect2D( + active = true, + onDirection = { dir -> + when (dir) { + NavDir.UP -> if (focus > 0) focus-- + NavDir.DOWN -> if (focus < hosts.size) focus++ + // Directional = state-targeted (left → unpinned, right → pinned), so holding a + // direction can't oscillate; asking for the state it's already in is a no-op. + NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) } + NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) } + } + }, + onActivate = { + val kh = hosts.getOrNull(focus) + if (kh != null) onToggle(kh) else onDismiss() + }, + ) + val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp + Box( + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .padding(24.dp) + .widthIn(max = 520.dp) + .heightIn(max = maxCardHeight) + .clip(RoundedCornerShape(24.dp)) + .background(Color(0xF01A1730)) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + "Pin “$profileName”", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Column( + Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (hosts.isEmpty()) { + DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.") + } else { + DialogText("A pinned profile appears as its own card on the host — one press connects with it.") + hosts.forEachIndexed { i, kh -> + PinHostRow( + label = kh.name, + on = pinned(kh), + focused = i == focus, + onClick = { onToggle(kh) }, + ) + } + } + Spacer(Modifier.size(4.dp)) + DialogButton( + "Done", + focused = focus == hosts.size, + primary = true, + enabled = true, + onClick = onDismiss, + ) + } + } + } +} + +/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) { + val visuals = animateConsoleFocus(active = focused) + // Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short + // landscape window pulls itself into view. + val intoView = remember { BringIntoViewRequester() } + LaunchedEffect(focused) { if (focused) intoView.bringIntoView() } + val shape = RoundedCornerShape(14.dp) + Row( + Modifier + .fillMaxWidth() + .bringIntoViewRequester(intoView) + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } + .clip(shape) + .background(visuals.background) + .border(1.dp, visuals.border, shape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.weight(1f)) + ConsoleSwitch(on = on, focused = focused) + } +} + /** * Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a * powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 42ec3017..a0309f3c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -57,6 +57,8 @@ import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import io.unom.punktfunk.kit.deviceBodyVibrator +import io.unom.punktfunk.kit.security.KnownHost +import io.unom.punktfunk.kit.security.KnownHostStore // The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView: // the couch-relevant subset of the touch settings restyled as a console page and fully navigable with @@ -72,6 +74,8 @@ private class GpRow( val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed val activate: () -> Unit, // A → cycle forward (wrapping) / flip val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text) + val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons + val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail) ) @Composable @@ -89,7 +93,35 @@ fun GamepadSettingsScreen( val hasBodyVibrator = remember { deviceBodyVibrator(context) != null } // Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`). val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null } - val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + + // The Profiles section's stores, constructed here the way ConnectScreen constructs its own. + // The catalog is read once per screen entry: this screen can't create or edit profiles + // (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved + // hosts DO change under it — every pin toggle writes one — so they live in state and refresh + // on each toggle, keeping the "Pinned to N hosts" counts honest. + val knownHostStore = remember { KnownHostStore(context) } + val profileStore = remember { ProfileStore(context) } + val profiles = remember { profileStore.all() } + var savedHosts by remember { mutableStateOf(knownHostStore.all()) } + // The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad + // (this screen's nav gates on it, the ConnectScreen-dialog pattern). + var pinProfile by remember { mutableStateOf(null) } + + // Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation + // only: pin appends at the end (card order), unpin removes, and the host's default binding + // (profileId) is never touched. + fun togglePin(kh: KnownHost, profile: StreamProfile) { + val pins = if (profile.id in kh.pinnedProfileIds) { + kh.pinnedProfileIds - profile.id + } else { + kh.pinnedProfileIds + profile.id + } + knownHostStore.save(kh.copy(pinnedProfileIds = pins)) + savedHosts = knownHostStore.all() + } + + val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + + buildProfileRows(profiles, savedHosts) { pinProfile = it } var focus by remember { mutableIntStateOf(0) } if (focus > rows.lastIndex) focus = rows.lastIndex // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the @@ -101,7 +133,9 @@ fun GamepadSettingsScreen( BackHandler(onBack = onBack) GamepadNavEffect2D( - active = navActive, + // The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen + // drops its probes — the pattern ConnectScreen's dialogs use. + active = navActive && pinProfile == null, onDirection = { dir -> when (dir) { NavDir.UP -> if (focus > 0) focus-- @@ -162,16 +196,41 @@ fun GamepadSettingsScreen( .then(if (landscape) Modifier else Modifier.systemBarsPadding()) .padding(ConsoleLegendInset), ) { + // The legend follows the focused row (the desktop console's hints() does the same): + // a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet" + // placeholder does nothing at all — advertising ↔/A on those would be a lie. + val focused = rows.getOrNull(focus) GamepadHintBar( - listOf( - GamepadHint('↔', Color(0xFF9A93C7), "Adjust"), - // Tappable too (touch escape hatch): Change cycles the focused row, Done leaves. - PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() }, - PadGlyph.hint('B', "Done", onClick = onBack), - ), + when { + focused != null && !focused.enabled -> listOf( + PadGlyph.hint('B', "Done", onClick = onBack), + ) + focused != null && !focused.adjustable -> listOf( + PadGlyph.hint('A', "Pin to hosts") { focused.activate() }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) + else -> listOf( + GamepadHint('↔', Color(0xFF9A93C7), "Adjust"), + // Tappable too (touch escape hatch): Change cycles the focused row, Done leaves. + PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) + }, hazeState = hazeState, ) } + + // The pin-to-hosts picker for the activated profile row — the console counterpart of the + // touch UI's per-profile pin toggles in the host edit sheet. + pinProfile?.let { p -> + GamepadPinHostsDialog( + profileName = p.name, + hosts = savedHosts, + pinned = { kh -> p.id in kh.pinnedProfileIds }, + onToggle = { kh -> togglePin(kh, p) }, + onDismiss = { pinProfile = null }, + ) + } } } @@ -180,8 +239,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) // The chevrons keep their layout slot and only fade, so the value never jumps sideways when - // focus arrives; the value colour cross-fades with them. - val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons") + // focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row + // navigates, the empty-catalog placeholder does nothing) never shows them at all. + val chevronAlpha by animateFloatAsState( + if (focused && row.adjustable) 0.6f else 0f, + tween(160), + label = "chevrons", + ) val valueColor by animateColorAsState( Color.White.copy(alpha = if (focused) 1f else 0.6f), tween(160), @@ -216,7 +280,9 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick row.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, - color = Color.White, + // A disabled row (the "No profiles yet" placeholder) dims but stays focusable, + // so its detail line can still explain what would go here. + color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f), maxLines = 1, ) Spacer(Modifier.weight(1f)) @@ -435,3 +501,52 @@ private fun buildSettingsRows( ) { update(s.copy(sc2Capture = it)) }, ) } + +/** + * The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4): + * one row per catalog profile, valued with how many saved hosts pin it, activating into the + * pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the touch + * interface, so an empty catalog shows one dimmed placeholder explaining where they come from + * instead of a dead-looking empty header. + */ +private fun buildProfileRows( + profiles: List, + savedHosts: List, + openPinPicker: (StreamProfile) -> Unit, +): List { + if (profiles.isEmpty()) { + return listOf( + GpRow( + id = "noProfiles", + header = "Profiles", + label = "No profiles yet", + value = "", + detail = "Profiles bundle stream settings for different uses. Create them in the " + + "touch interface, then pin them here as one-press connect cards.", + adjust = { false }, + activate = {}, + adjustable = false, + enabled = false, + ), + ) + } + return profiles.mapIndexed { i, p -> + // Counted straight off the host records, so it agrees with what the carousel renders. + val pins = savedHosts.count { p.id in it.pinnedProfileIds } + GpRow( + id = "profile:${p.id}", + header = if (i == 0) "Profiles" else null, + label = p.name, + value = when (pins) { + 0 -> "Not pinned" + 1 -> "Pinned to 1 host" + else -> "Pinned to $pins hosts" + }, + detail = "Pin this profile to a host and it appears as its own card — one press " + + "connects with it. Profiles are created and edited in the touch interface.", + adjust = { false }, + activate = { openPinPicker(p) }, + adjustable = false, + ) + } +}