Compare commits
6 Commits
7e8d3314d0
...
5457b98c66
| Author | SHA1 | Date | |
|---|---|---|---|
| 5457b98c66 | |||
|
|
1593c55894 | ||
|
|
1cc18e5b72 | ||
|
|
24582128c5 | ||
|
|
0b038434b1 | ||
|
|
d576f77435 |
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 42
|
versionCode = 45
|
||||||
versionName = "0.5.22"
|
versionName = "0.5.25"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -108,6 +109,7 @@ fun NESController(
|
|||||||
onKey: (String) -> Unit,
|
onKey: (String) -> Unit,
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
onPlayerToggle: () -> Unit = {},
|
onPlayerToggle: () -> Unit = {},
|
||||||
|
onToggleStyle: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
@ -205,6 +207,7 @@ fun NESController(
|
|||||||
) {
|
) {
|
||||||
PlayerPill(c, playerId, onPlayerToggle)
|
PlayerPill(c, playerId, onPlayerToggle)
|
||||||
SettingsBtn(c, Modifier, onMenu)
|
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) */
|
/** Player ID toggle pill (P1/P2/ALL) */
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
||||||
|
|||||||
@ -21,10 +21,22 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
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.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@ -37,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
|
||||||
@ -58,7 +71,6 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerEntry
|
import com.archipelago.app.data.ServerEntry
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
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.SurfaceDark
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
@ -82,27 +94,29 @@ fun NESMenu(
|
|||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
servers: List<ServerEntry>,
|
servers: List<ServerEntry>,
|
||||||
activeServer: ServerEntry?,
|
activeServer: ServerEntry?,
|
||||||
isGamepadMode: Boolean,
|
|
||||||
controllerStyle: ControllerStyle,
|
|
||||||
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,
|
||||||
onToggleStyle: () -> Unit,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)? = null,
|
onBackToWebView: (() -> Unit)? = null,
|
||||||
onMeshParty: (() -> Unit)? = null,
|
onMeshParty: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
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(
|
Box(
|
||||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||||
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, 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(
|
private fun MenuPanel(
|
||||||
servers: List<ServerEntry>,
|
servers: List<ServerEntry>,
|
||||||
activeServer: ServerEntry?,
|
activeServer: ServerEntry?,
|
||||||
isGamepadMode: Boolean,
|
|
||||||
controllerStyle: ControllerStyle,
|
|
||||||
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,
|
||||||
onToggleStyle: () -> Unit,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)?,
|
onBackToWebView: (() -> Unit)?,
|
||||||
onMeshParty: (() -> Unit)?,
|
onMeshParty: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
@ -155,188 +167,252 @@ private fun MenuPanel(
|
|||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var page by remember { mutableStateOf(HubPage.HUB) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 420.dp)
|
.widthIn(max = 420.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 20.dp)
|
.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))
|
.clip(RoundedCornerShape(PANEL_R))
|
||||||
.background(PanelBg)
|
.background(PanelBg.copy(alpha = 0.86f))
|
||||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
||||||
.padding(22.dp),
|
.verticalScroll(rememberScrollState())
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
// Title
|
// Header: back (on sub-pages) or title, and a close on the hub.
|
||||||
Text(
|
Row(
|
||||||
"Menu",
|
Modifier.fillMaxWidth(),
|
||||||
color = TextPrimary,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontSize = 18.sp,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
fontWeight = FontWeight.SemiBold,
|
) {
|
||||||
letterSpacing = 2.sp,
|
if (page == HubPage.HUB) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
|
||||||
textAlign = TextAlign.Center,
|
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
||||||
)
|
} else {
|
||||||
Spacer(Modifier.height(2.dp))
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
||||||
// Servers
|
Spacer(Modifier.width(12.dp))
|
||||||
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,
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
if (editing != null) "Edit Server" else "Add Server",
|
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
|
||||||
color = TextMuted,
|
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||||
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(
|
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
||||||
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)) {
|
Spacer(Modifier.height(4.dp))
|
||||||
Box(Modifier.weight(1f)) {
|
|
||||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
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) {
|
HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
|
||||||
// Add server by scanning the node's pairing QR
|
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
|
||||||
Box(
|
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
|
Modifier
|
||||||
.size(ROW_H)
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(ROW_R))
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
.background(RowBg)
|
.background(FieldBg)
|
||||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
.clickable { onScanQr() },
|
.padding(12.dp),
|
||||||
contentAlignment = Alignment.Center,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Row(
|
||||||
Icons.Default.QrCodeScanner,
|
Modifier.fillMaxWidth(),
|
||||||
contentDescription = stringResource(R.string.add_server_qr),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
tint = BitcoinOrange,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.size(24.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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))
|
private enum class HubPage { HUB, NODES, FIPS }
|
||||||
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
|
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
|
|
||||||
// Mode toggle
|
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||||
MenuItem(
|
@Composable
|
||||||
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
|
private fun HubCard(
|
||||||
onClick = onToggleMode,
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
)
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
// Style toggle
|
onClick: () -> Unit,
|
||||||
MenuItem(
|
) {
|
||||||
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
|
Row(
|
||||||
onClick = onToggleStyle,
|
Modifier
|
||||||
)
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
// FIPS mesh oversight — identity, mesh address, link status, controls.
|
.background(RowBg)
|
||||||
FipsSection()
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
|
.clickable { onClick() }
|
||||||
// Phone↔phone mesh pairing + chat
|
.padding(16.dp),
|
||||||
if (onMeshParty != null) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty)
|
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))
|
||||||
}
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
// Back to dashboard
|
Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||||
if (onBackToWebView != null) {
|
Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1)
|
||||||
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */
|
||||||
private data class FipsInfo(
|
private data class FipsInfo(
|
||||||
val available: Boolean,
|
val available: Boolean,
|
||||||
@ -354,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() {
|
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(false) }
|
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).
|
||||||
@ -380,7 +456,9 @@ private fun FipsSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 },
|
||||||
@ -421,7 +499,7 @@ private fun FipsSection() {
|
|||||||
onClick = {
|
onClick = {
|
||||||
FipsManager.requestMeshRestart(context)
|
FipsManager.requestMeshRestart(context)
|
||||||
info = null
|
info = null
|
||||||
expanded = false
|
if (!embedded) expanded = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ fun NESPortraitController(
|
|||||||
onMouseScroll: (Int) -> Unit = { _ -> },
|
onMouseScroll: (Int) -> Unit = { _ -> },
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
onPlayerToggle: () -> Unit = {},
|
onPlayerToggle: () -> Unit = {},
|
||||||
|
onToggleStyle: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
val isClassic = style == ControllerStyle.CLASSIC
|
val isClassic = style == ControllerStyle.CLASSIC
|
||||||
@ -151,6 +152,10 @@ fun NESPortraitController(
|
|||||||
PlayerPill(c, playerId, onPlayerToggle)
|
PlayerPill(c, playerId, onPlayerToggle)
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
SettingsBtn(c, Modifier, onMenu)
|
SettingsBtn(c, Modifier, onMenu)
|
||||||
|
onToggleStyle?.let {
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
StyleBtn(c, Modifier, it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import android.content.res.Configuration
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
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.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -54,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()
|
||||||
@ -63,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) }
|
||||||
@ -90,6 +97,9 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
||||||
ws.playerId = playerId
|
ws.playerId = playerId
|
||||||
}
|
}
|
||||||
|
fun toggleStyle() {
|
||||||
|
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||||
|
}
|
||||||
val connectionState by ws.state.collectAsState()
|
val connectionState by ws.state.collectAsState()
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
@ -153,6 +163,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
onKey = { ws.sendKey(it) },
|
onKey = { ws.sendKey(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
onPlayerToggle = ::togglePlayer,
|
onPlayerToggle = ::togglePlayer,
|
||||||
|
onToggleStyle = ::toggleStyle,
|
||||||
)
|
)
|
||||||
isGamepadMode && !isLandscape -> NESPortraitController(
|
isGamepadMode && !isLandscape -> NESPortraitController(
|
||||||
style = controllerStyle,
|
style = controllerStyle,
|
||||||
@ -163,6 +174,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
onMouseScroll = { ws.sendScroll(it) },
|
onMouseScroll = { ws.sendScroll(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
onPlayerToggle = ::togglePlayer,
|
onPlayerToggle = ::togglePlayer,
|
||||||
|
onToggleStyle = ::toggleStyle,
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
||||||
@ -182,12 +194,20 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Settings icon top-right in keyboard mode
|
// Settings + style icons top-right in keyboard mode
|
||||||
com.archipelago.app.ui.components.SettingsBtn(
|
Row(
|
||||||
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
onClick = { showModal = true },
|
) {
|
||||||
)
|
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,
|
visible = showModal,
|
||||||
servers = savedServers,
|
servers = savedServers,
|
||||||
activeServer = activeServer,
|
activeServer = activeServer,
|
||||||
isGamepadMode = isGamepadMode,
|
|
||||||
controllerStyle = controllerStyle,
|
|
||||||
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
|
||||||
@ -245,10 +263,8 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
onRemote = { isGamepadMode = true; showModal = false },
|
||||||
onToggleStyle = {
|
onKeyboard = { isGamepadMode = false; showModal = false },
|
||||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
|
||||||
},
|
|
||||||
onBackToWebView = { showModal = false; onBack() },
|
onBackToWebView = { showModal = false; onBack() },
|
||||||
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
|
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
@ -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
|
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
|
||||||
* (patient — a cold session may still be establishing), else LAN anyway so
|
* (patient — a cold session may still be establishing). If NEITHER answers,
|
||||||
* the existing error/fallback path handles it. */
|
* 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 =
|
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
|
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
|
||||||
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
|
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.
|
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
||||||
@ -274,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.
|
||||||
@ -352,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
|
||||||
@ -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
|
// 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
|
||||||
@ -755,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@ -208,6 +208,11 @@ function onResize() {
|
|||||||
updateTabBarHeight()
|
updateTabBarHeight()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onInsetsInjected() {
|
||||||
|
readSafeAreaTop()
|
||||||
|
updateTabBarHeight()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateTabBarHeight()
|
updateTabBarHeight()
|
||||||
// Re-measure after the first paint: on mount the bar may not have its final
|
// Re-measure after the first paint: on mount the bar may not have its final
|
||||||
@ -216,13 +221,21 @@ onMounted(() => {
|
|||||||
requestAnimationFrame(updateTabBarHeight)
|
requestAnimationFrame(updateTabBarHeight)
|
||||||
readSafeAreaTop()
|
readSafeAreaTop()
|
||||||
window.addEventListener('resize', onResize)
|
window.addEventListener('resize', onResize)
|
||||||
// Re-read after WebView injection has had time to run. The injected
|
// The Android WebView injects --safe-area-top asynchronously and fires this
|
||||||
// safe-area-bottom padding changes the bar's height, so re-measure too.
|
// event when it lands. An authenticated session mounts the dashboard BEFORE
|
||||||
setTimeout(() => { readSafeAreaTop(); updateTabBarHeight() }, 500)
|
// 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(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', onResize)
|
window.removeEventListener('resize', onResize)
|
||||||
|
window.removeEventListener('archy-insets', onInsetsInjected)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Re-measure on route changes
|
// Re-measure on route changes
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user