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) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ConnectionState> = _state
|
||||
|
||||
private val trustManager = object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = 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}""")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Job?>(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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Job?>(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 })
|
||||
}
|
||||
}
|
||||
@@ -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<String>, 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<Job?>(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) }
|
||||
}
|
||||
@@ -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<ServerEntry>,
|
||||
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<ServerEntry>,
|
||||
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,
|
||||
)
|
||||
@@ -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<ServerEntry>,
|
||||
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<ServerEntry>,
|
||||
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(),
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<String>, 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<String>, 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<Job?>(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) }
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
106
Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt
Normal file
106
Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt
Normal file
@@ -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()) }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -19,4 +19,6 @@
|
||||
<string name="disconnect">Disconnect</string>
|
||||
<string name="server_unreachable">Server unreachable</string>
|
||||
<string name="retry">Retry</string>
|
||||
<string name="remote_input">Remote Control</string>
|
||||
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user