Compare commits
2 Commits
75e470bfa4
...
993f30456f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
993f30456f | ||
|
|
aa95e42383 |
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,9 +86,11 @@ fun NESMenu(
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
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()) {
|
||||
Text(text = "Port ${server.port}", style = MaterialTheme.typography.labelMedium, color = TextMuted)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Android/app/src/main/res/drawable/bg_synthwave.jpg
Normal file
BIN
Android/app/src/main/res/drawable/bg_synthwave.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 869 KiB |
@ -5,6 +5,6 @@
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#030202"
|
||||
android:fillColor="#0A0A0A"
|
||||
android:pathData="M0,0h108v108H0z" />
|
||||
</vector>
|
||||
|
||||
@ -1,45 +1,20 @@
|
||||
<?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"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
android:viewportWidth="752"
|
||||
android:viewportHeight="752">
|
||||
|
||||
<group
|
||||
android:pivotX="512"
|
||||
android:pivotY="512"
|
||||
android:scaleX="0.55"
|
||||
android:scaleY="0.55">
|
||||
|
||||
<!-- Row 1: 4 blocks -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
|
||||
<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" />
|
||||
android:pivotX="376"
|
||||
android:pivotY="376"
|
||||
android:scaleX="0.72"
|
||||
android:scaleY="0.72">
|
||||
<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" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
33
Android/app/src/main/res/drawable/ic_logo.xml
Normal file
33
Android/app/src/main/res/drawable/ic_logo.xml
Normal 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>
|
||||
@ -23,4 +23,6 @@
|
||||
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
|
||||
<string name="close">Close</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>
|
||||
|
||||
10
Android/logo.svg
Normal file
10
Android/logo.svg
Normal 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 |
@ -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>
|
||||
</svg>
|
||||
</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>
|
||||
<!-- Label -->
|
||||
<span class="app-icon-label">{{ getTitle(id, pkg) }}</span>
|
||||
@ -114,7 +121,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
||||
@ -152,6 +159,28 @@ const activePage = ref(0)
|
||||
const longPressTriggered = ref(false)
|
||||
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 result: [string, PackageDataEntry][][] = []
|
||||
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) {
|
||||
markLaunching(id)
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
@ -305,6 +335,15 @@ function scrollToPage(index: number) {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user