feat: NES portrait controller, remote input handler updates
- NESPortraitController layout for vertical phone use - Updated NESController and NESKeyboard components - Remote input WebSocket handler and API route registration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
@@ -15,13 +16,15 @@ 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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -34,17 +37,21 @@ 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.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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
|
||||
@@ -52,71 +59,90 @@ import com.archipelago.app.ui.theme.NES
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Color palettes
|
||||
// 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 dpad: Color, val dpadHi: Color,
|
||||
val btn: Color, val btnPress: Color,
|
||||
val capsule: Color, val capsulePress: Color,
|
||||
val inlayBg: Color, val inlayBorder: 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),
|
||||
dpad = Color(0xFF0C0C0C), dpadHi = Color(0xFF1A1A1A),
|
||||
btn = NES.ClassicButtonRed, btnPress = NES.ClassicButtonRedPress,
|
||||
capsule = Color(0xFF1C1C1C), capsulePress = Color(0xFF0E0E0E),
|
||||
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
|
||||
)
|
||||
|
||||
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),
|
||||
dpad = Color(0xFF080808), dpadHi = Color(0xFF141418),
|
||||
btn = NES.DarkButtonMain, btnPress = NES.DarkButtonMainPress,
|
||||
capsule = Color(0xFF121216), capsulePress = Color(0xFF0A0A0C),
|
||||
inlayBg = Color(0xFF060608), inlayBorder = Color(0xFF444448),
|
||||
)
|
||||
|
||||
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Main NES Controller (Gamepad mode)
|
||||
// Landscape NES Controller
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
@Composable
|
||||
fun NESController(
|
||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||
onKey: (String) -> Unit,
|
||||
onTwoFingerHold: () -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val c = paletteFor(style)
|
||||
val isClassic = style == ControllerStyle.CLASSIC
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.twoFingerHold(onTwoFingerHold)
|
||||
.padding(horizontal = 32.dp, vertical = 20.dp),
|
||||
.background(Color(0xFF0C0C0C)) // Slightly lighter than black for shadow visibility
|
||||
.twoFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// 3D drop shadow layers for realism
|
||||
// Shadow platform
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.86f)
|
||||
.aspectRatio(2.3f)
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(Color(0xFF000000)),
|
||||
)
|
||||
// Controller body
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.88f)
|
||||
.fillMaxWidth(0.86f)
|
||||
.aspectRatio(2.3f)
|
||||
.shadow(24.dp, RoundedCornerShape(16.dp), ambientColor = Color.Black, spotColor = Color.Black)
|
||||
.shadow(8.dp, RoundedCornerShape(16.dp), ambientColor = Color(0x40000000))
|
||||
.shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(c.body)
|
||||
.background(
|
||||
Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f)))
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)),
|
||||
) {
|
||||
// Top highlight edge
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
|
||||
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
|
||||
)
|
||||
|
||||
// Face plate
|
||||
Box(
|
||||
Modifier
|
||||
@@ -124,25 +150,18 @@ fun NESController(
|
||||
.padding(14.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(c.face)
|
||||
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(10.dp)),
|
||||
) {
|
||||
// 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))
|
||||
Ridges(c.ridge, Modifier.align(Alignment.CenterStart).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
|
||||
Ridges(c.ridge, Modifier.align(Alignment.CenterEnd).width(7.dp).fillMaxHeight().padding(vertical = 12.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)
|
||||
// D-Pad in inlay (more left margin)
|
||||
Inlay(c, Modifier.align(Alignment.CenterStart).padding(start = 48.dp).size(140.dp)) {
|
||||
OnePointDPad(c, 120.dp, onKey)
|
||||
}
|
||||
|
||||
// ── Center: ARCHIPELAGO + START/SELECT ───
|
||||
// Center: Logo + START/SELECT
|
||||
Column(
|
||||
Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -150,53 +169,44 @@ fun NESController(
|
||||
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
|
||||
),
|
||||
modifier = Modifier.width(180.dp),
|
||||
colorFilter = ColorFilter.tint(if (isClassic) 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") }
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Inlay(c, Modifier.padding(horizontal = 4.dp)) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
|
||||
CapsuleBtn("START", c, 64.dp, 28.dp) { 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)
|
||||
) {
|
||||
// A/B Buttons in inlay (same size as D-pad inlay, more right margin)
|
||||
Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
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)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
RoundBtn(c, 52.dp) { onKey("Escape") }
|
||||
Text("B", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.width(16.dp))
|
||||
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))
|
||||
RoundBtn(c, 52.dp) { onKey("Return") }
|
||||
Text("A", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings button (bottom center)
|
||||
SettingsBtn(c, Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp), onMenu)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +216,107 @@ fun NESController(
|
||||
// Shared sub-components
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** Inlay well — dark recessed area with border */
|
||||
@Composable
|
||||
fun Inlay(c: NESPalette, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(c.inlayBg)
|
||||
.border(3.dp, c.inlayBorder, RoundedCornerShape(10.dp))
|
||||
.padding(4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
/** One-piece D-pad — single cross shape, touch detects direction */
|
||||
@Composable
|
||||
fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var job by remember { mutableStateOf<Job?>(null) }
|
||||
var activeDir by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(Unit) { onDispose { job?.cancel() } }
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = { offset ->
|
||||
val cx = this@pointerInput.size.width / 2f
|
||||
val cy = this@pointerInput.size.height / 2f
|
||||
val dx = offset.x - cx
|
||||
val dy = offset.y - cy
|
||||
val dead = cx * 0.24f
|
||||
if (abs(dx) < dead && abs(dy) < dead) {
|
||||
tryAwaitRelease(); return@detectTapGestures
|
||||
}
|
||||
val dir = if (abs(dx) > abs(dy)) {
|
||||
if (dx > 0) "Right" else "Left"
|
||||
} else {
|
||||
if (dy > 0) "Down" else "Up"
|
||||
}
|
||||
activeDir = dir; onDir(dir)
|
||||
job?.cancel()
|
||||
job = scope.launch { delay(300); while (true) { onDir(dir); delay(90) } }
|
||||
tryAwaitRelease()
|
||||
job?.cancel(); activeDir = null
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
val w = size.toPx()
|
||||
val arm = w * 0.33f // arm width = 1/3 of total
|
||||
val offset = (w - arm) / 2f
|
||||
|
||||
// Cross shape
|
||||
val crossColor = c.dpad
|
||||
|
||||
// Vertical bar
|
||||
drawRoundRect(
|
||||
color = crossColor,
|
||||
topLeft = Offset(offset, 0f),
|
||||
size = Size(arm, w),
|
||||
cornerRadius = CornerRadius(4.dp.toPx()),
|
||||
)
|
||||
// Horizontal bar
|
||||
drawRoundRect(
|
||||
color = crossColor,
|
||||
topLeft = Offset(0f, offset),
|
||||
size = Size(w, arm),
|
||||
cornerRadius = CornerRadius(4.dp.toPx()),
|
||||
)
|
||||
|
||||
// Top-edge lighting
|
||||
drawRoundRect(
|
||||
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
|
||||
topLeft = Offset(offset, 0f),
|
||||
size = Size(arm, w * 0.15f),
|
||||
cornerRadius = CornerRadius(4.dp.toPx()),
|
||||
)
|
||||
drawRoundRect(
|
||||
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
|
||||
topLeft = Offset(0f, offset),
|
||||
size = Size(w, arm * 0.3f),
|
||||
cornerRadius = CornerRadius(4.dp.toPx()),
|
||||
)
|
||||
|
||||
// Active direction highlight
|
||||
activeDir?.let { dir ->
|
||||
val hi = c.dpadHi
|
||||
when (dir) {
|
||||
"Up" -> drawRoundRect(hi, Offset(offset, 0f), Size(arm, arm), CornerRadius(4.dp.toPx()))
|
||||
"Down" -> drawRoundRect(hi, Offset(offset, w - arm), Size(arm, arm), CornerRadius(4.dp.toPx()))
|
||||
"Left" -> drawRoundRect(hi, Offset(0f, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
|
||||
"Right" -> drawRoundRect(hi, Offset(w - arm, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
|
||||
}
|
||||
}
|
||||
|
||||
// Center circle
|
||||
drawCircle(c.dpadHi, radius = w * 0.06f, center = Offset(w / 2f, w / 2f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Ridges(color: Color, modifier: Modifier) {
|
||||
Canvas(modifier = modifier) {
|
||||
@@ -214,129 +325,64 @@ fun Ridges(color: Color, modifier: Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A/B round button with lighting */
|
||||
@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) }
|
||||
fun RoundBtn(c: NESPalette, sz: Dp = 52.dp, onClick: () -> Unit) {
|
||||
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 })
|
||||
},
|
||||
.background(Brush.verticalGradient(
|
||||
if (p) listOf(c.btnPress, c.btn.copy(alpha = 0.85f))
|
||||
else listOf(c.btn, c.btn.copy(alpha = 0.8f))
|
||||
))
|
||||
.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))
|
||||
)
|
||||
)
|
||||
}
|
||||
if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/** START/SELECT capsule */
|
||||
@Composable
|
||||
fun CapsuleBtn(label: String, c: NESPalette, onClick: () -> Unit) {
|
||||
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, 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 })
|
||||
},
|
||||
.width(w).height(h)
|
||||
.shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(4.dp))
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Brush.verticalGradient(
|
||||
if (p) listOf(c.capsulePress, c.capsule)
|
||||
else listOf(c.capsule, c.capsule.copy(alpha = 0.85f))
|
||||
))
|
||||
.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)
|
||||
if (!p) Box(Modifier.fillMaxSize().clip(RoundedCornerShape(4.dp)).background(
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.05f), Color.Transparent))
|
||||
))
|
||||
Text(label, color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small settings gear button */
|
||||
@Composable
|
||||
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (p) c.capsulePress else c.capsule)
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Settings, "Settings", Modifier.size(14.dp), tint = c.labelMuted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,3 +400,4 @@ fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
||||
} while (ev.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -23,11 +22,9 @@ 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
|
||||
@@ -39,10 +36,9 @@ 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
|
||||
private val KEY_H = 42.dp
|
||||
private val GAP = 4.dp
|
||||
|
||||
/** NES-themed keyboard — keys styled like D-pad buttons, inside controller body */
|
||||
@Composable
|
||||
fun NESKeyboard(
|
||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||
@@ -51,11 +47,10 @@ fun NESKeyboard(
|
||||
) {
|
||||
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
|
||||
val keyBgP = c.dpadHi
|
||||
val keyTxt = c.labelMuted
|
||||
val accent = if (isClassic) NES.ClassicLabel else c.labelMuted
|
||||
|
||||
var layer by remember { mutableStateOf(NKLayer.ALPHA) }
|
||||
var shifted by remember { mutableStateOf(false) }
|
||||
@@ -65,108 +60,143 @@ fun NESKeyboard(
|
||||
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(
|
||||
// NES body wrapping keyboard
|
||||
Column(
|
||||
modifier = modifier
|
||||
.shadow(16.dp, RoundedCornerShape(14.dp), ambientColor = Color.Black)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(c.body)
|
||||
.padding(10.dp)
|
||||
.padding(8.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(c.face)
|
||||
.padding(8.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(GAP),
|
||||
) {
|
||||
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") }
|
||||
when (layer) {
|
||||
NKLayer.ALPHA -> {
|
||||
KeyRow("q w e r t y u i o p".split(" "), up, keyBg, keyBgP, keyTxt, ::ch)
|
||||
KeyRow("a s d f g h j k l".split(" "), up, keyBg, keyBgP, keyTxt, ::ch, inset = 16.dp)
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
NKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), keyBg, keyBgP, if (up) accent else keyTxt) {
|
||||
if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true
|
||||
}
|
||||
}
|
||||
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") }
|
||||
"z x c v b n m".split(" ").forEach { k ->
|
||||
NKey(if (up) k.uppercase() else k, Modifier.weight(1f), keyBg, keyBgP, keyTxt, 17) { ch(k) }
|
||||
}
|
||||
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { 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
|
||||
NKLayer.NUM -> {
|
||||
KeyRow("1 2 3 4 5 6 7 8 9 0".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
|
||||
KeyRow("- / : ; ( ) \$ & @ \"".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
NKey("#+=", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.SYM }
|
||||
". , ? ! '".split(" ").forEach { k ->
|
||||
NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) }
|
||||
}
|
||||
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
|
||||
}
|
||||
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") }
|
||||
}
|
||||
NKLayer.SYM -> {
|
||||
KeyRow("[ ] { } # % ^ * + =".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
|
||||
KeyRow("_ \\ | ~ < > ` @ !".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
NKey("123", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.NUM }
|
||||
". , ? ! '".split(" ").forEach { k ->
|
||||
NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) }
|
||||
}
|
||||
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bottom row
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
NKey(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) {
|
||||
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false
|
||||
}
|
||||
NKey(",", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("comma") }
|
||||
NKey("space", Modifier.weight(5f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
|
||||
NKey(".", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("period") }
|
||||
NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Key row — each key gets equal weight */
|
||||
@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) } }
|
||||
private fun KeyRow(
|
||||
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 { k ->
|
||||
NKey(
|
||||
label = if (up) k.uppercase() else k,
|
||||
modifier = Modifier.weight(1f),
|
||||
bg = bg, bgP = bgP, txt = txt,
|
||||
fontSize = 17,
|
||||
onTap = { onKey(k) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** D-pad style key — flat, dark, with subtle top-edge lighting */
|
||||
/** Single NES key — D-pad style flat dark button */
|
||||
@Composable
|
||||
private fun DK(label: String, modifier: Modifier = Modifier, bg: Color, bgP: Color, txt: Color, fontSize: Int = 12, onTap: () -> Unit) {
|
||||
private fun NKey(
|
||||
label: String, modifier: Modifier = Modifier,
|
||||
bg: Color, bgP: Color, txt: Color,
|
||||
fontSize: Int = 13, 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))
|
||||
)
|
||||
)
|
||||
.height(KEY_H)
|
||||
.clip(RoundedCornerShape(4.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))
|
||||
RoundedCornerShape(4.dp))
|
||||
else Modifier
|
||||
)
|
||||
.pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) },
|
||||
.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) }
|
||||
) {
|
||||
Text(label, color = txt, fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Repeatable NES key (backspace) */
|
||||
@Composable
|
||||
private fun DKRepeat(label: String, modifier: Modifier, bg: Color, bgP: Color, txt: Color, onTap: () -> Unit) {
|
||||
private fun NRepKey(
|
||||
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) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var job by remember { mutableStateOf<Job?>(null) }
|
||||
DisposableEffect(Unit) { onDispose { job?.cancel() } }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.height(KEY_H)
|
||||
.clip(RoundedCornerShape(4.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
|
||||
}) },
|
||||
.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) }
|
||||
) {
|
||||
Text(label, color = txt, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
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.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
import com.archipelago.app.ui.theme.NES
|
||||
|
||||
/**
|
||||
* Portrait gamepad — vertical remote shape like Apple TV but NES-styled.
|
||||
* Large trackpad top, D-pad middle, A/B + START/SELECT bottom.
|
||||
*/
|
||||
@Composable
|
||||
fun NESPortraitController(
|
||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||
onKey: (String) -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
) {
|
||||
val c = paletteFor(style)
|
||||
val isClassic = style == ControllerStyle.CLASSIC
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0C0C0C))
|
||||
.twoFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Remote body — tall vertical shape
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.fillMaxSize()
|
||||
.shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f))))
|
||||
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)),
|
||||
) {
|
||||
// Top highlight
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
|
||||
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
|
||||
)
|
||||
|
||||
// Face plate
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(14.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(c.face)
|
||||
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(14.dp))
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
// Trackpad area (touch surface for mouse)
|
||||
Trackpad(
|
||||
onMove = { _, _ -> }, // Not used in gamepad, but keeps the visual
|
||||
onClick = { onKey("Return") },
|
||||
onScroll = { dy ->
|
||||
if (dy > 0) onKey("Down") else onKey("Up")
|
||||
},
|
||||
onTwoFingerHold = onMenu,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// D-Pad
|
||||
Inlay(c, Modifier.size(150.dp)) {
|
||||
OnePointDPad(c, 130.dp, onKey)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Logo
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo_wide),
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier.width(140.dp),
|
||||
colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// A/B Buttons
|
||||
Inlay(c, Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RoundBtn(c, 52.dp) { onKey("Escape") }
|
||||
Spacer(Modifier.width(24.dp))
|
||||
RoundBtn(c, 52.dp) { onKey("Return") }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// START / SELECT
|
||||
Inlay(c, Modifier) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
|
||||
CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Settings
|
||||
SettingsBtn(c, Modifier, onMenu)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,11 @@ 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
|
||||
@@ -32,8 +26,6 @@ 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
|
||||
@@ -43,11 +35,11 @@ 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.NESPortraitController
|
||||
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
|
||||
@@ -62,7 +54,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
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 isGamepadMode by remember { mutableStateOf(true) }
|
||||
var showModal by remember { mutableStateOf(false) }
|
||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
|
||||
|
||||
@@ -71,51 +63,54 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
|
||||
BackHandler { onBack() }
|
||||
DisposableEffect(Unit) { onDispose { ws.disconnect() } }
|
||||
|
||||
LaunchedEffect(activeServer) {
|
||||
activeServer?.let { ws.connect(it.toUrl(), it.password) }
|
||||
}
|
||||
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) }
|
||||
kotlinx.coroutines.delay(3000); activeServer?.let { ws.connect(it.toUrl(), it.password) }
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.background(Color(0xFF0C0C0C))
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
) {
|
||||
if (isGamepadMode) {
|
||||
// NES controller — centered with margins
|
||||
NESController(
|
||||
when {
|
||||
isGamepadMode && isLandscape -> 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 },
|
||||
)
|
||||
isGamepadMode && !isLandscape -> NESPortraitController(
|
||||
style = controllerStyle,
|
||||
onKey = { ws.sendKey(it) },
|
||||
onMenu = { showModal = true },
|
||||
)
|
||||
else -> {
|
||||
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Trackpad(
|
||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||
onClick = { ws.sendClick(it) },
|
||||
onScroll = { ws.sendScroll(it) },
|
||||
onTwoFingerHold = { showModal = true },
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
NESKeyboard(
|
||||
style = controllerStyle,
|
||||
onKey = { ws.sendKey(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection dot
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(6.dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Modifier.align(Alignment.TopStart).padding(6.dp).size(8.dp)
|
||||
.clip(CircleShape).background(
|
||||
when (connectionState) {
|
||||
ConnectionState.CONNECTED -> SuccessGreen
|
||||
ConnectionState.CONNECTING -> BitcoinOrange
|
||||
@@ -125,7 +120,6 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
),
|
||||
)
|
||||
|
||||
// NES Menu
|
||||
NESMenu(
|
||||
visible = showModal,
|
||||
servers = savedServers,
|
||||
@@ -134,14 +128,10 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
controllerStyle = controllerStyle,
|
||||
onDismiss = { showModal = false },
|
||||
onSelectServer = { server ->
|
||||
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }
|
||||
showModal = false
|
||||
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
|
||||
},
|
||||
onAddServer = { server ->
|
||||
scope.launch {
|
||||
prefs.addSavedServer(server)
|
||||
if (activeServer == null) prefs.setActiveServer(server)
|
||||
}
|
||||
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
||||
},
|
||||
onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } },
|
||||
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
||||
@@ -152,34 +142,3 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user