diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index cd8bc3bf..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 = 42 - versionName = "0.5.22" + versionCode = 45 + versionName = "0.5.25" vectorDrawables { useSupportLibrary = true diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt index 724f7c3b..00931af1 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -108,6 +109,7 @@ fun NESController( onKey: (String) -> Unit, onMenu: () -> Unit, onPlayerToggle: () -> Unit = {}, + onToggleStyle: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val c = paletteFor(style) @@ -205,6 +207,7 @@ fun NESController( ) { PlayerPill(c, playerId, onPlayerToggle) SettingsBtn(c, Modifier, onMenu) + onToggleStyle?.let { StyleBtn(c, Modifier, it) } } } } @@ -431,6 +434,23 @@ fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Uni } } +/** Dark/Classic style toggle — lives next to the settings gear (the menu hub + * no longer carries it). */ +@Composable +fun StyleBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + modifier = modifier + .size(48.dp) + .clip(CircleShape) + .background(if (p) c.capsulePress else c.capsule) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Palette, "Controller style", Modifier.size(26.dp), tint = c.labelMuted) + } +} + /** Player ID toggle pill (P1/P2/ALL) */ @Composable fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) { 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 2ee124bd..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 @@ -21,10 +21,22 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField @@ -37,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 @@ -58,7 +71,6 @@ import androidx.compose.ui.unit.sp import com.archipelago.app.R import com.archipelago.app.data.ServerEntry import com.archipelago.app.ui.theme.BitcoinOrange -import com.archipelago.app.ui.theme.ControllerStyle import com.archipelago.app.ui.theme.SurfaceDark import com.archipelago.app.ui.theme.TextMuted import com.archipelago.app.ui.theme.TextPrimary @@ -82,27 +94,29 @@ fun NESMenu( visible: Boolean, servers: List, activeServer: ServerEntry?, - isGamepadMode: Boolean, - controllerStyle: ControllerStyle, onDismiss: () -> Unit, onSelectServer: (ServerEntry) -> Unit, onAddServer: (ServerEntry) -> Unit, onScanQr: (() -> Unit)? = null, onEditServer: (ServerEntry, ServerEntry) -> Unit, onRemoveServer: (ServerEntry) -> Unit, - onToggleMode: () -> Unit, - onToggleStyle: () -> Unit, + onRemote: () -> Unit, + onKeyboard: () -> Unit, onBackToWebView: (() -> Unit)? = null, onMeshParty: (() -> Unit)? = null, ) { AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + // Contained hub overlay: a centred glass panel (not full-screen) that + // holds the card page and its sub-pages (Nodes, FIPS) and scrolls + // inside its own bounds when content is tall. Tapping the dimmed + // backdrop dismisses. Box( Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f)) .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() }, contentAlignment = Alignment.Center, ) { AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) { - MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView, onMeshParty) + MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty) } } } @@ -112,16 +126,14 @@ fun NESMenu( private fun MenuPanel( servers: List, activeServer: ServerEntry?, - isGamepadMode: Boolean, - controllerStyle: ControllerStyle, onDismiss: () -> Unit, onSelectServer: (ServerEntry) -> Unit, onAddServer: (ServerEntry) -> Unit, onScanQr: (() -> Unit)?, onEditServer: (ServerEntry, ServerEntry) -> Unit, onRemoveServer: (ServerEntry) -> Unit, - onToggleMode: () -> Unit, - onToggleStyle: () -> Unit, + onRemote: () -> Unit, + onKeyboard: () -> Unit, onBackToWebView: (() -> Unit)?, onMeshParty: (() -> Unit)?, ) { @@ -155,188 +167,252 @@ private fun MenuPanel( resetForm() } + var page by remember { mutableStateOf(HubPage.HUB) } + Column( modifier = Modifier .widthIn(max = 420.dp) + .fillMaxWidth() .padding(horizontal = 20.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) + .background(PanelBg.copy(alpha = 0.86f)) .border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R)) .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {} - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - // Title - Text( - "Menu", - color = TextPrimary, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 2.sp, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(2.dp)) - - // Servers - servers.forEach { server -> - val active = server.serialize() == activeServer?.serialize() - MenuItem( - label = server.displayName(), - selected = active, - onClick = { onSelectServer(server) }, - onEdit = { startEdit(server) }, - onRemove = { onRemoveServer(server) }, - ) - } - - if (servers.isEmpty()) { - Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp)) - } - - // Add / edit server - if (showAdd || editing != null) { - Column( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(ROW_R)) - .background(FieldBg) - .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + // Header: back (on sub-pages) or title, and a close on the hub. + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + if (page == HubPage.HUB) { + Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp) + IconRound(Icons.Default.Close, "Close") { onDismiss() } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB } + Spacer(Modifier.width(12.dp)) Text( - if (editing != null) "Edit Server" else "Add Server", - color = TextMuted, - fontSize = 13.sp, - letterSpacing = 1.sp, - fontWeight = FontWeight.Medium, - ) - Text( - "Cancel", - color = TextMuted, - fontSize = 13.sp, - modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp), + if (page == HubPage.NODES) "Nodes" else "FIPS Mesh", + color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, ) } - GlassField( - value = nm, onValueChange = { nm = it }, - placeholder = "Name (optional)", - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next), - ) - GlassField( - value = addr, onValueChange = { addr = it.trim() }, - placeholder = "192.168.1.100", - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - GlassField( - value = pwd, onValueChange = { pwd = it }, - placeholder = "Password", - modifier = Modifier.weight(1f), - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), - keyboardActions = KeyboardActions(onGo = { submit() }), - ) - Box( - Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f)) - .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp)) - .clickable { submit() }, - contentAlignment = Alignment.Center, - ) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) } - } - // HTTPS scheme toggle (available on both add and edit). - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(ROW_R)) - .clickable { https = !https } - .padding(vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text("Use HTTPS", color = TextMuted, fontSize = 13.sp) - Box( - Modifier - .width(46.dp).height(26.dp) - .clip(RoundedCornerShape(13.dp)) - .background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg) - .border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)), - contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart, - ) { - Box( - Modifier - .padding(horizontal = 3.dp) - .size(20.dp) - .clip(RoundedCornerShape(10.dp)) - .background(if (https) Color.White else TextMuted), - ) - } - } + IconRound(Icons.Default.Close, "Close") { onDismiss() } } - } else { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Box(Modifier.weight(1f)) { - MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true }) + } + Spacer(Modifier.height(4.dp)) + + when (page) { + HubPage.HUB -> { + // Card page — one card per destination. Dashboard first: it's a + // peer of the others so three-finger → hub → Dashboard returns + // to the node UI, same shape as every other option. + if (onBackToWebView != null) { + HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() } } - if (onScanQr != null) { - // Add server by scanning the node's pairing QR - Box( + 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 + } + if (FipsNative.available) { + HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS } + } + if (onMeshParty != null) { + HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() } + } + // Dark/Classic style lives on the remote/keyboard screen next to + // the settings button — not here. + } + + HubPage.NODES -> { + servers.forEach { server -> + val active = server.serialize() == activeServer?.serialize() + MenuItem( + label = server.displayName(), + selected = active, + onClick = { onSelectServer(server) }, + onEdit = { startEdit(server) }, + onRemove = { onRemoveServer(server) }, + ) + } + if (servers.isEmpty()) { + Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp)) + } + + if (showAdd || editing != null) { + Column( Modifier - .size(ROW_H) + .fillMaxWidth() .clip(RoundedCornerShape(ROW_R)) - .background(RowBg) + .background(FieldBg) .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) - .clickable { onScanQr() }, - contentAlignment = Alignment.Center, + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon( - Icons.Default.QrCodeScanner, - contentDescription = stringResource(R.string.add_server_qr), - tint = BitcoinOrange, - modifier = Modifier.size(24.dp), + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + if (editing != null) "Edit Server" else "Add Server", + color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium, + ) + Text( + "Cancel", color = TextMuted, fontSize = 13.sp, + modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp), + ) + } + GlassField( + value = nm, onValueChange = { nm = it }, + placeholder = "Name (optional)", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next), ) + GlassField( + value = addr, onValueChange = { addr = it.trim() }, + placeholder = "192.168.1.100", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + GlassField( + value = pwd, onValueChange = { pwd = it }, + placeholder = "Password", + modifier = Modifier.weight(1f), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { submit() }), + ) + Box( + Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f)) + .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp)) + .clickable { submit() }, + contentAlignment = Alignment.Center, + ) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) } + } + // HTTPS scheme toggle (available on both add and edit). + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .clickable { https = !https } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Use HTTPS", color = TextMuted, fontSize = 13.sp) + Box( + Modifier + .width(46.dp).height(26.dp) + .clip(RoundedCornerShape(13.dp)) + .background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg) + .border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)), + contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart, + ) { + Box( + Modifier + .padding(horizontal = 3.dp) + .size(20.dp) + .clip(RoundedCornerShape(10.dp)) + .background(if (https) Color.White else TextMuted), + ) + } + } + } + } else { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.weight(1f)) { + MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true }) + } + if (onScanQr != null) { + Box( + Modifier + .size(ROW_H) + .clip(RoundedCornerShape(ROW_R)) + .background(RowBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .clickable { onScanQr() }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.QrCodeScanner, + contentDescription = stringResource(R.string.add_server_qr), + tint = BitcoinOrange, + modifier = Modifier.size(24.dp), + ) + } + } } } } + + HubPage.FIPS -> { + FipsSection(embedded = true) + } } + } +} - Spacer(Modifier.height(2.dp)) - Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder)) - Spacer(Modifier.height(2.dp)) +private enum class HubPage { HUB, NODES, FIPS } - // Mode toggle - MenuItem( - label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad", - onClick = onToggleMode, - ) - - // Style toggle - MenuItem( - label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark", - onClick = onToggleStyle, - ) - - // FIPS mesh oversight — identity, mesh address, link status, controls. - FipsSection() - - // Phone↔phone mesh pairing + chat - if (onMeshParty != null) { - MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty) +/** Big tappable destination card for the hub page: icon + title + subtitle. */ +@Composable +private fun HubCard( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .background(RowBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .clickable { onClick() } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp)) } - - // Back to dashboard - if (onBackToWebView != null) { - MenuItem(label = "Back to Dashboard", onClick = onBackToWebView) + Column(Modifier.weight(1f)) { + Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1) } } } +/** Small circular icon button used in the hub header. */ +@Composable +private fun IconRound( + icon: androidx.compose.ui.graphics.vector.ImageVector, + desc: String, + onClick: () -> Unit, +) { + Box( + Modifier + .size(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(RowBg) + .border(1.dp, RowBorder, RoundedCornerShape(20.dp)) + .clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp)) + } +} + /** Snapshot of the phone's mesh identity + state for the FIPS menu section. */ private data class FipsInfo( val available: Boolean, @@ -354,11 +430,11 @@ private data class FipsInfo( * Collapsed by default so the menu stays compact. */ @Composable -private fun FipsSection() { +private fun FipsSection(embedded: Boolean = false) { if (!FipsNative.available) return val context = LocalContext.current val clipboard = LocalClipboardManager.current - var expanded by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(embedded) } var info by remember { mutableStateOf(null) } // Load identity/state when the section opens (cheap DataStore + JSON read). @@ -380,7 +456,9 @@ private fun FipsSection() { } 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 }, @@ -421,7 +499,7 @@ private fun FipsSection() { onClick = { FipsManager.requestMeshRestart(context) info = null - expanded = false + if (!embedded) expanded = false }, ) } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt index 0b93e684..eaf3afc0 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt @@ -43,6 +43,7 @@ fun NESPortraitController( onMouseScroll: (Int) -> Unit = { _ -> }, onMenu: () -> Unit, onPlayerToggle: () -> Unit = {}, + onToggleStyle: (() -> Unit)? = null, ) { val c = paletteFor(style) val isClassic = style == ControllerStyle.CLASSIC @@ -151,6 +152,10 @@ fun NESPortraitController( PlayerPill(c, playerId, onPlayerToggle) Spacer(Modifier.width(10.dp)) SettingsBtn(c, Modifier, onMenu) + onToggleStyle?.let { + Spacer(Modifier.width(10.dp)) + StyleBtn(c, Modifier, it) + } } } } 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 4642dcce..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 @@ -4,8 +4,10 @@ import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background +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.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -54,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() @@ -63,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) } @@ -90,6 +97,9 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) { playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 } ws.playerId = playerId } + fun toggleStyle() { + controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC + } val connectionState by ws.state.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current @@ -153,6 +163,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) { onKey = { ws.sendKey(it) }, onMenu = { showModal = true }, onPlayerToggle = ::togglePlayer, + onToggleStyle = ::toggleStyle, ) isGamepadMode && !isLandscape -> NESPortraitController( style = controllerStyle, @@ -163,6 +174,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) { onMouseScroll = { ws.sendScroll(it) }, onMenu = { showModal = true }, onPlayerToggle = ::togglePlayer, + onToggleStyle = ::toggleStyle, ) else -> { // Keyboard mode: trackpad fills top, keyboard pinned bottom @@ -182,12 +194,20 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) { modifier = Modifier.fillMaxWidth(), ) } - // Settings icon top-right in keyboard mode - com.archipelago.app.ui.components.SettingsBtn( - c = com.archipelago.app.ui.components.paletteFor(controllerStyle), - modifier = Modifier.align(Alignment.TopEnd).padding(8.dp), - onClick = { showModal = true }, - ) + // Settings + style icons top-right in keyboard mode + Row( + Modifier.align(Alignment.TopEnd).padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + com.archipelago.app.ui.components.SettingsBtn( + c = com.archipelago.app.ui.components.paletteFor(controllerStyle), + onClick = { showModal = true }, + ) + com.archipelago.app.ui.components.StyleBtn( + c = com.archipelago.app.ui.components.paletteFor(controllerStyle), + onClick = ::toggleStyle, + ) + } } } } @@ -210,8 +230,6 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) { visible = showModal, servers = savedServers, activeServer = activeServer, - isGamepadMode = isGamepadMode, - controllerStyle = controllerStyle, onDismiss = { showModal = false }, onSelectServer = { server -> scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false @@ -245,10 +263,8 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) { } } }, - onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false }, - onToggleStyle = { - controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC - }, + 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 a61e96e8..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, @@ -227,13 +233,17 @@ private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try { } /** Fastest answering origin: LAN inside a short window, else the mesh ULA - * (patient — a cold session may still be establishing), else LAN anyway so - * the existing error/fallback path handles it. */ + * (patient — a cold session may still be establishing). If NEITHER answers, + * fall back to the mesh URL when we have one — off-LAN the LAN IP is + * unreachable, and loading it just produced a confusing "can't reach + * 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL + * instead means the load retries against the path that's actually coming up, + * and any error shows the mesh address rather than a dead LAN IP. */ private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String = withContext(Dispatchers.IO) { if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl - lanUrl + meshUrl ?: lanUrl } /** Apply the WebView settings shared by the kiosk view and the in-app browser. @@ -274,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. @@ -352,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 @@ -737,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 @@ -755,7 +778,7 @@ fun WebViewScreen( if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) { if (System.currentTimeMillis() - threeFingerStart > 500) { threeFingerFired = true - onRemoteInput() + showHubMenu = true } } } @@ -880,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) + } + }, + ) } } diff --git a/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk index 9bc05e51..5c02c37b 100644 Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ diff --git a/neode-ui/src/views/dashboard/DashboardMobileNav.vue b/neode-ui/src/views/dashboard/DashboardMobileNav.vue index dbe88cd8..12f5dc3b 100644 --- a/neode-ui/src/views/dashboard/DashboardMobileNav.vue +++ b/neode-ui/src/views/dashboard/DashboardMobileNav.vue @@ -208,6 +208,11 @@ function onResize() { updateTabBarHeight() } +function onInsetsInjected() { + readSafeAreaTop() + updateTabBarHeight() +} + onMounted(() => { updateTabBarHeight() // Re-measure after the first paint: on mount the bar may not have its final @@ -216,13 +221,21 @@ onMounted(() => { requestAnimationFrame(updateTabBarHeight) readSafeAreaTop() window.addEventListener('resize', onResize) - // Re-read after WebView injection has had time to run. The injected - // safe-area-bottom padding changes the bar's height, so re-measure too. - setTimeout(() => { readSafeAreaTop(); updateTabBarHeight() }, 500) + // The Android WebView injects --safe-area-top asynchronously and fires this + // event when it lands. An authenticated session mounts the dashboard BEFORE + // the injection (fresh installs mount after login, long after it), so a + // one-shot read here bakes in 0 and content slides under the growing fixed + // tab bar — the update-install-only overlap bug. + window.addEventListener('archy-insets', onInsetsInjected) + // Fallback retry ladder for APKs that predate the event. + for (const delay of [500, 1500, 3000, 6000]) { + setTimeout(onInsetsInjected, delay) + } }) onBeforeUnmount(() => { window.removeEventListener('resize', onResize) + window.removeEventListener('archy-insets', onInsetsInjected) }) // Re-measure on route changes