guided scan zone

This commit is contained in:
Hadrian Burkhardt
2026-02-13 03:13:29 +01:00
parent d4539efee6
commit 471270a396
4 changed files with 347 additions and 151 deletions
+10 -1
View File
@@ -21,6 +21,15 @@
- [x] Live readability feedback in camera - [x] Live readability feedback in camera
- Show real-time hint when a code is visible vs readable. - Show real-time hint when a code is visible vs readable.
- [x] Scan stability hardening
- Temporal box stabilization (smoothing + short persistence) and same-code holdoff to reduce repeat scans.
- [x] Guided scan zone
- Only accept live camera scans when the readable code is inside the center aiming frame.
- [x] Advanced gallery preview
- Open selected image in full focus mode, support pinch zoom/pan, and run live re-detection with overlay markers linked to list entries.
## Mid-size Features (3-7 days) ## Mid-size Features (3-7 days)
1. Import history 1. Import history
@@ -54,4 +63,4 @@
## Suggested Next 2 ## Suggested Next 2
1. Import history (CSV/JSON restore + merge policy) 1. Import history (CSV/JSON restore + merge policy)
2. Favorites / pin scans 2. Tagging (tags/folders + filter chips)
@@ -30,18 +30,27 @@ class ScannerViewModel(
private val saveScan: suspend (content: String, type: String) -> Unit, private val saveScan: suspend (content: String, type: String) -> Unit,
private val nowProvider: () -> Long = { System.currentTimeMillis() } private val nowProvider: () -> Long = { System.currentTimeMillis() }
) : ViewModel() { ) : ViewModel() {
private companion object {
const val GENERAL_DEBOUNCE_MS = 800L
const val SAME_CODE_HOLDOFF_MS = 2500L
}
private val _uiState = MutableStateFlow(ScannerUiState()) private val _uiState = MutableStateFlow(ScannerUiState())
val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow() val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow()
private val recentScanKeySet = LinkedHashSet<String>(200) private val recentScanKeySet = LinkedHashSet<String>(200)
private val batchKeySet = LinkedHashSet<String>(100) private val batchKeySet = LinkedHashSet<String>(100)
private var lastAcceptedKey: String? = null
private var lastAcceptedTimestamp: Long = 0L
fun onScan(result: ScanResult) { fun onScan(result: ScanResult) {
val now = nowProvider() val now = nowProvider()
val current = _uiState.value val current = _uiState.value
if (!current.analysisEnabled) return if (!current.analysisEnabled) return
if (now - current.lastScanTimestamp < 800) return
val key = "${result.type}|${result.content}" val key = "${result.type}|${result.content}"
if (now - current.lastScanTimestamp < GENERAL_DEBOUNCE_MS) return
if (key == lastAcceptedKey && now - lastAcceptedTimestamp < SAME_CODE_HOLDOFF_MS) return
val isDuplicate = key in recentScanKeySet val isDuplicate = key in recentScanKeySet
recentScanKeySet.remove(key) recentScanKeySet.remove(key)
recentScanKeySet.add(key) recentScanKeySet.add(key)
@@ -49,6 +58,8 @@ class ScannerViewModel(
recentScanKeySet.remove(recentScanKeySet.first()) recentScanKeySet.remove(recentScanKeySet.first())
} }
val updatedRecent = recentScanKeySet.toList().asReversed() val updatedRecent = recentScanKeySet.toList().asReversed()
lastAcceptedKey = key
lastAcceptedTimestamp = now
_uiState.value = if (current.batchMode) { _uiState.value = if (current.batchMode) {
val updatedBatch = if (key in batchKeySet) { val updatedBatch = if (key in batchKeySet) {
@@ -6,6 +6,7 @@ import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.ImageDecoder import android.graphics.ImageDecoder
import android.graphics.Paint
import android.media.AudioManager import android.media.AudioManager
import android.media.ToneGenerator import android.media.ToneGenerator
import android.net.Uri import android.net.Uri
@@ -17,6 +18,7 @@ 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
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
@@ -59,11 +61,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
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.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.nativeCanvas
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.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
@@ -71,6 +79,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -89,8 +98,11 @@ import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage 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.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
private data class GalleryScanCandidate( private data class GalleryScanCandidate(
val result: ScanResult, val result: ScanResult,
@@ -133,7 +145,6 @@ fun ScannerScreen(
var showRiskWarning by remember { mutableStateOf(false) } var showRiskWarning by remember { mutableStateOf(false) }
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 imageScanCandidates by remember { mutableStateOf<List<GalleryScanCandidate>>(emptyList()) } var imageScanCandidates by remember { mutableStateOf<List<GalleryScanCandidate>>(emptyList()) }
var imageScanPreviewUri by remember { mutableStateOf<Uri?>(null) } var imageScanPreviewUri by remember { mutableStateOf<Uri?>(null) }
var hasPotentialInView by remember { mutableStateOf(false) } var hasPotentialInView by remember { mutableStateOf(false) }
@@ -173,6 +184,8 @@ fun ScannerScreen(
showImageScanFailed = true showImageScanFailed = true
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
} }
imageScanPreviewUri = uri
imageScanCandidates = emptyList()
imageScanner.process(image) imageScanner.process(image)
.addOnSuccessListener { barcodes -> .addOnSuccessListener { barcodes ->
@@ -198,14 +211,7 @@ fun ScannerScreen(
box = normalizedBox box = normalizedBox
) )
}.distinctBy { "${it.result.type}|${it.result.content}" } }.distinctBy { "${it.result.type}|${it.result.content}" }
imageScanCandidates = candidates
when (candidates.size) {
0 -> showNoCodeInImage = true
else -> {
imageScanPreviewUri = uri
imageScanCandidates = candidates
}
}
} }
.addOnFailureListener { .addOnFailureListener {
showImageScanFailed = true showImageScanFailed = true
@@ -252,6 +258,7 @@ fun ScannerScreen(
val density = LocalDensity.current val density = LocalDensity.current
val viewW = with(density) { maxWidth.toPx() } val viewW = with(density) { maxWidth.toPx() }
val viewH = with(density) { maxHeight.toPx() } val viewH = with(density) { maxHeight.toPx() }
val galleryOpen = imageScanPreviewUri != null
val aimW = viewW * 0.62f val aimW = viewW * 0.62f
val aimH = with(density) { 200.dp.toPx() } val aimH = with(density) { 200.dp.toPx() }
val aimLeft = (viewW - aimW) / 2f val aimLeft = (viewW - aimW) / 2f
@@ -259,7 +266,7 @@ fun ScannerScreen(
val aimRight = aimLeft + aimW val aimRight = aimLeft + aimW
val aimBottom = aimTop + aimH val aimBottom = aimTop + aimH
if (cameraGranted) { if (cameraGranted && !galleryOpen) {
CameraPreview( CameraPreview(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
analysisEnabled = analysisEnabled, analysisEnabled = analysisEnabled,
@@ -443,7 +450,7 @@ fun ScannerScreen(
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = if (batchMode) 12.dp else 80.dp) .padding(bottom = if (batchMode) 12.dp else 80.dp)
) )
} else { } else if (!galleryOpen) {
PermissionContent( PermissionContent(
showSettingsHint = showSettingsHint, showSettingsHint = showSettingsHint,
onRequestPermission = { permissionLauncher.launch(Manifest.permission.CAMERA) }, onRequestPermission = { permissionLauncher.launch(Manifest.permission.CAMERA) },
@@ -455,6 +462,12 @@ fun ScannerScreen(
context.startActivity(intent) context.startActivity(intent)
} }
) )
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
)
} }
if (lastResult != null && !batchMode) { if (lastResult != null && !batchMode) {
@@ -567,19 +580,7 @@ fun ScannerScreen(
) )
} }
if (showNoCodeInImage) { if (imageScanPreviewUri != null) {
AlertDialog(
onDismissRequest = { showNoCodeInImage = false },
text = { Text(stringResource(R.string.no_code_found_in_image)) },
confirmButton = {
TextButton(onClick = { showNoCodeInImage = false }) {
Text(stringResource(R.string.confirm))
}
}
)
}
if (imageScanCandidates.isNotEmpty()) {
GalleryScanPreviewDialog( GalleryScanPreviewDialog(
imageUri = imageScanPreviewUri, imageUri = imageScanPreviewUri,
candidates = imageScanCandidates, candidates = imageScanCandidates,
@@ -733,128 +734,6 @@ 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? { private fun loadBitmapFromUri(context: android.content.Context, uri: Uri): Bitmap? {
return try { return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@@ -870,6 +749,280 @@ private fun loadBitmapFromUri(context: android.content.Context, uri: Uri): Bitma
} }
} }
private suspend fun detectBarcodes(
scanner: com.google.mlkit.vision.barcode.BarcodeScanner,
image: InputImage
): List<Barcode> = suspendCancellableCoroutine { cont ->
scanner.process(image)
.addOnSuccessListener { barcodes ->
if (cont.isActive) cont.resume(barcodes)
}
.addOnFailureListener { error ->
if (cont.isActive) cont.resumeWithException(error)
}
}
@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) } }
var liveCandidates by remember(imageUri, candidates) { mutableStateOf(candidates) }
var zoom by remember(imageUri) { mutableStateOf(1f) }
var pan by remember(imageUri) { mutableStateOf(Offset.Zero) }
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
var scanTick by remember { mutableStateOf(0) }
val markerPaint = remember {
Paint().apply {
color = android.graphics.Color.WHITE
textAlign = Paint.Align.CENTER
textSize = 34f
isAntiAlias = true
isFakeBoldText = true
}
}
val scanner = remember {
BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.enableAllPotentialBarcodes()
.build()
)
}
DisposableEffect(Unit) {
onDispose { scanner.close() }
}
LaunchedEffect(bitmap, viewportSize, zoom, pan, scanTick) {
val bmp = bitmap ?: return@LaunchedEffect
val vw = viewportSize.width.toFloat()
val vh = viewportSize.height.toFloat()
if (vw <= 0f || vh <= 0f) return@LaunchedEffect
delay(120)
val imgW = bmp.width.toFloat()
val imgH = bmp.height.toFloat()
if (imgW <= 1f || imgH <= 1f) return@LaunchedEffect
val baseScale = max(vw / imgW, vh / imgH)
val effectiveScale = (baseScale * zoom).coerceAtLeast(0.01f)
val cx = vw * 0.5f
val cy = vh * 0.5f
fun screenToImageX(screenX: Float): Float {
return ((screenX - cx - pan.x) / effectiveScale) + (imgW * 0.5f)
}
fun screenToImageY(screenY: Float): Float {
return ((screenY - cy - pan.y) / effectiveScale) + (imgH * 0.5f)
}
val left = screenToImageX(0f).coerceIn(0f, imgW - 1f)
val right = screenToImageX(vw).coerceIn(0f, imgW - 1f)
val top = screenToImageY(0f).coerceIn(0f, imgH - 1f)
val bottom = screenToImageY(vh).coerceIn(0f, imgH - 1f)
val cropLeft = minOf(left, right).toInt()
val cropTop = minOf(top, bottom).toInt()
val cropW = (kotlin.math.abs(right - left)).toInt().coerceAtLeast(8)
val cropH = (kotlin.math.abs(bottom - top)).toInt().coerceAtLeast(8)
val boundedW = cropW.coerceAtMost(bmp.width - cropLeft)
val boundedH = cropH.coerceAtMost(bmp.height - cropTop)
if (boundedW <= 4 || boundedH <= 4) return@LaunchedEffect
val cropped = Bitmap.createBitmap(bmp, cropLeft, cropTop, boundedW, boundedH)
val barcodes = try {
detectBarcodes(scanner, InputImage.fromBitmap(cropped, 0))
} catch (_: Exception) {
emptyList()
}
val live = barcodes.mapNotNull { barcode ->
val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val normalizedBox = barcode.boundingBox?.let { bounds ->
val leftN = ((bounds.left + cropLeft) / imgW).coerceIn(0f, 1f)
val topN = ((bounds.top + cropTop) / imgH).coerceIn(0f, 1f)
val rightN = ((bounds.right + cropLeft) / imgW).coerceIn(0f, 1f)
val bottomN = ((bounds.bottom + cropTop) / imgH).coerceIn(0f, 1f)
val corners = barcode.cornerPoints?.map { p ->
com.clean.scanner.data.scanner.DetectionPoint(
x = ((p.x + cropLeft) / imgW).coerceIn(0f, 1f),
y = ((p.y + cropTop) / imgH).coerceIn(0f, 1f)
)
} ?: emptyList()
DetectionBox(leftN, topN, rightN, bottomN, corners)
}
GalleryScanCandidate(
result = ScanResult(content = raw, type = barcode.valueType.toHumanType()),
box = normalizedBox
)
}.distinctBy { "${it.result.type}|${it.result.content}" }
liveCandidates = live
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.image_scan_pick_title, liveCandidates.size)) },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (bitmap != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
.onSizeChanged {
viewportSize = it
scanTick++
}
.pointerInput(bitmap) {
detectTransformGestures { _, panChange, zoomChange, _ ->
zoom = (zoom * zoomChange).coerceIn(1f, 6f)
pan += panChange
scanTick++
}
}
.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)
.graphicsLayer {
scaleX = zoom
scaleY = zoom
translationX = pan.x
translationY = pan.y
},
contentScale = ContentScale.Crop
)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
) {
val imageW = bitmap.width.toFloat()
val imageH = bitmap.height.toFloat()
if (imageW <= 0f || imageH <= 0f) return@Canvas
val baseScale = max(size.width / imageW, size.height / imageH)
val effectiveScale = baseScale * zoom
val cx = size.width * 0.5f
val cy = size.height * 0.5f
fun imageToScreen(ix: Float, iy: Float): Offset {
val sx = cx + ((ix - imageW * 0.5f) * effectiveScale) + pan.x
val sy = cy + ((iy - imageH * 0.5f) * effectiveScale) + pan.y
return Offset(sx, sy)
}
liveCandidates.forEachIndexed { index, candidate ->
val box = candidate.box ?: return@forEachIndexed
val color = Color(0xFF4AE3A3).copy(alpha = 0.96f)
val points = box.corners.map { p ->
imageToScreen(p.x * imageW, p.y * imageH)
}
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 lt = imageToScreen(box.left * imageW, box.top * imageH)
val rb = imageToScreen(box.right * imageW, box.bottom * imageH)
val left = minOf(lt.x, rb.x)
val top = minOf(lt.y, rb.y)
val right = maxOf(lt.x, rb.x)
val bottom = maxOf(lt.y, rb.y)
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)
)
}
}
val center = if (points.isNotEmpty()) {
val sx = points.sumOf { it.x.toDouble() }.toFloat() / points.size
val sy = points.sumOf { it.y.toDouble() }.toFloat() / points.size
Offset(sx, sy)
} else {
imageToScreen(
(box.left + box.right) * 0.5f * imageW,
(box.top + box.bottom) * 0.5f * imageH
)
}
drawCircle(
color = Color.Black.copy(alpha = 0.65f),
radius = 16f,
center = center
)
drawContext.canvas.nativeCanvas.drawText(
"${index + 1}",
center.x,
center.y + 11f,
markerPaint
)
}
}
}
}
if (liveCandidates.isEmpty()) {
Text(text = stringResource(R.string.no_code_found_in_image))
} else {
Text(text = stringResource(R.string.image_scan_pick_subtitle))
liveCandidates.forEachIndexed { index, candidate ->
TextButton(
onClick = { onPick(candidate) },
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "${index + 1}. ${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))
}
}
)
}
@Composable @Composable
private fun PermissionContent( private fun PermissionContent(
showSettingsHint: Boolean, showSettingsHint: Boolean,
@@ -50,7 +50,7 @@ class ScannerViewModelTest {
viewModel.onScan(result) viewModel.onScan(result)
viewModel.resumeScanning() viewModel.resumeScanning()
now = 2_000L now = 4_000L
viewModel.onScan(result) viewModel.onScan(result)
advanceUntilIdle() advanceUntilIdle()
@@ -75,7 +75,7 @@ class ScannerViewModelTest {
now = 2_000L now = 2_000L
viewModel.onScan(ScanResult(content = "B", type = "Text")) viewModel.onScan(ScanResult(content = "B", type = "Text"))
now = 3_000L now = 3_600L
viewModel.onScan(ScanResult(content = "A", type = "Text")) viewModel.onScan(ScanResult(content = "A", type = "Text"))
advanceUntilIdle() advanceUntilIdle()
@@ -92,4 +92,27 @@ class ScannerViewModelTest {
assertTrue(viewModel.uiState.value.batchResults.isEmpty()) assertTrue(viewModel.uiState.value.batchResults.isEmpty())
assertEquals(3, saved.size) assertEquals(3, saved.size)
} }
@Test
fun sameCodeWithinHoldoff_isIgnored() = runTest {
val saved = mutableListOf<Pair<String, String>>()
var now = 1_000L
val viewModel = ScannerViewModel(
saveScan = { content, type -> saved += content to type },
nowProvider = { now }
)
val result = ScanResult(content = "HOLD", type = "Text")
viewModel.setBatchMode(true)
viewModel.onScan(result)
now = 2_000L
viewModel.onScan(result)
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(1, state.scanFeedbackNonce)
assertEquals(1, saved.size)
assertEquals(1, state.batchResults.size)
}
} }