feat(companion): three-finger opens the hub menu over the dashboard — 0.5.25

The dashboard's three-finger hold now opens the menu overlay in place
instead of jumping to the remote screen; the hub's Remote and Keyboard
cards do the navigating (Keyboard lands directly in keyboard mode via a
nav arg). The dashboard host carries the full Nodes page — add, edit,
remove and QR pairing — and Mesh Party.

Also: the panel scales to 92% of screen height instead of a fixed 560dp
cap so pages fit without scrolling; the FIPS sub-page no longer repeats
the FIPS Mesh header inside the detail card; the safe-area injection
fires an archy-insets event the web UI listens for (update-install
status-bar fix, other half in neode-ui).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-27 17:11:48 +01:00
parent 24582128c5
commit 1cc18e5b72
5 changed files with 130 additions and 28 deletions

View File

@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app" applicationId = "com.archipelago.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 44 versionCode = 45
versionName = "0.5.24" versionName = "0.5.25"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true

View File

@ -49,6 +49,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import com.archipelago.app.fips.FipsManager import com.archipelago.app.fips.FipsManager
@ -93,14 +94,14 @@ fun NESMenu(
visible: Boolean, visible: Boolean,
servers: List<ServerEntry>, servers: List<ServerEntry>,
activeServer: ServerEntry?, activeServer: ServerEntry?,
isGamepadMode: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit, onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit, onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)? = null, onScanQr: (() -> Unit)? = null,
onEditServer: (ServerEntry, ServerEntry) -> Unit, onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit, onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit, onRemote: () -> Unit,
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)? = null, onBackToWebView: (() -> Unit)? = null,
onMeshParty: (() -> Unit)? = null, onMeshParty: (() -> Unit)? = null,
) { ) {
@ -115,7 +116,7 @@ fun NESMenu(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) { AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, isGamepadMode, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onBackToWebView, onMeshParty) MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
} }
} }
} }
@ -125,14 +126,14 @@ fun NESMenu(
private fun MenuPanel( private fun MenuPanel(
servers: List<ServerEntry>, servers: List<ServerEntry>,
activeServer: ServerEntry?, activeServer: ServerEntry?,
isGamepadMode: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit, onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit, onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)?, onScanQr: (() -> Unit)?,
onEditServer: (ServerEntry, ServerEntry) -> Unit, onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit, onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit, onRemote: () -> Unit,
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)?, onBackToWebView: (() -> Unit)?,
onMeshParty: (() -> Unit)?, onMeshParty: (() -> Unit)?,
) { ) {
@ -173,9 +174,9 @@ private fun MenuPanel(
.widthIn(max = 420.dp) .widthIn(max = 420.dp)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 20.dp) .padding(horizontal = 20.dp)
// Cap height so the glass panel never fills the screen; its content // Cap height just short of the full screen; the panel wraps short
// scrolls inside these bounds when tall. // content and only scrolls in the rare case it outgrows this.
.heightIn(max = 560.dp) .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
.clip(RoundedCornerShape(PANEL_R)) .clip(RoundedCornerShape(PANEL_R))
.background(PanelBg.copy(alpha = 0.86f)) .background(PanelBg.copy(alpha = 0.86f))
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R)) .border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
@ -215,12 +216,8 @@ private fun MenuPanel(
if (onBackToWebView != null) { if (onBackToWebView != null) {
HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() } HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() }
} }
HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
if (!isGamepadMode) onToggleMode() else onDismiss() HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
}
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") {
if (isGamepadMode) onToggleMode() else onDismiss()
}
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") { HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
page = HubPage.NODES page = HubPage.NODES
} }
@ -356,7 +353,7 @@ private fun MenuPanel(
} }
HubPage.FIPS -> { HubPage.FIPS -> {
FipsSection(startExpanded = true) FipsSection(embedded = true)
} }
} }
} }
@ -433,11 +430,11 @@ private data class FipsInfo(
* Collapsed by default so the menu stays compact. * Collapsed by default so the menu stays compact.
*/ */
@Composable @Composable
private fun FipsSection(startExpanded: Boolean = false) { private fun FipsSection(embedded: Boolean = false) {
if (!FipsNative.available) return if (!FipsNative.available) return
val context = LocalContext.current val context = LocalContext.current
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
var expanded by remember { mutableStateOf(startExpanded) } var expanded by remember { mutableStateOf(embedded) }
var info by remember { mutableStateOf<FipsInfo?>(null) } var info by remember { mutableStateOf<FipsInfo?>(null) }
// Load identity/state when the section opens (cheap DataStore + JSON read). // Load identity/state when the section opens (cheap DataStore + JSON read).
@ -459,7 +456,9 @@ private fun FipsSection(startExpanded: Boolean = false) {
} }
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
MenuItem( // Embedded in the hub's FIPS sub-page the header row would duplicate
// the page title, so only the standalone (collapsible) form shows it.
if (!embedded) MenuItem(
label = "FIPS Mesh", label = "FIPS Mesh",
labelColor = BitcoinOrange, labelColor = BitcoinOrange,
onClick = { expanded = !expanded }, onClick = { expanded = !expanded },
@ -500,7 +499,7 @@ private fun FipsSection(startExpanded: Boolean = false) {
onClick = { onClick = {
FipsManager.requestMeshRestart(context) FipsManager.requestMeshRestart(context)
info = null info = null
expanded = false if (!embedded) expanded = false
}, },
) )
} }

View File

@ -12,9 +12,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.archipelago.app.data.PairResult import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences import com.archipelago.app.data.ServerPreferences
@ -177,11 +179,25 @@ fun AppNavHost(
onRemoteInput = { onRemoteInput = {
navController.navigate(Routes.REMOTE_INPUT) navController.navigate(Routes.REMOTE_INPUT)
}, },
onRemoteKeyboard = {
navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true")
},
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
) )
} }
} }
composable(Routes.REMOTE_INPUT) { composable(
"${Routes.REMOTE_INPUT}?keyboard={keyboard}",
arguments = listOf(
navArgument("keyboard") {
type = NavType.BoolType
defaultValue = false
},
),
) { entry ->
RemoteInputScreen( RemoteInputScreen(
onBack = { onBack = {
navController.popBackStack() navController.popBackStack()
@ -189,6 +205,7 @@ fun AppNavHost(
onMeshParty = { onMeshParty = {
navController.navigate(Routes.MESH_PARTY) navController.navigate(Routes.MESH_PARTY)
}, },
startInKeyboard = entry.arguments?.getBoolean("keyboard") == true,
) )
} }

View File

@ -56,7 +56,12 @@ import com.archipelago.app.ui.theme.TextMuted
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) { fun RemoteInputScreen(
onBack: () -> Unit,
onMeshParty: (() -> Unit)? = null,
// Land on the keyboard instead of the gamepad (hub menu's Keyboard card).
startInKeyboard: Boolean = false,
) {
val context = LocalContext.current val context = LocalContext.current
val prefs = remember { ServerPreferences(context) } val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -65,7 +70,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
val savedServers by prefs.savedServers.collectAsState(initial = emptyList()) val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null) val activeServer by prefs.activeServer.collectAsState(initial = null)
var isGamepadMode by remember { mutableStateOf(true) } var isGamepadMode by remember { mutableStateOf(!startInKeyboard) }
var showModal by remember { mutableStateOf(false) } var showModal by remember { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) } var showQrScanner by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) } var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
@ -225,7 +230,6 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
visible = showModal, visible = showModal,
servers = savedServers, servers = savedServers,
activeServer = activeServer, activeServer = activeServer,
isGamepadMode = isGamepadMode,
onDismiss = { showModal = false }, onDismiss = { showModal = false },
onSelectServer = { server -> onSelectServer = { server ->
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
@ -259,7 +263,8 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
} }
} }
}, },
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false }, onRemote = { isGamepadMode = true; showModal = false },
onKeyboard = { isGamepadMode = false; showModal = false },
onBackToWebView = { showModal = false; onBack() }, onBackToWebView = { showModal = false; onBack() },
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } }, onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
) )

View File

@ -88,8 +88,11 @@ import androidx.compose.ui.viewinterop.AndroidView
import android.webkit.ValueCallback import android.webkit.ValueCallback
import com.archipelago.app.R import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.GestureHintOverlay import com.archipelago.app.ui.components.GestureHintOverlay
import com.archipelago.app.ui.components.MeshLoadingScreen import com.archipelago.app.ui.components.MeshLoadingScreen
import com.archipelago.app.ui.components.NESMenu
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.components.WalletQrScannerModal import com.archipelago.app.ui.components.WalletQrScannerModal
import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed import com.archipelago.app.ui.theme.ErrorRed
@ -179,6 +182,9 @@ private fun injectSafeAreaVars(view: WebView) {
document.head.appendChild(style); document.head.appendChild(style);
} }
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }'; style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
// Vue components sample the var into reactive state; tell them it
// changed (an authenticated session can mount before we run).
window.dispatchEvent(new CustomEvent('archy-insets', { detail: { top: ${sat}, bottom: ${sab} } }));
})(); })();
""".trimIndent(), """.trimIndent(),
null, null,
@ -278,6 +284,10 @@ fun WebViewScreen(
serverUrl: String, serverUrl: String,
onDisconnect: () -> Unit, onDisconnect: () -> Unit,
onRemoteInput: () -> Unit = {}, onRemoteInput: () -> Unit = {},
// Like onRemoteInput but landing on the keyboard (the hub menu's Keyboard card).
onRemoteKeyboard: () -> Unit = {},
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
onMeshParty: (() -> Unit)? = null,
// Stored password for this server (from QR pairing or manual entry). When // Stored password for this server (from QR pairing or manual entry). When
// non-blank, the login page is auto-filled and submitted — the one-step // non-blank, the login page is auto-filled and submitted — the one-step
// demo flow from docs/companion-pairing-qr.md. // demo flow from docs/companion-pairing-qr.md.
@ -356,6 +366,14 @@ fun WebViewScreen(
// One-time three-finger-hold teaching overlay (initial=true: never flash // One-time three-finger-hold teaching overlay (initial=true: never flash
// it while DataStore is still loading). // it while DataStore is still loading).
val prefs = remember { ServerPreferences(webViewContext) } val prefs = remember { ServerPreferences(webViewContext) }
// Hub menu overlay state — the three-finger hold opens the menu right here
// over the dashboard (it used to jump to the remote screen).
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null)
var showHubMenu by remember { mutableStateOf(false) }
var showPairScanner by remember { mutableStateOf(false) }
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true) val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
var gestureHintDismissed by remember { mutableStateOf(false) } var gestureHintDismissed by remember { mutableStateOf(false) }
// Don't teach the gesture on top of the login/splash — arm the overlay // Don't teach the gesture on top of the login/splash — arm the overlay
@ -741,7 +759,8 @@ fun WebViewScreen(
} }
} }
// Three-finger hold (500ms) → navigate to remote input. // Three-finger hold (500ms) → open the hub menu overlay
// in place (Remote/Keyboard cards do the navigating).
// Three fingers, not two: two-finger scroll/pinch on the // Three fingers, not two: two-finger scroll/pinch on the
// page collided with the old two-finger hold. // page collided with the old two-finger hold.
var threeFingerStart = 0L var threeFingerStart = 0L
@ -759,7 +778,7 @@ fun WebViewScreen(
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) { if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
if (System.currentTimeMillis() - threeFingerStart > 500) { if (System.currentTimeMillis() - threeFingerStart > 500) {
threeFingerFired = true threeFingerFired = true
onRemoteInput() showHubMenu = true
} }
} }
} }
@ -884,6 +903,68 @@ fun WebViewScreen(
) )
} }
} }
// Hub menu overlay — opened by the three-finger hold, drawn above
// everything (also reachable from the error screen, where switching
// servers is exactly what's needed).
NESMenu(
visible = showHubMenu,
servers = savedServers,
activeServer = activeServer,
onDismiss = { showHubMenu = false },
onSelectServer = { server ->
showHubMenu = false
scope.launch { prefs.setActiveServer(server) }
},
onAddServer = { server ->
scope.launch {
prefs.addSavedServer(server)
if (activeServer == null) prefs.setActiveServer(server)
}
},
onScanQr = { showPairScanner = true },
onEditServer = { original, updated ->
scope.launch {
prefs.updateSavedServer(original, updated)
// Editing the live server reloads the kiosk with the new
// address/credentials via the activeServer recomposition.
if (original.serialize() == activeServer?.serialize()) {
prefs.setActiveServer(updated)
}
}
},
onRemoveServer = { server ->
scope.launch {
prefs.removeSavedServer(server)
// Nothing left to show — back to the Connect screen.
val remaining = savedServers.count { it.serialize() != server.serialize() }
if (remaining == 0) {
prefs.clearActiveServer()
showHubMenu = false
onDisconnect()
}
}
},
onRemote = { showHubMenu = false; onRemoteInput() },
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
onBackToWebView = { showHubMenu = false },
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
)
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
// open behind it so the new entry appears as soon as it closes.
QrScannerOverlay(
visible = showPairScanner,
onDismiss = { showPairScanner = false },
onServerScanned = { scan ->
showPairScanner = false
scope.launch {
val merged = prefs.upsertServer(scan.server)
FipsManager.registerNode(webViewContext, scan.fips, merged.displayName())
if (activeServer == null) prefs.setActiveServer(merged)
}
},
)
} }
} }