feat(companion): settings menu hub redesign — 0.5.24

Three-finger menu becomes a contained card hub: Dashboard, Remote,
Keyboard, Nodes, FIPS Mesh, Mesh Party — with Nodes and FIPS as
sub-pages behind a back header. The panel is a centred glass card that
scrolls within its own bounds instead of filling the screen.

The Dark/Classic style toggle leaves the menu and becomes a palette
button next to the settings gear on all three input surfaces (landscape
controller, portrait controller, keyboard mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-27 16:34:36 +01:00
parent d576f77435
commit 0b038434b1
5 changed files with 288 additions and 173 deletions

View File

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

View File

@ -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) {

View File

@ -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
@ -58,7 +70,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
@ -83,7 +94,6 @@ fun NESMenu(
servers: List<ServerEntry>, servers: List<ServerEntry>,
activeServer: ServerEntry?, activeServer: ServerEntry?,
isGamepadMode: Boolean, isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit, onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit, onAddServer: (ServerEntry) -> Unit,
@ -91,18 +101,21 @@ fun NESMenu(
onEditServer: (ServerEntry, ServerEntry) -> Unit, onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit, onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit, onToggleMode: () -> Unit,
onToggleStyle: () -> 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, isGamepadMode, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onBackToWebView, onMeshParty)
} }
} }
} }
@ -113,7 +126,6 @@ private fun MenuPanel(
servers: List<ServerEntry>, servers: List<ServerEntry>,
activeServer: ServerEntry?, activeServer: ServerEntry?,
isGamepadMode: Boolean, isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit, onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit, onAddServer: (ServerEntry) -> Unit,
@ -121,7 +133,6 @@ private fun MenuPanel(
onEditServer: (ServerEntry, ServerEntry) -> Unit, onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit, onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit, onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onBackToWebView: (() -> Unit)?, onBackToWebView: (() -> Unit)?,
onMeshParty: (() -> Unit)?, onMeshParty: (() -> Unit)?,
) { ) {
@ -155,188 +166,256 @@ 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 so the glass panel never fills the screen; its content
// scrolls inside these bounds when tall.
.heightIn(max = 560.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") {
// Add server by scanning the node's pairing QR if (!isGamepadMode) onToggleMode() else onDismiss()
Box( }
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") {
if (isGamepadMode) onToggleMode() else onDismiss()
}
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
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(startExpanded = 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 +433,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(startExpanded: 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(startExpanded) }
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).

View File

@ -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)
}
} }
} }
} }

View File

@ -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
@ -90,6 +92,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 +158,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 +169,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 +189,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,
)
}
} }
} }
} }
@ -211,7 +226,6 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
servers = savedServers, servers = savedServers,
activeServer = activeServer, activeServer = activeServer,
isGamepadMode = isGamepadMode, 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
@ -246,9 +260,6 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
} }
}, },
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false }, onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
onToggleStyle = {
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() } },
) )