From 1dfcb0b2f6c4fe368aa2e4c0941179d073ba70a4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 12 Jul 2026 01:29:18 +0200 Subject: [PATCH] feat(android): default-UI connect/wake modal (Material dialog) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the Apple client: the connect/wake overlay was showing the full-screen aurora takeover in the default touch UI too. Make ConnectOverlay mode-aware — gamepad/console keeps the aurora ConnectTakeover, the default UI now renders a Material 3 AlertDialog over the host grid (inert scrim; Back/buttons cancel), matching the app's other touch dialogs. Extract a shared connectCopy() so both presentations read identically; ConnectTakeover is now console-only. Screenshot scenes updated (touch phases -> modal over the host grid via shootScreen; console stays a root capture); record-mode tests green. Co-Authored-By: Claude Opus 4.8 --- .../io/unom/punktfunk/ConnectOverlay.kt | 198 +++++++++--------- .../punktfunk/screenshots/ScreenshotTest.kt | 19 +- .../unom/punktfunk/screenshots/ShotScenes.kt | 29 ++- 3 files changed, 138 insertions(+), 108 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt index eecd8de0..1b3ad31a 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt @@ -8,31 +8,27 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource 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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontFamily @@ -40,10 +36,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties /** - * Which phase of the connect takeover to draw — the pure view model [ConnectOverlay] resolves from the - * live dial/wake state, so [ConnectTakeover] can render (and be screenshot-tested) statelessly. + * Which phase of the connect flow to draw — the pure view model [ConnectOverlay] resolves from the + * live dial/wake state, so [ConnectTakeover] / [ConnectModal] can render (and be screenshot-tested) + * statelessly. */ internal sealed interface ConnectPhase { val hostName: String @@ -58,25 +56,52 @@ internal sealed interface ConnectPhase { data class WakeTimedOut(override val hostName: String) : ConnectPhase } +/** Per-phase copy, shared by the console takeover and the touch modal so both read identically. */ +private data class ConnectCopy( + val title: String, + val subtitle: String, + /** Monospace the subtitle so a ticking seconds counter doesn't jitter its width. */ + val monoSubtitle: Boolean, + val cancelLabel: String, +) + +private fun connectCopy(phase: ConnectPhase): ConnectCopy = when (phase) { + is ConnectPhase.Connecting -> ConnectCopy( + "Connecting to ${phase.hostName}", "Establishing a secure connection…", false, "Cancel", + ) + is ConnectPhase.Waking -> ConnectCopy( + "Waking ${phase.hostName}…", + "Waiting for it to come online · ${phase.seconds}s", + true, + // A wake-only wait (no dial after) says "Stop Waiting"; a wake that will connect says "Cancel". + if (!phase.connectsAfter) "Stop Waiting" else "Cancel", + ) + is ConnectPhase.WakeTimedOut -> ConnectCopy( + "${phase.hostName} didn't wake", + "It may still be booting, or it's powered off / off this network.", + false, + "Cancel", + ) +} + /** - * The unified full-screen "getting you connected" takeover — one look for BOTH phases of reaching a - * host, so the user gets feedback the instant they pick one and it flows seamlessly into a wake if the - * host turns out to be asleep: + * The unified "getting you connected" feedback — one flow for BOTH phases of reaching a host, so the + * user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns + * out to be asleep: * * - **Connecting** ([connectingHostName] non-null): the dial is in flight. Shown immediately on tap, * so a host that takes a beat to answer no longer looks like nothing happened. * - **Waking** ([WakeController.waking] non-null): the dial failed on a sleeping host, so we're firing - * Wake-on-LAN and waiting for it to advertise again (the old standalone `WakeOverlay`'s job), - * escalating to a retry/cancel prompt on timeout. + * Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on timeout. + * + * Presentation is mode-aware (mirrors the Apple client): in the **console / gamepad** UI it's a + * full-screen aurora [ConnectTakeover] — the same signature backdrop the console home uses, driven by + * the pad (B cancels, A retries once timed out) with a hint bar. In the **default touch** UI it's a + * Material [ConnectModal] over the host grid, matching the app's other dialogs — the aurora takeover + * looked out of place there. * * The two phases hand off within a single Compose frame (see ConnectScreen's `doConnectDirect` → - * `waker.start` → redial), so the overlay never blinks between them. It replaces both the touch grid's - * tiny inline "Connecting…" row and the old centred "Waking…" card with one opaque, aurora-backed - * screen — the same signature backdrop the console UI uses, so it reads as a deliberate takeover. - * - * Rendered over BOTH the touch grid and the console home; it swallows input to the screen behind it, - * and in console mode the pad drives it (B cancels via the BackHandler, A retries once a wake has timed - * out) with a hint bar spelling that out, while the touch buttons work for a pointer either way. + * `waker.start` → redial), so nothing blinks between them. */ @Composable fun ConnectOverlay( @@ -97,55 +122,77 @@ fun ConnectOverlay( // System Back / pad B (remapped) cancels whatever's in flight — a plain dial or the wake wait. val cancel = { if (waking != null) waker.cancel() else onCancelConnect() } - BackHandler { cancel() } + if (gamepadUi) { + BackHandler { cancel() } // A retries once a wake has timed out; B falls through to the BackHandler above. GamepadNavEffect2D( active = true, onDirection = {}, onActivate = { if (phase is ConnectPhase.WakeTimedOut) waker.retry() }, ) + ConnectTakeover(phase = phase, onCancel = cancel, onRetry = { waker.retry() }) + } else { + // The AlertDialog owns its own scrim + system-Back handling (routed to cancel). + ConnectModal(phase = phase, onCancel = cancel, onRetry = { waker.retry() }) } - - ConnectTakeover(phase = phase, gamepadUi = gamepadUi, onCancel = cancel, onRetry = { waker.retry() }) } /** - * The stateless view of the connect takeover: an opaque aurora backdrop with a centred spinner/title/ - * subtitle for [phase], plus its actions (touch pills, or a console hint bar in [gamepadUi] mode). + * The default-UI presentation: a Material dialog over the host grid, matching the app's other touch + * dialogs. A spinner (or the sleep glyph once timed out) sits above the title; the scrim is inert so a + * stray tap can't drop a connect in flight — only the buttons or system Back cancel. + */ +@Composable +internal fun ConnectModal( + phase: ConnectPhase, + onCancel: () -> Unit, + onRetry: () -> Unit, +) { + val copy = connectCopy(phase) + val timedOut = phase is ConnectPhase.WakeTimedOut + AlertDialog( + onDismissRequest = onCancel, + properties = DialogProperties(dismissOnClickOutside = false), + icon = { + if (timedOut) { + Icon(Icons.Filled.Bedtime, contentDescription = null) + } else { + CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp) + } + }, + title = { Text(copy.title, textAlign = TextAlign.Center) }, + text = { + Text( + copy.subtitle, + textAlign = TextAlign.Center, + fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default, + ) + }, + // No confirm action until the wake times out; then "Try Again" is the primary button. + confirmButton = { + if (timedOut) TextButton(onClick = onRetry) { Text("Try Again") } + }, + dismissButton = { + TextButton(onClick = onCancel) { Text(copy.cancelLabel) } + }, + ) +} + +/** + * The console / gamepad presentation: an opaque aurora backdrop with a centred spinner/title/subtitle + * for [phase], plus a bottom hint bar spelling out the pad actions (B cancels, A retries once timed + * out) — glyph-driven like every other console screen. onClick keeps the hints tappable too, so a + * user without a working pad can still get out. */ @Composable internal fun ConnectTakeover( phase: ConnectPhase, - gamepadUi: Boolean, onCancel: () -> Unit, onRetry: () -> Unit, ) { + val copy = connectCopy(phase) val timedOut = phase is ConnectPhase.WakeTimedOut - // Copy per phase. Titles lead with the verb + host (parallel across phases); the waking counter - // gets a monospace subtitle so its width doesn't jitter as the seconds tick. - val title: String - val subtitle: String - val monoSubtitle: Boolean - when (phase) { - is ConnectPhase.Connecting -> { - title = "Connecting to ${phase.hostName}" - subtitle = "Establishing a secure connection…" - monoSubtitle = false - } - is ConnectPhase.Waking -> { - title = "Waking ${phase.hostName}…" - subtitle = "Waiting for it to come online · ${phase.seconds}s" - monoSubtitle = true - } - is ConnectPhase.WakeTimedOut -> { - title = "${phase.hostName} didn't wake" - subtitle = "It may still be booting, or it's powered off / off this network." - monoSubtitle = false - } - } - // A wake-only wait (no dial after) says "Stop Waiting"; every other phase is a plain "Cancel". - val cancelLabel = if (phase is ConnectPhase.Waking && !phase.connectsAfter) "Stop Waiting" else "Cancel" Box( Modifier @@ -173,40 +220,25 @@ internal fun ConnectTakeover( PulsingSpinner() } Text( - title, + copy.title, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 24.sp, textAlign = TextAlign.Center, ) Text( - subtitle, + copy.subtitle, color = Color.White.copy(alpha = 0.65f), fontSize = 14.sp, textAlign = TextAlign.Center, - fontFamily = if (monoSubtitle) FontFamily.Monospace else FontFamily.Default, + fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default, ) - // Touch: real tappable pills. Console: the bottom hint bar owns the actions instead, so the - // look stays glyph-driven like every other console screen. - if (!gamepadUi) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(top = 8.dp), - ) { - OverlayButton(cancelLabel, primary = false, onClick = onCancel) - if (timedOut) OverlayButton("Try Again", primary = true, onClick = onRetry) - } - } } - if (gamepadUi) { - // B cancels, A retries (only once timed out). onClick keeps the cells tappable too, so a - // user without a working pad can still get out. - val hints = buildList { - add(PadGlyph.hint('B', cancelLabel, onClick = onCancel)) - if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry)) - } - GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp)) + val hints = buildList { + add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel)) + if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry)) } + GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp)) } } @@ -243,25 +275,3 @@ private fun PulsingSpinner() { ) } } - -/** A pill action button styled for the dark aurora — filled violet when [primary], else glassy. */ -@Composable -private fun OverlayButton(label: String, primary: Boolean, onClick: () -> Unit) { - val shape = RoundedCornerShape(14.dp) - Box( - Modifier - .clip(shape) - .background(if (primary) Color(0xFF6656F2) else Color(0x1FFFFFFF)) - .border(1.dp, Color.White.copy(alpha = if (primary) 0f else 0.18f), shape) - .clickable(onClick = onClick) - .padding(horizontal = 26.dp, vertical = 13.dp), - contentAlignment = Alignment.Center, - ) { - Text( - label, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = Color.White, - ) - } -} 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 749549b3..431177e1 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,17 +68,28 @@ class ScreenshotTest { @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) } + // The touch flow is a Material dialog over the host grid (a separate window → shootScreen). @Test - fun connecting() = shootRoot("connecting") { ConnectingScene() } + fun connecting() = shootScreen("connecting") { + HostsScene() + ConnectingScene() + } @Test - fun waking() = shootRoot("waking") { WakingScene() } + fun waking() = shootScreen("waking") { + HostsScene() + WakingScene() + } @Test - fun wakeTimedOut() = shootRoot("wake-timed-out") { WakeTimedOutScene() } + fun wakeTimedOut() = shootScreen("wake-timed-out") { + HostsScene() + WakeTimedOutScene() + } + // The console flow is the full-screen aurora takeover (a root capture). @Test - fun connectingConsole() = shootRoot("connecting-console") { ConnectingScene(gamepadUi = true) } + fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() } @Test fun trust() = shootScreen("trust") { 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 ffb55a83..35b69aea 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.unom.punktfunk.BrandDark +import io.unom.punktfunk.ConnectModal import io.unom.punktfunk.ConnectPhase import io.unom.punktfunk.ConnectTakeover import io.unom.punktfunk.Settings @@ -219,21 +220,29 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) { } /** - * The unified full-screen connect takeover (the real [ConnectTakeover]) in each phase — instant - * "Connecting…" feedback, the "Waking…" wait, and the wake-timed-out prompt. `gamepadUi = true` - * renders the console variant with its bottom hint bar instead of touch pills. + * The default-UI connect flow (the real [ConnectModal]) in each phase — instant "Connecting…" + * feedback, the "Waking…" wait, and the wake-timed-out prompt. These render as a Material dialog over + * the host grid, so the test composes [HostsScene] behind them and captures the whole screen. */ @Composable -internal fun ConnectingScene(gamepadUi: Boolean = false) = - ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), gamepadUi, onCancel = {}, onRetry = {}) +internal fun ConnectingScene() = + ConnectModal(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {}) @Composable -internal fun WakingScene(gamepadUi: Boolean = false) = - ConnectTakeover( +internal fun WakingScene() = + ConnectModal( ConnectPhase.Waking("Living Room PC", seconds = 12, connectsAfter = true), - gamepadUi, onCancel = {}, onRetry = {}, + onCancel = {}, onRetry = {}, ) @Composable -internal fun WakeTimedOutScene(gamepadUi: Boolean = false) = - ConnectTakeover(ConnectPhase.WakeTimedOut("Living Room PC"), gamepadUi, onCancel = {}, onRetry = {}) +internal fun WakeTimedOutScene() = + ConnectModal(ConnectPhase.WakeTimedOut("Living Room PC"), onCancel = {}, onRetry = {}) + +/** + * The console / gamepad connect flow (the real full-screen [ConnectTakeover]) — the aurora backdrop + * with a bottom hint bar, the same signature look the console home uses. + */ +@Composable +internal fun ConnectConsoleScene() = + ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})