CodexRestoreChooseAndCamera.kt

12.5 KB · raw

1// D10 — Codex restore "choose" surface + camera scan path.2//3// Drop-in composables for ui/codex/CodexRestoreScreen.kt. Self-contained: depends4// only on what already exists in the module plus the camera additions drafted in5// CodexViewModel.camera.kt (CameraScanState, chooseRestore/startCameraScan/6// cameraTimedOut/onCameraFrame on CodexViewModel) and CodexAztecFrame (decode).7//8// Package is the real screen's package so these merge in with no import churn.9// Required NEW imports for CodexRestoreScreen.kt are listed in INTEGRATION.md.10//11// CameraX deps must be added to app/build.gradle (see INTEGRATION.md). Until then12// this file will not compile in isolation — that is expected; the orchestrator13// adds the deps and the camera VM members in the same pass.1415package com.peachpasswords.ui.codex1617import android.Manifest18import android.content.Intent19import android.content.pm.PackageManager20import android.net.Uri21import android.provider.Settings22import androidx.activity.compose.rememberLauncherForActivityResult23import androidx.activity.result.contract.ActivityResultContracts24import androidx.camera.core.CameraSelector25import androidx.camera.core.ImageAnalysis26import androidx.camera.core.Preview27import androidx.camera.lifecycle.ProcessCameraProvider28import androidx.camera.view.PreviewView29import androidx.compose.foundation.BorderStroke30import androidx.compose.foundation.background31import androidx.compose.foundation.border32import androidx.compose.foundation.clickable33import androidx.compose.foundation.layout.Arrangement34import androidx.compose.foundation.layout.Box35import androidx.compose.foundation.layout.Column36import androidx.compose.foundation.layout.Row37import androidx.compose.foundation.layout.Spacer38import androidx.compose.foundation.layout.aspectRatio39import androidx.compose.foundation.layout.fillMaxSize40import androidx.compose.foundation.layout.fillMaxWidth41import androidx.compose.foundation.layout.height42import androidx.compose.foundation.layout.padding43import androidx.compose.foundation.shape.RoundedCornerShape44import androidx.compose.material.icons.Icons45import androidx.compose.material.icons.outlined.Description46import androidx.compose.material.icons.outlined.PhotoCamera47import androidx.compose.material3.Icon48import androidx.compose.material3.Text49import androidx.compose.runtime.Composable50import androidx.compose.runtime.DisposableEffect51import androidx.compose.runtime.LaunchedEffect52import androidx.compose.runtime.getValue53import androidx.compose.runtime.mutableStateOf54import androidx.compose.runtime.remember55import androidx.compose.runtime.setValue56import androidx.compose.ui.Alignment57import androidx.compose.ui.Modifier58import androidx.compose.ui.draw.clip59import androidx.compose.ui.draw.drawBehind60import androidx.compose.ui.geometry.Offset61import androidx.compose.ui.graphics.Color62import androidx.compose.ui.graphics.StrokeCap63import androidx.compose.ui.platform.LocalContext64import androidx.compose.ui.text.font.FontStyle65import androidx.compose.ui.unit.dp66import androidx.compose.ui.unit.sp67import androidx.compose.ui.viewinterop.AndroidView68import androidx.core.content.ContextCompat69import androidx.lifecycle.compose.LocalLifecycleOwner70import com.peachpasswords.ui.components.PeachEmptyOrEndState71import com.peachpasswords.ui.theme.PeachSpacing72import com.peachpasswords.ui.theme.peachColors73import com.peachpasswords.ui.theme.PeachType74import java.util.concurrent.Executors7576/**77 * The two-card "choose" surface shown before any restore work begins. Replaces78 * the old straight-to-PDF-picker behaviour: the user explicitly picks scanning79 * with the camera or importing the previously-saved PDF.80 *81 * Glass-panel cards mirror the OnboardingScreen / SurfaceElevated style.82 */83@Composable84internal fun CodexRestoreChoose(85    accent: Color,86    onScanCamera: () -> Unit,87    onImportPdf: () -> Unit,88    modifier: Modifier = Modifier,89) {90    Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(PeachSpacing.md)) {91        ChooseCard(92            icon = Icons.Outlined.PhotoCamera,93            iconTint = accent,94            title = "Scan with camera",95            body = "Point your camera at the printed Aztec grid",96            onClick = onScanCamera,97        )98        ChooseCard(99            icon = Icons.Outlined.Description,100            iconTint = peachColors.T2,101            title = "Import PDF",102            body = "Import the PDF you saved when generating your Codex",103            onClick = onImportPdf,104        )105        Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {106            PeachEmptyOrEndState(message = "Choose a source to begin.")107        }108    }109}110111@Composable112private fun ChooseCard(113    icon: androidx.compose.ui.graphics.vector.ImageVector,114    iconTint: Color,115    title: String,116    body: String,117    onClick: () -> Unit,118) {119    Row(120        Modifier121            .fillMaxWidth()122            .clip(RoundedCornerShape(18.dp))123            .background(peachColors.SurfaceElevated)124            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))125            .clickable(onClick = onClick)126            .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),127        verticalAlignment = Alignment.CenterVertically,128        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.lg),129    ) {130        Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.height(24.dp))131        Column(Modifier.fillMaxWidth()) {132            Text(133                title,134                color = peachColors.T0,135                fontFamily = PeachType.Display,136                fontStyle = FontStyle.Italic,137                fontSize = 19.sp,138            )139            Spacer(Modifier.height(PeachSpacing.xs))140            Text(body, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)141        }142    }143}144145/**146 * Camera capture surface. Requests CAMERA at first composition; on denial shows a147 * settings-deep-link error card. On grant, binds a CameraX preview + an148 * ImageAnalysis analyzer that hands each frame to [onFrame] (the VM decodes one149 * Aztec, accumulates it, and reports back whether a full payload reconstructed).150 *151 * A 10s no-detection timer flips to a retry overlay; [onTimeout] lets the VM152 * record the timeout, and [onRetry] restarts scanning (resets the timer).153 *154 * @param onFrame analyze one frame; returns true once decoding is complete so the155 *   analyzer stops. The implementation MUST close the ImageProxy.156 */157@Composable158internal fun CodexCameraScan(159    accent: Color,160    timedOut: Boolean,161    codesFound: Int,162    onFrame: (androidx.camera.core.ImageProxy) -> Boolean,163    onTimeout: () -> Unit,164    onRetry: () -> Unit,165) {166    val context = LocalContext.current167    var granted by remember {168        mutableStateOf(169            ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==170                PackageManager.PERMISSION_GRANTED,171        )172    }173    val permLauncher = rememberLauncherForActivityResult(174        ActivityResultContracts.RequestPermission(),175    ) { granted = it }176177    LaunchedEffect(Unit) {178        if (!granted) permLauncher.launch(Manifest.permission.CAMERA)179    }180181    if (!granted) {182        CameraDeniedCard(accent)183        return184    }185186    Column(Modifier.fillMaxWidth()) {187        Box(188            Modifier189                .fillMaxWidth()190                .aspectRatio(4f / 3f)191                .clip(RoundedCornerShape(8.dp)),192        ) {193            CameraPreview(onFrame = onFrame)194            CornerGuides(accent)195            Text(196                if (codesFound == 0) "scanning…" else "$codesFound code${if (codesFound == 1) "" else "s"} read",197                color = accent,198                fontFamily = PeachType.Mono,199                fontSize = 10.sp,200                modifier = Modifier201                    .align(Alignment.BottomCenter)202                    .padding(bottom = 10.dp),203            )204            if (timedOut) NoDetectionOverlay(accent, onRetry)205        }206    }207208    // Single 10s window per scan attempt; retry re-keys this effect via codesFound209    // reset + the parent re-entering the Scanning state. Cancel if a code arrives.210    LaunchedEffect(timedOut) {211        if (!timedOut) {212            kotlinx.coroutines.delay(10_000)213            onTimeout()214        }215    }216}217218@Composable219private fun CameraPreview(onFrame: (androidx.camera.core.ImageProxy) -> Boolean) {220    val context = LocalContext.current221    val lifecycleOwner = LocalLifecycleOwner.current222    val analysisExecutor = remember { Executors.newSingleThreadExecutor() }223224    DisposableEffect(Unit) {225        onDispose { analysisExecutor.shutdown() }226    }227228    AndroidView(229        modifier = Modifier.fillMaxSize(),230        factory = { ctx ->231            val previewView = PreviewView(ctx).apply {232                scaleType = PreviewView.ScaleType.FILL_CENTER233            }234            val providerFuture = ProcessCameraProvider.getInstance(ctx)235            providerFuture.addListener({236                val provider = providerFuture.get()237                val preview = Preview.Builder().build().also {238                    it.setSurfaceProvider(previewView.surfaceProvider)239                }240                val analysis = ImageAnalysis.Builder()241                    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)242                    .build()243                analysis.setAnalyzer(analysisExecutor) { proxy ->244                    try {245                        onFrame(proxy)246                    } finally {247                        proxy.close()248                    }249                }250                try {251                    provider.unbindAll()252                    provider.bindToLifecycle(253                        lifecycleOwner,254                        CameraSelector.DEFAULT_BACK_CAMERA,255                        preview,256                        analysis,257                    )258                } catch (_: Exception) {259                    // bind can fail if the lifecycle is already stopped; the260                    // preview simply stays blank — the timeout overlay covers it.261                }262            }, ContextCompat.getMainExecutor(ctx))263            previewView264        },265    )266}267268/** Accent corner guides: 20dp arms, 2dp stroke, at each corner of the frame. */269@Composable270private fun CornerGuides(accent: Color) {271    Box(272        Modifier273            .fillMaxSize()274            .padding(PeachSpacing.sm)275            .drawBehind {276                val arm = 20.dp.toPx()277                val w = 2.dp.toPx()278                fun corner(ox: Float, oy: Float, dx: Float, dy: Float) {279                    drawLine(accent, Offset(ox, oy), Offset(ox + dx * arm, oy), w, StrokeCap.Round)280                    drawLine(accent, Offset(ox, oy), Offset(ox, oy + dy * arm), w, StrokeCap.Round)281                }282                corner(0f, 0f, 1f, 1f)283                corner(size.width, 0f, -1f, 1f)284                corner(0f, size.height, 1f, -1f)285                corner(size.width, size.height, -1f, -1f)286            },287    )288}289290@Composable291private fun NoDetectionOverlay(accent: Color, onRetry: () -> Unit) {292    Column(293        Modifier294            .fillMaxSize()295            .background(Color(0xCC0A0703))296            .padding(PeachSpacing.xl),297        verticalArrangement = Arrangement.Center,298        horizontalAlignment = Alignment.CenterHorizontally,299    ) {300        Text(301            "No Aztec codes detected — try better lighting",302            color = peachColors.T1,303            fontFamily = PeachType.Mono,304            fontSize = 11.sp,305        )306        Spacer(Modifier.height(PeachSpacing.lg))307        Box(308            Modifier309                .clip(RoundedCornerShape(8.dp))310                .border(BorderStroke(1.dp, accent), RoundedCornerShape(8.dp))311                .clickable(onClick = onRetry)312                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm),313        ) {314            Text("Try again", color = accent, fontFamily = PeachType.Mono, fontSize = 11.sp)315        }316    }317}318319@Composable320private fun CameraDeniedCard(accent: Color) {321    val context = LocalContext.current322    PeachEmptyOrEndState(323        message = "Camera access needed",324        detail = "Grant camera permission in Settings to scan your Codex.",325        action = {326            CodexButton("Open Settings", accent, filled = true) {327                val intent = Intent(328                    Settings.ACTION_APPLICATION_DETAILS_SETTINGS,329                    Uri.fromParts("package", context.packageName, null),330                ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)331                context.startActivity(intent)332            }333        },334    )335}