guided scan zone
This commit is contained in:
+10
-1
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user