stabilized bounding boxes

This commit is contained in:
Hadrian Burkhardt
2026-02-13 02:14:40 +01:00
parent 88dedef4b6
commit d4539efee6
5 changed files with 393 additions and 160 deletions
@@ -22,6 +22,11 @@ data class DetectionBox(
val corners: List<DetectionPoint> = emptyList() val corners: List<DetectionPoint> = emptyList()
) )
private data class TrackedDetection(
val box: DetectionBox,
val missCount: Int
)
class MlKitBarcodeAnalyzer( class MlKitBarcodeAnalyzer(
private val isAnalysisEnabled: () -> Boolean = { true }, private val isAnalysisEnabled: () -> Boolean = { true },
private val onDetectionStateChanged: ( private val onDetectionStateChanged: (
@@ -31,8 +36,18 @@ class MlKitBarcodeAnalyzer(
sourceWidth: Int, sourceWidth: Int,
sourceHeight: Int sourceHeight: Int
) -> Unit = { _, _, _, _, _ -> }, ) -> Unit = { _, _, _, _, _ -> },
private val onDetected: (ScanResult) -> Unit private val onDetected: (
result: ScanResult,
readableBox: DetectionBox?,
sourceWidth: Int,
sourceHeight: Int
) -> Unit
) : ImageAnalysis.Analyzer, AutoCloseable { ) : 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( private val scanner = BarcodeScanning.getClient(
BarcodeScannerOptions.Builder() BarcodeScannerOptions.Builder()
@@ -52,9 +67,15 @@ class MlKitBarcodeAnalyzer(
private var lastSourceWidth = 0 private var lastSourceWidth = 0
@Volatile @Volatile
private var lastSourceHeight = 0 private var lastSourceHeight = 0
private var trackedDetections: List<TrackedDetection> = emptyList()
private var trackedSourceWidth = 0
private var trackedSourceHeight = 0
override fun analyze(imageProxy: ImageProxy) { override fun analyze(imageProxy: ImageProxy) {
if (!isAnalysisEnabled()) { if (!isAnalysisEnabled()) {
trackedDetections = emptyList()
trackedSourceWidth = 0
trackedSourceHeight = 0
publishDetectionState( publishDetectionState(
hasPotential = false, hasPotential = false,
hasReadable = false, hasReadable = false,
@@ -87,44 +108,73 @@ class MlKitBarcodeAnalyzer(
val bounds = barcode.boundingBox ?: return@mapNotNull null val bounds = barcode.boundingBox ?: return@mapNotNull null
val normalized = normalizeBoundingBox( val normalized = normalizeBoundingBox(
rect = bounds, rect = bounds,
width = imageProxy.width, sourceWidth = sourceWidth,
height = imageProxy.height, sourceHeight = sourceHeight
rotationDegrees = rotationDegrees
) ?: return@mapNotNull null ) ?: return@mapNotNull null
val normalizedCorners = barcode.cornerPoints?.map { p -> val normalizedCorners = barcode.cornerPoints?.map { p ->
normalizePoint( normalizePoint(
x = p.x.toFloat(), x = p.x.toFloat(),
y = p.y.toFloat(), y = p.y.toFloat(),
width = imageProxy.width, sourceWidth = sourceWidth,
height = imageProxy.height, sourceHeight = sourceHeight
rotationDegrees = rotationDegrees
) )
}?.filterNotNull() ?: emptyList() }?.filterNotNull() ?: emptyList()
normalized.copy(corners = normalizedCorners) normalized.copy(corners = normalizedCorners)
} }
val stabilizedBoxes = stabilizeBoxes(boxes, sourceWidth, sourceHeight)
publishDetectionState( publishDetectionState(
hasPotential = barcodes.isNotEmpty(), hasPotential = stabilizedBoxes.isNotEmpty(),
hasReadable = readable != null, hasReadable = readable != null,
boxes = boxes, boxes = stabilizedBoxes,
sourceWidth = sourceWidth, sourceWidth = sourceWidth,
sourceHeight = sourceHeight sourceHeight = sourceHeight
) )
if (readable != null) { 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( onDetected(
ScanResult( ScanResult(
content = readable.rawValue.orEmpty(), content = readable.rawValue.orEmpty(),
type = readable.valueType.toHumanType() type = readable.valueType.toHumanType()
) ),
gatedReadableBox,
sourceWidth,
sourceHeight
) )
} }
} }
.addOnFailureListener { .addOnFailureListener {
val sourceW = trackedSourceWidth
val sourceH = trackedSourceHeight
val stabilizedBoxes = if (sourceW > 0 && sourceH > 0) {
stabilizeBoxes(emptyList(), sourceW, sourceH)
} else {
trackedDetections = emptyList()
emptyList()
}
publishDetectionState( publishDetectionState(
hasPotential = false, hasPotential = stabilizedBoxes.isNotEmpty(),
hasReadable = false, hasReadable = false,
boxes = emptyList(), boxes = stabilizedBoxes,
sourceWidth = 0, sourceWidth = if (stabilizedBoxes.isNotEmpty()) sourceW else 0,
sourceHeight = 0 sourceHeight = if (stabilizedBoxes.isNotEmpty()) sourceH else 0
) )
} }
.addOnCompleteListener { .addOnCompleteListener {
@@ -177,94 +227,120 @@ class MlKitBarcodeAnalyzer(
private fun normalizeBoundingBox( private fun normalizeBoundingBox(
rect: Rect, rect: Rect,
width: Int, sourceWidth: Int,
height: Int, sourceHeight: Int
rotationDegrees: Int
): DetectionBox? { ): DetectionBox? {
if (width <= 0 || height <= 0) return null if (sourceWidth <= 0 || sourceHeight <= 0) return null
val outWidth = sourceWidth.toFloat()
var left = rect.left.toFloat() val outHeight = sourceHeight.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
return DetectionBox( return DetectionBox(
left = (left / outWidth).coerceIn(0f, 1f), left = (rect.left.toFloat() / outWidth).coerceIn(0f, 1f),
top = (top / outHeight).coerceIn(0f, 1f), top = (rect.top.toFloat() / outHeight).coerceIn(0f, 1f),
right = (right / outWidth).coerceIn(0f, 1f), right = (rect.right.toFloat() / outWidth).coerceIn(0f, 1f),
bottom = (bottom / outHeight).coerceIn(0f, 1f) bottom = (rect.bottom.toFloat() / outHeight).coerceIn(0f, 1f)
) )
} }
private fun normalizePoint( private fun normalizePoint(
x: Float, x: Float,
y: Float, y: Float,
width: Int, sourceWidth: Int,
height: Int, sourceHeight: Int
rotationDegrees: Int
): DetectionPoint? { ): 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 private fun stabilizeBoxes(
var outY = y rawBoxes: List<DetectionBox>,
var outWidth = width.toFloat() sourceWidth: Int,
var outHeight = height.toFloat() sourceHeight: Int
): List<DetectionBox> {
if (sourceWidth <= 0 || sourceHeight <= 0) {
trackedDetections = emptyList()
trackedSourceWidth = 0
trackedSourceHeight = 0
return emptyList()
}
when (rotationDegrees) { if (trackedSourceWidth != sourceWidth || trackedSourceHeight != sourceHeight) {
90 -> { trackedDetections = emptyList()
outX = height - y }
outY = x trackedSourceWidth = sourceWidth
outWidth = height.toFloat() trackedSourceHeight = sourceHeight
outHeight = width.toFloat()
} val remaining = trackedDetections.toMutableList()
180 -> { val next = mutableListOf<TrackedDetection>()
outX = width - x
outY = height - y rawBoxes.forEach { raw ->
outWidth = width.toFloat() val matchIndex = remaining.indices.minByOrNull { idx ->
outHeight = height.toFloat() distanceBetweenCenters(remaining[idx].box, raw)
} } ?: -1
270 -> { if (matchIndex >= 0) {
outX = y val previous = remaining[matchIndex]
outY = width - x val distance = distanceBetweenCenters(previous.box, raw)
outWidth = height.toFloat() if (distance <= MATCH_DISTANCE_THRESHOLD) {
outHeight = width.toFloat() 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 remaining.forEach { stale ->
return DetectionPoint( val misses = stale.missCount + 1
x = (outX / outWidth).coerceIn(0f, 1f), if (misses <= MAX_MISSED_FRAMES) {
y = (outY / outHeight).coerceIn(0f, 1f) 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
) )
} }
} }
@@ -41,7 +41,7 @@ fun CameraPreview(
sourceWidth: Int, sourceWidth: Int,
sourceHeight: Int sourceHeight: Int
) -> Unit = { _, _, _, _, _ -> }, ) -> Unit = { _, _, _, _, _ -> },
onScan: (String, String) -> Unit onScan: (String, String, DetectionBox?, Int, Int) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.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<ProcessCameraProvider?>(null) } val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
@@ -3,18 +3,24 @@ package com.clean.scanner.ui.screens
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.media.AudioManager import android.media.AudioManager
import android.media.ToneGenerator import android.media.ToneGenerator
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.Settings import android.provider.Settings
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize 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.geometry.CornerRadius
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -82,6 +90,12 @@ import com.google.mlkit.vision.common.InputImage
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
import kotlin.math.max import kotlin.math.max
import kotlin.math.min
private data class GalleryScanCandidate(
val result: ScanResult,
val box: DetectionBox?
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -120,7 +134,8 @@ fun ScannerScreen(
var pendingOpenUrl by remember { mutableStateOf<String?>(null) } var pendingOpenUrl by remember { mutableStateOf<String?>(null) }
var showImageScanFailed by remember { mutableStateOf(false) } var showImageScanFailed by remember { mutableStateOf(false) }
var showNoCodeInImage by remember { mutableStateOf(false) } var showNoCodeInImage by remember { mutableStateOf(false) }
var imageScanCandidates by remember { mutableStateOf<List<ScanResult>>(emptyList()) } var imageScanCandidates by remember { mutableStateOf<List<GalleryScanCandidate>>(emptyList()) }
var imageScanPreviewUri by remember { mutableStateOf<Uri?>(null) }
var hasPotentialInView by remember { mutableStateOf(false) } var hasPotentialInView by remember { mutableStateOf(false) }
var hasReadableInView by remember { mutableStateOf(false) } var hasReadableInView by remember { mutableStateOf(false) }
var detectionBoxes by remember { mutableStateOf<List<DetectionBox>>(emptyList()) } var detectionBoxes by remember { mutableStateOf<List<DetectionBox>>(emptyList()) }
@@ -163,19 +178,33 @@ fun ScannerScreen(
.addOnSuccessListener { barcodes -> .addOnSuccessListener { barcodes ->
val candidates = barcodes.mapNotNull { barcode -> val candidates = barcodes.mapNotNull { barcode ->
val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
ScanResult(content = raw, type = barcode.valueType.toHumanType()) val normalizedBox = barcode.boundingBox?.let { bounds ->
}.distinctBy { "${it.type}|${it.content}" } 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) { when (candidates.size) {
0 -> showNoCodeInImage = true 0 -> showNoCodeInImage = true
1 -> { else -> {
if (barcodes.size > 1) { imageScanPreviewUri = uri
imageScanCandidates = candidates imageScanCandidates = candidates
} else {
onScan(candidates.first())
}
} }
else -> imageScanCandidates = candidates
} }
} }
.addOnFailureListener { .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) { if (cameraGranted) {
CameraPreview( CameraPreview(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -233,7 +272,25 @@ fun ScannerScreen(
detectionSourceWidth = sourceWidth detectionSourceWidth = sourceWidth
detectionSourceHeight = sourceHeight 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)) onScan(ScanResult(content = content, type = type))
} }
) )
@@ -292,29 +349,22 @@ fun ScannerScreen(
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .align(Alignment.Center)
.fillMaxWidth(0.7f) .fillMaxWidth(0.62f)
.height(220.dp) .height(200.dp)
) { ) {
val guideColor = when { val guideColor = when {
hasReadableInView -> Color(0xFF4AE3A3) hasReadableInView -> Color(0xFF4AE3A3)
hasPotentialInView -> Color(0xFFFFC857) hasPotentialInView -> Color(0xFFFFC857)
else -> Color(0xFF7CE6C6) else -> Color(0xFF7CE6C6)
} }
val cx = size.width / 2f
val cy = size.height / 2f
drawRoundRect( drawRoundRect(
color = guideColor.copy(alpha = 0.10f), color = guideColor.copy(alpha = 0.08f),
cornerRadius = CornerRadius(26f, 26f) cornerRadius = CornerRadius(22f, 22f)
) )
drawRoundRect( drawRoundRect(
color = guideColor.copy(alpha = 0.90f), color = guideColor.copy(alpha = 0.90f),
cornerRadius = CornerRadius(26f, 26f), cornerRadius = CornerRadius(22f, 22f),
style = Stroke(width = 4f) style = Stroke(width = 3.5f)
)
drawCircle(
color = guideColor.copy(alpha = 0.90f),
radius = 8f,
center = androidx.compose.ui.geometry.Offset(cx, cy)
) )
} }
@@ -322,7 +372,7 @@ fun ScannerScreen(
text = when { text = when {
hasReadableInView -> stringResource(R.string.live_readable_detected) hasReadableInView -> stringResource(R.string.live_readable_detected)
hasPotentialInView -> stringResource(R.string.live_potential_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, color = Color.White,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -530,51 +580,17 @@ fun ScannerScreen(
} }
if (imageScanCandidates.isNotEmpty()) { if (imageScanCandidates.isNotEmpty()) {
AlertDialog( GalleryScanPreviewDialog(
onDismissRequest = { imageScanCandidates = emptyList() }, imageUri = imageScanPreviewUri,
title = { Text(stringResource(R.string.image_scan_pick_title, imageScanCandidates.size)) }, candidates = imageScanCandidates,
text = { onPick = { candidate ->
Column( onScan(candidate.result)
modifier = Modifier imageScanCandidates = emptyList()
.fillMaxWidth() imageScanPreviewUri = null
.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()
)
}
}
}
}
}, },
confirmButton = {}, onDismiss = {
dismissButton = { imageScanCandidates = emptyList()
TextButton(onClick = { imageScanCandidates = emptyList() }) { imageScanPreviewUri = null
Text(stringResource(R.string.cancel))
}
} }
) )
} }
@@ -717,6 +733,143 @@ private fun buildBatchExport(results: List<BatchScanRecord>): String {
} }
} }
@Composable
private fun GalleryScanPreviewDialog(
imageUri: Uri?,
candidates: List<GalleryScanCandidate>,
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 @Composable
private fun PermissionContent( private fun PermissionContent(
showSettingsHint: Boolean, showSettingsHint: Boolean,
+1
View File
@@ -33,6 +33,7 @@
<string name="content_value">Inhalt</string> <string name="content_value">Inhalt</string>
<string name="request_camera">Kamera erlauben</string> <string name="request_camera">Kamera erlauben</string>
<string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string> <string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string>
<string name="aim_center_hint">Code im mittleren Rahmen ausrichten.</string>
<string name="live_potential_detected">Code erkannt. Ruhig halten oder näher rangehen.</string> <string name="live_potential_detected">Code erkannt. Ruhig halten oder näher rangehen.</string>
<string name="live_readable_detected">Lesbarer Code erkannt.</string> <string name="live_readable_detected">Lesbarer Code erkannt.</string>
<string name="share_history">Historie teilen</string> <string name="share_history">Historie teilen</string>
+1
View File
@@ -33,6 +33,7 @@
<string name="content_value">Content</string> <string name="content_value">Content</string>
<string name="request_camera">Allow camera</string> <string name="request_camera">Allow camera</string>
<string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string> <string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string>
<string name="aim_center_hint">Aim the code inside the center frame.</string>
<string name="live_potential_detected">Code spotted. Hold steady or move closer.</string> <string name="live_potential_detected">Code spotted. Hold steady or move closer.</string>
<string name="live_readable_detected">Readable code detected.</string> <string name="live_readable_detected">Readable code detected.</string>
<string name="share_history">Share history</string> <string name="share_history">Share history</string>