diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt index cccd11a0..c5bc1049 100644 --- a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt @@ -18,7 +18,11 @@ data class ServerEntry( val useHttps: Boolean, val port: 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 { val scheme = if (useHttps) "https" else "http" val portSuffix = if (port.isNotBlank()) ":$port" else "" @@ -31,7 +35,9 @@ data class ServerEntry( 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 { fun deserialize(raw: String): ServerEntry? { @@ -42,6 +48,7 @@ data class ServerEntry( useHttps = parts[1].toBooleanStrictOrNull() ?: false, port = parts.getOrElse(2) { "" }, 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 activePortKey = stringPreferencesKey("active_port") private val activePasswordKey = stringPreferencesKey("active_password") + private val activeNameKey = stringPreferencesKey("active_name") private val savedServersKey = stringSetPreferencesKey("saved_servers") private val introSeenKey = booleanPreferencesKey("intro_seen") @@ -63,6 +71,7 @@ class ServerPreferences(private val context: Context) { useHttps = prefs[activeHttpsKey] ?: false, port = prefs[activePortKey] ?: "", password = prefs[activePasswordKey] ?: "", + name = prefs[activeNameKey] ?: "", ) } @@ -81,6 +90,7 @@ class ServerPreferences(private val context: Context) { prefs[activeHttpsKey] = server.useHttps prefs[activePortKey] = server.port prefs[activePasswordKey] = server.password + prefs[activeNameKey] = server.name } addSavedServer(server) } @@ -91,6 +101,7 @@ class ServerPreferences(private val context: Context) { prefs.remove(activeHttpsKey) prefs.remove(activePortKey) prefs.remove(activePasswordKey) + prefs.remove(activeNameKey) } } @@ -104,7 +115,16 @@ class ServerPreferences(private val context: Context) { suspend fun removeSavedServer(server: ServerEntry) { context.dataStore.edit { prefs -> 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() } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt index 8f7e043c..ec40e657 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt @@ -108,7 +108,9 @@ private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) { .pointerInput(key) { detectTapGestures(onPress = { 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() }) }, diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt index 4b18b23c..05751f9c 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt @@ -83,13 +83,15 @@ val ClassicPalette = NESPalette( 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( - body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge, - label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted, - dpad = Color(0xFF080808), dpadHi = Color(0xFF141418), - btn = NES.DarkButtonMain, btnPress = NES.DarkButtonMainPress, - capsule = Color(0xFF121216), capsulePress = Color(0xFF0A0A0C), - inlayBg = Color(0xFF060608), inlayBorder = Color(0xFF444448), + body = Color(0xFF0D0D0F), face = Color(0xFF0A0A0A), ridge = Color(0x14FFFFFF), + label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777), + dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A), + btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF), + capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF), + inlayBg = Color(0xFF0A0A0A), inlayBorder = Color(0x1FFFFFFF), ) fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette @@ -113,7 +115,6 @@ fun NESController( Box( modifier = modifier .fillMaxSize() - .background(Color(0xFF0C0C0C)) // Slightly lighter than black for shadow visibility .twoFingerHold(onMenu) .padding(horizontal = 40.dp, vertical = 24.dp), contentAlignment = Alignment.Center, @@ -193,13 +194,13 @@ fun NESController( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - // C on top (white) - ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 44.dp) { onKey("c") } + // C on top + GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") } Spacer(Modifier.height(6.dp)) // B + A on bottom row Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 44.dp) { onKey("b") } - ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 44.dp) { onKey("a") } + GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") } + 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) 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() 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 */ @Composable fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) { diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt index d30805fd..ca827a5e 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -3,6 +3,8 @@ package com.archipelago.app.ui.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -34,17 +36,35 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType 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.sp 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.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 fun NESMenu( visible: Boolean, @@ -66,7 +86,9 @@ fun NESMenu( .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() }, 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)?, ) { var showAdd by remember { mutableStateOf(false) } + var nm by remember { mutableStateOf("") } var addr 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( modifier = Modifier - .widthIn(max = 360.dp) - .clip(RoundedCornerShape(4.dp)) - .background(NES.MenuPanel) - .border(3.dp, NES.MenuBorder, RoundedCornerShape(4.dp)) + .widthIn(max = 420.dp) + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(PANEL_R)) + .background(PanelBg) + .border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R)) .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {} - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + .padding(22.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { // Title - Text("- MENU -", color = NES.MenuText, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp, - modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center) - Spacer(Modifier.height(4.dp)) + Text( + "Menu", + color = TextPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 2.sp, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(2.dp)) // Servers servers.forEach { server -> val active = server.serialize() == activeServer?.serialize() MenuItem( - label = (if (active) "\u25B6 " else " ") + server.address, + label = server.displayName(), selected = active, onClick = { onSelectServer(server) }, onRemove = { onRemoveServer(server) }, @@ -116,69 +154,70 @@ private fun MenuPanel( } 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 if (showAdd) { Column( - Modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.3f)).padding(8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .background(FieldBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - OutlinedTextField( - value = addr, onValueChange = { addr = it.trim() }, - placeholder = { Text("192.168.1.100", color = NES.MenuMuted, fontSize = 11.sp) }, - modifier = Modifier.fillMaxWidth().height(48.dp), singleLine = true, - textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp), - colors = nesFieldColors(), - shape = RoundedCornerShape(2.dp), + GlassField( + value = nm, onValueChange = { nm = it }, + placeholder = "Name (optional)", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next), ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( + 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 = { Text("PASSWORD", color = NES.MenuMuted, fontSize = 11.sp) }, - modifier = Modifier.weight(1f).height(48.dp), singleLine = true, + placeholder = "Password", + modifier = Modifier.weight(1f), visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), - keyboardActions = KeyboardActions(onGo = { - 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), + keyboardActions = KeyboardActions(onGo = { submit() }), ) Box( - Modifier.size(48.dp).clip(RoundedCornerShape(2.dp)).background(NES.MenuSelected) - .clickable { - if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false } - }, + 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 = NES.MenuText, fontSize = 10.sp, fontWeight = FontWeight.Bold) } + ) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) } } } } else { - MenuItem(label = " ADD SERVER", onClick = { showAdd = true }) + MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true }) } 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)) // Mode toggle MenuItem( - label = if (isGamepadMode) " SWITCH TO KEYBOARD" else " SWITCH TO GAMEPAD", + label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad", onClick = onToggleMode, ) // Style toggle MenuItem( - label = if (controllerStyle == ControllerStyle.CLASSIC) " STYLE: CLASSIC" else " STYLE: DARK", + label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark", onClick = onToggleStyle, ) // Back to dashboard 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( label: String, selected: Boolean = false, + labelColor: Color = TextPrimary, onClick: () -> Unit, onRemove: (() -> Unit)? = null, ) { Row( Modifier .fillMaxWidth() - .height(32.dp) - .background(if (selected) NES.MenuSelected.copy(alpha = 0.15f) else Color.Transparent) + .height(ROW_H) + .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() } - .padding(horizontal = 8.dp), + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, 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) { - Text("\u2715", color = NES.MenuMuted, fontSize = 10.sp, - modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp)) + Text( + "✕", + color = TextMuted, + fontSize = 16.sp, + modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp), + ) } } } +/** Glass text field with centered input text. */ @Composable -private fun nesFieldColors() = OutlinedTextFieldDefaults.colors( - focusedBorderColor = NES.MenuBorder, - unfocusedBorderColor = NES.MenuMuted, - cursorColor = NES.MenuText, - focusedTextColor = NES.MenuText, - unfocusedTextColor = NES.MenuText, -) +private fun GlassField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + 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), + ) +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt index 8894c8c8..2193d4b8 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt @@ -50,7 +50,6 @@ fun NESPortraitController( Box( Modifier .fillMaxSize() - .background(Color(0xFF0C0C0C)) .twoFingerHold(onMenu) .padding(horizontal = 40.dp, vertical = 24.dp), contentAlignment = Alignment.Center, @@ -119,11 +118,11 @@ fun NESPortraitController( Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), 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)) Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { - ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 46.dp) { onKey("b") } - ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 46.dp) { onKey("a") } + GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") } + GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") } } } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt index 56a860c3..5426a825 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape 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.graphics.Brush 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.stringResource import androidx.compose.ui.text.style.TextAlign @@ -67,26 +68,45 @@ fun IntroScreen(onContinue: () -> Unit) { Box( modifier = Modifier .fillMaxSize() - .background(SurfaceBlack) - .windowInsetsPadding(WindowInsets.safeDrawing), - contentAlignment = Alignment.Center, + .background(SurfaceBlack), ) { + // 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( modifier = Modifier + .align(Alignment.Center) .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) .padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - // Wide pixel-art logo + // Circular badge logo Image( - painter = painterResource(id = R.drawable.ic_logo_wide), + painter = painterResource(id = R.drawable.ic_logo), contentDescription = "Archipelago", modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) + .size(160.dp) .alpha(logoAlpha.value), - colorFilter = ColorFilter.tint(Color.White), ) Spacer(modifier = Modifier.height(48.dp)) @@ -102,7 +122,7 @@ fun IntroScreen(onContinue: () -> Unit) { Text( text = stringResource(R.string.welcome_title), style = MaterialTheme.typography.headlineLarge, - color = TextPrimary, + color = Color(0xFFFAFAFA), textAlign = TextAlign.Center, ) @@ -111,7 +131,7 @@ fun IntroScreen(onContinue: () -> Unit) { Text( text = stringResource(R.string.welcome_subtitle), style = MaterialTheme.typography.bodyLarge, - color = TextMuted, + color = Color(0xFFFAFAFA), textAlign = TextAlign.Center, lineHeight = 26.sp, ) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt index 4e743eb7..94322deb 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt @@ -2,6 +2,7 @@ package com.archipelago.app.ui.screens import android.content.res.Configuration import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,13 +25,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import com.archipelago.app.R import com.archipelago.app.data.ServerPreferences import com.archipelago.app.network.ConnectionState import com.archipelago.app.network.InputWebSocket @@ -58,7 +63,7 @@ fun RemoteInputScreen(onBack: () -> Unit) { var isGamepadMode by remember { mutableStateOf(true) } 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 val ws = remember { InputWebSocket(scope) } @@ -113,9 +118,31 @@ fun RemoteInputScreen(onBack: () -> Unit) { Box( Modifier .fillMaxSize() - .background(Color(0xFF0C0C0C)) - .windowInsetsPadding(WindowInsets.safeDrawing), + .background(Color(0xFF0C0C0C)), ) { + // 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 { isGamepadMode && isLandscape -> NESController( style = controllerStyle, @@ -174,6 +201,7 @@ fun RemoteInputScreen(onBack: () -> Unit) { } ), ) + } NESMenu( visible = showModal, @@ -188,7 +216,20 @@ fun RemoteInputScreen(onBack: () -> Unit) { onAddServer = { 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 }, onToggleStyle = { controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt index 0712f825..738eb4a8 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource @@ -97,6 +98,7 @@ fun ServerConnectScreen( val scope = rememberCoroutineScope() val keyboard = LocalSoftwareKeyboardController.current + var name by remember { mutableStateOf("") } var address by remember { mutableStateOf("") } var port by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } @@ -132,12 +134,33 @@ fun ServerConnectScreen( Box( modifier = Modifier .fillMaxSize() - .background(SurfaceBlack) - .windowInsetsPadding(WindowInsets.safeDrawing), + .background(SurfaceBlack), ) { + // 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( modifier = Modifier .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) .verticalScroll(state = rememberScrollState()) .drawWithContent { drawContent() } .padding(horizontal = 24.dp) @@ -145,14 +168,11 @@ fun ServerConnectScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { - // Wide logo + // Circular badge logo Image( - painter = painterResource(id = R.drawable.ic_logo_wide), + painter = painterResource(id = R.drawable.ic_logo), contentDescription = "Archipelago", - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colorFilter = ColorFilter.tint(Color.White), + modifier = Modifier.size(96.dp), ) Spacer(modifier = Modifier.height(4.dp)) @@ -178,6 +198,7 @@ fun ServerConnectScreen( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) + .background(Color.Black.copy(alpha = 0.6f)) .background( Brush.verticalGradient( colors = listOf( @@ -190,6 +211,34 @@ fun ServerConnectScreen( .padding(20.dp), ) { 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( value = address, onValueChange = { @@ -275,7 +324,7 @@ fun ServerConnectScreen( keyboardActions = KeyboardActions( onGo = { keyboard?.hide() - connect(ServerEntry(address, useHttps, port, password)) + connect(ServerEntry(address, useHttps, port, password, name)) }, ), colors = OutlinedTextFieldDefaults.colors( @@ -345,7 +394,7 @@ fun ServerConnectScreen( text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect), onClick = { keyboard?.hide() - connect(ServerEntry(address, useHttps, port, password)) + connect(ServerEntry(address, useHttps, port, password, name)) }, modifier = Modifier.fillMaxWidth().height(56.dp), ) @@ -391,6 +440,7 @@ private fun SavedServerItem( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.6f)) .background( Brush.verticalGradient( colors = listOf( @@ -414,9 +464,15 @@ private fun SavedServerItem( ) Spacer(modifier = Modifier.width(12.dp)) Column { - Text(text = server.address, style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (server.port.isNotBlank()) { - Text(text = "Port ${server.port}", style = MaterialTheme.typography.labelMedium, color = TextMuted) + Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) + val secondary = buildString { + 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) } } } diff --git a/Android/app/src/main/res/drawable/bg_synthwave.jpg b/Android/app/src/main/res/drawable/bg_synthwave.jpg new file mode 100644 index 00000000..2f3afb80 Binary files /dev/null and b/Android/app/src/main/res/drawable/bg_synthwave.jpg differ diff --git a/Android/app/src/main/res/drawable/ic_launcher_background.xml b/Android/app/src/main/res/drawable/ic_launcher_background.xml index 2fc77436..539a3dc0 100644 --- a/Android/app/src/main/res/drawable/ic_launcher_background.xml +++ b/Android/app/src/main/res/drawable/ic_launcher_background.xml @@ -5,6 +5,6 @@ android:viewportWidth="108" android:viewportHeight="108"> diff --git a/Android/app/src/main/res/drawable/ic_launcher_foreground.xml b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml index f631630b..88405981 100644 --- a/Android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,45 +1,20 @@ - + + android:viewportWidth="752" + android:viewportHeight="752"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:pivotX="376" + android:pivotY="376" + android:scaleX="0.72" + android:scaleY="0.72"> + diff --git a/Android/app/src/main/res/drawable/ic_logo.xml b/Android/app/src/main/res/drawable/ic_logo.xml new file mode 100644 index 00000000..275ad1d6 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index d28fcec6..a6fadd29 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -23,4 +23,6 @@ Use your phone as a keyboard and mouse for the kiosk Close Open in browser + Server Name (optional) + My Archipelago diff --git a/Android/logo.svg b/Android/logo.svg new file mode 100644 index 00000000..f218f5a4 --- /dev/null +++ b/Android/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + +