feat(android): default-UI connect/wake modal (Material dialog)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 01:29:18 +02:00
parent e87fd42cee
commit 1dfcb0b2f6
3 changed files with 138 additions and 108 deletions
@@ -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,
)
}
}
@@ -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") {
@@ -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 = {})