Companion: three-finger menu gesture, native wallet QR scanner, scan/upload chooser #104
@ -75,6 +75,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
|
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
|
||||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||||
|
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
||||||
|
|
||||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
||||||
val address = prefs[activeAddressKey] ?: return@map null
|
val address = prefs[activeAddressKey] ?: return@map null
|
||||||
@ -97,6 +98,11 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[introSeenKey] ?: false
|
prefs[introSeenKey] ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-shot flag for the three-finger-hold teaching overlay. */
|
||||||
|
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[gestureHintSeenKey] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setActiveServer(server: ServerEntry) {
|
suspend fun setActiveServer(server: ServerEntry) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
prefs[activeAddressKey] = server.address
|
prefs[activeAddressKey] = server.address
|
||||||
@ -214,4 +220,10 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[introSeenKey] = true
|
prefs[introSeenKey] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun markGestureHintSeen() {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[gestureHintSeenKey] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,7 @@ import com.archipelago.app.ui.theme.neoRaised
|
|||||||
@Composable
|
@Composable
|
||||||
fun GamepadLayout(
|
fun GamepadLayout(
|
||||||
onKey: (String) -> Unit,
|
onKey: (String) -> Unit,
|
||||||
onTwoFingerHold: () -> Unit,
|
onThreeFingerHold: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val surface = Neo.surface()
|
val surface = Neo.surface()
|
||||||
@ -54,9 +54,9 @@ fun GamepadLayout(
|
|||||||
do {
|
do {
|
||||||
val ev = awaitPointerEvent()
|
val ev = awaitPointerEvent()
|
||||||
val a = ev.changes.filter { !it.changedToUp() }
|
val a = ev.changes.filter { !it.changedToUp() }
|
||||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() }
|
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() }
|
||||||
if (a.size < 2) t = 0L
|
if (a.size < 3) t = 0L
|
||||||
} while (ev.changes.any { it.pressed })
|
} while (ev.changes.any { it.pressed })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,147 @@
|
|||||||
|
package com.archipelago.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
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.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
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.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.archipelago.app.R
|
||||||
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-launch teaching overlay for the three-finger hold gesture. Three
|
||||||
|
* fingertip dots pulse in a "press" rhythm with an expanding ring while a
|
||||||
|
* short caption explains what the gesture opens. Dismissed by tapping
|
||||||
|
* anywhere (or automatically after a few seconds) — shown once, ever.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GestureHintOverlay(onDismiss: () -> Unit) {
|
||||||
|
// Auto-dismiss so a user who taps nothing is never stuck behind the scrim.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(6500)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
val transition = rememberInfiniteTransition(label = "gesture-hint")
|
||||||
|
// Fingertips press down together…
|
||||||
|
val press by transition.animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = 0.86f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse),
|
||||||
|
label = "press",
|
||||||
|
)
|
||||||
|
// …while a ring ripples outward on each press cycle.
|
||||||
|
val ripple by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)),
|
||||||
|
label = "ripple",
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.72f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onDismiss,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
// Hand: three fingertip dots in a natural arc + ripple ring.
|
||||||
|
Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(150.dp)
|
||||||
|
.scale(0.4f + ripple * 0.6f)
|
||||||
|
.border(
|
||||||
|
2.dp,
|
||||||
|
BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f),
|
||||||
|
CircleShape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
FingerDot(x = (-44).dp, y = 14.dp, scale = press)
|
||||||
|
FingerDot(x = 0.dp, y = (-12).dp, scale = press)
|
||||||
|
FingerDot(x = 44.dp, y = 8.dp, scale = press)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.gesture_hint_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.gesture_hint_body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 48.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color.White.copy(alpha = 0.12f))
|
||||||
|
.clickable(onClick = onDismiss)
|
||||||
|
.padding(horizontal = 28.dp, vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.gesture_hint_got_it),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.offset(x = x, y = y)
|
||||||
|
.size(26.dp)
|
||||||
|
.scale(scale)
|
||||||
|
.background(Color.White.copy(alpha = 0.92f), CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -116,7 +116,7 @@ fun NESController(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.twoFingerHold(onMenu)
|
.threeFingerHold(onMenu)
|
||||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@ -451,17 +451,17 @@ fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Two-finger hold gesture modifier */
|
/** Three-finger hold gesture modifier (two fingers stay free for scrolling) */
|
||||||
fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
fun Modifier.threeFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
awaitFirstDown(requireUnconsumed = false)
|
awaitFirstDown(requireUnconsumed = false)
|
||||||
var t = 0L; var fired = false
|
var t = 0L; var fired = false
|
||||||
do {
|
do {
|
||||||
val ev = awaitPointerEvent()
|
val ev = awaitPointerEvent()
|
||||||
val a = ev.changes.filter { !it.changedToUp() }
|
val a = ev.changes.filter { !it.changedToUp() }
|
||||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
||||||
if (a.size < 2) t = 0L
|
if (a.size < 3) t = 0L
|
||||||
} while (ev.changes.any { it.pressed })
|
} while (ev.changes.any { it.pressed })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,7 +50,7 @@ fun NESPortraitController(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.twoFingerHold(onMenu)
|
.threeFingerHold(onMenu)
|
||||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@ -87,7 +87,7 @@ fun NESPortraitController(
|
|||||||
onMove = { dx, dy -> onMouseMove(dx, dy) },
|
onMove = { dx, dy -> onMouseMove(dx, dy) },
|
||||||
onClick = { onMouseClick(it) },
|
onClick = { onMouseClick(it) },
|
||||||
onScroll = { dy -> onMouseScroll(dy) },
|
onScroll = { dy -> onMouseScroll(dy) },
|
||||||
onTwoFingerHold = onMenu,
|
onThreeFingerHold = onMenu,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
|
|||||||
@ -217,13 +217,20 @@ fun QrScannerOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared by the pairing scanner and the wallet scan modal. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||||
val previewView = remember {
|
val previewView = remember {
|
||||||
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
|
PreviewView(context).apply {
|
||||||
|
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||||
|
// TextureView, not the SurfaceView default: SurfaceView punches a
|
||||||
|
// hole in the window, which black-flashes inside Compose fades and
|
||||||
|
// ignores rounded-corner clipping (wallet modal).
|
||||||
|
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
@ -282,7 +289,19 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var lastAttempt = 0L
|
||||||
|
|
||||||
override fun analyze(image: ImageProxy) {
|
override fun analyze(image: ImageProxy) {
|
||||||
|
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
|
||||||
|
// retry) pegs a core when run at camera rate, and that CPU contention
|
||||||
|
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
|
||||||
|
// frames skipped here are simply dropped, so decodes stay current.
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastAttempt < 140) {
|
||||||
|
image.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastAttempt = now
|
||||||
try {
|
try {
|
||||||
val plane = image.planes[0]
|
val plane = image.planes[0]
|
||||||
val buffer = plane.buffer
|
val buffer = plane.buffer
|
||||||
|
|||||||
@ -32,7 +32,7 @@ fun Trackpad(
|
|||||||
onMove: (dx: Int, dy: Int) -> Unit,
|
onMove: (dx: Int, dy: Int) -> Unit,
|
||||||
onClick: (button: Int) -> Unit,
|
onClick: (button: Int) -> Unit,
|
||||||
onScroll: (dy: Int) -> Unit,
|
onScroll: (dy: Int) -> Unit,
|
||||||
onTwoFingerHold: () -> Unit,
|
onThreeFingerHold: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var fingers by remember { mutableIntStateOf(0) }
|
var fingers by remember { mutableIntStateOf(0) }
|
||||||
@ -53,7 +53,7 @@ fun Trackpad(
|
|||||||
val t0 = System.currentTimeMillis()
|
val t0 = System.currentTimeMillis()
|
||||||
var maxPtrs = 1
|
var maxPtrs = 1
|
||||||
var holdFired = false
|
var holdFired = false
|
||||||
var twoStart = 0L
|
var threeStart = 0L
|
||||||
var scrollAcc = 0f
|
var scrollAcc = 0f
|
||||||
fingers = 1
|
fingers = 1
|
||||||
|
|
||||||
@ -64,19 +64,24 @@ fun Trackpad(
|
|||||||
fingers = active.size
|
fingers = active.size
|
||||||
|
|
||||||
when {
|
when {
|
||||||
active.size >= 2 -> {
|
// Three fingers = hold for menu; two = scroll. Kept
|
||||||
if (twoStart == 0L) twoStart = System.currentTimeMillis()
|
// on separate counts so a long two-finger scroll can
|
||||||
if (!holdFired && System.currentTimeMillis() - twoStart > 500) {
|
// never fire the menu mid-gesture.
|
||||||
|
active.size >= 3 -> {
|
||||||
|
if (threeStart == 0L) threeStart = System.currentTimeMillis()
|
||||||
|
if (!holdFired && System.currentTimeMillis() - threeStart > 500) {
|
||||||
holdFired = true
|
holdFired = true
|
||||||
onTwoFingerHold()
|
onThreeFingerHold()
|
||||||
}
|
}
|
||||||
if (!holdFired) {
|
ev.changes.forEach { it.consume() }
|
||||||
val dy = active.map { it.positionChange().y }.average().toFloat()
|
}
|
||||||
scrollAcc += dy
|
active.size == 2 -> {
|
||||||
if (kotlin.math.abs(scrollAcc) > 12f) {
|
threeStart = 0L
|
||||||
onScroll(if (scrollAcc > 0) 1 else -1)
|
val dy = active.map { it.positionChange().y }.average().toFloat()
|
||||||
scrollAcc = 0f
|
scrollAcc += dy
|
||||||
}
|
if (kotlin.math.abs(scrollAcc) > 12f) {
|
||||||
|
onScroll(if (scrollAcc > 0) 1 else -1)
|
||||||
|
scrollAcc = 0f
|
||||||
}
|
}
|
||||||
ev.changes.forEach { it.consume() }
|
ev.changes.forEach { it.consume() }
|
||||||
}
|
}
|
||||||
@ -99,7 +104,11 @@ fun Trackpad(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (fingers >= 2) "hold for menu" else "",
|
text = when {
|
||||||
|
fingers >= 3 -> "hold for menu"
|
||||||
|
fingers == 2 -> "scroll"
|
||||||
|
else -> ""
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = muted.copy(alpha = 0.4f),
|
color = muted.copy(alpha = 0.4f),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,294 @@
|
|||||||
|
package com.archipelago.app.ui.components
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
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.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
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.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.archipelago.app.R
|
||||||
|
import com.archipelago.app.ui.screens.GlassButton
|
||||||
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.BinaryBitmap
|
||||||
|
import com.google.zxing.DecodeHintType
|
||||||
|
import com.google.zxing.MultiFormatReader
|
||||||
|
import com.google.zxing.NotFoundException
|
||||||
|
import com.google.zxing.RGBLuminanceSource
|
||||||
|
import com.google.zxing.common.HybridBinarizer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native replacement for the web wallet's scan pane — same visual design as
|
||||||
|
* neode-ui's WalletScanModal (dark glass card, square preview, orange
|
||||||
|
* viewfinder, status strip) but the camera and decoding run natively, so the
|
||||||
|
* preview doesn't lag the way getUserMedia does inside a WebView.
|
||||||
|
*
|
||||||
|
* Decoded text is handed back to the page ([onDecoded]) which does all the
|
||||||
|
* detection/spend logic; the page in turn streams status lines (animated-QR
|
||||||
|
* progress, "not recognised" errors) back in via [status] and closes the
|
||||||
|
* modal through the JS bridge once it accepts a code.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WalletQrScannerModal(
|
||||||
|
visible: Boolean,
|
||||||
|
status: Pair<String, Boolean>?, // message from the web page + isError
|
||||||
|
onDecoded: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var hasPermission by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted -> hasPermission = granted }
|
||||||
|
|
||||||
|
// Local error from a failed image upload; a fresh web status replaces it.
|
||||||
|
var uploadError by remember { mutableStateOf<String?>(null) }
|
||||||
|
val noQrMessage = stringResource(R.string.no_qr_in_image)
|
||||||
|
val imagePicker = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.GetContent()
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
val decoded = decodeQrFromUri(context, uri)
|
||||||
|
if (decoded != null) {
|
||||||
|
uploadError = null
|
||||||
|
onDecoded(decoded)
|
||||||
|
} else {
|
||||||
|
uploadError = noQrMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(visible) {
|
||||||
|
if (visible) {
|
||||||
|
uploadError = null
|
||||||
|
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
hasPermission = granted
|
||||||
|
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(status) { if (status != null) uploadError = null }
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||||
|
BackHandler { onDismiss() }
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.6f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onDismiss,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.widthIn(max = 420.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(Color(0xF212151C))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = {}, // swallow — only the scrim dismisses
|
||||||
|
)
|
||||||
|
.padding(24.dp),
|
||||||
|
) {
|
||||||
|
// Header — mirrors the web modal's title row
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scan_to_send),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
stringResource(R.string.close),
|
||||||
|
tint = Color.White.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Square camera preview with the orange viewfinder
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (hasPermission) {
|
||||||
|
// Throttle repeat frames: a static QR decodes ~20x/s but
|
||||||
|
// the page only needs one; animated QRs still stream
|
||||||
|
// because each frame's text differs.
|
||||||
|
var lastText by remember { mutableStateOf("") }
|
||||||
|
var lastSentAt by remember { mutableStateOf(0L) }
|
||||||
|
CameraQrPreview(onDecoded = { text ->
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (text != lastText || now - lastSentAt > 250) {
|
||||||
|
lastText = text
|
||||||
|
lastSentAt = now
|
||||||
|
onDecoded(text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(0.62f)
|
||||||
|
.border(
|
||||||
|
2.dp,
|
||||||
|
BitcoinOrange.copy(alpha = 0.85f),
|
||||||
|
RoundedCornerShape(16.dp),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.camera_permission_needed),
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
GlassButton(
|
||||||
|
text = stringResource(R.string.grant_camera_access),
|
||||||
|
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Status strip — same slot the web modal uses for hints/errors
|
||||||
|
val message = uploadError ?: status?.first
|
||||||
|
val isError = uploadError != null || status?.second == true
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.White.copy(alpha = 0.05f))
|
||||||
|
.padding(12.dp)
|
||||||
|
.defaultMinSize(minHeight = 24.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message?.takeIf { it.isNotBlank() }
|
||||||
|
?: stringResource(R.string.scan_wallet_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
GlassButton(
|
||||||
|
text = stringResource(R.string.upload_qr_image),
|
||||||
|
onClick = { imagePicker.launch("image/*") },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
|
||||||
|
private fun decodeQrFromUri(context: Context, uri: Uri): String? {
|
||||||
|
return try {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||||
|
var sample = 1
|
||||||
|
val maxDim = maxOf(bounds.outWidth, bounds.outHeight)
|
||||||
|
while (maxDim / (sample * 2) >= 1600) sample *= 2
|
||||||
|
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||||
|
val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) }
|
||||||
|
?: return null
|
||||||
|
val pixels = IntArray(bmp.width * bmp.height)
|
||||||
|
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
|
||||||
|
val source = RGBLuminanceSource(bmp.width, bmp.height, pixels)
|
||||||
|
val reader = MultiFormatReader().apply {
|
||||||
|
setHints(
|
||||||
|
mapOf(
|
||||||
|
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||||
|
DecodeHintType.TRY_HARDER to true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
|
||||||
|
} catch (_: NotFoundException) {
|
||||||
|
// Light-on-dark QRs (dark-themed wallets) decode inverted.
|
||||||
|
reader.reset()
|
||||||
|
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -172,7 +172,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||||
onClick = { ws.sendClick(it) },
|
onClick = { ws.sendClick(it) },
|
||||||
onScroll = { ws.sendScroll(it) },
|
onScroll = { ws.sendScroll(it) },
|
||||||
onTwoFingerHold = { showModal = true },
|
onThreeFingerHold = { showModal = true },
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -53,11 +53,14 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -68,13 +71,21 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import android.webkit.ValueCallback
|
||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
|
import com.archipelago.app.data.ServerPreferences
|
||||||
|
import com.archipelago.app.ui.components.GestureHintOverlay
|
||||||
|
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||||
@ -176,6 +187,39 @@ fun WebViewScreen(
|
|||||||
// while this is shown, so closing it returns instantly with no reload.
|
// while this is shown, so closing it returns instantly with no reload.
|
||||||
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
|
||||||
|
// bridge; status lines stream back from the page while it's up.
|
||||||
|
var walletScannerVisible by remember { mutableStateOf(false) }
|
||||||
|
var walletScannerStatus by remember { mutableStateOf<Pair<String, Boolean>?>(null) }
|
||||||
|
|
||||||
|
// One-time three-finger-hold teaching overlay (initial=true: never flash
|
||||||
|
// it while DataStore is still loading).
|
||||||
|
val prefs = remember { ServerPreferences(webViewContext) }
|
||||||
|
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
|
||||||
|
var gestureHintDismissed by remember { mutableStateOf(false) }
|
||||||
|
// Don't teach the gesture on top of the login/splash — arm the overlay
|
||||||
|
// ~2 minutes after the kiosk first finishes loading, once the user has
|
||||||
|
// settled in.
|
||||||
|
var gestureHintReady by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { isLoading }.first { !it }
|
||||||
|
delay(120_000)
|
||||||
|
gestureHintReady = true
|
||||||
|
}
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// <input type="file"> support — without a chooser implementation the
|
||||||
|
// WebView silently ignores file inputs (broke the wallet's upload path).
|
||||||
|
var pendingFileChooser by remember { mutableStateOf<ValueCallback<Array<android.net.Uri>>?>(null) }
|
||||||
|
val fileChooserLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
pendingFileChooser?.onReceiveValue(
|
||||||
|
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data),
|
||||||
|
)
|
||||||
|
pendingFileChooser = null
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
||||||
webView?.goBack()
|
webView?.goBack()
|
||||||
}
|
}
|
||||||
@ -301,6 +345,35 @@ fun WebViewScreen(
|
|||||||
"ArchipelagoNative",
|
"ArchipelagoNative",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Wallet QR bridge. The web scan modal calls:
|
||||||
|
// window.ArchipelagoQr.open() — show the native scanner
|
||||||
|
// window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines
|
||||||
|
// window.ArchipelagoQr.close() — code accepted, tear down
|
||||||
|
// Decodes flow back through window.__archyQrResult(text);
|
||||||
|
// a user cancel calls window.__archyQrCancelled().
|
||||||
|
addJavascriptInterface(
|
||||||
|
object {
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun open() {
|
||||||
|
webViewRef.post {
|
||||||
|
walletScannerStatus = null
|
||||||
|
walletScannerVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun setStatus(message: String, isError: Boolean) {
|
||||||
|
webViewRef.post { walletScannerStatus = message to isError }
|
||||||
|
}
|
||||||
|
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun close() {
|
||||||
|
webViewRef.post { walletScannerVisible = false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ArchipelagoQr",
|
||||||
|
)
|
||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@ -412,6 +485,27 @@ fun WebViewScreen(
|
|||||||
loadProgress = newProgress
|
loadProgress = newProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onShowFileChooser(
|
||||||
|
view: WebView?,
|
||||||
|
filePathCallback: ValueCallback<Array<android.net.Uri>>?,
|
||||||
|
fileChooserParams: FileChooserParams?,
|
||||||
|
): Boolean {
|
||||||
|
pendingFileChooser?.onReceiveValue(null)
|
||||||
|
pendingFileChooser = filePathCallback
|
||||||
|
val intent = fileChooserParams?.createIntent()
|
||||||
|
if (intent == null) {
|
||||||
|
pendingFileChooser = null
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
fileChooserLauncher.launch(intent)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
pendingFileChooser = null
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wallet QR scanner: grant the page camera access.
|
// Wallet QR scanner: grant the page camera access.
|
||||||
// Only video capture is granted — anything else the
|
// Only video capture is granted — anything else the
|
||||||
// page asks for is denied as before.
|
// page asks for is denied as before.
|
||||||
@ -467,22 +561,24 @@ fun WebViewScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-finger hold (500ms) → navigate to remote input
|
// Three-finger hold (500ms) → navigate to remote input.
|
||||||
var twoFingerStart = 0L
|
// Three fingers, not two: two-finger scroll/pinch on the
|
||||||
var twoFingerFired = false
|
// page collided with the old two-finger hold.
|
||||||
|
var threeFingerStart = 0L
|
||||||
|
var threeFingerFired = false
|
||||||
setOnTouchListener { _, event ->
|
setOnTouchListener { _, event ->
|
||||||
val pointerCount = event.pointerCount
|
val pointerCount = event.pointerCount
|
||||||
when (event.actionMasked) {
|
when (event.actionMasked) {
|
||||||
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
|
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
|
||||||
if (pointerCount >= 2) {
|
if (pointerCount >= 3) {
|
||||||
twoFingerStart = System.currentTimeMillis()
|
threeFingerStart = System.currentTimeMillis()
|
||||||
twoFingerFired = false
|
threeFingerFired = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
android.view.MotionEvent.ACTION_MOVE -> {
|
android.view.MotionEvent.ACTION_MOVE -> {
|
||||||
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) {
|
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
|
||||||
if (System.currentTimeMillis() - twoFingerStart > 500) {
|
if (System.currentTimeMillis() - threeFingerStart > 500) {
|
||||||
twoFingerFired = true
|
threeFingerFired = true
|
||||||
onRemoteInput()
|
onRemoteInput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -490,8 +586,8 @@ fun WebViewScreen(
|
|||||||
android.view.MotionEvent.ACTION_UP,
|
android.view.MotionEvent.ACTION_UP,
|
||||||
android.view.MotionEvent.ACTION_POINTER_UP,
|
android.view.MotionEvent.ACTION_POINTER_UP,
|
||||||
android.view.MotionEvent.ACTION_CANCEL -> {
|
android.view.MotionEvent.ACTION_CANCEL -> {
|
||||||
if (event.pointerCount <= 2) {
|
if (event.pointerCount <= 3) {
|
||||||
twoFingerStart = 0L
|
threeFingerStart = 0L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -527,6 +623,38 @@ fun WebViewScreen(
|
|||||||
onClose = { inAppUrl = null },
|
onClose = { inAppUrl = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native wallet QR scanner, opened by the page via ArchipelagoQr.
|
||||||
|
WalletQrScannerModal(
|
||||||
|
visible = walletScannerVisible,
|
||||||
|
status = walletScannerStatus,
|
||||||
|
onDecoded = { text ->
|
||||||
|
webView?.evaluateJavascript(
|
||||||
|
"window.__archyQrResult && window.__archyQrResult(${JSONObject.quote(text)})",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
walletScannerVisible = false
|
||||||
|
webView?.evaluateJavascript(
|
||||||
|
"window.__archyQrCancelled && window.__archyQrCancelled()",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// First-launch teaching overlay for the three-finger hold — armed
|
||||||
|
// ~2 minutes after login so it never fights the splash/first look.
|
||||||
|
if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed &&
|
||||||
|
!isLoading && inAppUrl == null
|
||||||
|
) {
|
||||||
|
GestureHintOverlay(
|
||||||
|
onDismiss = {
|
||||||
|
gestureHintDismissed = true
|
||||||
|
scope.launch { prefs.markGestureHintSeen() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,4 +41,11 @@
|
|||||||
<string name="edit_server_title">Edit Server</string>
|
<string name="edit_server_title">Edit Server</string>
|
||||||
<string name="save_changes">Save Changes</string>
|
<string name="save_changes">Save Changes</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
|
<string name="gesture_hint_title">Hold with three fingers</string>
|
||||||
|
<string name="gesture_hint_body">Anywhere in the app — opens the remote control and menu</string>
|
||||||
|
<string name="gesture_hint_got_it">Got it</string>
|
||||||
|
<string name="scan_to_send">Scan to send</string>
|
||||||
|
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
|
||||||
|
<string name="upload_qr_image">Upload image</string>
|
||||||
|
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -50,7 +50,9 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
|
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
|
||||||
<button v-if="!liveCameraUnavailable" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
|
||||||
|
the native bridge still provides a live camera -->
|
||||||
|
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||||
Scan with camera
|
Scan with camera
|
||||||
</button>
|
</button>
|
||||||
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||||
@ -271,6 +273,23 @@ type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
|
|||||||
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
|
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
|
||||||
type Pane = 'scan' | 'amount' | 'success'
|
type Pane = 'scan' | 'amount' | 'success'
|
||||||
|
|
||||||
|
// JS bridge the Android companion injects: when present, live scanning is
|
||||||
|
// delegated to a native camera modal (styled like this one) — the WebView's
|
||||||
|
// getUserMedia preview lags, and over plain http it doesn't exist at all.
|
||||||
|
// Decodes come back through the window.__archyQr* callbacks; status lines
|
||||||
|
// (animated-QR progress, errors) mirror out to the native modal's strip.
|
||||||
|
interface ArchipelagoQrBridge {
|
||||||
|
open(): void
|
||||||
|
setStatus(message: string, isError: boolean): void
|
||||||
|
close(): void
|
||||||
|
}
|
||||||
|
interface NativeWindow extends Window {
|
||||||
|
ArchipelagoQr?: ArchipelagoQrBridge
|
||||||
|
__archyQrResult?: (text: string) => void
|
||||||
|
__archyQrCancelled?: () => void
|
||||||
|
}
|
||||||
|
const nativeWin = window as NativeWindow
|
||||||
|
|
||||||
const PRESETS = [21, 2100, 21000, 100000]
|
const PRESETS = [21, 2100, 21000, 100000]
|
||||||
|
|
||||||
const props = defineProps<{ show: boolean }>()
|
const props = defineProps<{ show: boolean }>()
|
||||||
@ -322,9 +341,24 @@ const autoStarting = ref(false)
|
|||||||
const scanChoice = ref<'unset' | 'camera'>('unset')
|
const scanChoice = ref<'unset' | 'camera'>('unset')
|
||||||
|
|
||||||
function chooseCamera() {
|
function chooseCamera() {
|
||||||
|
if (startNativeScan()) return
|
||||||
scanChoice.value = 'camera'
|
scanChoice.value = 'camera'
|
||||||
void nextTick(() => startScanning())
|
void nextTick(() => startScanning())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Native scanner (companion app) ---
|
||||||
|
const hasNativeQr = !!nativeWin.ArchipelagoQr
|
||||||
|
const nativeScanActive = ref(false)
|
||||||
|
|
||||||
|
function startNativeScan(): boolean {
|
||||||
|
const bridge = nativeWin.ArchipelagoQr
|
||||||
|
if (!bridge) return false
|
||||||
|
nativeWin.__archyQrResult = (text: string) => handleScanned(text)
|
||||||
|
nativeWin.__archyQrCancelled = () => { nativeScanActive.value = false }
|
||||||
|
nativeScanActive.value = true
|
||||||
|
bridge.open()
|
||||||
|
return true
|
||||||
|
}
|
||||||
const scanStatus = ref('')
|
const scanStatus = ref('')
|
||||||
const scanStatusIsError = ref(false)
|
const scanStatusIsError = ref(false)
|
||||||
const cameraError = ref(false)
|
const cameraError = ref(false)
|
||||||
@ -374,8 +408,20 @@ function stopScanning() {
|
|||||||
qrScanner.value?.destroy()
|
qrScanner.value?.destroy()
|
||||||
qrScanner.value = null
|
qrScanner.value = null
|
||||||
isScanning.value = false
|
isScanning.value = false
|
||||||
|
if (nativeScanActive.value) {
|
||||||
|
nativeScanActive.value = false
|
||||||
|
nativeWin.ArchipelagoQr?.close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirror status lines onto the native modal while it's up — it covers the
|
||||||
|
// page, so this strip is the only feedback the user can see.
|
||||||
|
watch([scanStatus, scanStatusIsError], () => {
|
||||||
|
if (nativeScanActive.value) {
|
||||||
|
nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function submitPaste() {
|
function submitPaste() {
|
||||||
const text = pasteInput.value.trim()
|
const text = pasteInput.value.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user