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.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bedtime import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontFamily 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 * Which phase of the connect flow to draw — the pure view model [ConnectOverlay] resolves from the
* live dial/wake state, so [ConnectTakeover] can render (and be screenshot-tested) statelessly. * live dial/wake state, so [ConnectTakeover] / [ConnectModal] can render (and be screenshot-tested)
* statelessly.
*/ */
internal sealed interface ConnectPhase { internal sealed interface ConnectPhase {
val hostName: String val hostName: String
@@ -58,25 +56,52 @@ internal sealed interface ConnectPhase {
data class WakeTimedOut(override val hostName: String) : 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 * The unified "getting you connected" feedback — one flow for BOTH phases of reaching a host, so the
* host, so the user gets feedback the instant they pick one and it flows seamlessly into a wake if the * user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
* host turns out to be asleep: * out to be asleep:
* *
* - **Connecting** ([connectingHostName] non-null): the dial is in flight. Shown immediately on tap, * - **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. * 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 * - **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), * Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on timeout.
* 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` → * 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 * `waker.start` → redial), so nothing blinks between them.
* 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.
*/ */
@Composable @Composable
fun ConnectOverlay( 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. // 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() } val cancel = { if (waking != null) waker.cancel() else onCancelConnect() }
BackHandler { cancel() }
if (gamepadUi) { if (gamepadUi) {
BackHandler { cancel() }
// A retries once a wake has timed out; B falls through to the BackHandler above. // A retries once a wake has timed out; B falls through to the BackHandler above.
GamepadNavEffect2D( GamepadNavEffect2D(
active = true, active = true,
onDirection = {}, onDirection = {},
onActivate = { if (phase is ConnectPhase.WakeTimedOut) waker.retry() }, 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/ * The default-UI presentation: a Material dialog over the host grid, matching the app's other touch
* subtitle for [phase], plus its actions (touch pills, or a console hint bar in [gamepadUi] mode). * 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 @Composable
internal fun ConnectTakeover( internal fun ConnectTakeover(
phase: ConnectPhase, phase: ConnectPhase,
gamepadUi: Boolean,
onCancel: () -> Unit, onCancel: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
) { ) {
val copy = connectCopy(phase)
val timedOut = phase is ConnectPhase.WakeTimedOut 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( Box(
Modifier Modifier
@@ -173,40 +220,25 @@ internal fun ConnectTakeover(
PulsingSpinner() PulsingSpinner()
} }
Text( Text(
title, copy.title,
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 24.sp, fontSize = 24.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Text( Text(
subtitle, copy.subtitle,
color = Color.White.copy(alpha = 0.65f), color = Color.White.copy(alpha = 0.65f),
fontSize = 14.sp, fontSize = 14.sp,
textAlign = TextAlign.Center, 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) { val hints = buildList {
// B cancels, A retries (only once timed out). onClick keeps the cells tappable too, so a add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
// user without a working pad can still get out. if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
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))
} }
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") @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) } 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 @Test
fun connecting() = shootRoot("connecting") { ConnectingScene() } fun connecting() = shootScreen("connecting") {
HostsScene()
ConnectingScene()
}
@Test @Test
fun waking() = shootRoot("waking") { WakingScene() } fun waking() = shootScreen("waking") {
HostsScene()
WakingScene()
}
@Test @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 @Test
fun connectingConsole() = shootRoot("connecting-console") { ConnectingScene(gamepadUi = true) } fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
@Test @Test
fun trust() = shootScreen("trust") { 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.unom.punktfunk.BrandDark import io.unom.punktfunk.BrandDark
import io.unom.punktfunk.ConnectModal
import io.unom.punktfunk.ConnectPhase import io.unom.punktfunk.ConnectPhase
import io.unom.punktfunk.ConnectTakeover import io.unom.punktfunk.ConnectTakeover
import io.unom.punktfunk.Settings 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 * The default-UI connect flow (the real [ConnectModal]) in each phase — instant "Connecting…"
* "Connecting…" feedback, the "Waking…" wait, and the wake-timed-out prompt. `gamepadUi = true` * feedback, the "Waking…" wait, and the wake-timed-out prompt. These render as a Material dialog over
* renders the console variant with its bottom hint bar instead of touch pills. * the host grid, so the test composes [HostsScene] behind them and captures the whole screen.
*/ */
@Composable @Composable
internal fun ConnectingScene(gamepadUi: Boolean = false) = internal fun ConnectingScene() =
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), gamepadUi, onCancel = {}, onRetry = {}) ConnectModal(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
@Composable @Composable
internal fun WakingScene(gamepadUi: Boolean = false) = internal fun WakingScene() =
ConnectTakeover( ConnectModal(
ConnectPhase.Waking("Living Room PC", seconds = 12, connectsAfter = true), ConnectPhase.Waking("Living Room PC", seconds = 12, connectsAfter = true),
gamepadUi, onCancel = {}, onRetry = {}, onCancel = {}, onRetry = {},
) )
@Composable @Composable
internal fun WakeTimedOutScene(gamepadUi: Boolean = false) = internal fun WakeTimedOutScene() =
ConnectTakeover(ConnectPhase.WakeTimedOut("Living Room PC"), gamepadUi, onCancel = {}, onRetry = {}) 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 = {})