diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt index 0ad4a9cb..3835236e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt @@ -365,6 +365,7 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { internal fun EditHostDialog( target: KnownHost, suggestedMacs: List, + profiles: List, onSave: (KnownHost) -> Unit, onDismiss: () -> Unit, ) { @@ -375,6 +376,12 @@ internal fun EditHostDialog( mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", ")) } var clipboard by remember(target) { mutableStateOf(target.clipboardSync) } + // A binding whose profile was deleted reads as "Default settings" (which is what it already + // resolves to) and is cleaned off the record on the next save — never an error state. + var boundId by remember(target, profiles) { + mutableStateOf(target.profileId?.takeIf { id -> profiles.any { it.id == id } }) + } + var pins by remember(target) { mutableStateOf(target.pinnedProfileIds) } AlertDialog( onDismissRequest = onDismiss, title = { Text("Edit host") }, @@ -424,6 +431,17 @@ internal fun EditHostDialog( } Switch(checked = clipboard, onCheckedChange = { clipboard = it }) } + if (profiles.isNotEmpty()) { + HostProfileBinding( + profiles = profiles, + boundId = boundId, + onBind = { boundId = it }, + pins = pins, + onTogglePin = { id -> + pins = if (id in pins) pins - id else pins + id + }, + ) + } } }, confirmButton = { @@ -437,6 +455,8 @@ internal fun EditHostDialog( port = port.toIntOrNull() ?: target.port, mac = KnownHostStore.parseMacs(mac), clipboardSync = clipboard, + profileId = boundId, + pinnedProfileIds = pins, ), ) }, 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 b0456f42..a0bb96a7 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 @@ -53,6 +53,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import io.unom.punktfunk.components.EmptyHostsState import io.unom.punktfunk.components.HostCard +import io.unom.punktfunk.components.HostMenuItem import io.unom.punktfunk.components.SectionLabel import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.NativeBridge @@ -196,6 +197,11 @@ fun ConnectScreen( val identityStore = remember { IdentityStore(context) } val knownHostStore = remember { KnownHostStore(context) } var savedHosts by remember { mutableStateOf(knownHostStore.all()) } + // The settings-profile catalog. Read here (not in the settings screen's copy) because this is + // where profiles are USED: to resolve what a tap connects with, to offer the one-offs, and to + // render the pinned cards. Re-read on entry, since Settings may have changed it in between. + val profileStore = remember { ProfileStore(context) } + var profiles by remember { mutableStateOf(profileStore.all()) } // Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay // rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can // take a minute-plus to advertise again. @@ -268,21 +274,41 @@ fun ConnectScreen( // Issue the native connect (shared by the normal connect and the request-access path). A plain // desktop connect (no library launch) — the library launcher calls [connectToHost] with an id. - suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long = - connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs) + suspend fun connectNative( + id: ClientIdentity, + targetHost: String, + targetPort: Int, + pinHex: String, + timeoutMs: Int, + profile: StreamProfile?, + ): Long = connectToHost( + context, settings.effectiveFor(profile), id, targetHost, targetPort, pinHex, + launch = null, timeoutMs = timeoutMs, + ) // What the stream screen is handed: the settings this connect actually used, plus the HOST's // clipboard decision (a property of the record, not a global). A host we never saved — a // connect that failed to pin — falls back to the on default the setting always had. - fun session(handle: Long, record: KnownHost?) = - ActiveSession(handle, settings, clipboardSync = record?.clipboardSync ?: true) + fun session(handle: Long, record: KnownHost?, profile: StreamProfile?) = ActiveSession( + handle, + settings.effectiveFor(profile), + clipboardSync = record?.clipboardSync ?: true, + profileName = profile?.name, + ) // The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint // the host presented (as an unpaired known host) so the next connect goes straight through and it // appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait // fallback) instead of the error status line — discovery is already restarted when it runs, so // the wait can observe the host reappear. - fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) { + fun doConnectDirect( + targetHost: String, + targetPort: Int, + name: String, + pinHex: String?, + profile: StreamProfile?, + onFailure: (() -> Unit)? = null, + ) { val id = identity ?: run { status = "Identity not ready yet — try again in a moment" return @@ -293,7 +319,7 @@ fun ConnectScreen( status = null discovery.stop() // free the Wi-Fi radio before the stream session scope.launch { - val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS) + val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS, profile) // Cancelled mid-dial: the UI's already been returned (and discovery restarted) by // cancelConnect — drop the just-opened session silently rather than navigating into it. if (thisAttempt.cancelled.get()) { @@ -310,7 +336,7 @@ fun ConnectScreen( record = knownHostStore.trust(targetHost, targetPort, name, fp, paired = false) } } - onConnected(session(handle, record)) + onConnected(session(handle, record, profile)) } else { discovery.start() val token = NativeBridge.nativeTakeLastError() @@ -347,12 +373,15 @@ fun ConnectScreen( // only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…" // overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already // seen live) dial straight through. - fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) { + fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?, oneOffProfile: String?) { if (identity == null) { status = "Identity not ready yet — try again in a moment" return } val kh = knownHostStore.get(targetHost, targetPort) + // Latched here, not per dial attempt: a wake-and-redial must stream with the same profile + // the user asked for, and the "applies from the next session" footers stay truthful. + val profile = profileStore.resolveFor(kh, oneOffProfile) val macs = kh?.mac ?: emptyList() // "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP // address change on a cold boot), else by address:port. Returns the CURRENT advert so we can @@ -363,7 +392,7 @@ fun ConnectScreen( if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) { // Fire-and-forget first packet (harmless if it's awake), then dial-first. scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) } - doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = { + doConnectDirect(targetHost, targetPort, name, pinHex, profile, onFailure = { waker.start( hostName = name, connectsAfter = true, @@ -379,12 +408,12 @@ fun ConnectScreen( knownHostStore.save(kh.copy(address = live.host, port = live.port)) savedHosts = knownHostStore.all() } - doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex) + doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex, profile) }, ) }) } else { - doConnectDirect(targetHost, targetPort, name, pinHex) + doConnectDirect(targetHost, targetPort, name, pinHex, profile) } } @@ -409,7 +438,9 @@ fun ConnectScreen( // Pin the advertised fingerprint for a discovered host (defence against an impostor while // we wait); a manually-typed host has none, so trust-on-first-use. val pinHex = target.advertisedFp ?: "" - val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS) + // A host being trusted for the first time can't have a binding yet, so this is always + // the plain defaults — a profile only ever enters via a later, deliberate choice. + val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS, null) // Cancelled while we were parked: tear the (possibly just-approved) session down and // don't touch UI a fresh action may now own. if (req.cancelled.get()) { @@ -427,7 +458,7 @@ fun ConnectScreen( record = knownHostStore.trust(target.host, target.port, target.name, fp, paired = true) savedHosts = knownHostStore.all() } - onConnected(session(handle, record)) + onConnected(session(handle, record, profile = null)) } else { // Cause-specific: an operator denial, an approval timeout, and a request that // never reached the host are different problems with different fixes. @@ -450,6 +481,10 @@ fun ConnectScreen( targetPort: Int, dh: DiscoveredHost? = null, manualName: String? = null, + // A one-off "Connect with ▸" pick. `null` = follow the host's binding (a plain tap); + // `""` = force the global defaults, which is a real choice on a bound host and must + // therefore survive as a value rather than collapsing into "unset". NEVER rebinds. + oneOffProfile: String? = null, ) { // Every dial/pair path funnels through here — with local network access denied the connect // can only EPERM its way to a 10 s timeout, so ask instead of pretending to try. @@ -465,7 +500,7 @@ fun ConnectScreen( when { // Known host whose advertised fp still matches the pin → silent pinned reconnect. known != null && (adv == null || adv == known.fpHex) -> - doConnect(targetHost, targetPort, known.name, known.fpHex) + doConnect(targetHost, targetPort, known.name, known.fpHex, oneOffProfile) // Known host whose fp changed → force re-pairing (no silent re-trust shortcut). known != null -> pendingTrust = PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED) @@ -480,6 +515,56 @@ fun ConnectScreen( } } + // Toggle a host+profile pin. Presentation only: it never touches the profile itself and never + // changes the host's default binding. + 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() + } + + // The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a + // user who never wants this feature sees no new clutter anywhere but the settings scope chips. + // "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding + // lives in the Edit sheet instead. + fun hostMenu(kh: KnownHost, pin: StreamProfile?): List = buildList { + if (profiles.isEmpty()) return@buildList + if (pin != null) { + add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) }) + } + add( + HostMenuItem("Connect with: Default settings", startsSection = true) { + // The empty reference is "force the defaults", not "unset" — on a bound host that + // is a real, different action from a plain tap. + connect(kh.address, kh.port, oneOffProfile = "") + }, + ) + profiles.forEach { p -> + add(HostMenuItem("Connect with: ${p.name}") { connect(kh.address, kh.port, oneOffProfile = p.id) }) + } + if (pin == null) { + profiles.forEachIndexed { i, p -> + val pinned = p.id in kh.pinnedProfileIds + add( + HostMenuItem( + if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}", + startsSection = i == 0, + ) { togglePin(kh, p) }, + ) + } + } + } + + // The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a + // pinned combination is a plain one-click connect instead of a trip through a menu. + val savedCards = savedHosts.flatMap { kh -> + listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) } + } + var showManualSheet by remember { mutableStateOf(false) } if (gamepadUi) { @@ -487,11 +572,15 @@ fun ConnectScreen( // every action above; the trailing Add Host tile opens the same manual-entry sheet. val tiles = buildList { savedHosts.forEach { kh -> + val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } } add( HomeTile( - id = "saved-${kh.address}:${kh.port}", + id = "saved-${kh.id}", title = kh.name, - subtitle = "${kh.address}:${kh.port}", + // The binding is what a press will actually do, so the tile says so — the + // console can't edit profiles, but it must never lie about which one it uses. + subtitle = bound?.let { "${kh.address}:${kh.port} · ${it.name}" } + ?: "${kh.address}:${kh.port}", filled = true, online = kh.isOnline(discovered, reachable), paired = kh.paired, @@ -499,6 +588,22 @@ fun ConnectScreen( activate = { connect(kh.address, kh.port) }, ), ) + // Pinned host+profile combinations, right after their host: one focus-and-press + // each, which is the affordance a controller surface does well (menus are not). + profileStore.pinsFor(kh).forEach { p -> + add( + HomeTile( + id = "pin-${kh.id}-${p.id}", + title = kh.name, + subtitle = p.name, + filled = true, + online = kh.isOnline(discovered, reachable), + paired = kh.paired, + knownHost = kh, + activate = { connect(kh.address, kh.port, oneOffProfile = p.id) }, + ), + ) + } } discoveredUnsaved.forEach { dh -> add( @@ -621,24 +726,42 @@ fun ConnectScreen( item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") } - items(savedHosts, key = { "saved-${it.address}-${it.port}" }) { kh -> + items(savedCards, key = { it.key }) { entry -> + val kh = entry.host + val pin = entry.pin + val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } } HostCard( name = kh.name, address = "${kh.address}:${kh.port}", status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU, online = kh.isOnline(discovered, reachable), enabled = !connecting, - onConnect = { connect(kh.address, kh.port) }, - onForget = { - knownHostStore.remove(kh) - savedHosts = knownHostStore.all() + // A pinned card connects with ITS profile; the host's own card follows the + // binding, which is exactly what its chip says it will do. + onConnect = { + if (pin != null) { + connect(kh.address, kh.port, oneOffProfile = pin.id) + } else { + connect(kh.address, kh.port) + } }, - onEdit = { editTarget = kh }, + // Edit / Forget / Wake live on the host's own card only: a pinned card is a + // shortcut, not a second host, and offering destructive host actions on it + // would blur exactly that. + onForget = if (pin != null) { + null + } else { + { + knownHostStore.remove(kh) + savedHosts = knownHostStore.all() + } + }, + onEdit = if (pin != null) null else ({ editTarget = kh }), // Explicit wake-only: offered when the host is offline and we have a MAC. Runs // through the WakeController so it shows the "Waking…" overlay and waits for // the host to come online (matched by fingerprint, so a new DHCP address on a // cold boot still counts as "up") rather than firing a single silent packet. - onWake = if (kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) { + onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) { { // The magic packet is UDP broadcast — LNP-blocked like everything else. if (!lnpGranted) { @@ -657,6 +780,10 @@ fun ConnectScreen( } else { null }, + profileLabel = pin?.name ?: bound?.name, + profileProminent = pin != null, + accent = accentColor(pin?.accent ?: bound?.accent), + menuItems = hostMenu(kh, pin), ) } } @@ -753,12 +880,12 @@ fun ConnectScreen( knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true) savedHosts = knownHostStore.all() pendingTrust = null - doConnect(pt.host, pt.port, pt.name, fp) + doConnect(pt.host, pt.port, pt.name, fp, null) } when (pt.kind) { PendingTrust.Kind.TRUST_NEW -> - if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null }) - else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null }) + if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, null) }, onPair, { pendingTrust = null }) + else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, null) }, onPair, { pendingTrust = null }) PendingTrust.Kind.FP_CHANGED -> if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null }) else FingerprintChangedDialog(pt, onPair, { pendingTrust = null }) @@ -841,6 +968,7 @@ fun ConnectScreen( EditHostDialog( target = kh, suggestedMacs = suggested, + profiles = profiles, onSave = onSaveHost, onDismiss = { editTarget = null }, ) @@ -881,6 +1009,15 @@ fun ConnectScreen( ) } +/** + * One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned + * host+profile cards. Pins are additive presentation state on the host record — never duplicated + * host entries, which would fork pairing, trust and renames (design §5.2a). + */ +private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) { + val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}" +} + /** * Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically * as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ProfileUi.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ProfileUi.kt new file mode 100644 index 00000000..a076fa84 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ProfileUi.kt @@ -0,0 +1,295 @@ +package io.unom.punktfunk + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * The scope switcher: the one new settings concept. Selecting a profile puts the WHOLE settings + * surface into that profile's scope — there is one settings UI, never a second parallel editor + * that drifts from it. "Default settings" is the base layer every profile inherits from. + * + * A chips row rather than a menu, because on touch the scopes are worth seeing at a glance and + * there are rarely more than a handful. The overflow beside them manages the SELECTED profile + * (rename / duplicate / delete); with no profiles at all the row is just "Default settings" and a + * "New profile" chip, which is all the clutter a user who never wants this feature ever sees. + */ +@Composable +internal fun ProfileScopeChips( + profiles: List, + selectedId: String?, + onSelect: (String?) -> Unit, + onNew: () -> Unit, + onRename: () -> Unit, + onDuplicate: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterChip( + selected = selectedId == null, + onClick = { onSelect(null) }, + label = { Text("Default settings") }, + ) + profiles.forEach { p -> + FilterChip( + selected = selectedId == p.id, + onClick = { onSelect(p.id) }, + label = { Text(p.name) }, + leadingIcon = p.accent?.let { accent -> + { AccentDot(accentColor(accent) ?: MaterialTheme.colorScheme.primary) } + }, + ) + } + AssistChip( + onClick = onNew, + label = { Text("New profile") }, + leadingIcon = { + Icon(Icons.Filled.Add, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize)) + }, + ) + if (selectedId != null) { + var menu by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { menu = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = "Manage profile") + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem(text = { Text("Rename…") }, onClick = { menu = false; onRename() }) + DropdownMenuItem(text = { Text("Duplicate") }, onClick = { menu = false; onDuplicate() }) + DropdownMenuItem(text = { Text("Delete…") }, onClick = { menu = false; onDelete() }) + } + } + } + } +} + +/** + * Name a profile — used by both "New profile…" and "Rename…". Names must be unique + * case-insensitively: two "Work" chips in a menu are ambiguous, and a `punktfunk://…?profile=Work` + * link would have to refuse rather than guess. [taken] is the live duplicate check, which lets a + * rename keep its own name (and change only its case). + */ +@Composable +internal fun ProfileNameDialog( + title: String, + initialName: String, + confirmLabel: String, + taken: (String) -> Boolean, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf(initialName) } + val trimmed = name.trim() + val duplicate = trimmed.isNotEmpty() && taken(trimmed) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + placeholder = { Text("e.g. Game, Work, Travel") }, + singleLine = true, + isError = duplicate, + ) + Text( + if (duplicate) { + "A profile called “$trimmed” already exists." + } else { + "A profile starts out inheriting every default setting. Whatever you " + + "change while it's selected becomes an override; everything else " + + "keeps following the defaults." + }, + style = MaterialTheme.typography.bodySmall, + color = if (duplicate) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + }, + confirmButton = { + TextButton( + enabled = trimmed.isNotEmpty() && !duplicate, + onClick = { onConfirm(trimmed) }, + ) { Text(confirmLabel) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +/** + * Deleting a profile is not destructive to anything but the profile — a host bound to it falls + * back to the default settings and a card pinned to it disappears, neither of which is an error. + * The warning counts both so the consequence is stated rather than discovered. + */ +@Composable +internal fun DeleteProfileDialog( + profile: StreamProfile, + boundHosts: Int, + pinnedCards: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Delete “${profile.name}”?") }, + text = { + Column { + val consequences = buildList { + if (boundHosts > 0) { + add("$boundHosts ${plural(boundHosts, "host", "hosts")} will fall back to the default settings") + } + if (pinnedCards > 0) { + add("$pinnedCards pinned ${plural(pinnedCards, "card", "cards")} will disappear") + } + } + Text( + if (consequences.isEmpty()) { + "Nothing uses this profile." + } else { + consequences.joinToString(", and ") + "." + }, + ) + Text( + "The settings it overrides aren't lost anywhere else — the defaults stay " + + "exactly as they are.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { TextButton(onClick = onConfirm) { Text("Delete") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +private fun plural(n: Int, one: String, many: String) = if (n == 1) one else many + +/** + * The per-host half of profiles, inside the host's Edit sheet: which profile a plain tap uses + * (the binding — the one thing that IS sticky; "Connect with ▸" on a card never rebinds), and which + * profiles get their own card in the host list. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun HostProfileBinding( + profiles: List, + boundId: String?, + onBind: (String?) -> Unit, + pins: List, + onTogglePin: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val bound = profiles.firstOrNull { it.id == boundId } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = bound?.name ?: "Default settings", + onValueChange = {}, + readOnly = true, + label = { Text("Profile") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Default settings") }, + onClick = { onBind(null); expanded = false }, + ) + profiles.forEach { p -> + DropdownMenuItem( + text = { Text(p.name) }, + onClick = { onBind(p.id); expanded = false }, + ) + } + } + } + Text( + "What a tap on this host connects with.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Pinned cards", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 8.dp), + ) + Text( + "A pinned profile gets its own card beside this host — one tap instead of a menu. " + + "Pinning changes nothing about which profile is the default.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + profiles.forEach { p -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(p.name, modifier = Modifier.weight(1f)) + Checkbox(checked = p.id in pins, onCheckedChange = { onTogglePin(p.id) }) + } + } + } +} + +/** The accent marker a profile's chip and its pinned cards wear. */ +@Composable +internal fun AccentDot(color: Color, size: Int = 10) { + Box(Modifier.size(size.dp).clip(CircleShape).background(color)) +} + +/** `#RRGGBB` → a Compose colour, or null when the stored string isn't one (never a crash). */ +internal fun accentColor(hex: String?): Color? { + val h = hex?.removePrefix("#") ?: return null + if (h.length != 6 || !h.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) return null + return runCatching { Color(h.toLong(16) or 0xFF000000L) }.getOrNull() +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt new file mode 100644 index 00000000..f20d0557 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt @@ -0,0 +1,355 @@ +package io.unom.punktfunk + +import android.content.Context +import io.unom.punktfunk.kit.security.KnownHost +import java.security.SecureRandom +import org.json.JSONObject + +/** + * Client settings profiles — named bundles of setting overrides applied on top of the global + * [Settings] (design/client-settings-profiles.md §4). The Kotlin mirror of + * `crates/pf-client-core/src/profiles.rs`; the model is the same on every client, so get it right + * here rather than re-deciding it. + * + * A profile overrides only the fields the user touched; everything else keeps following the global + * defaults **live**, so fixing a global once fixes it everywhere. That is why an overlay is sparse + * nullable fields rather than a snapshot copy, and why a value is written on touch and cleared only + * on an explicit "reset to default" — never by diffing against the current global at save time. A + * stored value that happens to equal today's global is a legitimate *pin*: the profile keeps it + * when the global later moves. + * + * Only tier-P settings are here. Device facts (which pad this device forwards, whether its console + * UI is on) and host facts (clipboard sync, which lives on the host record) are deliberately absent + * — see the design's §3 curation. + * + * Values are stored exactly as [SettingsStore] persists them — ints for the compositor/gamepad wire + * bytes, enum names for the rest — so there is one encoding of a setting on this platform rather + * than two. The catalog is client-local (v1 has no profile sync or export), so nothing else reads + * it. + */ +data class SettingsOverlay( + val width: Int? = null, + val height: Int? = null, + val hz: Int? = null, + val bitrateKbps: Int? = null, + val renderScale: Double? = null, + val codec: String? = null, + val hdrEnabled: Boolean? = null, + val compositor: Int? = null, + val audioChannels: Int? = null, + val micEnabled: Boolean? = null, + val touchMode: TouchMode? = null, + val mouseMode: MouseMode? = null, + val invertScroll: Boolean? = null, + val gamepad: Int? = null, + val statsVerbosity: StatsVerbosity? = null, + /** + * Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere + * else, but here it is the one knob a marginal link wants turned off per host. + */ + val lowLatencyMode: Boolean? = null, + /** + * Overlay keys a newer build wrote and this one doesn't model — carried through a load→save + * round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client + * must not erase what a newer one stored. + */ + val extra: Map = emptyMap(), +) { + /** The one resolution seam: this overlay on top of [base]. Pure, so it is fully testable. */ + fun apply(base: Settings): Settings = base.copy( + width = width ?: base.width, + height = height ?: base.height, + hz = hz ?: base.hz, + bitrateKbps = bitrateKbps ?: base.bitrateKbps, + renderScale = renderScale ?: base.renderScale, + codec = codec ?: base.codec, + hdrEnabled = hdrEnabled ?: base.hdrEnabled, + compositor = compositor ?: base.compositor, + audioChannels = audioChannels ?: base.audioChannels, + micEnabled = micEnabled ?: base.micEnabled, + touchMode = touchMode ?: base.touchMode, + mouseMode = mouseMode ?: base.mouseMode, + invertScroll = invertScroll ?: base.invertScroll, + gamepad = gamepad ?: base.gamepad, + statsVerbosity = statsVerbosity ?: base.statsVerbosity, + lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode, + ) + + /** + * Record, as overrides, every tier-P field that differs between two settings snapshots. + * + * The settings UI commits a whole `Settings` per control (`update(s.copy(codec = …))`), so it + * can't hand over a list of touched fields — it hands over "what the control was showing" and + * "what it shows now", and the only field that can differ is the one the user just touched. + * + * This is NOT the diff-on-save the design rejects: the comparison is against the EFFECTIVE + * settings the control was displaying, not against the globals, so setting a value back to + * whatever the global happens to be still records an override — the pin. It only ever adds + * overrides; removing one is [clear], a different, explicit operation. + */ + fun absorb(before: Settings, after: Settings): SettingsOverlay = copy( + width = if (after.width != before.width) after.width else width, + height = if (after.height != before.height) after.height else height, + hz = if (after.hz != before.hz) after.hz else hz, + bitrateKbps = if (after.bitrateKbps != before.bitrateKbps) after.bitrateKbps else bitrateKbps, + renderScale = if (after.renderScale != before.renderScale) after.renderScale else renderScale, + codec = if (after.codec != before.codec) after.codec else codec, + hdrEnabled = if (after.hdrEnabled != before.hdrEnabled) after.hdrEnabled else hdrEnabled, + compositor = if (after.compositor != before.compositor) after.compositor else compositor, + audioChannels = if (after.audioChannels != before.audioChannels) after.audioChannels else audioChannels, + micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled, + touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode, + mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode, + invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll, + gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad, + statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity, + lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode, + ) + + /** + * Drop one override by its field name, putting the row back to inheriting. [FIELD_RESOLUTION] + * is the one alias, covering the width/height pair a single control drives. An unknown name is + * a no-op. + */ + fun clear(field: String): SettingsOverlay = when (field) { + FIELD_RESOLUTION -> copy(width = null, height = null) + "refresh_hz" -> copy(hz = null) + "bitrate_kbps" -> copy(bitrateKbps = null) + "render_scale" -> copy(renderScale = null) + "codec" -> copy(codec = null) + "hdr_enabled" -> copy(hdrEnabled = null) + "compositor" -> copy(compositor = null) + "audio_channels" -> copy(audioChannels = null) + "mic_enabled" -> copy(micEnabled = null) + "touch_mode" -> copy(touchMode = null) + "mouse_mode" -> copy(mouseMode = null) + "invert_scroll" -> copy(invertScroll = null) + "gamepad" -> copy(gamepad = null) + "stats_verbosity" -> copy(statsVerbosity = null) + "low_latency_mode" -> copy(lowLatencyMode = null) + else -> this + } + + /** The field names this overlay overrides — what the settings rows draw their markers from. */ + fun overridden(): Set = buildSet { + if (width != null || height != null) add(FIELD_RESOLUTION) + if (hz != null) add("refresh_hz") + if (bitrateKbps != null) add("bitrate_kbps") + if (renderScale != null) add("render_scale") + if (codec != null) add("codec") + if (hdrEnabled != null) add("hdr_enabled") + if (compositor != null) add("compositor") + if (audioChannels != null) add("audio_channels") + if (micEnabled != null) add("mic_enabled") + if (touchMode != null) add("touch_mode") + if (mouseMode != null) add("mouse_mode") + if (invertScroll != null) add("invert_scroll") + if (gamepad != null) add("gamepad") + if (statsVerbosity != null) add("stats_verbosity") + if (lowLatencyMode != null) add("low_latency_mode") + } + + /** + * True when the profile overrides nothing — "inherits everything", the state a freshly created + * profile starts in. A profile holding only a newer build's field is NOT empty. + */ + fun isEmpty(): Boolean = overridden().isEmpty() && extra.isEmpty() + + internal fun toJson(): JSONObject { + val j = JSONObject() + // Unknown keys first, so a modelled field always wins over a stale carried-through one. + extra.forEach { (k, v) -> j.put(k, v) } + width?.let { j.put("width", it) } + height?.let { j.put("height", it) } + hz?.let { j.put("refresh_hz", it) } + bitrateKbps?.let { j.put("bitrate_kbps", it) } + renderScale?.let { j.put("render_scale", it) } + codec?.let { j.put("codec", it) } + hdrEnabled?.let { j.put("hdr_enabled", it) } + compositor?.let { j.put("compositor", it) } + audioChannels?.let { j.put("audio_channels", it) } + micEnabled?.let { j.put("mic_enabled", it) } + touchMode?.let { j.put("touch_mode", it.name) } + mouseMode?.let { j.put("mouse_mode", it.storedName) } + invertScroll?.let { j.put("invert_scroll", it) } + gamepad?.let { j.put("gamepad", it) } + statsVerbosity?.let { j.put("stats_verbosity", it.name) } + lowLatencyMode?.let { j.put("low_latency_mode", it) } + return j + } + + companion object { + /** The width/height pair, which one control drives — the reset alias, as on every client. */ + const val FIELD_RESOLUTION = "resolution" + + /** Keys this build models; everything else in a stored overlay is carried through. */ + private val KNOWN = setOf( + "width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec", + "hdr_enabled", "compositor", "audio_channels", "mic_enabled", "touch_mode", + "mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", "low_latency_mode", + ) + + internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay( + width = j.optIntOrNull("width"), + height = j.optIntOrNull("height"), + hz = j.optIntOrNull("refresh_hz"), + bitrateKbps = j.optIntOrNull("bitrate_kbps"), + renderScale = if (j.has("render_scale")) j.optDouble("render_scale") else null, + codec = j.optStringOrNull("codec"), + hdrEnabled = j.optBooleanOrNull("hdr_enabled"), + compositor = j.optIntOrNull("compositor"), + audioChannels = j.optIntOrNull("audio_channels"), + micEnabled = j.optBooleanOrNull("mic_enabled"), + touchMode = j.optStringOrNull("touch_mode") + ?.let { n -> TouchMode.entries.firstOrNull { it.name == n } }, + mouseMode = j.optStringOrNull("mouse_mode") + ?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } }, + invertScroll = j.optBooleanOrNull("invert_scroll"), + gamepad = j.optIntOrNull("gamepad"), + statsVerbosity = j.optStringOrNull("stats_verbosity") + ?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } }, + lowLatencyMode = j.optBooleanOrNull("low_latency_mode"), + extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) }, + ) + } +} + +/** + * One named bundle of overrides. [id] is stable across renames — host bindings, pinned cards and + * `punktfunk://` links all point at it, never at the name. + */ +data class StreamProfile( + val id: String, + /** User-facing and editable; unique case-insensitively (menus are ambiguous otherwise). */ + val name: String, + /** `#RRGGBB` chip colour. Reserved by the schema; pinned cards tint their subtitle with it. */ + val accent: String? = null, + val overrides: SettingsOverlay = SettingsOverlay(), + /** Profile keys a newer build wrote — preserved across a load→save round-trip. */ + val extra: Map = emptyMap(), +) + +/** What a `profile=` / one-off reference resolved to. Ambiguity is reported, never guessed. */ +enum class ProfileResolution { FOUND, NOT_FOUND, AMBIGUOUS } + +/** + * The profile catalog — client-wide, not per host: "Work" applied to three hosts is one profile, + * and the per-host part is only the binding on the host record ([KnownHost.profileId]). + * + * Stored one JSON string per profile keyed by id in its own `punktfunk_profiles` prefs file — the + * `KnownHostStore` pattern, and deliberately not inside the settings file, which is rewritten + * wholesale by several writers. + */ +class ProfileStore(context: Context) { + private val prefs = + context.applicationContext.getSharedPreferences("punktfunk_profiles", Context.MODE_PRIVATE) + + /** Every profile, name-sorted — the order the scope switcher and the menus show. */ + fun all(): List = prefs.all.values + .mapNotNull { (it as? String)?.let(::parse) } + .sortedBy { it.name.lowercase() } + + fun byId(id: String): StreamProfile? = prefs.getString(id, null)?.let(::parse) + + fun save(profile: StreamProfile) { + prefs.edit().putString(profile.id, encode(profile)).apply() + } + + fun delete(id: String) { + prefs.edit().remove(id).apply() + } + + /** + * Resolve a reference the way every surface must: exact id first, then a unique + * case-insensitive name. Two profiles sharing a name resolve to [ProfileResolution.AMBIGUOUS] + * — a link or a flag naming two profiles must refuse, not pick whichever came first. + */ + fun resolve(reference: String): Pair { + if (reference.isEmpty()) return null to ProfileResolution.NOT_FOUND + byId(reference)?.let { return it to ProfileResolution.FOUND } + val hits = all().filter { it.name.equals(reference, ignoreCase = true) } + return when (hits.size) { + 1 -> hits[0] to ProfileResolution.FOUND + 0 -> null to ProfileResolution.NOT_FOUND + else -> null to ProfileResolution.AMBIGUOUS + } + } + + /** + * Is this name already used (case-insensitively) by a *different* profile? The create/rename + * guard — [except] is the profile being renamed, so renaming "Work" to "work" is allowed. + */ + fun nameTaken(name: String, except: String? = null): Boolean = + all().any { it.name.equals(name, ignoreCase = true) && it.id != except } + + /** + * The profile a connect to [host] should use: the one-off pick, else the host's binding, else + * none. [oneOff] is a reference (id or unique name); the empty string means "force the global + * defaults" — a real choice ("Connect with ▸ Default settings" on a bound host), not "unset", + * which is why it must survive as a value all the way down here. A binding whose profile was + * deleted resolves as none: never an error, never a blocked connect. + */ + fun resolveFor(host: KnownHost?, oneOff: String?): StreamProfile? = when { + oneOff != null -> resolve(oneOff).first + else -> host?.profileId?.let(::byId) + } + + /** [host]'s pinned profiles, in card order, with duplicates and deleted profiles dropped. */ + fun pinsFor(host: KnownHost): List = + host.pinnedProfileIds.distinct().mapNotNull(::byId) + + private fun parse(s: String): StreamProfile? = runCatching { + val j = JSONObject(s) + StreamProfile( + id = j.getString("id"), + name = j.getString("name"), + accent = j.optStringOrNull("accent"), + overrides = SettingsOverlay.fromJson(j.optJSONObject("overrides") ?: JSONObject()), + extra = j.keys().asSequence() + .filter { it !in setOf("id", "name", "accent", "overrides") } + .associateWith { j.get(it) }, + ) + }.getOrNull() + + private fun encode(p: StreamProfile): String { + val j = JSONObject() + p.extra.forEach { (k, v) -> j.put(k, v) } + j.put("id", p.id) + j.put("name", p.name) + p.accent?.let { j.put("accent", it) } + j.put("overrides", p.overrides.toJson()) + return j.toString() + } +} + +/** + * A new, empty profile: it inherits everything, which is the right creation default under + * inherit-by-exception (Duplicate covers "start from that other profile"). The id is 12 lowercase + * hex characters — the shape the Rust `new_profile_id` mints. + */ +fun newProfile(name: String): StreamProfile = StreamProfile(id = newProfileId(), name = name) + +private val PROFILE_ID_RNG = SecureRandom() + +fun newProfileId(): String { + val b = ByteArray(6) + PROFILE_ID_RNG.nextBytes(b) + return b.joinToString("") { "%02x".format(it) } +} + +/** + * The settings a connect to [host] should use: the resolved profile's overrides on top of these + * globals, resolved ONCE per connect (matching the latch-at-connect model the "applies from the + * next session" footers promise). See [ProfileStore.resolveFor] for the precedence. + */ +fun Settings.effectiveFor(profile: StreamProfile?): Settings = + profile?.overrides?.apply(this) ?: this + +// ---- org.json null-vs-absent helpers (optInt and friends can't tell 0 from "not there") --------- + +private fun JSONObject.optIntOrNull(key: String): Int? = if (has(key)) optInt(key) else null +private fun JSONObject.optBooleanOrNull(key: String): Boolean? = + if (has(key)) optBoolean(key) else null + +private fun JSONObject.optStringOrNull(key: String): String? = + if (has(key)) optString(key).ifEmpty { null } else null 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 999c05a5..3c1db1d2 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 @@ -44,6 +44,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -51,9 +52,12 @@ import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -72,12 +76,13 @@ import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.deviceBodyVibrator +import io.unom.punktfunk.kit.security.KnownHostStore /** * Stream settings, organised as an iOS-Settings / Android-system-settings style list of category * subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen * it becomes a two-pane list-detail (the list stays on the left, the detail on the right). Edits - * persist immediately via [onChange]; [onBack] returns to the connect screen. + * persist immediately; [onBack] returns to the connect screen. * * **Structure mirrors the desktop/Apple settings revamp** ([SettingsCategory], and the Windows * client's `app/settings.rs`), so every client reads the same way: General = session/app behaviour, @@ -86,6 +91,12 @@ import io.unom.punktfunk.kit.deviceBodyVibrator * [SettingDropdown]'s `caption` and [ToggleRow]'s `subtitle`) rather than as loose paragraphs * floating between controls; the only form-level notes are the "applies from the next session" * footers, one per affected category. + * + * **One settings UI, not two.** The scope switcher on top edits either the global defaults + * ([onChange], the base layer every profile inherits from) or one profile's overrides — the same + * rows either way, so a profile can never drift from the surface it overrides. In profile scope + * only profileable settings render, every row shows the EFFECTIVE value, and a row the profile + * overrides carries a marker and a reset. See [SettingsOverlay] for the model. */ @Composable fun SettingsScreen( @@ -98,14 +109,48 @@ fun SettingsScreen( * `PUNKTFUNK_SHOT_SETTINGS_SCOPE` seeds its scope. */ initialCategory: SettingsCategory? = null, + /** Seeds the scope the same way, for a screenshot of a profile being edited. */ + initialProfileId: String? = null, ) { - var s by remember { mutableStateOf(initial) } + var globals by remember { mutableStateOf(initial) } val context = LocalContext.current + val profileStore = remember { ProfileStore(context) } + val hostStore = remember { KnownHostStore(context) } + var profiles by remember { mutableStateOf(profileStore.all()) } + // Which layer is being edited: null = the global defaults, else a profile id. Survives rotation + // but not a trip out of Settings — coming back should land on the defaults, which is what the + // host cards actually connect with unless they say otherwise. + var scopeId by rememberSaveable { mutableStateOf(initialProfileId) } + // A profile deleted from under the scope (or a store that never had it) falls back to defaults. + val active = scopeId?.let { id -> profiles.firstOrNull { it.id == id } } + if (scopeId != null && active == null) scopeId = null + var showLicenses by remember { mutableStateOf(false) } var showControllers by remember { mutableStateOf(false) } + var naming by remember { mutableStateOf(null) } + var deleting by remember { mutableStateOf(null) } + + // Every row renders the EFFECTIVE value: the globals with this profile's overrides on top, so a + // row the profile doesn't override reads as the live global — and keeps following it. + val s = globals.effectiveFor(active) + fun update(next: Settings) { - s = next - onChange(next) + val profile = active + if (profile == null) { + globals = next + onChange(next) + } else { + // `absorb` against what the control was SHOWING, so picking today's global value is + // still recorded as an override (the pin) — see [SettingsOverlay.absorb]. + profileStore.save(profile.copy(overrides = profile.overrides.absorb(s, next))) + profiles = profileStore.all() + } + } + + fun resetField(field: String) { + val profile = active ?: return + profileStore.save(profile.copy(overrides = profile.overrides.clear(field))) + profiles = profileStore.all() } // Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off. @@ -133,96 +178,255 @@ fun SettingsScreen( // Selected category persists across rotation (stored by name — null = the bare list on a phone). var selectedName by rememberSaveable { mutableStateOf(initialCategory?.name) } - val selected = selectedName?.let { n -> SettingsCategory.entries.firstOrNull { it.name == n } } + val categories = SettingsCategory.entries.filter { active == null || it.profileable } + val selected = selectedName + ?.let { n -> categories.firstOrNull { it.name == n } } - BoxWithConstraints(Modifier.fillMaxSize()) { - val twoPane = maxWidth >= 640.dp - // A two-column layout must never show an empty detail — land on the first category. - LaunchedEffect(twoPane) { - if (twoPane && selected == null) selectedName = SettingsCategory.General.name - } - - val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back -> - CategoryDetail( - category = cat, - settings = s, - onChange = ::update, - context = context, - onMicChange = onMicChange, - onOpenControllers = { showControllers = true }, - onOpenLicenses = { showLicenses = true }, - onBack = back, + Column(Modifier.fillMaxSize()) { + ProfileScopeChips( + profiles = profiles, + selectedId = active?.id, + onSelect = { id -> + scopeId = id + // About has no profileable rows at all; don't strand the pane on it. + if (id != null && selected?.profileable == false) selectedName = null + }, + onNew = { naming = NameIntent.New }, + onRename = { active?.let { naming = NameIntent.Rename(it) } }, + onDuplicate = { + active?.let { p -> + val copy = newProfile(uniqueName(profileStore, p.name)).copy( + accent = p.accent, + overrides = p.overrides, + ) + profileStore.save(copy) + profiles = profileStore.all() + scopeId = copy.id + } + }, + onDelete = { deleting = active }, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) + if (active != null) { + Text( + "Overrides the default settings for hosts that use “${active.name}”.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), ) } + HorizontalDivider() - if (twoPane) { - BackHandler(onBack = onBack) - Row(Modifier.fillMaxSize()) { - CategoryList( - selected = selected, - twoPane = true, - onSelect = { selectedName = it.name }, - modifier = Modifier.width(300.dp).fillMaxHeight(), - ) - VerticalDivider() - Box(Modifier.weight(1f).fillMaxHeight()) { - // Cross-fade the detail pane as the selected category changes. - AnimatedContent( - targetState = selected ?: SettingsCategory.General, - transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) }, - label = "SettingsPane", - ) { cat -> detail(cat, null) } + CompositionLocalProvider( + LocalSettingsScope provides SettingsScopeState( + profileScope = active != null, + overridden = active?.overrides?.overridden() ?: emptySet(), + onReset = ::resetField, + ), + ) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val twoPane = maxWidth >= 640.dp + // A two-column layout must never show an empty detail — land on the first category. + LaunchedEffect(twoPane, categories) { + if (twoPane && selected == null) selectedName = categories.first().name } - } - } else { - // Compact: the category list pushes to a full-screen detail and back, like the iOS / - // Android system settings — a horizontal slide that tracks the drill-in direction. - BackHandler { if (selected != null) selectedName = null else onBack() } - AnimatedContent( - targetState = selected, - transitionSpec = { - if (targetState != null) { - slideInHorizontally { it } + fadeIn() togetherWith - slideOutHorizontally { -it } + fadeOut() - } else { - slideInHorizontally { -it } + fadeIn() togetherWith - slideOutHorizontally { it } + fadeOut() - } - }, - label = "SettingsPush", - ) { sel -> - if (sel == null) { - CategoryList( - selected = null, - twoPane = false, - onSelect = { selectedName = it.name }, - modifier = Modifier.fillMaxSize(), + + val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back -> + CategoryDetail( + category = cat, + settings = s, + onChange = ::update, + context = context, + onMicChange = onMicChange, + onOpenControllers = { showControllers = true }, + onOpenLicenses = { showLicenses = true }, + onBack = back, ) + } + + if (twoPane) { + BackHandler(onBack = onBack) + Row(Modifier.fillMaxSize()) { + CategoryList( + categories = categories, + selected = selected, + twoPane = true, + onSelect = { selectedName = it.name }, + modifier = Modifier.width(300.dp).fillMaxHeight(), + ) + VerticalDivider() + Box(Modifier.weight(1f).fillMaxHeight()) { + // Cross-fade the detail pane as the selected category changes. + AnimatedContent( + targetState = selected ?: categories.first(), + transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) }, + label = "SettingsPane", + ) { cat -> detail(cat, null) } + } + } } else { - detail(sel) { selectedName = null } + // Compact: the category list pushes to a full-screen detail and back, like the + // iOS / Android system settings — a horizontal slide tracking the drill-in. + BackHandler { if (selected != null) selectedName = null else onBack() } + AnimatedContent( + targetState = selected, + transitionSpec = { + if (targetState != null) { + slideInHorizontally { it } + fadeIn() togetherWith + slideOutHorizontally { -it } + fadeOut() + } else { + slideInHorizontally { -it } + fadeIn() togetherWith + slideOutHorizontally { it } + fadeOut() + } + }, + label = "SettingsPush", + ) { sel -> + if (sel == null) { + CategoryList( + categories = categories, + selected = null, + twoPane = false, + onSelect = { selectedName = it.name }, + modifier = Modifier.fillMaxSize(), + ) + } else { + detail(sel) { selectedName = null } + } + } } } } } + + naming?.let { intent -> + val editing = (intent as? NameIntent.Rename)?.profile + ProfileNameDialog( + title = if (editing == null) "New profile" else "Rename profile", + initialName = editing?.name.orEmpty(), + confirmLabel = if (editing == null) "Create" else "Rename", + taken = { profileStore.nameTaken(it, except = editing?.id) }, + onConfirm = { name -> + val saved = editing?.copy(name = name) ?: newProfile(name) + profileStore.save(saved) + profiles = profileStore.all() + scopeId = saved.id + naming = null + }, + onDismiss = { naming = null }, + ) + } + + deleting?.let { profile -> + val hosts = hostStore.all() + DeleteProfileDialog( + profile = profile, + boundHosts = hosts.count { it.profileId == profile.id }, + pinnedCards = hosts.count { profile.id in it.pinnedProfileIds }, + onConfirm = { + profileStore.delete(profile.id) + profiles = profileStore.all() + scopeId = null + deleting = null + // Bindings and pins are left dangling on purpose: they resolve to "no profile" and + // to "no card", which is exactly right, and rewriting every host record here would + // be a second write pass over data the user didn't ask us to touch. + }, + onDismiss = { deleting = null }, + ) + } } +/** What the name dialog is for. */ +private sealed interface NameIntent { + data object New : NameIntent + data class Rename(val profile: StreamProfile) : NameIntent +} + +/** "Work" → "Work copy" → "Work copy 2" — the first name Duplicate can actually save. */ +private fun uniqueName(store: ProfileStore, base: String): String { + val first = "$base copy" + if (!store.nameTaken(first)) return first + var n = 2 + while (store.nameTaken("$first $n")) n++ + return "$first $n" +} + +// ---- Scope plumbing ---------------------------------------------------------------------------- + +/** + * What the rows need to know about the layer being edited. Carried as a composition local rather + * than threaded through every category and row: the rows are the same rows in both scopes, and the + * scope only decides whether tier-G rows render at all and whether a row wears an override marker. + */ +private class SettingsScopeState( + val profileScope: Boolean, + val overridden: Set, + val onReset: (String) -> Unit, +) + +private val LocalSettingsScope = compositionLocalOf { + SettingsScopeState(profileScope = false, overridden = emptySet(), onReset = {}) +} + +/** + * Wraps rows that are facts about THIS DEVICE or this app rather than about a stream — the console + * UI toggle, the library browser, auto-wake, the controller diagnostics, rumble mirroring, SC2 + * capture (design §3, tiers G and H). They never belong to a profile, so in profile scope they + * simply don't render. + */ +@Composable +private fun DeviceScopeOnly(content: @Composable () -> Unit) { + if (!LocalSettingsScope.current.profileScope) content() +} + +/** + * The accent marker and reset a row wears when the selected profile overrides it. Nothing renders + * in the defaults scope, or on a row the profile inherits — an inherited row shows the live global + * value in the ordinary quiet style, which is the whole point of inherit-by-default. + */ +@Composable +private fun OverrideBadge(field: String?) { + val scope = LocalSettingsScope.current + if (!scope.profileScope || field == null || field !in scope.overridden) return + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + AccentDot(MaterialTheme.colorScheme.primary, size = 8) + Text( + "Overridden", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 6.dp).weight(1f), + ) + TextButton(onClick = { scope.onReset(field) }) { Text("Reset to default") } + } +} + +// ---- Categories -------------------------------------------------------------------------------- + /** * The top-level settings groups — each opens its own subpage (list on phone, split on tablet). * The map and its order are the cross-client one (Apple's `SettingsCategory`, the Windows * NavigationView, the GTK pages): General, Display, Input, Audio, Controllers, About. + * + * [profileable] is false for a category with no profileable rows at all — it isn't offered in + * profile scope, rather than opening onto an empty page. */ -enum class SettingsCategory(val title: String, val icon: ImageVector) { +enum class SettingsCategory( + val title: String, + val icon: ImageVector, + internal val profileable: Boolean = true, +) { General("General", Icons.Filled.Tune), Display("Display", Icons.Filled.Tv), Input("Input", Icons.Filled.TouchApp), Audio("Audio", Icons.AutoMirrored.Filled.VolumeUp), Controllers("Controllers", Icons.Filled.SportsEsports), - About("About", Icons.Filled.Info), + About("About", Icons.Filled.Info, profileable = false), } /** The category list — the settings root. Highlights the [selected] row when it drives a detail pane. */ @Composable private fun CategoryList( + categories: List, selected: SettingsCategory?, twoPane: Boolean, onSelect: (SettingsCategory) -> Unit, @@ -239,7 +443,7 @@ private fun CategoryList( style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(start = 8.dp, bottom = 12.dp), ) - SettingsCategory.entries.forEach { cat -> + categories.forEach { cat -> val highlighted = twoPane && selected == cat Row( modifier = Modifier @@ -307,48 +511,51 @@ private fun CategoryDetail( } } -// ---- The categories ---------------------------------------------------------------------------- - @Composable private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) { - SettingsGroup("Session") { - ToggleRow( - title = "Auto-wake on connect", - subtitle = "Connecting to a saved host that isn't seen on the network sends " + - "Wake-on-LAN and waits for it to boot. Turn off if hosts behind a VPN look " + - "offline when they aren't — connects then go straight through.", - checked = s.autoWakeEnabled, - onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) }, - ) + DeviceScopeOnly { + SettingsGroup("Session") { + ToggleRow( + title = "Auto-wake on connect", + subtitle = "Connecting to a saved host that isn't seen on the network sends " + + "Wake-on-LAN and waits for it to boot. Turn off if hosts behind a VPN look " + + "offline when they aren't — connects then go straight through.", + checked = s.autoWakeEnabled, + onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) }, + ) + } } SettingsGroup("Statistics") { SettingDropdown( label = "Stats overlay", options = STATS_VERBOSITY_OPTIONS, selected = s.statsVerbosity, + field = "stats_verbosity", caption = "Live session stats in a corner overlay — Compact is a single " + "fps · latency · bitrate line, Normal adds the resolution and reliability lines, " + "Detailed adds the decoder, colour and latency-breakdown lines. A 3-finger tap " + "cycles the tiers live.", ) { v -> update(s.copy(statsVerbosity = v)) } } - SettingsGroup("Library") { - ToggleRow( - title = "Game library", - subtitle = "Browse a paired host's Steam and custom games and launch one directly " + - "(press Y on a saved host). No extra host setup.", - checked = s.libraryEnabled, - onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) }, - ) - } - SettingsGroup("Interface") { - ToggleRow( - title = "Controller-optimized UI", - subtitle = "Switch to the console home (host carousel) whenever a controller is " + - "connected. Turn off to keep the touch interface. A TV is always in this mode.", - checked = s.gamepadUiEnabled, - onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) }, - ) + DeviceScopeOnly { + SettingsGroup("Library") { + ToggleRow( + title = "Game library", + subtitle = "Browse a paired host's Steam and custom games and launch one directly " + + "(press Y on a saved host). No extra host setup.", + checked = s.libraryEnabled, + onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) }, + ) + } + SettingsGroup("Interface") { + ToggleRow( + title = "Controller-optimized UI", + subtitle = "Switch to the console home (host carousel) whenever a controller is " + + "connected. Turn off to keep the touch interface. A TV is always in this mode.", + checked = s.gamepadUiEnabled, + onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) }, + ) + } } } @@ -368,6 +575,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an // stored its label carries the live value, like the native row carries ($nw × $nh). ((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"), selected = if (showCustom) -1 to -1 else s.width to s.height, + field = SettingsOverlay.FIELD_RESOLUTION, caption = "The host drives a real virtual output at exactly this size — true pixels, " + "no scaling. “Native display” follows this device's panel.", ) { (w, h) -> @@ -396,6 +604,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an label = "Refresh rate", options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) }, selected = s.hz, + field = "refresh_hz", caption = "“Native” resolves to this display's refresh rate at connect.", ) { hz -> update(s.copy(hz = hz)) } } @@ -407,6 +616,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an // Snap the stored value (a Float round-tripped to Double) to the nearest preset so the // exact Double keys match. selected = RenderScale.PRESETS.minByOrNull { kotlin.math.abs(it - s.renderScale) } ?: 1.0, + field = "render_scale", caption = "Above native supersamples for sharpness, at more bandwidth AND decode; " + "below renders lighter on the host and the link. This device resamples the " + "result to the screen.", @@ -416,6 +626,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an label = "Bitrate", options = BITRATE_OPTIONS, selected = s.bitrateKbps, + field = "bitrate_kbps", caption = "Automatic lets the host decide (its default, clamped to what it supports).", ) { kbps -> update(s.copy(bitrateKbps = kbps)) } @@ -426,6 +637,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an label = "Video codec", options = codecOptionsFor(s.codec, av1Capable), selected = s.codec, + field = "codec", caption = "A preference — the host falls back if it can't encode this one.", ) { c -> update(s.copy(codec = c)) } @@ -442,13 +654,15 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an }, checked = s.hdrEnabled && hdrCapable, enabled = hdrCapable, + field = "hdr_enabled", onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) }, ) } - // The decode pipeline is a fact about THIS device's SoC, not about the stream — the desktop - // clients group their decoder/GPU pickers here for the same reason. Android has no decoder or - // adapter choice (MediaCodec resolves both), so the master toggle is this group's only row. + // The desktop clients group their decoder and GPU pickers here, and keep them out of profiles — + // they are facts about that machine's hardware. Android has neither choice (MediaCodec resolves + // both), and the one knob it does have IS worth varying per host: a marginal link is exactly + // where you want the plain decode path back (design §3 lists it as an Android-only profileable). SettingsGroup("Decoding") { ToggleRow( title = "Low-latency mode", @@ -456,6 +670,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an "mode). On by default — turn off to fall back to the plain decode path if the " + "stream stutters or glitches on this device.", checked = s.lowLatencyMode, + field = "low_latency_mode", onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) }, ) } @@ -465,6 +680,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an label = "Compositor", options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, selected = s.compositor, + field = "compositor", caption = "The backend the host uses for its virtual output (Linux hosts only). A " + "specific choice falls back to auto-detection when that backend isn't available.", ) { c -> update(s.copy(compositor = c)) } @@ -478,6 +694,7 @@ private fun InputSettings(s: Settings, update: (Settings) -> Unit) { label = "Touch input", options = TOUCH_MODE_OPTIONS, selected = s.touchMode, + field = "touch_mode", caption = "Trackpad: relative cursor like a laptop touchpad — tap to click, " + "two-finger tap right-clicks, two fingers scroll, tap-then-drag holds the " + "button. Direct pointer: the cursor jumps to your finger. Touch passthrough: " + @@ -489,6 +706,7 @@ private fun InputSettings(s: Settings, update: (Settings) -> Unit) { label = "Mouse input", options = MOUSE_MODE_OPTIONS, selected = s.mouseMode, + field = "mouse_mode", caption = "Capture locks a connected mouse to the stream and sends relative motion " + "(mouse-look) — best for games; click the stream to re-capture. Desktop leaves " + "the pointer free to enter and leave the stream and sends absolute positions — " + @@ -498,10 +716,11 @@ private fun InputSettings(s: Settings, update: (Settings) -> Unit) { title = "Invert scroll direction", subtitle = "Reverses the wheel and two-finger touch scroll direction sent to the host", checked = s.invertScroll, + field = "invert_scroll", onCheckedChange = { on -> update(s.copy(invertScroll = on)) }, ) - // "Shared clipboard" is NOT here any more: it is a trust decision about one host, so it - // lives on the host record and is edited from that host's Edit sheet. + // "Shared clipboard" is NOT here: it is a trust decision about one host, so it lives on the + // host record and is edited from that host's Edit sheet. } } @@ -512,6 +731,7 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: label = "Audio channels", options = AUDIO_CHANNEL_OPTIONS, selected = s.audioChannels, + field = "audio_channels", caption = "The speaker layout requested from the host. It downmixes if its own " + "output has fewer channels.", ) { ch -> update(s.copy(audioChannels = ch)) } @@ -519,6 +739,7 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: title = "Microphone", subtitle = "This device's microphone feeds the host's virtual microphone", checked = s.micEnabled, + field = "mic_enabled", onCheckedChange = onMicChange, ) } @@ -531,39 +752,42 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo label = "Controller type", options = GAMEPAD_OPTIONS, selected = s.gamepad, + field = "gamepad", caption = "The virtual pad created on the host. Automatic matches your controller — " + "a DualSense keeps adaptive triggers, lightbar, touchpad and motion. Every " + "connected controller is forwarded, each as its own player.", ) { g -> update(s.copy(gamepad = g)) } - ClickableRow( - title = "Connected controllers", - subtitle = "What the app detects, with a live input test", - onClick = onOpenControllers, - ) - // Rumble mirroring needs a body vibrator to mirror ONTO — a TV box has none, so the row - // would be a silent no-op there. - val context = LocalContext.current - val hasBodyVibrator = remember { deviceBodyVibrator(context) != null } - if (hasBodyVibrator) { + DeviceScopeOnly { + ClickableRow( + title = "Connected controllers", + subtitle = "What the app detects, with a live input test", + onClick = onOpenControllers, + ) + // Rumble mirroring needs a body vibrator to mirror ONTO — a TV box has none, so the row + // would be a silent no-op there. + val context = LocalContext.current + val hasBodyVibrator = remember { deviceBodyVibrator(context) != null } + if (hasBodyVibrator) { + ToggleRow( + title = "Rumble on this phone", + subtitle = "Also play controller 1's rumble on this phone's own vibration " + + "motor — for clip-on pads without rumble motors", + checked = s.rumbleOnPhone, + onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) }, + ) + } + // NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do + // with rumbling this device's body, and the gate hid the toggle on exactly the machines + // that most want it — TV boxes, where a Steam Controller 2 is the whole input story. ToggleRow( - title = "Rumble on this phone", - subtitle = "Also play controller 1's rumble on this phone's own vibration " + - "motor — for clip-on pads without rumble motors", - checked = s.rumbleOnPhone, - onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) }, + title = "Steam Controller 2 passthrough", + subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " + + "Bluetooth): it navigates these menus and streams as-is — Steam on the " + + "host drives it like the physical pad (trackpads, gyro, haptics)", + checked = s.sc2Capture, + onCheckedChange = { on -> update(s.copy(sc2Capture = on)) }, ) } - // NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do - // with rumbling this device's body, and the gate hid the toggle on exactly the machines - // that most want it — TV boxes, where a Steam Controller 2 is the whole input story. - ToggleRow( - title = "Steam Controller 2 passthrough", - subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " + - "Bluetooth): it navigates these menus and streams as-is — Steam on the " + - "host drives it like the physical pad (trackpads, gyro, haptics)", - checked = s.sc2Capture, - onCheckedChange = { on -> update(s.copy(sc2Capture = on)) }, - ) } } @@ -639,7 +863,10 @@ private fun SettingsGroup( } } -/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */ +/** + * A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row; + * [field] is the overlay field this row writes, which is what its override marker keys on. + */ @Composable private fun ToggleRow( title: String, @@ -647,23 +874,27 @@ private fun ToggleRow( checked: Boolean, onCheckedChange: (Boolean) -> Unit, enabled: Boolean = true, + field: String? = null, ) { // Dim the labels when disabled so the row reads as inactive (the Switch dims itself). val labelAlpha = if (enabled) 1f else 0.38f - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text( - title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = labelAlpha), - ) - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = labelAlpha), - ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + OverrideBadge(field) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = labelAlpha), + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = labelAlpha), + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) } - Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) } } @@ -695,7 +926,8 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) { * A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. * [caption] is the field's own explanation, rendered directly under the control — the `described()` * idiom the other clients use, so a dropdown's guidance belongs to it instead of floating as a - * loose paragraph between rows. + * loose paragraph between rows. [field] is the overlay field this row writes, which is what its + * override marker keys on. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -703,6 +935,7 @@ private fun SettingDropdown( label: String, options: List>, selected: T, + field: String? = null, caption: String? = null, onSelect: (T) -> Unit, ) { @@ -710,6 +943,7 @@ private fun SettingDropdown( val selectedLabel = options.firstOrNull { it.first == selected }?.second ?: options.firstOrNull()?.second.orEmpty() Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + OverrideBadge(field) ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { OutlinedTextField( value = selectedLabel, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt index bd25ae8f..37887bdd 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt @@ -40,6 +40,12 @@ internal fun StatsOverlay( verbosity: StatsVerbosity, decoderLabel: String = "", codecLabel: String = "", + /** + * The settings profile this session resolved, appended to the first line when there is one — + * the in-stream answer to "which profile am I on?", as on the other clients. Absent (the + * common case: no profile) the line is exactly what it always was. + */ + profileName: String? = null, modifier: Modifier = Modifier, ) { if (verbosity == StatsVerbosity.OFF || s.size < 10) return @@ -56,13 +62,17 @@ internal fun StatsOverlay( .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) .padding(horizontal = 8.dp, vertical = 4.dp), ) { + val profileTag = profileName?.let { " · $it" }.orEmpty() // Compact: everything the glance-value needs on one line, nothing else. if (verbosity == StatsVerbosity.COMPACT) { - statLine(compactLine(s, latValid), Color.White) + statLine(compactLine(s, latValid) + profileTag, Color.White) return@Column } - statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White) + statLine( + "$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag", + Color.White, + ) if (detailed && decoderLabel.isNotEmpty()) { statLine(decoderLabel, Color(0xFFB0D0FF)) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index da763ca7..30d89171 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -470,7 +470,10 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // BEFORE the transparent gesture layer below, so it shows through and never eats touches. if (statsOn) { stats?.let { - StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) + StatsOverlay( + it, statsVerbosity, decoderLabel, codecLabel, session.profileName, + Modifier.align(Alignment.TopStart).padding(12.dp), + ) } } // "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt index df48e413..dea3c02f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt @@ -13,12 +13,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -32,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -48,9 +51,26 @@ fun SectionLabel(text: String) { ) } +/** + * One row of a host card's overflow menu. [startsSection] draws a divider above it, which is how + * the profile actions ("Connect with: …", "Pin as card: …") stay legible next to the host actions + * in one flat menu — Compose has no submenus, and the Windows client made the same call. + */ +data class HostMenuItem( + val label: String, + val startsSection: Boolean = false, + val onClick: () -> Unit, +) + /** * A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for - * saved hosts) an overflow menu with Rename / Forget. Tapping the card connects. + * saved hosts) an overflow menu with Wake / Edit / Forget plus whatever [menuItems] adds. Tapping + * the card connects. + * + * [profileLabel] names the settings profile this card connects with. On a host's own card that is + * its default binding, drawn as a quiet chip — the card says what a tap will do. On a **pinned + * card** ([profileProminent]) the host name is still the title, but the profile is the loud part, + * because the pin exists to make that one combination a single tap. */ @Composable fun HostCard( @@ -63,6 +83,10 @@ fun HostCard( onForget: (() -> Unit)?, onEdit: (() -> Unit)? = null, onWake: (() -> Unit)? = null, + profileLabel: String? = null, + profileProminent: Boolean = false, + accent: Color? = null, + menuItems: List = emptyList(), ) { // D-pad / controller focus highlight: a clickable card is focusable, but the default state // layer is too subtle on a TV across a room — draw a clear primary-colour border when focused. @@ -106,6 +130,10 @@ fun HostCard( overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, ) + if (profileLabel != null) { + Spacer(Modifier.height(8.dp)) + ProfileChip(profileLabel, accent, prominent = profileProminent) + } Spacer(Modifier.height(12.dp)) Row( verticalAlignment = Alignment.CenterVertically, @@ -116,7 +144,7 @@ fun HostCard( } } - if (onForget != null || onEdit != null || onWake != null) { + if (onForget != null || onEdit != null || onWake != null || menuItems.isNotEmpty()) { var menu by remember { mutableStateOf(false) } Box(modifier = Modifier.align(Alignment.TopEnd)) { IconButton(enabled = enabled, onClick = { menu = true }) { @@ -155,6 +183,16 @@ fun HostCard( }, ) } + menuItems.forEach { item -> + if (item.startsSection) HorizontalDivider() + DropdownMenuItem( + text = { Text(item.label) }, + onClick = { + menu = false + item.onClick() + }, + ) + } } } } @@ -162,6 +200,37 @@ fun HostCard( } } +/** + * The profile a card connects with. Quiet on a bound host's own card (it is a note about what a tap + * does); filled and tinted on a pinned card, where the profile IS the reason the card exists — the + * accent field the schema reserves earns its keep here. + */ +@Composable +private fun ProfileChip(label: String, accent: Color?, prominent: Boolean) { + val tint = accent ?: MaterialTheme.colorScheme.primary + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(tint.copy(alpha = if (prominent) 0.24f else 0.12f)) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(7.dp).clip(CircleShape).background(tint)) + Spacer(Modifier.width(6.dp)) + Text( + label, + style = if (prominent) { + MaterialTheme.typography.labelLarge + } else { + MaterialTheme.typography.labelMedium + }, + color = tint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + /** A circular avatar with the host's first letter (Apple-contact style). */ @Composable fun HostAvatar(name: String) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt index b3066cae..da365398 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt @@ -42,6 +42,11 @@ data class ActiveSession( val handle: Long, val settings: io.unom.punktfunk.Settings, val clipboardSync: Boolean, + /** + * The settings profile this session resolved, if any — shown on the stats overlay's first line + * so "which profile am I on?" is answerable from inside the stream, as on the other clients. + */ + val profileName: String? = null, ) /** Trust state of a host, shown as a colored pill on its card. */ diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/ProfilesTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ProfilesTest.kt new file mode 100644 index 00000000..2f90dc9c --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ProfilesTest.kt @@ -0,0 +1,228 @@ +package io.unom.punktfunk + +import io.unom.punktfunk.kit.security.KnownHost +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * The profile model — the part of this feature that is wrong-or-right rather than pretty-or-ugly. + * A profile is a named bundle of OVERRIDES, not a snapshot: an untouched field keeps following the + * global live, a touched one is recorded even when it equals today's global (a pin), and the only + * way back to inheriting is an explicit reset. These tests are the Kotlin twin of the Rust + * `profiles.rs` suite, so the two can't drift. + * + * `sdk = [36]` for the same reason the screenshot tests pin it: Robolectric ships android-all jars + * only up to API 36 while the app compiles against 37. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class ProfilesTest { + private val base = Settings( + width = 1920, + height = 1080, + bitrateKbps = 20_000, + codec = "hevc", + touchMode = TouchMode.TRACKPAD, + mouseMode = MouseMode.DESKTOP, + ) + + @Test + fun overlayAppliesOnlyWhatItOverrides() { + val empty = SettingsOverlay() + assertTrue(empty.isEmpty()) + assertEquals(base, empty.apply(base)) + + val overlay = SettingsOverlay( + width = 3840, + height = 2160, + hz = 120, + bitrateKbps = 80_000, + renderScale = 1.5, + codec = "av1", + hdrEnabled = false, + compositor = 4, + audioChannels = 6, + micEnabled = true, + touchMode = TouchMode.POINTER, + mouseMode = MouseMode.CAPTURE, + invertScroll = true, + gamepad = 6, + statsVerbosity = StatsVerbosity.DETAILED, + lowLatencyMode = false, + ) + assertFalse(overlay.isEmpty()) + val out = overlay.apply(base) + assertEquals(Triple(3840, 2160, 120), Triple(out.width, out.height, out.hz)) + assertEquals(80_000, out.bitrateKbps) + assertEquals(1.5, out.renderScale, 0.0) + assertEquals("av1", out.codec) + assertFalse(out.hdrEnabled) + assertEquals(4, out.compositor) + assertEquals(6, out.audioChannels) + assertTrue(out.micEnabled) + assertEquals(TouchMode.POINTER, out.touchMode) + assertEquals(MouseMode.CAPTURE, out.mouseMode) + assertTrue(out.invertScroll) + assertEquals(6, out.gamepad) + assertEquals(StatsVerbosity.DETAILED, out.statsVerbosity) + assertFalse(out.lowLatencyMode) + + // Device-scope settings are not in the overlay at all, so no profile can move them. + assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled) + assertEquals(base.libraryEnabled, out.libraryEnabled) + assertEquals(base.autoWakeEnabled, out.autoWakeEnabled) + assertEquals(base.sc2Capture, out.sc2Capture) + } + + @Test + fun anOverrideEqualToTheGlobalIsAPinThatSurvivesTheGlobalMoving() { + val pin = SettingsOverlay(bitrateKbps = 20_000) // exactly what `base` says today + assertFalse(pin.isEmpty()) + assertEquals(20_000, pin.apply(base.copy(bitrateKbps = 50_000)).bitrateKbps) + } + + @Test + fun absorbRecordsTheTouchedFieldOnly() { + var o = SettingsOverlay() + + // One control fires: before = what it was showing, after = what the user picked. + var before = o.apply(base) + o = o.absorb(before, before.copy(codec = "av1")) + assertEquals("av1", o.codec) + assertNull("nothing else may be recorded", o.bitrateKbps) + + // Setting it BACK to the global's value is still an override — the pin case, and the whole + // difference between this and diffing against the globals at save time. + before = o.apply(base) + o = o.absorb(before, before.copy(codec = "hevc")) + assertEquals("hevc", o.codec) + assertEquals("hevc", o.apply(base.copy(codec = "h264")).codec) + + // Identical snapshots record nothing. + before = o.apply(base) + assertEquals(o, o.absorb(before, before)) + } + + @Test + fun clearDropsOneOverride() { + val o = SettingsOverlay(width = 3840, height = 2160, codec = "av1") + assertEquals(setOf(SettingsOverlay.FIELD_RESOLUTION, "codec"), o.overridden()) + assertNull(o.clear("codec").codec) + // Width and height are one control, so they reset together. + val reset = o.clear(SettingsOverlay.FIELD_RESOLUTION) + assertNull(reset.width) + assertNull(reset.height) + assertEquals(o, o.clear("no_such_field")) // unknown names are a no-op, never a crash + } + + @Test + fun catalogRoundTripsAndPreservesWhatItCannotRepresent() { + val store = ProfileStore(RuntimeEnvironment.getApplication()) + val game = newProfile("Game").copy( + accent = "#ff8800", + overrides = SettingsOverlay( + width = 3840, + height = 2160, + hz = 120, + // A codec string this build's picker can't show is still stored and still applied: + // the host is the component that decides what it can encode. + codec = "vvc-from-the-future", + extra = mapOf("some_new_axis" to 7), + ), + extra = mapOf("future_profile_key" to "kept"), + ) + store.save(game) + store.save(newProfile("Work")) + + val loaded = store.byId(game.id)!! + assertEquals("Game", loaded.name) + assertEquals("#ff8800", loaded.accent) + assertEquals("vvc-from-the-future", loaded.overrides.codec) + assertEquals(3840, loaded.overrides.width) + // The don't-clobber rule: an older build must not erase a newer one's keys by opening it. + assertEquals(mapOf("some_new_axis" to 7), loaded.overrides.extra) + assertEquals(mapOf("future_profile_key" to "kept"), loaded.extra) + assertEquals("vvc-from-the-future", loaded.overrides.apply(base).codec) + + // A profile that overrides nothing is the "inherits everything" one a create starts at. + assertTrue(store.all().first { it.name == "Work" }.overrides.isEmpty()) + assertEquals(listOf("Game", "Work"), store.all().map { it.name }) + } + + @Test + fun resolvePrefersIdsAndRefusesAmbiguity() { + val store = ProfileStore(RuntimeEnvironment.getApplication()) + val work = newProfile("Work") + val work2 = newProfile("work") // saved directly: the UI's name guard is what prevents this + val game = newProfile("Game") + listOf(work, work2, game).forEach(store::save) + + assertEquals(ProfileResolution.FOUND, store.resolve(work.id).second) + assertEquals(work.id, store.resolve(work.id).first!!.id) + // Two profiles carry this name — refuse rather than pick whichever came first. + assertEquals(ProfileResolution.AMBIGUOUS, store.resolve("Work").second) + assertNull(store.resolve("Work").first) + assertEquals(game.id, store.resolve("GAME").first!!.id) // names match case-insensitively + assertEquals(ProfileResolution.NOT_FOUND, store.resolve("nope").second) + assertEquals(ProfileResolution.NOT_FOUND, store.resolve("").second) + + assertTrue(store.nameTaken("GAME")) + assertFalse(store.nameTaken("GAME", except = game.id)) // renaming in place is allowed + assertFalse(store.nameTaken("Travel")) + } + + @Test + fun profilePrecedenceIsOneOffThenBindingThenNone() { + val store = ProfileStore(RuntimeEnvironment.getApplication()) + val work = newProfile("Work") + val game = newProfile("Game") + listOf(work, game).forEach(store::save) + val bound = host().copy(profileId = work.id) + + // A plain tap follows the binding… + assertEquals(work.id, store.resolveFor(bound, oneOff = null)!!.id) + // …a one-off wins over it, by id or by unique name, and never rebinds anything… + assertEquals(game.id, store.resolveFor(bound, oneOff = game.id)!!.id) + assertEquals(game.id, store.resolveFor(bound, oneOff = "game")!!.id) + assertEquals(work.id, store.resolveFor(bound, oneOff = null)!!.id) + // …and the empty reference is a real choice — "force the global defaults" — not "unset". + assertNull(store.resolveFor(bound, oneOff = "")) + // An unbound host is today's behaviour: the globals. + assertNull(store.resolveFor(host(), oneOff = null)) + assertNull(store.resolveFor(null, oneOff = null)) + } + + @Test + fun aDeletedProfileLeavesNoErrorBehind() { + val store = ProfileStore(RuntimeEnvironment.getApplication()) + val work = newProfile("Work") + store.save(work) + val h = host().copy(profileId = work.id, pinnedProfileIds = listOf(work.id, work.id)) + assertEquals(1, store.pinsFor(h).size) // a duplicate pin is one card, not two + + store.delete(work.id) + // A dangling binding resolves as "no profile" — never an error, never a blocked connect — + // and its pinned card simply stops rendering. + assertNull(store.resolveFor(h, oneOff = null)) + assertTrue(store.pinsFor(h).isEmpty()) + assertEquals(base, base.effectiveFor(store.resolveFor(h, oneOff = null))) + } + + @Test + fun mintedIdsAreWellFormed() { + val id = newProfileId() + assertEquals(12, id.length) + assertTrue(id.all { it.isDigit() || it in 'a'..'f' }) + assertNotEquals(id, newProfileId()) + } + + private fun host() = KnownHost("192.168.1.42", 9777, "Desk", "a".repeat(64), paired = true) +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 6dade670..4d81f6e9 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -68,6 +68,9 @@ class ScreenshotTest { SettingsCategoryScene(io.unom.punktfunk.SettingsCategory.Input) } + @Test + fun settingsProfile() = shootRoot("settings-profile") { SettingsProfileScene() } + @Test @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) } diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 55ff24cb..00637342 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -19,10 +19,12 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.unom.punktfunk.BrandDark @@ -35,8 +37,12 @@ import io.unom.punktfunk.SettingsCategory import io.unom.punktfunk.SettingsScreen import io.unom.punktfunk.StatsOverlay import io.unom.punktfunk.StatsVerbosity +import io.unom.punktfunk.ProfileStore +import io.unom.punktfunk.SettingsOverlay import io.unom.punktfunk.components.HostCard +import io.unom.punktfunk.components.HostMenuItem import io.unom.punktfunk.components.SectionLabel +import io.unom.punktfunk.newProfile import io.unom.punktfunk.models.HostStatus // The CI screenshot scenes: the REAL app composables, fed embedded mock state, under the forced @@ -49,10 +55,20 @@ internal fun ShotTheme(content: @Composable () -> Unit) { MaterialTheme(colorScheme = BrandDark, content = content) } -private data class MockHost(val name: String, val address: String, val status: HostStatus) +private data class MockHost( + val name: String, + val address: String, + val status: HostStatus, + val profile: String? = null, + val pin: String? = null, + val accent: Color? = null, +) private val SAVED = listOf( - MockHost("Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED), + MockHost( + "Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED, + profile = "Game", pin = "Work", accent = Color(0xFFFF8A4C), + ), MockHost("Office", "192.168.1.50:9777", HostStatus.TOFU), ) private val DISCOVERED = listOf( @@ -87,8 +103,32 @@ internal fun HostsScene() { } } item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") } - items(SAVED) { h -> - HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {}) + // A pinned card is its OWN grid cell right after its host — the same flat list the + // connect screen builds, not a second card crammed into the host's cell. + SAVED.forEach { h -> + item { + HostCard( + h.name, h.address, h.status, enabled = true, + onConnect = {}, onForget = {}, onEdit = {}, + // The bound profile is a quiet chip: the card says what a tap will do. + profileLabel = h.profile, + accent = h.accent, + menuItems = listOf( + HostMenuItem("Connect with: Default settings", startsSection = true) {}, + HostMenuItem("Connect with: Game") {}, + ), + ) + } + if (h.pin != null) { + item { + HostCard( + h.name, h.address, h.status, enabled = true, + onConnect = {}, onForget = null, + profileLabel = h.pin, profileProminent = true, accent = h.accent, + menuItems = listOf(HostMenuItem("Unpin card", startsSection = true) {}), + ) + } + } } item(span = { GridItemSpan(maxLineSpan) }) { Spacer(Modifier.height(12.dp)) @@ -142,6 +182,36 @@ internal fun SettingsCategoryScene(category: SettingsCategory) { } } +/** + * The same settings surface in a PROFILE's scope: the scope chips with "Game" selected, only + * profileable rows, every row showing the effective value, and the overridden ones carrying their + * marker and reset. One settings UI, two layers — this shot is what proves it stayed one. + */ +@Composable +internal fun SettingsProfileScene() { + val store = ProfileStore(LocalContext.current) + val profile = remember { + val p = newProfile("Game").copy( + accent = "#FF8A4C", + // A representative mix: a resolution and refresh the profile pins, and a codec — the + // rest of the page keeps following the defaults, visibly unmarked. + overrides = SettingsOverlay(width = 3840, height = 2160, hz = 120, codec = "h264"), + ) + store.save(p) + store.save(newProfile("Work")) + p + } + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + SettingsScreen( + initial = SHOT_SETTINGS, + onChange = {}, + onBack = {}, + initialCategory = SettingsCategory.Display, + initialProfileId = profile.id, + ) + } +} + /** The real TOFU AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.TRUST_NEW), shown over the host grid. */ @Composable internal fun TrustDialog() {