Compare commits

...

2 Commits

Author SHA1 Message Date
Dorian
993f30456f feat(neode-ui): instant press feedback + launching spinner on app icons
Tapping a dashboard app icon now scales it down immediately (CSS :active)
and shows a per-icon spinner until the app overlay opens, so the tap is
acknowledged even while the app session spins up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:21:48 +01:00
Dorian
aa95e42383 feat(android): circular logo, synthwave backgrounds, glass modal, server names + UX fixes
- New circular badge logo (ic_logo) on Intro + Connect screens; launcher
  icon rebuilt as dark circle + white grid.
- Reddish synthwave backdrop (bg-intro-2) behind Intro, Connect, and the
  remote/gamepad (edge-to-edge with a light scrim); controllers no longer
  paint an opaque fill over it.
- Server name: added to ServerEntry/prefs, the Connect form, the modal
  add-form, and saved-server rows; removal now matches by connection
  identity (rename- and legacy-format-safe).
- NESMenu modal restyled to glassmorphism #0A0A0A with centered, larger
  fields. Connect-form glass cards given a darker base for legibility.
- Intro title/subtitle set to #FAFAFA.
- Deleting the last server clears the active server and returns to Connect.
- D-pad auto-repeat initial delay raised to 500ms so a tap sends one key
  (fixes doubled nav sound).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:21:48 +01:00
15 changed files with 440 additions and 142 deletions

View File

@ -18,7 +18,11 @@ data class ServerEntry(
val useHttps: Boolean, val useHttps: Boolean,
val port: String = "", val port: String = "",
val password: String = "", val password: String = "",
val name: String = "",
) { ) {
/** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address }
fun toUrl(): String { fun toUrl(): String {
val scheme = if (useHttps) "https" else "http" val scheme = if (useHttps) "https" else "http"
val portSuffix = if (port.isNotBlank()) ":$port" else "" val portSuffix = if (port.isNotBlank()) ":$port" else ""
@ -31,7 +35,9 @@ data class ServerEntry(
return "$scheme://$address$portSuffix" return "$scheme://$address$portSuffix"
} }
fun serialize(): String = "$address|$useHttps|$port|$password" // name is the trailing field so entries saved before naming existed
// (4 fields) still deserialize, with name defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name"
companion object { companion object {
fun deserialize(raw: String): ServerEntry? { fun deserialize(raw: String): ServerEntry? {
@ -42,6 +48,7 @@ data class ServerEntry(
useHttps = parts[1].toBooleanStrictOrNull() ?: false, useHttps = parts[1].toBooleanStrictOrNull() ?: false,
port = parts.getOrElse(2) { "" }, port = parts.getOrElse(2) { "" },
password = parts.getOrElse(3) { "" }, password = parts.getOrElse(3) { "" },
name = parts.getOrElse(4) { "" },
) )
} }
} }
@ -53,6 +60,7 @@ class ServerPreferences(private val context: Context) {
private val activeHttpsKey = booleanPreferencesKey("active_https") private val activeHttpsKey = booleanPreferencesKey("active_https")
private val activePortKey = stringPreferencesKey("active_port") private val activePortKey = stringPreferencesKey("active_port")
private val activePasswordKey = stringPreferencesKey("active_password") private val activePasswordKey = stringPreferencesKey("active_password")
private val activeNameKey = stringPreferencesKey("active_name")
private val savedServersKey = stringSetPreferencesKey("saved_servers") private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen") private val introSeenKey = booleanPreferencesKey("intro_seen")
@ -63,6 +71,7 @@ class ServerPreferences(private val context: Context) {
useHttps = prefs[activeHttpsKey] ?: false, useHttps = prefs[activeHttpsKey] ?: false,
port = prefs[activePortKey] ?: "", port = prefs[activePortKey] ?: "",
password = prefs[activePasswordKey] ?: "", password = prefs[activePasswordKey] ?: "",
name = prefs[activeNameKey] ?: "",
) )
} }
@ -81,6 +90,7 @@ class ServerPreferences(private val context: Context) {
prefs[activeHttpsKey] = server.useHttps prefs[activeHttpsKey] = server.useHttps
prefs[activePortKey] = server.port prefs[activePortKey] = server.port
prefs[activePasswordKey] = server.password prefs[activePasswordKey] = server.password
prefs[activeNameKey] = server.name
} }
addSavedServer(server) addSavedServer(server)
} }
@ -91,6 +101,7 @@ class ServerPreferences(private val context: Context) {
prefs.remove(activeHttpsKey) prefs.remove(activeHttpsKey)
prefs.remove(activePortKey) prefs.remove(activePortKey)
prefs.remove(activePasswordKey) prefs.remove(activePasswordKey)
prefs.remove(activeNameKey)
} }
} }
@ -104,7 +115,16 @@ class ServerPreferences(private val context: Context) {
suspend fun removeSavedServer(server: ServerEntry) { suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet() val current = prefs[savedServersKey] ?: emptySet()
prefs[savedServersKey] = current - server.serialize() // Match by connection identity (address/port/scheme) rather than the
// exact serialized string, so a rename — or the legacy 4-field format
// saved before names existed — still removes the right entry.
prefs[savedServersKey] = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
}.toSet()
} }
} }

View File

@ -108,7 +108,9 @@ private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) {
.pointerInput(key) { .pointerInput(key) {
detectTapGestures(onPress = { detectTapGestures(onPress = {
p = true; onDir(key) p = true; onDir(key)
job = scope.launch { delay(350); while (true) { onDir(key); delay(100) } } // 500ms initial delay so a normal tap sends one key, not two
// (a touch tap often exceeds 350ms → doubled nav sound).
job = scope.launch { delay(500); while (true) { onDir(key); delay(100) } }
tryAwaitRelease(); p = false; job?.cancel() tryAwaitRelease(); p = false; job?.cancel()
}) })
}, },

View File

@ -83,13 +83,15 @@ val ClassicPalette = NESPalette(
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999), inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
) )
// Glassmorphism-black (OS design): #0A0A0A surfaces, subtle white-alpha borders,
// translucent-white buttons. Accents come from each button's ring.
val DarkPalette = NESPalette( val DarkPalette = NESPalette(
body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge, body = Color(0xFF0D0D0F), face = Color(0xFF0A0A0A), ridge = Color(0x14FFFFFF),
label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted, label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777),
dpad = Color(0xFF080808), dpadHi = Color(0xFF141418), dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A),
btn = NES.DarkButtonMain, btnPress = NES.DarkButtonMainPress, btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF),
capsule = Color(0xFF121216), capsulePress = Color(0xFF0A0A0C), capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF),
inlayBg = Color(0xFF060608), inlayBorder = Color(0xFF444448), inlayBg = Color(0xFF0A0A0A), inlayBorder = Color(0x1FFFFFFF),
) )
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
@ -113,7 +115,6 @@ fun NESController(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(Color(0xFF0C0C0C)) // Slightly lighter than black for shadow visibility
.twoFingerHold(onMenu) .twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp), .padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@ -193,13 +194,13 @@ fun NESController(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
// C on top (white) // C on top
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 44.dp) { onKey("c") } GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") }
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
// B + A on bottom row // B + A on bottom row
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 44.dp) { onKey("b") } GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") }
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 44.dp) { onKey("a") } GlassFaceBtn("A", Color(0xFFF7931A), 44.dp) { onKey("a") }
} }
} }
} }
@ -264,7 +265,9 @@ fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
} }
activeDir = dir; onDir(dir) activeDir = dir; onDir(dir)
job?.cancel() job?.cancel()
job = scope.launch { delay(300); while (true) { onDir(dir); delay(90) } } // 500ms initial delay so a normal tap sends one key, not
// two (a touch tap often exceeds 300ms → doubled nav sound).
job = scope.launch { delay(500); while (true) { onDir(dir); delay(90) } }
tryAwaitRelease() tryAwaitRelease()
job?.cancel(); activeDir = null job?.cancel(); activeDir = null
}, },
@ -375,6 +378,28 @@ fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Uni
} }
} }
/** Glass face button — dark translucent fill, colored ring + letter (OS style) */
@Composable
fun GlassFaceBtn(label: String, accent: Color, sz: Dp = 44.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.clip(CircleShape)
.background(
Brush.verticalGradient(
if (p) listOf(Color.White.copy(alpha = 0.05f), Color.White.copy(alpha = 0.02f))
else listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
)
)
.border(1.5.dp, accent.copy(alpha = if (p) 0.95f else 0.55f), CircleShape)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = accent.copy(alpha = if (p) 1f else 0.85f), fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
/** START/SELECT capsule */ /** START/SELECT capsule */
@Composable @Composable
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) { fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {

View File

@ -3,6 +3,8 @@ package com.archipelago.app.ui.components
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -34,17 +36,35 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
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.ControllerStyle import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.NES import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
/** NES-styled modal menu — dark blue panel with white borders */ // Glassmorphism palette (OS design): near-black surfaces, subtle white borders,
// Bitcoin-orange accent.
private val PanelBg = SurfaceDark // #0A0A0A
private val PanelBorder = Color.White.copy(alpha = 0.12f)
private val RowBg = Color.White.copy(alpha = 0.05f)
private val RowBorder = Color.White.copy(alpha = 0.08f)
private val FieldBg = Color.White.copy(alpha = 0.04f)
private val PANEL_R = 20.dp
private val ROW_R = 14.dp
private val ROW_H = 54.dp
private val FIELD_H = 58.dp
/** Glassmorphism modal menu — #0A0A0A surface, subtle white borders. */
@Composable @Composable
fun NESMenu( fun NESMenu(
visible: Boolean, visible: Boolean,
@ -66,7 +86,9 @@ fun NESMenu(
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() }, .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView) AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
}
} }
} }
} }
@ -86,29 +108,45 @@ private fun MenuPanel(
onBackToWebView: (() -> Unit)?, onBackToWebView: (() -> Unit)?,
) { ) {
var showAdd by remember { mutableStateOf(false) } var showAdd by remember { mutableStateOf(false) }
var nm by remember { mutableStateOf("") }
var addr by remember { mutableStateOf("") } var addr by remember { mutableStateOf("") }
var pwd by remember { mutableStateOf("") } var pwd by remember { mutableStateOf("") }
fun submit() {
if (addr.isNotBlank()) {
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
nm = ""; addr = ""; pwd = ""; showAdd = false
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.widthIn(max = 360.dp) .widthIn(max = 420.dp)
.clip(RoundedCornerShape(4.dp)) .padding(horizontal = 20.dp)
.background(NES.MenuPanel) .clip(RoundedCornerShape(PANEL_R))
.border(3.dp, NES.MenuBorder, RoundedCornerShape(4.dp)) .background(PanelBg)
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {} .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
.padding(16.dp), .padding(22.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
// Title // Title
Text("- MENU -", color = NES.MenuText, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp, Text(
modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center) "Menu",
Spacer(Modifier.height(4.dp)) color = TextPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(2.dp))
// Servers // Servers
servers.forEach { server -> servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize() val active = server.serialize() == activeServer?.serialize()
MenuItem( MenuItem(
label = (if (active) "\u25B6 " else " ") + server.address, label = server.displayName(),
selected = active, selected = active,
onClick = { onSelectServer(server) }, onClick = { onSelectServer(server) },
onRemove = { onRemoveServer(server) }, onRemove = { onRemoveServer(server) },
@ -116,69 +154,70 @@ private fun MenuPanel(
} }
if (servers.isEmpty()) { if (servers.isEmpty()) {
Text(" NO SERVERS", color = NES.MenuMuted, fontSize = 11.sp, modifier = Modifier.padding(vertical = 4.dp)) Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
} }
// Add server // Add server
if (showAdd) { if (showAdd) {
Column( Column(
Modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.3f)).padding(8.dp), Modifier
verticalArrangement = Arrangement.spacedBy(6.dp), .fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
OutlinedTextField( GlassField(
value = addr, onValueChange = { addr = it.trim() }, value = nm, onValueChange = { nm = it },
placeholder = { Text("192.168.1.100", color = NES.MenuMuted, fontSize = 11.sp) }, placeholder = "Name (optional)",
modifier = Modifier.fillMaxWidth().height(48.dp), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
colors = nesFieldColors(),
shape = RoundedCornerShape(2.dp),
) )
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { GlassField(
OutlinedTextField( 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 }, value = pwd, onValueChange = { pwd = it },
placeholder = { Text("PASSWORD", color = NES.MenuMuted, fontSize = 11.sp) }, placeholder = "Password",
modifier = Modifier.weight(1f).height(48.dp), singleLine = true, modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { keyboardActions = KeyboardActions(onGo = { submit() }),
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
}),
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
colors = nesFieldColors(),
shape = RoundedCornerShape(2.dp),
) )
Box( Box(
Modifier.size(48.dp).clip(RoundedCornerShape(2.dp)).background(NES.MenuSelected) Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
.clickable { .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false } .clickable { submit() },
},
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { Text("OK", color = NES.MenuText, fontSize = 10.sp, fontWeight = FontWeight.Bold) } ) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
} }
} }
} else { } else {
MenuItem(label = " ADD SERVER", onClick = { showAdd = true }) MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
} }
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(NES.MenuBorder.copy(alpha = 0.3f))) Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
// Mode toggle // Mode toggle
MenuItem( MenuItem(
label = if (isGamepadMode) " SWITCH TO KEYBOARD" else " SWITCH TO GAMEPAD", label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
onClick = onToggleMode, onClick = onToggleMode,
) )
// Style toggle // Style toggle
MenuItem( MenuItem(
label = if (controllerStyle == ControllerStyle.CLASSIC) " STYLE: CLASSIC" else " STYLE: DARK", label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
onClick = onToggleStyle, onClick = onToggleStyle,
) )
// Back to dashboard // Back to dashboard
if (onBackToWebView != null) { if (onBackToWebView != null) {
MenuItem(label = " BACK TO DASHBOARD", onClick = onBackToWebView) MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
} }
} }
} }
@ -187,32 +226,69 @@ private fun MenuPanel(
private fun MenuItem( private fun MenuItem(
label: String, label: String,
selected: Boolean = false, selected: Boolean = false,
labelColor: Color = TextPrimary,
onClick: () -> Unit, onClick: () -> Unit,
onRemove: (() -> Unit)? = null, onRemove: (() -> Unit)? = null,
) { ) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(32.dp) .height(ROW_H)
.background(if (selected) NES.MenuSelected.copy(alpha = 0.15f) else Color.Transparent) .clip(RoundedCornerShape(ROW_R))
.background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
.border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() } .clickable { onClick() }
.padding(horizontal = 8.dp), .padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text(label, color = if (selected) NES.MenuSelected else NES.MenuText, fontSize = 11.sp, fontWeight = FontWeight.Medium) Text(
label,
color = if (selected) BitcoinOrange else labelColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
if (onRemove != null) { if (onRemove != null) {
Text("\u2715", color = NES.MenuMuted, fontSize = 10.sp, Text(
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp)) "",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp),
)
} }
} }
} }
/** Glass text field with centered input text. */
@Composable @Composable
private fun nesFieldColors() = OutlinedTextFieldDefaults.colors( private fun GlassField(
focusedBorderColor = NES.MenuBorder, value: String,
unfocusedBorderColor = NES.MenuMuted, onValueChange: (String) -> Unit,
cursorColor = NES.MenuText, placeholder: String,
focusedTextColor = NES.MenuText, modifier: Modifier = Modifier,
unfocusedTextColor = NES.MenuText, visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
) keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
placeholder = {
Text(placeholder, color = TextMuted, fontSize = 15.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
},
modifier = modifier.fillMaxWidth().height(FIELD_H),
singleLine = true,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, textAlign = TextAlign.Center),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
}

View File

@ -50,7 +50,6 @@ fun NESPortraitController(
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color(0xFF0C0C0C))
.twoFingerHold(onMenu) .twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp), .padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@ -119,11 +118,11 @@ fun NESPortraitController(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 46.dp) { onKey("c") } GlassFaceBtn("C", Color(0xFFBBBBBB), 46.dp) { onKey("c") }
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 46.dp) { onKey("b") } GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") }
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 46.dp) { onKey("a") } GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") }
} }
} }
} }

View File

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -41,7 +42,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -67,26 +68,45 @@ fun IntroScreen(onContinue: () -> Unit) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(SurfaceBlack) .background(SurfaceBlack),
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.Center,
) { ) {
// Reddish synthwave backdrop
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Dark scrim so the title/buttons stay legible over the art
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.55f),
Color.Black.copy(alpha = 0.35f),
Color.Black.copy(alpha = 0.75f),
),
)
),
)
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp), .padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
// Wide pixel-art logo // Circular badge logo
Image( Image(
painter = painterResource(id = R.drawable.ic_logo_wide), painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago", contentDescription = "Archipelago",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .size(160.dp)
.padding(horizontal = 8.dp)
.alpha(logoAlpha.value), .alpha(logoAlpha.value),
colorFilter = ColorFilter.tint(Color.White),
) )
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
@ -102,7 +122,7 @@ fun IntroScreen(onContinue: () -> Unit) {
Text( Text(
text = stringResource(R.string.welcome_title), text = stringResource(R.string.welcome_title),
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
color = TextPrimary, color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
@ -111,7 +131,7 @@ fun IntroScreen(onContinue: () -> Unit) {
Text( Text(
text = stringResource(R.string.welcome_subtitle), text = stringResource(R.string.welcome_subtitle),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = TextMuted, color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
lineHeight = 26.sp, lineHeight = 26.sp,
) )

View File

@ -2,6 +2,7 @@ package com.archipelago.app.ui.screens
import android.content.res.Configuration import android.content.res.Configuration
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -24,13 +25,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.network.ConnectionState import com.archipelago.app.network.ConnectionState
import com.archipelago.app.network.InputWebSocket import com.archipelago.app.network.InputWebSocket
@ -58,7 +63,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
var isGamepadMode by remember { mutableStateOf(true) } var isGamepadMode by remember { mutableStateOf(true) }
var showModal by remember { mutableStateOf(false) } var showModal by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) } var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2 var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
val ws = remember { InputWebSocket(scope) } val ws = remember { InputWebSocket(scope) }
@ -113,9 +118,31 @@ fun RemoteInputScreen(onBack: () -> Unit) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color(0xFF0C0C0C)) .background(Color(0xFF0C0C0C)),
.windowInsetsPadding(WindowInsets.safeDrawing),
) { ) {
// Reddish synthwave backdrop behind the controller
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Light scrim — the controller body provides its own contrast, so keep
// this subtle and let the backdrop show through around it.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.4f),
Color.Black.copy(alpha = 0.25f),
Color.Black.copy(alpha = 0.45f),
),
)
),
)
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
when { when {
isGamepadMode && isLandscape -> NESController( isGamepadMode && isLandscape -> NESController(
style = controllerStyle, style = controllerStyle,
@ -174,6 +201,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
} }
), ),
) )
}
NESMenu( NESMenu(
visible = showModal, visible = showModal,
@ -188,7 +216,20 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onAddServer = { server -> onAddServer = { server ->
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) } scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
}, },
onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } }, onRemoveServer = { server ->
scope.launch {
prefs.removeSavedServer(server)
// Deleting the last server leaves nothing to control — drop the
// active server and return to the Connect screen.
val remaining = savedServers.count { it.serialize() != server.serialize() }
if (remaining == 0) {
ws.disconnect()
prefs.clearActiveServer()
showModal = false
onBack()
}
}
},
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false }, onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
onToggleStyle = { onToggleStyle = {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC

View File

@ -55,6 +55,7 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -97,6 +98,7 @@ fun ServerConnectScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val keyboard = LocalSoftwareKeyboardController.current val keyboard = LocalSoftwareKeyboardController.current
var name by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") } var address by remember { mutableStateOf("") }
var port by remember { mutableStateOf("") } var port by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
@ -132,12 +134,33 @@ fun ServerConnectScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(SurfaceBlack) .background(SurfaceBlack),
.windowInsetsPadding(WindowInsets.safeDrawing),
) { ) {
// Reddish synthwave backdrop
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Dark scrim so the form stays legible over the art
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.6f),
Color.Black.copy(alpha = 0.45f),
Color.Black.copy(alpha = 0.8f),
),
)
),
)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(state = rememberScrollState()) .verticalScroll(state = rememberScrollState())
.drawWithContent { drawContent() } .drawWithContent { drawContent() }
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
@ -145,14 +168,11 @@ fun ServerConnectScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
// Wide logo // Circular badge logo
Image( Image(
painter = painterResource(id = R.drawable.ic_logo_wide), painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago", contentDescription = "Archipelago",
modifier = Modifier modifier = Modifier.size(96.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp),
colorFilter = ColorFilter.tint(Color.White),
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
@ -178,6 +198,7 @@ fun ServerConnectScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
colors = listOf( colors = listOf(
@ -190,6 +211,34 @@ fun ServerConnectScreen(
.padding(20.dp), .padding(20.dp),
) { ) {
Column { Column {
OutlinedTextField(
value = name,
onValueChange = {
name = it
errorMessage = null
},
label = { Text(stringResource(R.string.server_name_label)) },
placeholder = { Text(stringResource(R.string.server_name_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( OutlinedTextField(
value = address, value = address,
onValueChange = { onValueChange = {
@ -275,7 +324,7 @@ fun ServerConnectScreen(
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onGo = { onGo = {
keyboard?.hide() keyboard?.hide()
connect(ServerEntry(address, useHttps, port, password)) connect(ServerEntry(address, useHttps, port, password, name))
}, },
), ),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(
@ -345,7 +394,7 @@ fun ServerConnectScreen(
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect), text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
onClick = { onClick = {
keyboard?.hide() keyboard?.hide()
connect(ServerEntry(address, useHttps, port, password)) connect(ServerEntry(address, useHttps, port, password, name))
}, },
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
) )
@ -391,6 +440,7 @@ private fun SavedServerItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
colors = listOf( colors = listOf(
@ -414,9 +464,15 @@ private fun SavedServerItem(
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column { Column {
Text(text = server.address, style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (server.port.isNotBlank()) { val secondary = buildString {
Text(text = "Port ${server.port}", style = MaterialTheme.typography.labelMedium, color = TextMuted) if (server.name.isNotBlank()) append(server.address)
if (server.port.isNotBlank()) {
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
}
}
if (secondary.isNotBlank()) {
Text(text = secondary, style = MaterialTheme.typography.labelMedium, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

View File

@ -5,6 +5,6 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path <path
android:fillColor="#030202" android:fillColor="#0A0A0A"
android:pathData="M0,0h108v108H0z" /> android:pathData="M0,0h108v108H0z" />
</vector> </vector>

View File

@ -1,45 +1,20 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Archipelago pixel-art "A" logo — scaled 90% and centered --> <!-- Archipelago pixel grid (from logo.svg), centered + scaled into the
adaptive-icon safe zone. Pairs with the dark circle background so the
launcher mask renders a clean circular badge. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="1024" android:viewportWidth="752"
android:viewportHeight="1024"> android:viewportHeight="752">
<group <group
android:pivotX="512" android:pivotX="376"
android:pivotY="512" android:pivotY="376"
android:scaleX="0.55" android:scaleX="0.72"
android:scaleY="0.55"> android:scaleY="0.72">
<path
<!-- Row 1: 4 blocks --> android:fillColor="#FFFFFF"
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" /> android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
<!-- Row 2: 2 blocks (right side) -->
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
<!-- Row 3: 6 blocks (full width) -->
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
<!-- Row 4: 4 blocks (sides only — the "A" gap) -->
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
<!-- Row 5: 4 blocks (bottom) -->
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
</group> </group>
</vector> </vector>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Archipelago circular badge logo (from logo.svg):
dark circle with a black→grey gradient ring + white pixel-grid mark. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="120dp"
android:height="120dp"
android:viewportWidth="752"
android:viewportHeight="752">
<!-- Ringed circle (circle converted to a path; stroke carries the gradient) -->
<path
android:fillColor="#0A0A0A"
android:strokeWidth="22.8834"
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="751.337"
android:startY="751.338"
android:endX="0"
android:endY="0">
<item android:offset="0" android:color="#FF000000" />
<item android:offset="1" android:color="#FF666666" />
</gradient>
</aapt:attr>
</path>
<!-- White Archipelago pixel grid -->
<path
android:fillColor="#FFFFFF"
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
</vector>

View File

@ -23,4 +23,6 @@
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string> <string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
<string name="close">Close</string> <string name="close">Close</string>
<string name="open_in_browser">Open in browser</string> <string name="open_in_browser">Open in browser</string>
<string name="server_name_label">Server Name (optional)</string>
<string name="server_name_placeholder">My Archipelago</string>
</resources> </resources>

10
Android/logo.svg Normal file
View File

@ -0,0 +1,10 @@
<svg width="752" height="752" viewBox="0 0 752 752" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="375.668" cy="375.669" r="364.227" fill="#0A0A0A" stroke="url(#paint0_linear_877_1990)" stroke-width="22.8834"/>
<path d="M253.805 278.37V222.28H309.853V278.37H253.805ZM315.797 278.37V222.28H372.694V278.37H315.797ZM378.639 278.37V222.28H435.536V278.37H378.639ZM441.481 278.37V222.28H497.529V278.37H441.481ZM441.481 341.259V284.319H497.529V341.259H441.481ZM503.473 341.259V284.319H560.37V341.259H503.473ZM190.963 404.148V347.208H247.86V404.148H190.963ZM253.805 404.148V347.208H309.853V404.148H253.805ZM315.797 404.148V347.208H372.694V404.148H315.797ZM378.639 404.148V347.208H435.536V404.148H378.639ZM441.481 404.148V347.208H497.529V404.148H441.481ZM503.473 404.148V347.208H560.37V404.148H503.473ZM190.963 466.187V410.097H247.86V466.187H190.963ZM253.805 466.187V410.097H309.853V466.187H253.805ZM441.481 466.187V410.097H497.529V466.187H441.481ZM503.473 466.187V410.097H560.37V466.187H503.473ZM253.805 529.076V472.136H309.853V529.076H253.805ZM315.797 529.076V472.136H372.694V529.076H315.797ZM378.639 529.076V472.136H435.536V529.076H378.639ZM441.481 529.076V472.136H497.529V529.076H441.481Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_877_1990" x1="751.337" y1="751.338" x2="0" y2="0.000976562" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-color="#666666"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -58,6 +58,13 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
</div> </div>
<!-- Launching overlay instant tap feedback while the app opens -->
<div v-if="launchingId === id" class="app-icon-installing">
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div> </div>
<!-- Label --> <!-- Label -->
<span class="app-icon-label">{{ getTitle(id, pkg) }}</span> <span class="app-icon-label">{{ getTitle(id, pkg) }}</span>
@ -114,7 +121,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { useServerStore } from '@/stores/server' import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher' import { useAppLauncherStore } from '@/stores/appLauncher'
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api' import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
@ -152,6 +159,28 @@ const activePage = ref(0)
const longPressTriggered = ref(false) const longPressTriggered = ref(false)
let longPressTimer: ReturnType<typeof setTimeout> | null = null let longPressTimer: ReturnType<typeof setTimeout> | null = null
// Per-icon "launching" spinner so a tap is acknowledged instantly even while
// the app session/iframe is still spinning up. Cleared when the launcher
// overlay opens, with a fallback timeout for the open-in-new-tab path.
const launchingId = ref<string | null>(null)
let launchClearTimer: ReturnType<typeof setTimeout> | null = null
function markLaunching(id: string) {
launchingId.value = id
if (launchClearTimer) clearTimeout(launchClearTimer)
launchClearTimer = setTimeout(() => {
if (launchingId.value === id) launchingId.value = null
}, 4000)
}
// Clear the spinner as soon as the app overlay actually opens.
watch(() => appLauncher.isOpen, (open) => {
if (open) {
launchingId.value = null
if (launchClearTimer) { clearTimeout(launchClearTimer); launchClearTimer = null }
}
})
const pages = computed(() => { const pages = computed(() => {
const result: [string, PackageDataEntry][][] = [] const result: [string, PackageDataEntry][][] = []
for (let i = 0; i < props.apps.length; i += ITEMS_PER_PAGE) { for (let i = 0; i < props.apps.length; i += ITEMS_PER_PAGE) {
@ -216,6 +245,7 @@ function openAppOptions(id: string) {
} }
function launchNow(id: string, pkg: PackageDataEntry) { function launchNow(id: string, pkg: PackageDataEntry) {
markLaunching(id)
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id] const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) { if (webOnlyUrl) {
@ -305,6 +335,15 @@ function scrollToPage(index: number) {
</script> </script>
<style scoped> <style scoped>
/* Instant press feedback: the icon scales down the moment it's touched, so the
tap is acknowledged even before the app finishes launching. */
.app-icon-frame {
transition: transform 0.12s ease;
}
.app-icon-item:active .app-icon-frame {
transform: scale(0.88);
}
.sideload-modal { .sideload-modal {
display: flex; display: flex;
flex-direction: column; flex-direction: column;