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:
parent
4b82b2a87e
commit
33dcda0f85
@ -82,6 +82,9 @@ dependencies {
|
|||||||
// Splash screen
|
// Splash screen
|
||||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
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-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ data class ServerEntry(
|
|||||||
val address: String,
|
val address: String,
|
||||||
val useHttps: Boolean,
|
val useHttps: Boolean,
|
||||||
val port: String = "",
|
val port: String = "",
|
||||||
|
val password: String = "",
|
||||||
) {
|
) {
|
||||||
fun toUrl(): String {
|
fun toUrl(): String {
|
||||||
val scheme = if (useHttps) "https" else "http"
|
val scheme = if (useHttps) "https" else "http"
|
||||||
@ -24,7 +25,13 @@ data class ServerEntry(
|
|||||||
return "$scheme://$address$portSuffix"
|
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 {
|
companion object {
|
||||||
fun deserialize(raw: String): ServerEntry? {
|
fun deserialize(raw: String): ServerEntry? {
|
||||||
@ -34,6 +41,7 @@ data class ServerEntry(
|
|||||||
address = parts[0],
|
address = parts[0],
|
||||||
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
|
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
|
||||||
port = parts.getOrElse(2) { "" },
|
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 activeAddressKey = stringPreferencesKey("active_address")
|
||||||
private val activeHttpsKey = booleanPreferencesKey("active_https")
|
private val activeHttpsKey = booleanPreferencesKey("active_https")
|
||||||
private val activePortKey = stringPreferencesKey("active_port")
|
private val activePortKey = stringPreferencesKey("active_port")
|
||||||
|
private val activePasswordKey = stringPreferencesKey("active_password")
|
||||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||||
|
|
||||||
@ -53,6 +62,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
address = address,
|
address = address,
|
||||||
useHttps = prefs[activeHttpsKey] ?: false,
|
useHttps = prefs[activeHttpsKey] ?: false,
|
||||||
port = prefs[activePortKey] ?: "",
|
port = prefs[activePortKey] ?: "",
|
||||||
|
password = prefs[activePasswordKey] ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +80,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[activeAddressKey] = server.address
|
prefs[activeAddressKey] = server.address
|
||||||
prefs[activeHttpsKey] = server.useHttps
|
prefs[activeHttpsKey] = server.useHttps
|
||||||
prefs[activePortKey] = server.port
|
prefs[activePortKey] = server.port
|
||||||
|
prefs[activePasswordKey] = server.password
|
||||||
}
|
}
|
||||||
addSavedServer(server)
|
addSavedServer(server)
|
||||||
}
|
}
|
||||||
@ -79,6 +90,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs.remove(activeAddressKey)
|
prefs.remove(activeAddressKey)
|
||||||
prefs.remove(activeHttpsKey)
|
prefs.remove(activeHttpsKey)
|
||||||
prefs.remove(activePortKey)
|
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 androidx.navigation.compose.rememberNavController
|
||||||
import com.archipelago.app.data.ServerPreferences
|
import com.archipelago.app.data.ServerPreferences
|
||||||
import com.archipelago.app.ui.screens.IntroScreen
|
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.ServerConnectScreen
|
||||||
import com.archipelago.app.ui.screens.WebViewScreen
|
import com.archipelago.app.ui.screens.WebViewScreen
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -19,6 +20,7 @@ object Routes {
|
|||||||
const val INTRO = "intro"
|
const val INTRO = "intro"
|
||||||
const val SERVER_CONNECT = "server_connect"
|
const val SERVER_CONNECT = "server_connect"
|
||||||
const val WEB_VIEW = "web_view"
|
const val WEB_VIEW = "web_view"
|
||||||
|
const val REMOTE_INPUT = "remote_input"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -31,7 +33,6 @@ fun AppNavHost() {
|
|||||||
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
||||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||||
|
|
||||||
// Wait for preferences to load before deciding
|
|
||||||
if (introSeen == null) return
|
if (introSeen == null) return
|
||||||
|
|
||||||
val startDestination = when {
|
val startDestination = when {
|
||||||
@ -70,7 +71,6 @@ fun AppNavHost() {
|
|||||||
composable(Routes.WEB_VIEW) {
|
composable(Routes.WEB_VIEW) {
|
||||||
val server = activeServer
|
val server = activeServer
|
||||||
if (server == null) {
|
if (server == null) {
|
||||||
// Server was cleared, go back to connect
|
|
||||||
ServerConnectScreen(
|
ServerConnectScreen(
|
||||||
onConnected = { _ ->
|
onConnected = { _ ->
|
||||||
navController.navigate(Routes.WEB_VIEW) {
|
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
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerConnectScreen(onConnected: (String) -> Unit) {
|
fun ServerConnectScreen(
|
||||||
|
onConnected: (String) -> Unit,
|
||||||
|
onRemoteInput: () -> Unit = {},
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefs = remember { ServerPreferences(context) }
|
val prefs = remember { ServerPreferences(context) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|||||||
@ -56,6 +56,7 @@ import com.archipelago.app.ui.theme.TextPrimary
|
|||||||
fun WebViewScreen(
|
fun WebViewScreen(
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
|
onRemoteInput: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
var loadProgress by remember { mutableIntStateOf(0) }
|
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
|
webView = this
|
||||||
loadUrl(serverUrl)
|
loadUrl(serverUrl)
|
||||||
}
|
}
|
||||||
@ -276,6 +308,7 @@ fun WebViewScreen(
|
|||||||
trackColor = SurfaceBlack,
|
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
|
package com.archipelago.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
@ -9,29 +11,45 @@ private val DarkColorScheme = darkColorScheme(
|
|||||||
onPrimary = SurfaceBlack,
|
onPrimary = SurfaceBlack,
|
||||||
primaryContainer = BitcoinOrangeDark,
|
primaryContainer = BitcoinOrangeDark,
|
||||||
onPrimaryContainer = TextPrimary,
|
onPrimaryContainer = TextPrimary,
|
||||||
|
|
||||||
secondary = BitcoinOrangeLight,
|
secondary = BitcoinOrangeLight,
|
||||||
onSecondary = SurfaceBlack,
|
onSecondary = SurfaceBlack,
|
||||||
|
|
||||||
background = SurfaceBlack,
|
background = SurfaceBlack,
|
||||||
onBackground = TextPrimary,
|
onBackground = TextPrimary,
|
||||||
|
|
||||||
surface = SurfaceDark,
|
surface = SurfaceDark,
|
||||||
onSurface = TextPrimary,
|
onSurface = TextPrimary,
|
||||||
surfaceVariant = SurfaceCard,
|
surfaceVariant = SurfaceCard,
|
||||||
onSurfaceVariant = TextSecondary,
|
onSurfaceVariant = TextSecondary,
|
||||||
|
|
||||||
outline = BorderDefault,
|
outline = BorderDefault,
|
||||||
outlineVariant = BorderSubtle,
|
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,
|
error = ErrorRed,
|
||||||
onError = TextPrimary,
|
onError = TextPrimary,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArchipelagoTheme(content: @Composable () -> Unit) {
|
fun ArchipelagoTheme(content: @Composable () -> Unit) {
|
||||||
|
val colorScheme = if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = DarkColorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -19,4 +19,6 @@
|
|||||||
<string name="disconnect">Disconnect</string>
|
<string name="disconnect">Disconnect</string>
|
||||||
<string name="server_unreachable">Server unreachable</string>
|
<string name="server_unreachable">Server unreachable</string>
|
||||||
<string name="retry">Retry</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>
|
</resources>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user