123456789101112131415package 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.Executors757677787980818283@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}144145146147148149150151152153154@param155156157@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 209 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 260 261 }262 }, ContextCompat.getMainExecutor(ctx))263 previewView264 },265 )266}267268269@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}