From cd874cb711818b2f4fe364ffcf06d127d18eb727 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 1 Apr 2026 22:42:33 +0100 Subject: [PATCH] feat: Android companion app remote input, themes, and network layer - RemoteInputScreen: touch/keyboard relay via WebSocket to /ws/remote-input - Network layer for server communication - UI components and NES/Neo theme variants - Updated navigation, server connect, and WebView screens - Build config and string resources updates Co-Authored-By: Claude Opus 4.6 (1M context) --- Android/app/build.gradle.kts | 3 + .../archipelago/app/data/ServerPreferences.kt | 14 +- .../archipelago/app/network/InputWebSocket.kt | 177 +++++++++ .../app/ui/components/ActionButtons.kt | 56 +++ .../com/archipelago/app/ui/components/DPad.kt | 119 ++++++ .../app/ui/components/GamepadLayout.kt | 134 +++++++ .../app/ui/components/NESController.kt | 356 ++++++++++++++++++ .../app/ui/components/NESKeyboard.kt | 172 +++++++++ .../archipelago/app/ui/components/NESMenu.kt | 218 +++++++++++ .../app/ui/components/ServerModal.kt | 263 +++++++++++++ .../archipelago/app/ui/components/Trackpad.kt | 107 ++++++ .../app/ui/components/VirtualKeyboard.kt | 173 +++++++++ .../archipelago/app/ui/navigation/NavGraph.kt | 15 +- .../app/ui/screens/RemoteInputScreen.kt | 185 +++++++++ .../app/ui/screens/ServerConnectScreen.kt | 5 +- .../app/ui/screens/WebViewScreen.kt | 33 ++ .../java/com/archipelago/app/ui/theme/NES.kt | 44 +++ .../java/com/archipelago/app/ui/theme/Neo.kt | 106 ++++++ .../com/archipelago/app/ui/theme/Theme.kt | 28 +- Android/app/src/main/res/values/strings.xml | 2 + 20 files changed, 2201 insertions(+), 9 deletions(-) create mode 100644 Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/ActionButtons.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/ServerModal.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/VirtualKeyboard.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/theme/NES.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 2cd5cb82..d788d408 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -82,6 +82,9 @@ dependencies { // Splash screen implementation("androidx.core:core-splashscreen:1.0.1") + // OkHttp for WebSocket (remote input) + implementation("com.squareup.okhttp3:okhttp:4.12.0") + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") } 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 691629c3..cccd11a0 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 @@ -17,6 +17,7 @@ data class ServerEntry( val address: String, val useHttps: Boolean, val port: String = "", + val password: String = "", ) { fun toUrl(): String { val scheme = if (useHttps) "https" else "http" @@ -24,7 +25,13 @@ data class ServerEntry( return "$scheme://$address$portSuffix" } - fun serialize(): String = "$address|$useHttps|$port" + fun toWsUrl(): String { + val scheme = if (useHttps) "wss" else "ws" + val portSuffix = if (port.isNotBlank()) ":$port" else "" + return "$scheme://$address$portSuffix" + } + + fun serialize(): String = "$address|$useHttps|$port|$password" companion object { fun deserialize(raw: String): ServerEntry? { @@ -34,6 +41,7 @@ data class ServerEntry( address = parts[0], useHttps = parts[1].toBooleanStrictOrNull() ?: false, port = parts.getOrElse(2) { "" }, + password = parts.getOrElse(3) { "" }, ) } } @@ -44,6 +52,7 @@ class ServerPreferences(private val context: Context) { private val activeAddressKey = stringPreferencesKey("active_address") private val activeHttpsKey = booleanPreferencesKey("active_https") private val activePortKey = stringPreferencesKey("active_port") + private val activePasswordKey = stringPreferencesKey("active_password") private val savedServersKey = stringSetPreferencesKey("saved_servers") private val introSeenKey = booleanPreferencesKey("intro_seen") @@ -53,6 +62,7 @@ class ServerPreferences(private val context: Context) { address = address, useHttps = prefs[activeHttpsKey] ?: false, port = prefs[activePortKey] ?: "", + password = prefs[activePasswordKey] ?: "", ) } @@ -70,6 +80,7 @@ class ServerPreferences(private val context: Context) { prefs[activeAddressKey] = server.address prefs[activeHttpsKey] = server.useHttps prefs[activePortKey] = server.port + prefs[activePasswordKey] = server.password } addSavedServer(server) } @@ -79,6 +90,7 @@ class ServerPreferences(private val context: Context) { prefs.remove(activeAddressKey) prefs.remove(activeHttpsKey) prefs.remove(activePortKey) + prefs.remove(activePasswordKey) } } diff --git a/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt b/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt new file mode 100644 index 00000000..af389534 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt @@ -0,0 +1,177 @@ +package com.archipelago.app.network + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + +enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, AUTH_FAILED, ERROR } + +class InputWebSocket( + private val scope: CoroutineScope, +) { + private var ws: WebSocket? = null + private var reconnectJob: Job? = null + private var reconnectAttempt = 0 + private var serverUrl: String = "" + private var password: String = "" + private var sessionCookie: String? = null + + private val _state = MutableStateFlow(ConnectionState.DISCONNECTED) + val state: StateFlow = _state + + private val trustManager = object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + + private val client: OkHttpClient by lazy { + val sc = SSLContext.getInstance("TLS") + sc.init(null, arrayOf(trustManager), java.security.SecureRandom()) + + OkHttpClient.Builder() + .sslSocketFactory(sc.socketFactory, trustManager) + .hostnameVerifier { _, _ -> true } + .pingInterval(30, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .connectTimeout(10, TimeUnit.SECONDS) + .build() + } + + fun connect(httpUrl: String, pwd: String = "") { + disconnect() + serverUrl = httpUrl + password = pwd + sessionCookie = null + reconnectAttempt = 0 + scope.launch(Dispatchers.IO) { doAuth() } + } + + private suspend fun doAuth() { + _state.value = ConnectionState.CONNECTING + + if (password.isBlank()) { + doConnect() + return + } + + try { + val body = """{"method":"auth.login","params":{"password":"$password"}}""" + .toRequestBody("application/json".toMediaType()) + val req = Request.Builder() + .url("$serverUrl/rpc/v1") + .post(body) + .build() + + val response = withContext(Dispatchers.IO) { client.newCall(req).execute() } + + if (response.isSuccessful) { + sessionCookie = response.headers("Set-Cookie") + .mapNotNull { cookie -> + cookie.split(";") + .firstOrNull() + ?.trim() + ?.takeIf { it.startsWith("session=") } + ?.removePrefix("session=") + } + .firstOrNull() + response.close() + + if (sessionCookie != null) { + doConnect() + } else { + _state.value = ConnectionState.AUTH_FAILED + } + } else { + response.close() + _state.value = ConnectionState.AUTH_FAILED + } + } catch (_: Exception) { + _state.value = ConnectionState.ERROR + scheduleReconnect() + } + } + + private fun doConnect() { + val wsUrl = serverUrl + .replace("https://", "wss://") + .replace("http://", "ws://") + .trimEnd('/') + "/ws/remote-input" + + val reqBuilder = Request.Builder().url(wsUrl) + sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") } + + ws = client.newWebSocket(reqBuilder.build(), object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + _state.value = ConnectionState.CONNECTED + reconnectAttempt = 0 + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + _state.value = ConnectionState.ERROR + scheduleReconnect() + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(1000, null) + _state.value = ConnectionState.DISCONNECTED + if (code != 1000) scheduleReconnect() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + _state.value = ConnectionState.DISCONNECTED + } + }) + } + + private fun scheduleReconnect() { + reconnectJob?.cancel() + reconnectJob = scope.launch(Dispatchers.IO) { + val delayMs = minOf(1000L * (1 shl minOf(reconnectAttempt, 5)), 30_000L) + reconnectAttempt++ + delay(delayMs) + doAuth() + } + } + + fun disconnect() { + reconnectJob?.cancel() + ws?.close(1000, "bye") + ws = null + _state.value = ConnectionState.DISCONNECTED + } + + // ─── Input senders ────────────────────────────────────────── + + fun sendKey(key: String) { + ws?.send("""{"t":"k","k":"$key"}""") + } + + fun sendMouseMove(dx: Int, dy: Int) { + ws?.send("""{"t":"m","x":$dx,"y":$dy}""") + } + + fun sendClick(button: Int = 1) { + ws?.send("""{"t":"c","b":$button}""") + } + + fun sendScroll(dy: Int) { + ws?.send("""{"t":"s","y":$dy}""") + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/ActionButtons.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/ActionButtons.kt new file mode 100644 index 00000000..75c7b797 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/ActionButtons.kt @@ -0,0 +1,56 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised + +private val R = 14.dp + +@Composable +fun ActionButtons( + onEscape: () -> Unit, + onEnter: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) { + NeoBtn("ESC", Neo.textSecondary(), Modifier.fillMaxWidth().weight(1f), onEscape) + NeoBtn("ENTER", BitcoinOrange.copy(alpha = 0.7f), Modifier.fillMaxWidth().weight(1f), onEnter) + } +} + +@Composable +private fun NeoBtn(label: String, color: androidx.compose.ui.graphics.Color, modifier: Modifier, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + Box( + modifier = modifier + .then(if (p) Modifier.neoInset(l, d, R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, R, 2.dp, 4.dp)) + .clip(RoundedCornerShape(R)) + .background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = if (p) color else color.copy(alpha = 0.7f), fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp) + } +} 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 new file mode 100644 index 00000000..8f7e043c --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt @@ -0,0 +1,119 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val BTN = 50.dp +private val BTN_R = 12.dp +private val GAP = 8.dp +private val NOB = 24.dp + +@Composable +fun DPad( + onDirection: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val surface = Neo.surface() + val raised = Neo.surfaceRaised() + val l = Neo.shadowLight() + val d = Neo.shadowDark() + + // Recessed well + Box( + modifier = modifier + .neoInset(l, d, 20.dp, 2.dp, 4.dp) + .clip(RoundedCornerShape(20.dp)) + .background(surface) + .padding(14.dp), + contentAlignment = Alignment.Center, + ) { + // Cross layout with explicit spacing + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Btn(Icons.Default.KeyboardArrowUp, "Up", onDirection) + Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer + Row(verticalAlignment = Alignment.CenterVertically) { + Btn(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Left", onDirection) + Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer + // Center nob + Box( + modifier = Modifier + .size(NOB) + .neoRaised(l, d, NOB / 2, 1.dp, 2.dp) + .clip(CircleShape) + .background(raised), + contentAlignment = Alignment.Center, + ) { + Box(Modifier.size(8.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f))) + } + Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer + Btn(Icons.AutoMirrored.Filled.KeyboardArrowRight, "Right", onDirection) + } + Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer + Btn(Icons.Default.KeyboardArrowDown, "Down", onDirection) + } + } +} + +@Composable +private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) { + val scope = rememberCoroutineScope() + var job by remember { mutableStateOf(null) } + var p by remember { mutableStateOf(false) } + val bg = Neo.surfaceRaised() + val l = Neo.shadowLight() + val d = Neo.shadowDark() + val tint = Neo.textPrimary() + DisposableEffect(Unit) { onDispose { job?.cancel() } } + + Box( + modifier = Modifier + .size(BTN) + .then(if (p) Modifier.neoInset(l, d, BTN_R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, BTN_R, 2.dp, 4.dp)) + .clip(RoundedCornerShape(BTN_R)) + .background(bg) + .pointerInput(key) { + detectTapGestures(onPress = { + p = true; onDir(key) + job = scope.launch { delay(350); while (true) { onDir(key); delay(100) } } + tryAwaitRelease(); p = false; job?.cancel() + }) + }, + contentAlignment = Alignment.Center, + ) { + Icon(icon, key, Modifier.fillMaxSize(0.48f), tint = if (p) tint.copy(alpha = 0.9f) else tint.copy(alpha = 0.5f)) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt new file mode 100644 index 00000000..f931ff21 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt @@ -0,0 +1,134 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised + +@Composable +fun GamepadLayout( + onKey: (String) -> Unit, + onTwoFingerHold: () -> Unit, + modifier: Modifier = Modifier, +) { + val surface = Neo.surface() + + Box( + modifier = modifier + .fillMaxSize() + .background(surface) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + var t = 0L; var fired = false + do { + val ev = awaitPointerEvent() + val a = ev.changes.filter { !it.changedToUp() } + if (a.size >= 2 && t == 0L) t = System.currentTimeMillis() + if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() } + if (a.size < 2) t = 0L + } while (ev.changes.any { it.pressed }) + } + } + .padding(horizontal = 24.dp, vertical = 16.dp), + ) { + // D-pad — centered left + DPad( + onDirection = onKey, + modifier = Modifier.align(Alignment.CenterStart).size(200.dp), + ) + + // Face buttons — centered right (diamond) + Column( + modifier = Modifier.align(Alignment.CenterEnd), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + FaceBtn("esc", 64.dp) { onKey("Escape") } + Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) { + FaceBtn("tab", 64.dp) { onKey("Tab") } + FaceBtn("enter", 64.dp, accent = true) { onKey("Return") } + } + FaceBtn("bksp", 64.dp) { onKey("BackSpace") } + } + + // Bottom: L, SELECT, START, R + Row( + modifier = Modifier.align(Alignment.BottomCenter), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PillBtn("L", 56.dp) { onKey("Prior") } + PillBtn("SELECT", 80.dp) { onKey("Escape") } + PillBtn("START", 80.dp) { onKey("Return") } + PillBtn("R", 56.dp) { onKey("Next") } + } + } +} + +@Composable +private fun FaceBtn(label: String, size: Dp, accent: Boolean = false, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + val tc = if (accent) BitcoinOrange.copy(alpha = 0.7f) else Neo.textSecondary() + + Box( + modifier = Modifier + .size(size) + .then(if (p) Modifier.neoInset(l, d, size / 2, 1.dp, 3.dp) else Modifier.neoRaised(l, d, size / 2, 2.dp, 4.dp)) + .clip(CircleShape) + .background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = if (p) tc.copy(alpha = 1f) else tc, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp) + } +} + +@Composable +private fun PillBtn(label: String, w: Dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + + Box( + modifier = Modifier + .width(w).height(34.dp) + .then(if (p) Modifier.neoInset(l, d, 8.dp, 1.dp, 2.dp) else Modifier.neoRaised(l, d, 8.dp, 2.dp, 4.dp)) + .clip(RoundedCornerShape(8.dp)) + .background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = Neo.textMuted(), fontSize = 9.sp, fontWeight = FontWeight.Medium, letterSpacing = 1.sp) + } +} 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 new file mode 100644 index 00000000..fee25bf9 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt @@ -0,0 +1,356 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.draw.shadow +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.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.sp +import com.archipelago.app.R +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.NES +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +// ═══════════════════════════════════════════════════════════ +// Color palettes +// ═══════════════════════════════════════════════════════════ + +data class NESPalette( + val body: Color, val face: Color, val ridge: Color, + val label: Color, val labelMuted: Color, + val dpad: Color, val dpadPress: Color, + val btnMain: Color, val btnMainPress: Color, + val select: Color, val selectPress: Color, + val inlayBorder: Color, val inlayBg: Color, +) + +val ClassicPalette = NESPalette( + body = NES.ClassicBody, face = NES.ClassicFace, ridge = NES.ClassicRidge, + label = NES.ClassicLabel, labelMuted = NES.ClassicLabelMuted, + dpad = NES.ClassicDPad, dpadPress = NES.ClassicDPadPress, + btnMain = NES.ClassicButtonRed, btnMainPress = NES.ClassicButtonRedPress, + select = NES.ClassicSelect, selectPress = Color(0xFF1A1A1A), + inlayBorder = Color(0xFF888888), inlayBg = Color(0xFF0E0E0E), +) + +val DarkPalette = NESPalette( + body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge, + label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted, + dpad = NES.DarkDPad, dpadPress = NES.DarkDPadPress, + btnMain = NES.DarkButtonMain, btnMainPress = NES.DarkButtonMainPress, + select = NES.DarkSelect, selectPress = Color(0xFF0E0E10), + inlayBorder = Color(0xFF3A3A3E), inlayBg = Color(0xFF0A0A0C), +) + +fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette + +// ═══════════════════════════════════════════════════════════ +// Main NES Controller (Gamepad mode) +// ═══════════════════════════════════════════════════════════ + +@Composable +fun NESController( + style: ControllerStyle = ControllerStyle.CLASSIC, + onKey: (String) -> Unit, + onTwoFingerHold: () -> Unit, + modifier: Modifier = Modifier, +) { + val c = paletteFor(style) + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black) + .twoFingerHold(onTwoFingerHold) + .padding(horizontal = 32.dp, vertical = 20.dp), + contentAlignment = Alignment.Center, + ) { + // 3D drop shadow layers for realism + Box( + Modifier + .fillMaxWidth(0.88f) + .aspectRatio(2.3f) + .shadow(24.dp, RoundedCornerShape(16.dp), ambientColor = Color.Black, spotColor = Color.Black) + .shadow(8.dp, RoundedCornerShape(16.dp), ambientColor = Color(0x40000000)) + .clip(RoundedCornerShape(16.dp)) + .background(c.body) + ) { + // Face plate + Box( + Modifier + .fillMaxSize() + .padding(14.dp) + .clip(RoundedCornerShape(10.dp)) + .background(c.face) + ) { + // Ridges + Ridges(c.ridge, Modifier.align(Alignment.CenterStart).width(7.dp).fillMaxHeight().padding(vertical = 14.dp)) + Ridges(c.ridge, Modifier.align(Alignment.CenterEnd).width(7.dp).fillMaxHeight().padding(vertical = 14.dp)) + + // ── D-Pad in inlay well ────────────────── + Box( + Modifier + .align(Alignment.CenterStart) + .padding(start = 32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(c.inlayBg) + .border(1.dp, c.inlayBorder, RoundedCornerShape(8.dp)) + .padding(6.dp) + ) { + CrossDPad(c, 48.dp, onKey) + } + + // ── Center: ARCHIPELAGO + START/SELECT ─── + Column( + Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = R.drawable.ic_logo_wide), + contentDescription = "Archipelago", + modifier = Modifier.width(120.dp), + colorFilter = ColorFilter.tint( + if (style == ControllerStyle.CLASSIC) NES.ClassicLabel else c.label + ), + ) + Spacer(Modifier.height(12.dp)) + // START/SELECT in inlay + Box( + Modifier + .clip(RoundedCornerShape(6.dp)) + .background(c.inlayBg) + .border(1.dp, c.inlayBorder, RoundedCornerShape(6.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + CapsuleBtn("SELECT", c) { onKey("Escape") } + CapsuleBtn("START", c) { onKey("Return") } + } + } + } + + // ── A/B Buttons in inlay well ──────────── + Box( + Modifier + .align(Alignment.CenterEnd) + .padding(end = 32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(c.inlayBg) + .border(1.dp, c.inlayBorder, RoundedCornerShape(8.dp)) + .padding(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(8.dp)) + RoundBtn(c, 48.dp) { onKey("Escape") } + Text("B", color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + RoundBtn(c, 48.dp) { onKey("Return") } + Text("A", color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + } + } + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════ +// Shared sub-components +// ═══════════════════════════════════════════════════════════ + +@Composable +fun Ridges(color: Color, modifier: Modifier) { + Canvas(modifier = modifier) { + val h = 1.5.dp.toPx(); val gap = 3.dp.toPx(); var y = 0f + while (y < size.height) { drawRect(color, Offset(0f, y), Size(size.width, h)); y += h + gap } + } +} + +@Composable +fun CrossDPad(c: NESPalette, sz: Dp, onDir: (String) -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CrossBtn("\u25B2", sz, "Up", c, onDir) + Row(verticalAlignment = Alignment.CenterVertically) { + CrossBtn("\u25C0", sz, "Left", c, onDir) + // Center with lighting + Box( + Modifier.size(sz).background(c.dpad), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(sz) + .background( + Brush.radialGradient( + listOf(Color.White.copy(alpha = 0.06f), Color.Transparent), + radius = sz.value * 2f, + ) + ) + ) + Box(Modifier.size(12.dp).clip(CircleShape).background(c.dpadPress) + .border(0.5.dp, Color.White.copy(alpha = 0.08f), CircleShape)) + } + CrossBtn("\u25B6", sz, "Right", c, onDir) + } + CrossBtn("\u25BC", sz, "Down", c, onDir) + } +} + +@Composable +private fun CrossBtn(sym: String, sz: Dp, key: String, c: NESPalette, onDir: (String) -> Unit) { + val scope = rememberCoroutineScope() + var job by remember { mutableStateOf(null) } + var p by remember { mutableStateOf(false) } + DisposableEffect(Unit) { onDispose { job?.cancel() } } + + Box( + Modifier + .size(sz) + .background( + Brush.verticalGradient( + if (p) listOf(c.dpadPress, c.dpad) + else listOf(c.dpad, c.dpad.copy(alpha = 0.9f)) + ) + ) + // Top-edge lighting effect + .then( + if (!p) Modifier.border( + width = 0.5.dp, + brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.08f), Color.Transparent)), + shape = RoundedCornerShape(0.dp), + ) else Modifier + ) + .pointerInput(key) { + detectTapGestures(onPress = { + p = true; onDir(key) + job = scope.launch { delay(300); while (true) { onDir(key); delay(90) } } + tryAwaitRelease(); p = false; job?.cancel() + }) + }, + contentAlignment = Alignment.Center, + ) { Text(sym, color = c.labelMuted, fontSize = 13.sp) } +} + +@Composable +fun RoundBtn(c: NESPalette, size: Dp = 48.dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + Modifier + .size(size) + .shadow(if (p) 1.dp else 4.dp, CircleShape) + .clip(CircleShape) + .background( + Brush.verticalGradient( + if (p) listOf(c.btnMainPress, c.btnMain.copy(alpha = 0.9f)) + else listOf(c.btnMain, c.btnMain.copy(alpha = 0.85f)) + ) + ) + .pointerInput(Unit) { + detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) + }, + contentAlignment = Alignment.Center, + ) { + // Lighting highlight + if (!p) { + Box( + Modifier.fillMaxSize().clip(CircleShape).background( + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.20f), Color.Transparent)) + ) + ) + } + } +} + +@Composable +fun CapsuleBtn(label: String, c: NESPalette, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + Modifier + .width(58.dp).height(16.dp) + .shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(3.dp)) + .clip(RoundedCornerShape(3.dp)) + .background( + Brush.verticalGradient( + if (p) listOf(c.selectPress, c.select) + else listOf(c.select, c.select.copy(alpha = 0.8f)) + ) + ) + .pointerInput(Unit) { + detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) + }, + contentAlignment = Alignment.Center, + ) { + // Lighting + if (!p) { + Box( + Modifier.fillMaxSize().clip(RoundedCornerShape(3.dp)).background( + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)) + ) + ) + } + Text(label, color = c.labelMuted, fontSize = 7.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp) + } +} + +/** Two-finger hold gesture modifier */ +fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + var t = 0L; var fired = false + do { + val ev = awaitPointerEvent() + val a = ev.changes.filter { !it.changedToUp() } + if (a.size >= 2 && t == 0L) t = System.currentTimeMillis() + if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() } + if (a.size < 2) t = 0L + } while (ev.changes.any { it.pressed }) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt new file mode 100644 index 00000000..d1b80409 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt @@ -0,0 +1,172 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +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 com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.NES +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private enum class NKLayer { ALPHA, NUM, SYM } +private val KEY_H = 38.dp +private val GAP = 3.dp + +/** NES-themed keyboard — keys styled like D-pad buttons, inside controller body */ +@Composable +fun NESKeyboard( + style: ControllerStyle = ControllerStyle.CLASSIC, + onKey: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val c = paletteFor(style) + val isClassic = style == ControllerStyle.CLASSIC + // Keys match the D-pad material + val keyBg = c.dpad + val keyBgPress = c.dpadPress + val keyText = c.labelMuted + val accentText = if (isClassic) NES.ClassicLabel else c.labelMuted + + var layer by remember { mutableStateOf(NKLayer.ALPHA) } + var shifted by remember { mutableStateOf(false) } + var capsLock by remember { mutableStateOf(false) } + val up = shifted || capsLock + + fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false } + fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) } + + // Controller body wrapping the keyboard + Box( + modifier = modifier + .shadow(16.dp, RoundedCornerShape(14.dp), ambientColor = Color.Black) + .clip(RoundedCornerShape(14.dp)) + .background(c.body) + .padding(10.dp) + .clip(RoundedCornerShape(8.dp)) + .background(c.face) + .padding(8.dp), + ) { + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(GAP), + ) { + when (layer) { + NKLayer.ALPHA -> { + KR("q w e r t y u i o p".split(" "), up, keyBg, keyBgPress, keyText, ::ch) + KR("a s d f g h j k l".split(" "), up, keyBg, keyBgPress, keyText, ::ch, inset = 14.dp) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + DK(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), keyBg, keyBgPress, if (up) accentText else keyText) { + if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true + } + "z x c v b n m".split(" ").forEach { DK(if (up) it.uppercase() else it, Modifier.height(KEY_H), keyBg, keyBgPress, keyText, 16) { ch(it) } } + DKRepeat("\u232B", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { emit("BackSpace") } + } + } + NKLayer.NUM -> { + KR("1 2 3 4 5 6 7 8 9 0".split(" "), false, keyBg, keyBgPress, keyText, ::emit) + KR("- / : ; ( ) \$ & @ \"".split(" "), false, keyBg, keyBgPress, keyText, ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + DK("#+=", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { layer = NKLayer.SYM } + ". , ? ! '".split(" ").forEach { DK(it, Modifier.height(KEY_H), keyBg, keyBgPress, keyText) { emit(it) } } + DKRepeat("\u232B", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { emit("BackSpace") } + } + } + NKLayer.SYM -> { + KR("[ ] { } # % ^ * + =".split(" "), false, keyBg, keyBgPress, keyText, ::emit) + KR("_ \\ | ~ < > ` @ !".split(" "), false, keyBg, keyBgPress, keyText, ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + DK("123", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { layer = NKLayer.NUM } + ". , ? ! '".split(" ").forEach { DK(it, Modifier.height(KEY_H), keyBg, keyBgPress, keyText) { emit(it) } } + DKRepeat("\u232B", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { emit("BackSpace") } + } + } + } + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + DK(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { + layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false + } + DK(",", Modifier.height(KEY_H), keyBg, keyBgPress, keyText) { emit("comma") } + DK("space", Modifier.weight(5f), keyBg, keyBgPress, keyText, 12) { emit("space") } + DK(".", Modifier.height(KEY_H), keyBg, keyBgPress, keyText) { emit("period") } + DK("\u23CE", Modifier.weight(1.4f), keyBg, keyBgPress, accentText, 15) { emit("Return") } + } + } + } +} + +@Composable +private fun KR(keys: List, up: Boolean, bg: Color, bgP: Color, txt: Color, onKey: (String) -> Unit, inset: Dp = 0.dp) { + Row(Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset), Arrangement.spacedBy(GAP)) { + keys.forEach { c -> DK(if (up) c.uppercase() else c, Modifier.height(KEY_H), bg, bgP, txt, 16) { onKey(c) } } + } +} + +/** D-pad style key — flat, dark, with subtle top-edge lighting */ +@Composable +private fun DK(label: String, modifier: Modifier = Modifier, bg: Color, bgP: Color, txt: Color, fontSize: Int = 12, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + modifier = modifier + .clip(RoundedCornerShape(3.dp)) + .background( + Brush.verticalGradient( + if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f)) + ) + ) + .then( + if (!p) Modifier.border(0.5.dp, + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)), + RoundedCornerShape(3.dp)) + else Modifier + ) + .pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = txt, fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1) } +} + +@Composable +private fun DKRepeat(label: String, modifier: Modifier, bg: Color, bgP: Color, txt: Color, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope(); var job by remember { mutableStateOf(null) } + DisposableEffect(Unit) { onDispose { job?.cancel() } } + Box( + modifier = modifier + .clip(RoundedCornerShape(3.dp)) + .background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f)))) + .pointerInput(Unit) { detectTapGestures(onPress = { + p = true; onTap(); job = scope.launch { delay(400); while (true) { onTap(); delay(55) } } + tryAwaitRelease(); job?.cancel(); p = false + }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = txt, fontSize = 15.sp) } +} 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 new file mode 100644 index 00000000..ab324052 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -0,0 +1,218 @@ +package com.archipelago.app.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.Color +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.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.NES + +/** NES-styled modal menu — dark blue panel with white borders */ +@Composable +fun NESMenu( + visible: Boolean, + servers: List, + activeServer: ServerEntry?, + isGamepadMode: Boolean, + controllerStyle: ControllerStyle, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onToggleMode: () -> Unit, + onToggleStyle: () -> Unit, + onBackToWebView: (() -> Unit)? = null, +) { + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + Box( + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f)) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView) + } + } +} + +@Composable +private fun MenuPanel( + servers: List, + activeServer: ServerEntry?, + isGamepadMode: Boolean, + controllerStyle: ControllerStyle, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onToggleMode: () -> Unit, + onToggleStyle: () -> Unit, + onBackToWebView: (() -> Unit)?, +) { + var showAdd by remember { mutableStateOf(false) } + var addr by remember { mutableStateOf("") } + var pwd by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .widthIn(max = 360.dp) + .clip(RoundedCornerShape(4.dp)) + .background(NES.MenuPanel) + .border(3.dp, NES.MenuBorder, RoundedCornerShape(4.dp)) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {} + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(6.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)) + + // Servers + servers.forEach { server -> + val active = server.serialize() == activeServer?.serialize() + MenuItem( + label = (if (active) "\u25B6 " else " ") + server.address, + selected = active, + onClick = { onSelectServer(server) }, + onRemove = { onRemoveServer(server) }, + ) + } + + if (servers.isEmpty()) { + Text(" NO SERVERS", color = NES.MenuMuted, fontSize = 11.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), + ) { + OutlinedTextField( + value = addr, onValueChange = { addr = it.trim() }, + placeholder = { Text("192.168.1.100", color = NES.MenuMuted, fontSize = 11.sp) }, + modifier = Modifier.fillMaxWidth().height(40.dp), singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp), + colors = nesFieldColors(), + shape = RoundedCornerShape(2.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField( + value = pwd, onValueChange = { pwd = it }, + placeholder = { Text("PASSWORD", color = NES.MenuMuted, fontSize = 11.sp) }, + modifier = Modifier.weight(1f).height(40.dp), singleLine = true, + 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), + ) + Box( + Modifier.size(40.dp).clip(RoundedCornerShape(2.dp)).background(NES.MenuSelected) + .clickable { + if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false } + }, + contentAlignment = Alignment.Center, + ) { Text("OK", color = NES.MenuText, fontSize = 10.sp, fontWeight = FontWeight.Bold) } + } + } + } else { + MenuItem(label = " ADD SERVER", onClick = { showAdd = true }) + } + + Spacer(Modifier.height(2.dp)) + Box(Modifier.fillMaxWidth().height(1.dp).background(NES.MenuBorder.copy(alpha = 0.3f))) + Spacer(Modifier.height(2.dp)) + + // Mode toggle + MenuItem( + 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", + onClick = onToggleStyle, + ) + + // Back to dashboard + if (onBackToWebView != null) { + MenuItem(label = " BACK TO DASHBOARD", onClick = onBackToWebView) + } + } +} + +@Composable +private fun MenuItem( + label: String, + selected: Boolean = false, + onClick: () -> Unit, + onRemove: (() -> Unit)? = null, +) { + Row( + Modifier + .fillMaxWidth() + .height(32.dp) + .background(if (selected) NES.MenuSelected.copy(alpha = 0.15f) else Color.Transparent) + .clickable { onClick() } + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, color = if (selected) NES.MenuSelected else NES.MenuText, fontSize = 11.sp, fontWeight = FontWeight.Medium) + if (onRemove != null) { + Text("\u2715", color = NES.MenuMuted, fontSize = 10.sp, + modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp)) + } + } +} + +@Composable +private fun nesFieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = NES.MenuBorder, + unfocusedBorderColor = NES.MenuMuted, + cursorColor = NES.MenuText, + focusedTextColor = NES.MenuText, + unfocusedTextColor = NES.MenuText, +) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/ServerModal.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/ServerModal.kt new file mode 100644 index 00000000..b326f82e --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/ServerModal.kt @@ -0,0 +1,263 @@ +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 +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Web +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.unit.dp +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import com.archipelago.app.ui.theme.neoRaised + +private val ROW_H = 48.dp +private val ROW_R = 12.dp + +@Composable +fun ServerModal( + visible: Boolean, + servers: List, + activeServer: ServerEntry?, + isGamepadMode: Boolean, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onToggleGamepadMode: () -> Unit, + onBackToWebView: (() -> Unit)? = null, +) { + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.55f)) + .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)) { + ModalBody(servers, activeServer, isGamepadMode, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleGamepadMode, onBackToWebView) + } + } + } +} + +@Composable +private fun ModalBody( + servers: List, + activeServer: ServerEntry?, + isGamepadMode: Boolean, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onToggleGamepadMode: () -> Unit, + onBackToWebView: (() -> Unit)?, +) { + val surface = Neo.surfaceRaised() + val light = Neo.shadowLight() + val dark = Neo.shadowDark() + var showAddForm by remember { mutableStateOf(false) } + var newAddress by remember { mutableStateOf("") } + var newPassword by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .widthIn(max = 380.dp) + .neoRaised(light, dark, 24.dp, 6.dp, 12.dp) + .clip(RoundedCornerShape(24.dp)) + .background(surface) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {} + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Header + Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Text("Servers", style = MaterialTheme.typography.titleMedium, color = Neo.textPrimary()) + IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Close, "Close", Modifier.size(16.dp), tint = Neo.textMuted()) + } + } + + // Server rows + servers.forEach { server -> + val isActive = server.serialize() == activeServer?.serialize() + ModalRow( + icon = if (isActive) Icons.Default.RadioButtonChecked else Icons.Default.RadioButtonUnchecked, + iconTint = if (isActive) BitcoinOrange else Neo.textMuted(), + label = server.address + if (server.port.isNotBlank()) ":${server.port}" else "", + onClick = { onSelectServer(server) }, + trailing = { + IconButton(onClick = { onRemoveServer(server) }, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, "Remove", Modifier.size(14.dp), tint = Neo.textMuted()) + } + }, + ) + } + + if (servers.isEmpty()) { + Text("No servers", style = MaterialTheme.typography.bodyMedium, color = Neo.textMuted(), modifier = Modifier.padding(vertical = 4.dp)) + } + + // Add server + if (showAddForm) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .background(Neo.surface()) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = newAddress, onValueChange = { newAddress = it.trim() }, + placeholder = { Text("192.168.1.100") }, + modifier = Modifier.fillMaxWidth(), singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + colors = neoFieldColors(), + shape = RoundedCornerShape(10.dp), + textStyle = MaterialTheme.typography.bodyMedium, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = newPassword, onValueChange = { newPassword = it }, + placeholder = { Text("Password") }, + modifier = Modifier.weight(1f), singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { + if (newAddress.isNotBlank()) { + onAddServer(ServerEntry(newAddress, false, password = newPassword)) + newAddress = ""; newPassword = ""; showAddForm = false + } + }), + colors = neoFieldColors(), + shape = RoundedCornerShape(10.dp), + textStyle = MaterialTheme.typography.bodyMedium, + ) + Box( + modifier = Modifier.size(36.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f)) + .clickable { + if (newAddress.isNotBlank()) { + onAddServer(ServerEntry(newAddress, false, password = newPassword)) + newAddress = ""; newPassword = ""; showAddForm = false + } + }, + contentAlignment = Alignment.Center, + ) { Icon(Icons.Default.Add, "Add", Modifier.size(16.dp), tint = BitcoinOrange) } + } + } + } else { + ModalRow(icon = Icons.Default.Add, iconTint = BitcoinOrange, label = "Add Server", labelColor = BitcoinOrange, onClick = { showAddForm = true }) + } + + HorizontalDivider(color = Neo.border(), modifier = Modifier.padding(vertical = 4.dp)) + + // Gamepad toggle — label says what you switch TO + ModalRow( + icon = if (isGamepadMode) Icons.Default.Keyboard else Icons.Default.Gamepad, + iconTint = Neo.textSecondary(), + label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad", + onClick = onToggleGamepadMode, + ) + + // Back to dashboard + if (onBackToWebView != null) { + ModalRow(icon = Icons.Default.Web, iconTint = Neo.textSecondary(), label = "Back to Dashboard", onClick = onBackToWebView) + } + } +} + +/** Uniform-height row used for all modal actions */ +@Composable +private fun ModalRow( + icon: ImageVector, + iconTint: Color, + label: String, + onClick: () -> Unit, + labelColor: Color = Neo.textPrimary(), + trailing: (@Composable () -> Unit)? = null, +) { + val bg = Neo.surface() + val light = Neo.shadowLight() + val dark = Neo.shadowDark() + + Row( + modifier = Modifier + .fillMaxWidth() + .height(ROW_H) + .neoRaised(light, dark, ROW_R, 2.dp, 5.dp) + .clip(RoundedCornerShape(ROW_R)) + .background(bg) + .clickable { onClick() } + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, Modifier.size(18.dp), tint = iconTint) + Spacer(Modifier.width(12.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium, color = labelColor, modifier = Modifier.weight(1f)) + if (trailing != null) trailing() + } +} + +@Composable +private fun neoFieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = BitcoinOrange.copy(alpha = 0.4f), + unfocusedBorderColor = Neo.border(), + cursorColor = BitcoinOrange, + focusedTextColor = Neo.textPrimary(), + unfocusedTextColor = Neo.textPrimary(), +) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt new file mode 100644 index 00000000..27b27ec7 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt @@ -0,0 +1,107 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.geometry.Offset +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.unit.dp +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset + +private const val TAP_THRESHOLD = 12f +private const val TAP_TIMEOUT = 250L + +@Composable +fun Trackpad( + onMove: (dx: Int, dy: Int) -> Unit, + onClick: (button: Int) -> Unit, + onScroll: (dy: Int) -> Unit, + onTwoFingerHold: () -> Unit, + modifier: Modifier = Modifier, +) { + var fingers by remember { mutableIntStateOf(0) } + val surface = Neo.surface() + val light = Neo.shadowLight() + val dark = Neo.shadowDark() + val muted = Neo.textMuted() + + Box( + modifier = modifier + .neoInset(light, dark, 20.dp, 3.dp, 6.dp) + .clip(RoundedCornerShape(20.dp)) + .background(surface) + .pointerInput(Unit) { + awaitEachGesture { + val first = awaitFirstDown(requireUnconsumed = false) + var total = Offset.Zero + val t0 = System.currentTimeMillis() + var maxPtrs = 1 + var holdFired = false + var twoStart = 0L + var scrollAcc = 0f + fingers = 1 + + do { + val ev = awaitPointerEvent() + val active = ev.changes.filter { !it.changedToUp() } + maxPtrs = maxOf(maxPtrs, active.size) + fingers = active.size + + when { + active.size >= 2 -> { + if (twoStart == 0L) twoStart = System.currentTimeMillis() + if (!holdFired && System.currentTimeMillis() - twoStart > 500) { + holdFired = true + onTwoFingerHold() + } + if (!holdFired) { + val dy = active.map { it.positionChange().y }.average().toFloat() + scrollAcc += dy + if (kotlin.math.abs(scrollAcc) > 12f) { + onScroll(if (scrollAcc > 0) 1 else -1) + scrollAcc = 0f + } + } + ev.changes.forEach { it.consume() } + } + active.size == 1 && maxPtrs == 1 -> { + val d = active.first().positionChange() + total += d + if (d != Offset.Zero) onMove(d.x.toInt(), d.y.toInt()) + active.first().consume() + } + } + } while (ev.changes.any { it.pressed }) + + fingers = 0 + val elapsed = System.currentTimeMillis() - t0 + if (maxPtrs == 1 && elapsed < TAP_TIMEOUT && total.getDistance() < TAP_THRESHOLD) { + onClick(1) + } + } + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = if (fingers >= 2) "hold for menu" else "", + style = MaterialTheme.typography.labelSmall, + color = muted.copy(alpha = 0.4f), + ) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/VirtualKeyboard.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/VirtualKeyboard.kt new file mode 100644 index 00000000..d09632de --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/VirtualKeyboard.kt @@ -0,0 +1,173 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +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 com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private enum class Layer { ALPHA, NUM, SYM } +private val KEY_H = 46.dp +private val KEY_R = 10.dp +private val GAP = 5.dp + +@Composable +fun VirtualKeyboard(onKey: (String) -> Unit, modifier: Modifier = Modifier) { + var layer by remember { mutableStateOf(Layer.ALPHA) } + var shifted by remember { mutableStateOf(false) } + var capsLock by remember { mutableStateOf(false) } + val up = shifted || capsLock + + fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false } + fun ch(c: String) { emit(if (up && layer == Layer.ALPHA) "shift+$c" else c) } + + Column( + modifier = modifier.background(Neo.surface()).padding(horizontal = 6.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(GAP), + ) { + when (layer) { + Layer.ALPHA -> { + CRow("q w e r t y u i o p".split(" "), up, ::ch) + CRow("a s d f g h j k l".split(" "), up, ::ch, inset = 18.dp) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), active = up) { + if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true + } + "z x c v b n m".split(" ").forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { ch(c) } } + RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") } + } + } + Layer.NUM -> { + SRow("1 2 3 4 5 6 7 8 9 0".split(" "), ::emit) + SRow("- / : ; ( ) \$ & @ \"".split(" "), ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey("#+=", Modifier.weight(1.4f)) { layer = Layer.SYM } + ". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } } + RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") } + } + } + Layer.SYM -> { + SRow("[ ] { } # % ^ * + =".split(" "), ::emit) + SRow("_ \\ | ~ < > ` @ !".split(" "), ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey("123", Modifier.weight(1.4f)) { layer = Layer.NUM } + ". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } } + RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") } + } + } + } + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey(if (layer == Layer.ALPHA) "123" else "ABC", Modifier.weight(1.4f)) { + layer = if (layer == Layer.ALPHA) Layer.NUM else Layer.ALPHA; shifted = false; capsLock = false + } + CKey(",", Modifier.weight(1f)) { emit("comma") } + CKey("space", Modifier.weight(5f), fontSize = 13) { emit("space") } + CKey(".", Modifier.weight(1f)) { emit("period") } + AKey("\u23CE", Modifier.weight(1.4f)) { emit("Return") } + } + } +} + +@Composable +private fun CRow(keys: List, up: Boolean, onKey: (String) -> Unit, inset: Dp = 0.dp) { + Row(Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset), Arrangement.spacedBy(GAP)) { + keys.forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { onKey(c) } } + } +} +@Composable +private fun SRow(keys: List, onKey: (String) -> Unit) { + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + keys.forEach { c -> CKey(c, Modifier.weight(1f)) { onKey(c) } } + } +} + +/** Character key */ +@Composable +private fun CKey(label: String, modifier: Modifier = Modifier, fontSize: Int = 19, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark(); val t = Neo.textPrimary() + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(bg) + .pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = t.copy(alpha = if (p) 0.9f else 0.7f), fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1) } +} + +/** Special key */ +@Composable +private fun SKey(label: String, modifier: Modifier = Modifier, active: Boolean = false, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark() + val tc = if (active) BitcoinOrange.copy(alpha = 0.8f) else Neo.textSecondary() + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(bg) + .pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = tc, fontSize = 14.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center) } +} + +/** Accent key (return) */ +@Composable +private fun AKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = BitcoinOrange.copy(alpha = 0.7f), fontSize = 17.sp, fontWeight = FontWeight.Bold) } +} + +/** Repeatable key (backspace) */ +@Composable +private fun RKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope(); var job by remember { mutableStateOf(null) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + DisposableEffect(Unit) { onDispose { job?.cancel() } } + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { + p = true; onTap(); job = scope.launch { delay(400); while (true) { onTap(); delay(55) } } + tryAwaitRelease(); job?.cancel(); p = false + }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = Neo.textSecondary(), fontSize = 17.sp) } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt index e6e736a4..4a58bc4c 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt @@ -11,6 +11,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.archipelago.app.data.ServerPreferences import com.archipelago.app.ui.screens.IntroScreen +import com.archipelago.app.ui.screens.RemoteInputScreen import com.archipelago.app.ui.screens.ServerConnectScreen import com.archipelago.app.ui.screens.WebViewScreen import kotlinx.coroutines.launch @@ -19,6 +20,7 @@ object Routes { const val INTRO = "intro" const val SERVER_CONNECT = "server_connect" const val WEB_VIEW = "web_view" + const val REMOTE_INPUT = "remote_input" } @Composable @@ -31,7 +33,6 @@ fun AppNavHost() { val introSeen by prefs.introSeen.collectAsState(initial = null) val activeServer by prefs.activeServer.collectAsState(initial = null) - // Wait for preferences to load before deciding if (introSeen == null) return val startDestination = when { @@ -70,7 +71,6 @@ fun AppNavHost() { composable(Routes.WEB_VIEW) { val server = activeServer if (server == null) { - // Server was cleared, go back to connect ServerConnectScreen( onConnected = { _ -> navController.navigate(Routes.WEB_VIEW) { @@ -89,8 +89,19 @@ fun AppNavHost() { } } }, + onRemoteInput = { + navController.navigate(Routes.REMOTE_INPUT) + }, ) } } + + composable(Routes.REMOTE_INPUT) { + RemoteInputScreen( + onBack = { + navController.popBackStack() + }, + ) + } } } 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 new file mode 100644 index 00000000..8e4c07e0 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt @@ -0,0 +1,185 @@ +package com.archipelago.app.ui.screens + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +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.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.Color +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.network.ConnectionState +import com.archipelago.app.network.InputWebSocket +import com.archipelago.app.ui.components.NESController +import com.archipelago.app.ui.components.NESKeyboard +import com.archipelago.app.ui.components.NESMenu +import com.archipelago.app.ui.components.Trackpad +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.ErrorRed +import com.archipelago.app.ui.theme.NES +import com.archipelago.app.ui.theme.SuccessGreen +import com.archipelago.app.ui.theme.TextMuted +import kotlinx.coroutines.launch + +@Composable +fun RemoteInputScreen(onBack: () -> Unit) { + val context = LocalContext.current + val prefs = remember { ServerPreferences(context) } + val scope = rememberCoroutineScope() + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + val savedServers by prefs.savedServers.collectAsState(initial = emptyList()) + val activeServer by prefs.activeServer.collectAsState(initial = null) + + var isGamepadMode by remember { mutableStateOf(true) } // Default to gamepad (NES controller) + var showModal by remember { mutableStateOf(false) } + var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) } + + val ws = remember { InputWebSocket(scope) } + val connectionState by ws.state.collectAsState() + + BackHandler { onBack() } + DisposableEffect(Unit) { onDispose { ws.disconnect() } } + + LaunchedEffect(activeServer) { + activeServer?.let { ws.connect(it.toUrl(), it.password) } + } + LaunchedEffect(connectionState) { + if (connectionState == ConnectionState.ERROR) { + kotlinx.coroutines.delay(3000) + activeServer?.let { ws.connect(it.toUrl(), it.password) } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .windowInsetsPadding(WindowInsets.safeDrawing), + ) { + if (isGamepadMode) { + // NES controller — centered with margins + NESController( + style = controllerStyle, + onKey = { ws.sendKey(it) }, + onTwoFingerHold = { showModal = true }, + ) + } else { + // Keyboard mode with trackpad + NESKeyboardLayout( + style = controllerStyle, + isLandscape = isLandscape, + onKey = { ws.sendKey(it) }, + onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) }, + onClick = { ws.sendClick(it) }, + onScroll = { ws.sendScroll(it) }, + onMenu = { showModal = true }, + ) + } + + // Connection dot + Box( + Modifier + .align(Alignment.TopStart) + .padding(6.dp) + .size(8.dp) + .clip(CircleShape) + .background( + when (connectionState) { + ConnectionState.CONNECTED -> SuccessGreen + ConnectionState.CONNECTING -> BitcoinOrange + ConnectionState.ERROR, ConnectionState.AUTH_FAILED -> ErrorRed + ConnectionState.DISCONNECTED -> TextMuted + } + ), + ) + + // NES Menu + NESMenu( + visible = showModal, + servers = savedServers, + activeServer = activeServer, + isGamepadMode = isGamepadMode, + controllerStyle = controllerStyle, + onDismiss = { showModal = false }, + onSelectServer = { server -> + scope.launch { ws.disconnect(); prefs.setActiveServer(server) } + showModal = false + }, + onAddServer = { server -> + scope.launch { + prefs.addSavedServer(server) + if (activeServer == null) prefs.setActiveServer(server) + } + }, + onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } }, + onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false }, + onToggleStyle = { + controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC + }, + onBackToWebView = { showModal = false; onBack() }, + ) + } +} + +@Composable +private fun NESKeyboardLayout( + style: ControllerStyle, + isLandscape: Boolean, + onKey: (String) -> Unit, + onMouseMove: (Int, Int) -> Unit, + onClick: (Int) -> Unit, + onScroll: (Int) -> Unit, + onMenu: () -> Unit, +) { + Column(Modifier.fillMaxSize()) { + // Trackpad fills available space above keyboard + Trackpad( + onMove = onMouseMove, + onClick = onClick, + onScroll = onScroll, + onTwoFingerHold = onMenu, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 16.dp, vertical = if (isLandscape) 6.dp else 10.dp), + ) + // NES keyboard pinned to bottom + NESKeyboard( + style = style, + onKey = onKey, + modifier = Modifier.fillMaxWidth(), + ) + } +} 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 66d7712b..d7c02ca6 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 @@ -83,7 +83,10 @@ import javax.net.ssl.SSLContext import javax.net.ssl.X509TrustManager @Composable -fun ServerConnectScreen(onConnected: (String) -> Unit) { +fun ServerConnectScreen( + onConnected: (String) -> Unit, + onRemoteInput: () -> Unit = {}, +) { val context = LocalContext.current val prefs = remember { ServerPreferences(context) } val scope = rememberCoroutineScope() diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index 1584faba..79dead60 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -56,6 +56,7 @@ import com.archipelago.app.ui.theme.TextPrimary fun WebViewScreen( serverUrl: String, onDisconnect: () -> Unit, + onRemoteInput: () -> Unit = {}, ) { var isLoading by remember { mutableStateOf(true) } var loadProgress by remember { mutableIntStateOf(0) } @@ -257,6 +258,37 @@ fun WebViewScreen( } } + // Two-finger hold (500ms) → navigate to remote input + var twoFingerStart = 0L + var twoFingerFired = false + setOnTouchListener { _, event -> + val pointerCount = event.pointerCount + when (event.actionMasked) { + android.view.MotionEvent.ACTION_POINTER_DOWN -> { + if (pointerCount >= 2) { + twoFingerStart = System.currentTimeMillis() + twoFingerFired = false + } + } + android.view.MotionEvent.ACTION_MOVE -> { + if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) { + if (System.currentTimeMillis() - twoFingerStart > 500) { + twoFingerFired = true + onRemoteInput() + } + } + } + android.view.MotionEvent.ACTION_UP, + android.view.MotionEvent.ACTION_POINTER_UP, + android.view.MotionEvent.ACTION_CANCEL -> { + if (event.pointerCount <= 2) { + twoFingerStart = 0L + } + } + } + false // don't consume — let WebView handle normally + } + webView = this loadUrl(serverUrl) } @@ -276,6 +308,7 @@ fun WebViewScreen( trackColor = SurfaceBlack, ) } + } } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/NES.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/NES.kt new file mode 100644 index 00000000..9ff432fb --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/NES.kt @@ -0,0 +1,44 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.ui.graphics.Color + +/** NES/8BitDo controller palettes */ +object NES { + // ── Classic (light body, red buttons) ────────────── + val ClassicBody = Color(0xFFD4D0C8) // warm light gray plastic + val ClassicFace = Color(0xFF1C1C1C) // dark face plate + val ClassicAccent = Color(0xFF8A8A8A) // mid gray trim + val ClassicRidge = Color(0xFFBBB8B0) // grip lines + val ClassicButtonRed = Color(0xFFC1121C) // A/B red + val ClassicButtonRedPress = Color(0xFF8A0D14) + val ClassicButtonGray = Color(0xFF5A5A5A) // turbo buttons + val ClassicButtonGrayPress = Color(0xFF3A3A3A) + val ClassicDPad = Color(0xFF1A1A1A) + val ClassicDPadPress = Color(0xFF2A2A2A) + val ClassicLabel = Color(0xFFC1121C) // red text labels + val ClassicLabelMuted = Color(0xFF6A6A6A) + val ClassicSelect = Color(0xFF2A2A2A) // START/SELECT + + // ── Transparent Dark ─────────────────────────────── + val DarkBody = Color(0xFF2A2A2E) // smoky translucent dark + val DarkFace = Color(0xFF151518) // darker face + val DarkAccent = Color(0xFF3A3A3E) // trim + val DarkRidge = Color(0xFF222226) // grip lines + val DarkButtonMain = Color(0xFF3A3A3E) // all buttons dark + val DarkButtonMainPress = Color(0xFF222226) + val DarkDPad = Color(0xFF0E0E10) + val DarkDPadPress = Color(0xFF1A1A1E) + val DarkLabel = Color(0xFF5A5A60) // muted labels + val DarkLabelMuted = Color(0xFF3A3A3E) + val DarkSelect = Color(0xFF1A1A1E) + + // ── Menu UI (NES-style) ──────────────────────────── + val MenuBg = Color(0xFF000000) + val MenuPanel = Color(0xFF0B1B4A) // dark navy + val MenuBorder = Color(0xFFFFFFFF) + val MenuText = Color(0xFFFFFFFF) + val MenuSelected = Color(0xFFC1121C) + val MenuMuted = Color(0xFF7A7A7A) +} + +enum class ControllerStyle { CLASSIC, DARK } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt new file mode 100644 index 00000000..57431684 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt @@ -0,0 +1,106 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +object Neo { + // ── Dark ─────────────────────────────────────────── + val DarkSurface = Color(0xFF0A0A0A) + val DarkSurfaceRaised = Color(0xFF0F0F11) + val DarkShadowLight = Color(0xFF151517) + val DarkShadowDark = Color(0xFF000000) + val DarkBorder = Color(0x0AFFFFFF) + + // ── Light ────────────────────────────────────────── + val LightSurface = Color(0xFFE0E0E4) + val LightSurfaceRaised = Color(0xFFE6E6EA) + val LightShadowLight = Color(0xFFF2F2F6) + val LightShadowDark = Color(0xFFB4B4BA) + val LightBorder = Color(0x0A000000) + + val LightTextPrimary = Color(0xFF141414) + val LightTextSecondary = Color(0xFF5A5A5A) + val LightTextMuted = Color(0xFF9A9A9A) + + // ── Accessors ────────────────────────────────────── + + @Composable @ReadOnlyComposable + fun surface() = if (isSystemInDarkTheme()) DarkSurface else LightSurface + + @Composable @ReadOnlyComposable + fun surfaceRaised() = if (isSystemInDarkTheme()) DarkSurfaceRaised else LightSurfaceRaised + + @Composable @ReadOnlyComposable + fun shadowLight() = if (isSystemInDarkTheme()) DarkShadowLight else LightShadowLight + + @Composable @ReadOnlyComposable + fun shadowDark() = if (isSystemInDarkTheme()) DarkShadowDark else LightShadowDark + + @Composable @ReadOnlyComposable + fun border() = if (isSystemInDarkTheme()) DarkBorder else LightBorder + + @Composable @ReadOnlyComposable + fun textPrimary() = if (isSystemInDarkTheme()) Color(0xFFD0D0D0) else LightTextPrimary + + @Composable @ReadOnlyComposable + fun textSecondary() = if (isSystemInDarkTheme()) Color(0xFF666666) else LightTextSecondary + + @Composable @ReadOnlyComposable + fun textMuted() = if (isSystemInDarkTheme()) Color(0xFF333333) else LightTextMuted +} + +/** Subtle neomorphic raised shadow */ +fun Modifier.neoRaised( + lightShadow: Color, + darkShadow: Color, + radius: Dp = 14.dp, + shadowOffset: Dp = 2.dp, + shadowBlur: Dp = 4.dp, +) = this.drawBehind { + val r = radius.toPx() + val off = shadowOffset.toPx() + val blur = shadowBlur.toPx() + drawIntoCanvas { canvas -> + val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) } + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, darkShadow.toArgb()) } + }) + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, lightShadow.toArgb()) } + }) + } +} + +/** Subtle neomorphic inset shadow */ +fun Modifier.neoInset( + lightShadow: Color, + darkShadow: Color, + radius: Dp = 14.dp, + shadowOffset: Dp = 1.dp, + shadowBlur: Dp = 3.dp, +) = this.drawBehind { + val r = radius.toPx() + val off = shadowOffset.toPx() + val blur = shadowBlur.toPx() + drawIntoCanvas { canvas -> + val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) } + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, darkShadow.toArgb()) } + }) + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, lightShadow.toArgb()) } + }) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt index 84a52335..9fa7d247 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt @@ -1,7 +1,9 @@ package com.archipelago.app.ui.theme +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable private val DarkColorScheme = darkColorScheme( @@ -9,29 +11,45 @@ private val DarkColorScheme = darkColorScheme( onPrimary = SurfaceBlack, primaryContainer = BitcoinOrangeDark, onPrimaryContainer = TextPrimary, - secondary = BitcoinOrangeLight, onSecondary = SurfaceBlack, - background = SurfaceBlack, onBackground = TextPrimary, - surface = SurfaceDark, onSurface = TextPrimary, surfaceVariant = SurfaceCard, onSurfaceVariant = TextSecondary, - outline = BorderDefault, outlineVariant = BorderSubtle, + error = ErrorRed, + onError = TextPrimary, +) +private val LightColorScheme = lightColorScheme( + primary = BitcoinOrange, + onPrimary = SurfaceBlack, + primaryContainer = BitcoinOrangeLight, + onPrimaryContainer = SurfaceBlack, + secondary = BitcoinOrangeDark, + onSecondary = TextPrimary, + background = Neo.LightSurface, + onBackground = Neo.LightTextPrimary, + surface = Neo.LightSurfaceRaised, + onSurface = Neo.LightTextPrimary, + surfaceVariant = Neo.LightSurface, + onSurfaceVariant = Neo.LightTextSecondary, + outline = Neo.LightBorder, + outlineVariant = Neo.LightBorder, error = ErrorRed, onError = TextPrimary, ) @Composable fun ArchipelagoTheme(content: @Composable () -> Unit) { + val colorScheme = if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme + MaterialTheme( - colorScheme = DarkColorScheme, + colorScheme = colorScheme, typography = Typography, content = content, ) diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 0d71030b..642bd4cd 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -19,4 +19,6 @@ Disconnect Server unreachable Retry + Remote Control + Use your phone as a keyboard and mouse for the kiosk