diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 6589bb3c..0fb58dd7 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 44 - versionName = "0.5.24" + versionCode = 45 + versionName = "0.5.25" vectorDrawables { useSupportLibrary = true diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt index c2b0f98f..3e7ef978 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -49,6 +49,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import com.archipelago.app.fips.FipsManager @@ -93,14 +94,14 @@ fun NESMenu( visible: Boolean, servers: List, activeServer: ServerEntry?, - isGamepadMode: Boolean, onDismiss: () -> Unit, onSelectServer: (ServerEntry) -> Unit, onAddServer: (ServerEntry) -> Unit, onScanQr: (() -> Unit)? = null, onEditServer: (ServerEntry, ServerEntry) -> Unit, onRemoveServer: (ServerEntry) -> Unit, - onToggleMode: () -> Unit, + onRemote: () -> Unit, + onKeyboard: () -> Unit, onBackToWebView: (() -> Unit)? = null, onMeshParty: (() -> Unit)? = null, ) { @@ -115,7 +116,7 @@ fun NESMenu( contentAlignment = Alignment.Center, ) { 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( servers: List, activeServer: ServerEntry?, - isGamepadMode: Boolean, onDismiss: () -> Unit, onSelectServer: (ServerEntry) -> Unit, onAddServer: (ServerEntry) -> Unit, onScanQr: (() -> Unit)?, onEditServer: (ServerEntry, ServerEntry) -> Unit, onRemoveServer: (ServerEntry) -> Unit, - onToggleMode: () -> Unit, + onRemote: () -> Unit, + onKeyboard: () -> Unit, onBackToWebView: (() -> Unit)?, onMeshParty: (() -> Unit)?, ) { @@ -173,9 +174,9 @@ private fun MenuPanel( .widthIn(max = 420.dp) .fillMaxWidth() .padding(horizontal = 20.dp) - // Cap height so the glass panel never fills the screen; its content - // scrolls inside these bounds when tall. - .heightIn(max = 560.dp) + // Cap height just short of the full screen; the panel wraps short + // content and only scrolls in the rare case it outgrows this. + .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp) .clip(RoundedCornerShape(PANEL_R)) .background(PanelBg.copy(alpha = 0.86f)) .border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R)) @@ -215,12 +216,8 @@ private fun MenuPanel( if (onBackToWebView != null) { HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() } } - HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { - if (!isGamepadMode) onToggleMode() else onDismiss() - } - HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { - if (isGamepadMode) onToggleMode() else onDismiss() - } + HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() } + HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() } HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") { page = HubPage.NODES } @@ -356,7 +353,7 @@ private fun MenuPanel( } HubPage.FIPS -> { - FipsSection(startExpanded = true) + FipsSection(embedded = true) } } } @@ -433,11 +430,11 @@ private data class FipsInfo( * Collapsed by default so the menu stays compact. */ @Composable -private fun FipsSection(startExpanded: Boolean = false) { +private fun FipsSection(embedded: Boolean = false) { if (!FipsNative.available) return val context = LocalContext.current val clipboard = LocalClipboardManager.current - var expanded by remember { mutableStateOf(startExpanded) } + var expanded by remember { mutableStateOf(embedded) } var info by remember { mutableStateOf(null) } // Load identity/state when the section opens (cheap DataStore + JSON read). @@ -459,7 +456,9 @@ private fun FipsSection(startExpanded: Boolean = false) { } 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", labelColor = BitcoinOrange, onClick = { expanded = !expanded }, @@ -500,7 +499,7 @@ private fun FipsSection(startExpanded: Boolean = false) { onClick = { FipsManager.requestMeshRestart(context) info = null - expanded = false + if (!embedded) expanded = false }, ) } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt index 40bfaaa3..138c817b 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt @@ -12,9 +12,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.archipelago.app.data.PairResult import com.archipelago.app.data.ServerEntry import com.archipelago.app.data.ServerPreferences @@ -177,11 +179,25 @@ fun AppNavHost( onRemoteInput = { 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( onBack = { navController.popBackStack() @@ -189,6 +205,7 @@ fun AppNavHost( onMeshParty = { navController.navigate(Routes.MESH_PARTY) }, + startInKeyboard = entry.arguments?.getBoolean("keyboard") == true, ) } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt index d25fb4b7..9af0ba08 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt @@ -56,7 +56,12 @@ import com.archipelago.app.ui.theme.TextMuted import kotlinx.coroutines.launch @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 prefs = remember { ServerPreferences(context) } val scope = rememberCoroutineScope() @@ -65,7 +70,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) { val savedServers by prefs.savedServers.collectAsState(initial = emptyList()) 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 showQrScanner by remember { mutableStateOf(false) } var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) } @@ -225,7 +230,6 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) { visible = showModal, servers = savedServers, activeServer = activeServer, - isGamepadMode = isGamepadMode, onDismiss = { showModal = false }, onSelectServer = { server -> 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() }, onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } }, ) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index e4f78fd2..2cb57488 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -88,8 +88,11 @@ import androidx.compose.ui.viewinterop.AndroidView import android.webkit.ValueCallback import com.archipelago.app.R 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.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.theme.BitcoinOrange import com.archipelago.app.ui.theme.ErrorRed @@ -179,6 +182,9 @@ private fun injectSafeAreaVars(view: WebView) { document.head.appendChild(style); } 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(), null, @@ -278,6 +284,10 @@ fun WebViewScreen( serverUrl: String, onDisconnect: () -> 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 // non-blank, the login page is auto-filled and submitted — the one-step // 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 // it while DataStore is still loading). 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) var gestureHintDismissed by remember { mutableStateOf(false) } // 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 // page collided with the old two-finger hold. var threeFingerStart = 0L @@ -759,7 +778,7 @@ fun WebViewScreen( if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) { if (System.currentTimeMillis() - threeFingerStart > 500) { 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) + } + }, + ) } }