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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-4
@@ -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 = {})
|
||||
|
||||
Reference in New Issue
Block a user