From d4539efee618a3c1ed90b638ea3f074b34aa2d16 Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Fri, 13 Feb 2026 02:14:40 +0100 Subject: [PATCH] stabilized bounding boxes --- .../data/scanner/MlKitBarcodeAnalyzer.kt | 252 +++++++++------ .../scanner/ui/components/CameraPreview.kt | 6 +- .../clean/scanner/ui/screens/ScannerScreen.kt | 293 +++++++++++++----- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 393 insertions(+), 160 deletions(-) diff --git a/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt b/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt index 3badaa1..508a877 100644 --- a/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt +++ b/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt @@ -22,6 +22,11 @@ data class DetectionBox( val corners: List = emptyList() ) +private data class TrackedDetection( + val box: DetectionBox, + val missCount: Int +) + class MlKitBarcodeAnalyzer( private val isAnalysisEnabled: () -> Boolean = { true }, private val onDetectionStateChanged: ( @@ -31,8 +36,18 @@ class MlKitBarcodeAnalyzer( sourceWidth: Int, sourceHeight: Int ) -> Unit = { _, _, _, _, _ -> }, - private val onDetected: (ScanResult) -> Unit + private val onDetected: ( + result: ScanResult, + readableBox: DetectionBox?, + sourceWidth: Int, + sourceHeight: Int + ) -> Unit ) : ImageAnalysis.Analyzer, AutoCloseable { + private companion object { + const val MATCH_DISTANCE_THRESHOLD = 0.18f + const val BOX_SMOOTHING_ALPHA = 0.35f + const val MAX_MISSED_FRAMES = 2 + } private val scanner = BarcodeScanning.getClient( BarcodeScannerOptions.Builder() @@ -52,9 +67,15 @@ class MlKitBarcodeAnalyzer( private var lastSourceWidth = 0 @Volatile private var lastSourceHeight = 0 + private var trackedDetections: List = emptyList() + private var trackedSourceWidth = 0 + private var trackedSourceHeight = 0 override fun analyze(imageProxy: ImageProxy) { if (!isAnalysisEnabled()) { + trackedDetections = emptyList() + trackedSourceWidth = 0 + trackedSourceHeight = 0 publishDetectionState( hasPotential = false, hasReadable = false, @@ -87,44 +108,73 @@ class MlKitBarcodeAnalyzer( val bounds = barcode.boundingBox ?: return@mapNotNull null val normalized = normalizeBoundingBox( rect = bounds, - width = imageProxy.width, - height = imageProxy.height, - rotationDegrees = rotationDegrees + sourceWidth = sourceWidth, + sourceHeight = sourceHeight ) ?: return@mapNotNull null val normalizedCorners = barcode.cornerPoints?.map { p -> normalizePoint( x = p.x.toFloat(), y = p.y.toFloat(), - width = imageProxy.width, - height = imageProxy.height, - rotationDegrees = rotationDegrees + sourceWidth = sourceWidth, + sourceHeight = sourceHeight ) }?.filterNotNull() ?: emptyList() normalized.copy(corners = normalizedCorners) } + val stabilizedBoxes = stabilizeBoxes(boxes, sourceWidth, sourceHeight) publishDetectionState( - hasPotential = barcodes.isNotEmpty(), + hasPotential = stabilizedBoxes.isNotEmpty(), hasReadable = readable != null, - boxes = boxes, + boxes = stabilizedBoxes, sourceWidth = sourceWidth, sourceHeight = sourceHeight ) if (readable != null) { + val readableBox = readable.boundingBox?.let { bounds -> + val normalized = normalizeBoundingBox( + rect = bounds, + sourceWidth = sourceWidth, + sourceHeight = sourceHeight + ) + val normalizedCorners = readable.cornerPoints?.map { p -> + normalizePoint( + x = p.x.toFloat(), + y = p.y.toFloat(), + sourceWidth = sourceWidth, + sourceHeight = sourceHeight + ) + }?.filterNotNull() ?: emptyList() + normalized?.copy(corners = normalizedCorners) + } + val gatedReadableBox = readableBox?.let { rb -> + stabilizedBoxes.minByOrNull { distanceBetweenCenters(it, rb) } ?: rb + } onDetected( ScanResult( content = readable.rawValue.orEmpty(), type = readable.valueType.toHumanType() - ) + ), + gatedReadableBox, + sourceWidth, + sourceHeight ) } } .addOnFailureListener { + val sourceW = trackedSourceWidth + val sourceH = trackedSourceHeight + val stabilizedBoxes = if (sourceW > 0 && sourceH > 0) { + stabilizeBoxes(emptyList(), sourceW, sourceH) + } else { + trackedDetections = emptyList() + emptyList() + } publishDetectionState( - hasPotential = false, + hasPotential = stabilizedBoxes.isNotEmpty(), hasReadable = false, - boxes = emptyList(), - sourceWidth = 0, - sourceHeight = 0 + boxes = stabilizedBoxes, + sourceWidth = if (stabilizedBoxes.isNotEmpty()) sourceW else 0, + sourceHeight = if (stabilizedBoxes.isNotEmpty()) sourceH else 0 ) } .addOnCompleteListener { @@ -177,94 +227,120 @@ class MlKitBarcodeAnalyzer( private fun normalizeBoundingBox( rect: Rect, - width: Int, - height: Int, - rotationDegrees: Int + sourceWidth: Int, + sourceHeight: Int ): DetectionBox? { - if (width <= 0 || height <= 0) return null - - var left = rect.left.toFloat() - var top = rect.top.toFloat() - var right = rect.right.toFloat() - var bottom = rect.bottom.toFloat() - var outWidth = width.toFloat() - var outHeight = height.toFloat() - - when (rotationDegrees) { - 90 -> { - left = height - rect.bottom.toFloat() - top = rect.left.toFloat() - right = height - rect.top.toFloat() - bottom = rect.right.toFloat() - outWidth = height.toFloat() - outHeight = width.toFloat() - } - 180 -> { - left = width - rect.right.toFloat() - top = height - rect.bottom.toFloat() - right = width - rect.left.toFloat() - bottom = height - rect.top.toFloat() - outWidth = width.toFloat() - outHeight = height.toFloat() - } - 270 -> { - left = rect.top.toFloat() - top = width - rect.right.toFloat() - right = rect.bottom.toFloat() - bottom = width - rect.left.toFloat() - outWidth = height.toFloat() - outHeight = width.toFloat() - } - } - - if (outWidth <= 0f || outHeight <= 0f) return null + if (sourceWidth <= 0 || sourceHeight <= 0) return null + val outWidth = sourceWidth.toFloat() + val outHeight = sourceHeight.toFloat() return DetectionBox( - left = (left / outWidth).coerceIn(0f, 1f), - top = (top / outHeight).coerceIn(0f, 1f), - right = (right / outWidth).coerceIn(0f, 1f), - bottom = (bottom / outHeight).coerceIn(0f, 1f) + left = (rect.left.toFloat() / outWidth).coerceIn(0f, 1f), + top = (rect.top.toFloat() / outHeight).coerceIn(0f, 1f), + right = (rect.right.toFloat() / outWidth).coerceIn(0f, 1f), + bottom = (rect.bottom.toFloat() / outHeight).coerceIn(0f, 1f) ) } private fun normalizePoint( x: Float, y: Float, - width: Int, - height: Int, - rotationDegrees: Int + sourceWidth: Int, + sourceHeight: Int ): DetectionPoint? { - if (width <= 0 || height <= 0) return null + if (sourceWidth <= 0 || sourceHeight <= 0) return null + val outWidth = sourceWidth.toFloat() + val outHeight = sourceHeight.toFloat() + return DetectionPoint( + x = (x / outWidth).coerceIn(0f, 1f), + y = (y / outHeight).coerceIn(0f, 1f) + ) + } - var outX = x - var outY = y - var outWidth = width.toFloat() - var outHeight = height.toFloat() + private fun stabilizeBoxes( + rawBoxes: List, + sourceWidth: Int, + sourceHeight: Int + ): List { + if (sourceWidth <= 0 || sourceHeight <= 0) { + trackedDetections = emptyList() + trackedSourceWidth = 0 + trackedSourceHeight = 0 + return emptyList() + } - when (rotationDegrees) { - 90 -> { - outX = height - y - outY = x - outWidth = height.toFloat() - outHeight = width.toFloat() - } - 180 -> { - outX = width - x - outY = height - y - outWidth = width.toFloat() - outHeight = height.toFloat() - } - 270 -> { - outX = y - outY = width - x - outWidth = height.toFloat() - outHeight = width.toFloat() + if (trackedSourceWidth != sourceWidth || trackedSourceHeight != sourceHeight) { + trackedDetections = emptyList() + } + trackedSourceWidth = sourceWidth + trackedSourceHeight = sourceHeight + + val remaining = trackedDetections.toMutableList() + val next = mutableListOf() + + rawBoxes.forEach { raw -> + val matchIndex = remaining.indices.minByOrNull { idx -> + distanceBetweenCenters(remaining[idx].box, raw) + } ?: -1 + if (matchIndex >= 0) { + val previous = remaining[matchIndex] + val distance = distanceBetweenCenters(previous.box, raw) + if (distance <= MATCH_DISTANCE_THRESHOLD) { + remaining.removeAt(matchIndex) + next += TrackedDetection( + box = smoothBox(previous.box, raw, BOX_SMOOTHING_ALPHA), + missCount = 0 + ) + } else { + next += TrackedDetection(raw, 0) + } + } else { + next += TrackedDetection(raw, 0) } } - if (outWidth <= 0f || outHeight <= 0f) return null - return DetectionPoint( - x = (outX / outWidth).coerceIn(0f, 1f), - y = (outY / outHeight).coerceIn(0f, 1f) + remaining.forEach { stale -> + val misses = stale.missCount + 1 + if (misses <= MAX_MISSED_FRAMES) { + next += stale.copy(missCount = misses) + } + } + + trackedDetections = next + return next.map { it.box } + } + + private fun distanceBetweenCenters(a: DetectionBox, b: DetectionBox): Float { + val ax = (a.left + a.right) * 0.5f + val ay = (a.top + a.bottom) * 0.5f + val bx = (b.left + b.right) * 0.5f + val by = (b.top + b.bottom) * 0.5f + val dx = ax - bx + val dy = ay - by + return kotlin.math.sqrt(dx * dx + dy * dy) + } + + private fun smoothBox(previous: DetectionBox, current: DetectionBox, alpha: Float): DetectionBox { + fun lerp(start: Float, end: Float): Float = start + (end - start) * alpha + + val corners = if (previous.corners.isNotEmpty() && previous.corners.size == current.corners.size) { + previous.corners.zip(current.corners).map { (p, c) -> + DetectionPoint( + x = lerp(p.x, c.x), + y = lerp(p.y, c.y) + ) + } + } else if (current.corners.isNotEmpty()) { + current.corners + } else { + previous.corners + } + + return DetectionBox( + left = lerp(previous.left, current.left).coerceIn(0f, 1f), + top = lerp(previous.top, current.top).coerceIn(0f, 1f), + right = lerp(previous.right, current.right).coerceIn(0f, 1f), + bottom = lerp(previous.bottom, current.bottom).coerceIn(0f, 1f), + corners = corners ) } } diff --git a/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt b/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt index e00526e..e1c2edc 100644 --- a/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt +++ b/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt @@ -41,7 +41,7 @@ fun CameraPreview( sourceWidth: Int, sourceHeight: Int ) -> Unit = { _, _, _, _, _ -> }, - onScan: (String, String) -> Unit + onScan: (String, String, DetectionBox?, Int, Int) -> Unit ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current @@ -67,7 +67,9 @@ fun CameraPreview( ) } }, - onDetected = { result -> latestOnScan.value(result.content, result.type) } + onDetected = { result, readableBox, sourceWidth, sourceHeight -> + latestOnScan.value(result.content, result.type, readableBox, sourceWidth, sourceHeight) + } ) } val cameraProviderRef = remember { mutableStateOf(null) } diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt index 2213a5a..4975188 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt @@ -3,18 +3,24 @@ package com.clean.scanner.ui.screens import android.Manifest import android.app.Activity import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder import android.media.AudioManager import android.media.ToneGenerator import android.net.Uri +import android.os.Build import android.provider.Settings import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -55,9 +61,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -82,6 +90,12 @@ import com.google.mlkit.vision.common.InputImage import java.text.DateFormat import java.util.Date import kotlin.math.max +import kotlin.math.min + +private data class GalleryScanCandidate( + val result: ScanResult, + val box: DetectionBox? +) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -120,7 +134,8 @@ fun ScannerScreen( var pendingOpenUrl by remember { mutableStateOf(null) } var showImageScanFailed by remember { mutableStateOf(false) } var showNoCodeInImage by remember { mutableStateOf(false) } - var imageScanCandidates by remember { mutableStateOf>(emptyList()) } + var imageScanCandidates by remember { mutableStateOf>(emptyList()) } + var imageScanPreviewUri by remember { mutableStateOf(null) } var hasPotentialInView by remember { mutableStateOf(false) } var hasReadableInView by remember { mutableStateOf(false) } var detectionBoxes by remember { mutableStateOf>(emptyList()) } @@ -163,19 +178,33 @@ fun ScannerScreen( .addOnSuccessListener { barcodes -> val candidates = barcodes.mapNotNull { barcode -> val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null - ScanResult(content = raw, type = barcode.valueType.toHumanType()) - }.distinctBy { "${it.type}|${it.content}" } + val normalizedBox = barcode.boundingBox?.let { bounds -> + val corners = barcode.cornerPoints?.map { p -> + com.clean.scanner.data.scanner.DetectionPoint( + x = (p.x / image.width.toFloat()).coerceIn(0f, 1f), + y = (p.y / image.height.toFloat()).coerceIn(0f, 1f) + ) + } ?: emptyList() + DetectionBox( + left = (bounds.left / image.width.toFloat()).coerceIn(0f, 1f), + top = (bounds.top / image.height.toFloat()).coerceIn(0f, 1f), + right = (bounds.right / image.width.toFloat()).coerceIn(0f, 1f), + bottom = (bounds.bottom / image.height.toFloat()).coerceIn(0f, 1f), + corners = corners + ) + } + GalleryScanCandidate( + result = ScanResult(content = raw, type = barcode.valueType.toHumanType()), + box = normalizedBox + ) + }.distinctBy { "${it.result.type}|${it.result.content}" } when (candidates.size) { 0 -> showNoCodeInImage = true - 1 -> { - if (barcodes.size > 1) { - imageScanCandidates = candidates - } else { - onScan(candidates.first()) - } + else -> { + imageScanPreviewUri = uri + imageScanCandidates = candidates } - else -> imageScanCandidates = candidates } } .addOnFailureListener { @@ -219,7 +248,17 @@ fun ScannerScreen( } } - Box(modifier = Modifier.fillMaxSize()) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val density = LocalDensity.current + val viewW = with(density) { maxWidth.toPx() } + val viewH = with(density) { maxHeight.toPx() } + val aimW = viewW * 0.62f + val aimH = with(density) { 200.dp.toPx() } + val aimLeft = (viewW - aimW) / 2f + val aimTop = (viewH - aimH) / 2f + val aimRight = aimLeft + aimW + val aimBottom = aimTop + aimH + if (cameraGranted) { CameraPreview( modifier = Modifier.fillMaxSize(), @@ -233,7 +272,25 @@ fun ScannerScreen( detectionSourceWidth = sourceWidth detectionSourceHeight = sourceHeight }, - onScan = { content, type -> + onScan = { content, type, readableBox, sourceWidth, sourceHeight -> + val box = readableBox ?: return@CameraPreview + if (sourceWidth <= 0 || sourceHeight <= 0 || viewW <= 0f || viewH <= 0f) { + return@CameraPreview + } + + val srcW = sourceWidth.toFloat() + val srcH = sourceHeight.toFloat() + val scale = max(viewW / srcW, viewH / srcH) + val scaledW = srcW * scale + val scaledH = srcH * scale + val offsetX = (viewW - scaledW) / 2f + val offsetY = (viewH - scaledH) / 2f + val centerX = offsetX + (((box.left + box.right) * 0.5f) * srcW * scale) + val centerY = offsetY + (((box.top + box.bottom) * 0.5f) * srcH * scale) + + val insideAim = centerX in aimLeft..aimRight && centerY in aimTop..aimBottom + if (!insideAim) return@CameraPreview + onScan(ScanResult(content = content, type = type)) } ) @@ -292,29 +349,22 @@ fun ScannerScreen( Canvas( modifier = Modifier .align(Alignment.Center) - .fillMaxWidth(0.7f) - .height(220.dp) + .fillMaxWidth(0.62f) + .height(200.dp) ) { val guideColor = when { hasReadableInView -> Color(0xFF4AE3A3) hasPotentialInView -> Color(0xFFFFC857) else -> Color(0xFF7CE6C6) } - val cx = size.width / 2f - val cy = size.height / 2f drawRoundRect( - color = guideColor.copy(alpha = 0.10f), - cornerRadius = CornerRadius(26f, 26f) + color = guideColor.copy(alpha = 0.08f), + cornerRadius = CornerRadius(22f, 22f) ) drawRoundRect( color = guideColor.copy(alpha = 0.90f), - cornerRadius = CornerRadius(26f, 26f), - style = Stroke(width = 4f) - ) - drawCircle( - color = guideColor.copy(alpha = 0.90f), - radius = 8f, - center = androidx.compose.ui.geometry.Offset(cx, cy) + cornerRadius = CornerRadius(22f, 22f), + style = Stroke(width = 3.5f) ) } @@ -322,7 +372,7 @@ fun ScannerScreen( text = when { hasReadableInView -> stringResource(R.string.live_readable_detected) hasPotentialInView -> stringResource(R.string.live_potential_detected) - else -> stringResource(R.string.pinch_to_zoom_hint) + else -> stringResource(R.string.aim_center_hint) }, color = Color.White, textAlign = TextAlign.Center, @@ -530,51 +580,17 @@ fun ScannerScreen( } if (imageScanCandidates.isNotEmpty()) { - AlertDialog( - onDismissRequest = { imageScanCandidates = emptyList() }, - title = { Text(stringResource(R.string.image_scan_pick_title, imageScanCandidates.size)) }, - text = { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - text = stringResource(R.string.image_scan_pick_subtitle), - modifier = Modifier.padding(bottom = 4.dp) - ) - imageScanCandidates.forEach { candidate -> - TextButton( - onClick = { - onScan(candidate) - imageScanCandidates = emptyList() - }, - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = candidate.type, - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = candidate.content, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - } + GalleryScanPreviewDialog( + imageUri = imageScanPreviewUri, + candidates = imageScanCandidates, + onPick = { candidate -> + onScan(candidate.result) + imageScanCandidates = emptyList() + imageScanPreviewUri = null }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = { imageScanCandidates = emptyList() }) { - Text(stringResource(R.string.cancel)) - } + onDismiss = { + imageScanCandidates = emptyList() + imageScanPreviewUri = null } ) } @@ -717,6 +733,143 @@ private fun buildBatchExport(results: List): String { } } +@Composable +private fun GalleryScanPreviewDialog( + imageUri: Uri?, + candidates: List, + onPick: (GalleryScanCandidate) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val bitmap = remember(imageUri) { + imageUri?.let { loadBitmapFromUri(context, it) } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.image_scan_pick_title, candidates.size)) }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (bitmap != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + .background(Color.Black.copy(alpha = 0.32f), RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource(R.string.scan_from_image), + modifier = Modifier + .fillMaxWidth() + .height(260.dp), + contentScale = androidx.compose.ui.layout.ContentScale.Fit + ) + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + ) { + val bitmapW = bitmap.width.toFloat() + val bitmapH = bitmap.height.toFloat() + if (bitmapW <= 0f || bitmapH <= 0f) return@Canvas + + val scale = min(size.width / bitmapW, size.height / bitmapH) + val drawW = bitmapW * scale + val drawH = bitmapH * scale + val offsetX = (size.width - drawW) / 2f + val offsetY = (size.height - drawH) / 2f + + candidates.forEach { candidate -> + val box = candidate.box ?: return@forEach + val color = Color(0xFF4AE3A3).copy(alpha = 0.96f) + val points = box.corners.map { p -> + androidx.compose.ui.geometry.Offset( + x = offsetX + (p.x * drawW), + y = offsetY + (p.y * drawH) + ) + } + if (points.size >= 4) { + val path = Path().apply { + moveTo(points.first().x, points.first().y) + points.drop(1).forEach { pt -> lineTo(pt.x, pt.y) } + close() + } + drawPath(path = path, color = color, style = Stroke(width = 4f)) + } else { + val left = offsetX + (box.left * drawW) + val top = offsetY + (box.top * drawH) + val right = offsetX + (box.right * drawW) + val bottom = offsetY + (box.bottom * drawH) + if (right > left && bottom > top) { + drawRoundRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(left, top), + size = androidx.compose.ui.geometry.Size(right - left, bottom - top), + cornerRadius = CornerRadius(10f, 10f), + style = Stroke(width = 4f) + ) + } + } + } + } + } + } + + Text(text = stringResource(R.string.image_scan_pick_subtitle)) + candidates.forEach { candidate -> + TextButton( + onClick = { onPick(candidate) }, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = candidate.result.type, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = candidate.result.content, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +private fun loadBitmapFromUri(context: android.content.Context, uri: Uri): Bitmap? { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val source = ImageDecoder.createSource(context.contentResolver, uri) + ImageDecoder.decodeBitmap(source) + } else { + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream) + } + } + } catch (_: Exception) { + null + } +} + @Composable private fun PermissionContent( showSettingsHint: Boolean, diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f04889e..aafa5f7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -33,6 +33,7 @@ Inhalt Kamera erlauben Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen + Code im mittleren Rahmen ausrichten. Code erkannt. Ruhig halten oder näher rangehen. Lesbarer Code erkannt. Historie teilen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e4df298..708e628 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,7 @@ Content Allow camera Pinch to zoom for small codes + Aim the code inside the center frame. Code spotted. Hold steady or move closer. Readable code detected. Share history