// D10 — Codex restore "choose" surface + camera scan path. // // Drop-in composables for ui/codex/CodexRestoreScreen.kt. Self-contained: depends // only on what already exists in the module plus the camera additions drafted in // CodexViewModel.camera.kt (CameraScanState, chooseRestore/startCameraScan/ // cameraTimedOut/onCameraFrame on CodexViewModel) and CodexAztecFrame (decode). // // Package is the real screen's package so these merge in with no import churn. // Required NEW imports for CodexRestoreScreen.kt are listed in INTEGRATION.md. // // CameraX deps must be added to app/build.gradle (see INTEGRATION.md). Until then // this file will not compile in isolation — that is expected; the orchestrator // adds the deps and the camera VM members in the same pass. package com.peachpasswords.ui.codex import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.PhotoCamera import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner import com.peachpasswords.ui.components.PeachEmptyOrEndState import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType import java.util.concurrent.Executors /** * The two-card "choose" surface shown before any restore work begins. Replaces * the old straight-to-PDF-picker behaviour: the user explicitly picks scanning * with the camera or importing the previously-saved PDF. * * Glass-panel cards mirror the OnboardingScreen / SurfaceElevated style. */ @Composable internal fun CodexRestoreChoose( accent: Color, onScanCamera: () -> Unit, onImportPdf: () -> Unit, modifier: Modifier = Modifier, ) { Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(PeachSpacing.md)) { ChooseCard( icon = Icons.Outlined.PhotoCamera, iconTint = accent, title = "Scan with camera", body = "Point your camera at the printed Aztec grid", onClick = onScanCamera, ) ChooseCard( icon = Icons.Outlined.Description, iconTint = peachColors.T2, title = "Import PDF", body = "Import the PDF you saved when generating your Codex", onClick = onImportPdf, ) Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { PeachEmptyOrEndState(message = "Choose a source to begin.") } } } @Composable private fun ChooseCard( icon: androidx.compose.ui.graphics.vector.ImageVector, iconTint: Color, title: String, body: String, onClick: () -> Unit, ) { Row( Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(peachColors.SurfaceElevated) .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp)) .clickable(onClick = onClick) .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(PeachSpacing.lg), ) { Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.height(24.dp)) Column(Modifier.fillMaxWidth()) { Text( title, color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp, ) Spacer(Modifier.height(PeachSpacing.xs)) Text(body, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp) } } } /** * Camera capture surface. Requests CAMERA at first composition; on denial shows a * settings-deep-link error card. On grant, binds a CameraX preview + an * ImageAnalysis analyzer that hands each frame to [onFrame] (the VM decodes one * Aztec, accumulates it, and reports back whether a full payload reconstructed). * * A 10s no-detection timer flips to a retry overlay; [onTimeout] lets the VM * record the timeout, and [onRetry] restarts scanning (resets the timer). * * @param onFrame analyze one frame; returns true once decoding is complete so the * analyzer stops. The implementation MUST close the ImageProxy. */ @Composable internal fun CodexCameraScan( accent: Color, timedOut: Boolean, codesFound: Int, onFrame: (androidx.camera.core.ImageProxy) -> Boolean, onTimeout: () -> Unit, onRetry: () -> Unit, ) { val context = LocalContext.current var granted by remember { mutableStateOf( ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED, ) } val permLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted = it } LaunchedEffect(Unit) { if (!granted) permLauncher.launch(Manifest.permission.CAMERA) } if (!granted) { CameraDeniedCard(accent) return } Column(Modifier.fillMaxWidth()) { Box( Modifier .fillMaxWidth() .aspectRatio(4f / 3f) .clip(RoundedCornerShape(8.dp)), ) { CameraPreview(onFrame = onFrame) CornerGuides(accent) Text( if (codesFound == 0) "scanning…" else "$codesFound code${if (codesFound == 1) "" else "s"} read", color = accent, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 10.dp), ) if (timedOut) NoDetectionOverlay(accent, onRetry) } } // Single 10s window per scan attempt; retry re-keys this effect via codesFound // reset + the parent re-entering the Scanning state. Cancel if a code arrives. LaunchedEffect(timedOut) { if (!timedOut) { kotlinx.coroutines.delay(10_000) onTimeout() } } } @Composable private fun CameraPreview(onFrame: (androidx.camera.core.ImageProxy) -> Boolean) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val analysisExecutor = remember { Executors.newSingleThreadExecutor() } DisposableEffect(Unit) { onDispose { analysisExecutor.shutdown() } } AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> val previewView = PreviewView(ctx).apply { scaleType = PreviewView.ScaleType.FILL_CENTER } val providerFuture = ProcessCameraProvider.getInstance(ctx) providerFuture.addListener({ val provider = providerFuture.get() val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } val analysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() analysis.setAnalyzer(analysisExecutor) { proxy -> try { onFrame(proxy) } finally { proxy.close() } } try { provider.unbindAll() provider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis, ) } catch (_: Exception) { // bind can fail if the lifecycle is already stopped; the // preview simply stays blank — the timeout overlay covers it. } }, ContextCompat.getMainExecutor(ctx)) previewView }, ) } /** Accent corner guides: 20dp arms, 2dp stroke, at each corner of the frame. */ @Composable private fun CornerGuides(accent: Color) { Box( Modifier .fillMaxSize() .padding(PeachSpacing.sm) .drawBehind { val arm = 20.dp.toPx() val w = 2.dp.toPx() fun corner(ox: Float, oy: Float, dx: Float, dy: Float) { drawLine(accent, Offset(ox, oy), Offset(ox + dx * arm, oy), w, StrokeCap.Round) drawLine(accent, Offset(ox, oy), Offset(ox, oy + dy * arm), w, StrokeCap.Round) } corner(0f, 0f, 1f, 1f) corner(size.width, 0f, -1f, 1f) corner(0f, size.height, 1f, -1f) corner(size.width, size.height, -1f, -1f) }, ) } @Composable private fun NoDetectionOverlay(accent: Color, onRetry: () -> Unit) { Column( Modifier .fillMaxSize() .background(Color(0xCC0A0703)) .padding(PeachSpacing.xl), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( "No Aztec codes detected — try better lighting", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 11.sp, ) Spacer(Modifier.height(PeachSpacing.lg)) Box( Modifier .clip(RoundedCornerShape(8.dp)) .border(BorderStroke(1.dp, accent), RoundedCornerShape(8.dp)) .clickable(onClick = onRetry) .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm), ) { Text("Try again", color = accent, fontFamily = PeachType.Mono, fontSize = 11.sp) } } } @Composable private fun CameraDeniedCard(accent: Color) { val context = LocalContext.current PeachEmptyOrEndState( message = "Camera access needed", detail = "Grant camera permission in Settings to scan your Codex.", action = { CodexButton("Open Settings", accent, filled = true) { val intent = Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null), ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } }, ) }