flashlight icon + bounding boxes

This commit is contained in:
Hadrian Burkhardt
2026-02-13 01:27:37 +01:00
parent 4f3dd5b5e3
commit 88dedef4b6
9 changed files with 469 additions and 45 deletions
+2 -1
View File
@@ -17,7 +17,8 @@ Offline-first, ad-free QR/barcode scanner built with Kotlin, Jetpack Compose, Ca
## MVP Features ## MVP Features
- Home: Scan-Button, lokaler Historie-Toggle (Default: OFF), Datenschutz-Dialog - Home: Scan-Button, lokaler Historie-Toggle (Default: OFF), Datenschutz-Dialog
- Scanner: CameraX Live-Preview, Fadenkreuz-Overlay, Taschenlampe, Debounce gegen Doppelscans - Scanner: CameraX Live-Preview, Fadenkreuz-Overlay, Taschenlampe, Debounce gegen Doppelscans, Live-Hinweise zu erkannten/lesbaren Codes
- Bild-Scan: Multi-Code-Erkennung aus einem Bild mit Ergebnis-Auswahl
- Ergebnis-Bottom-Sheet: Typ/Inhalt + Copy/Share/Open/Scan again - Ergebnis-Bottom-Sheet: Typ/Inhalt + Copy/Share/Open/Scan again
- URL-Sicherheitswarnung bei lokalem `riskScore >= 3` (kein Blocken, nur Hinweis) - URL-Sicherheitswarnung bei lokalem `riskScore >= 3` (kein Blocken, nur Hinweis)
- Historie: Suche, Swipe-to-delete, Alles-löschen, Detailansicht mit Volltext - Historie: Suche, Swipe-to-delete, Alles-löschen, Detailansicht mit Volltext
+7 -4
View File
@@ -18,6 +18,9 @@
- [x] Settings for scan feedback - [x] Settings for scan feedback
- Toggle haptic/beep on successful scan. - Toggle haptic/beep on successful scan.
- [x] Live readability feedback in camera
- Show real-time hint when a code is visible vs readable.
## Mid-size Features (3-7 days) ## Mid-size Features (3-7 days)
1. Import history 1. Import history
@@ -30,8 +33,8 @@
3. Tagging 3. Tagging
- Add tags/folders and filter chips in History. - Add tags/folders and filter chips in History.
4. Improved image scan 4. [x] Improved image scan
- Support multi-code detection from one image and let user pick result. - Multi-code detection from one image with result picker.
## Bigger Features (1-3 weeks) ## Bigger Features (1-3 weeks)
@@ -50,5 +53,5 @@
## Suggested Next 2 ## Suggested Next 2
1. Banner/snackbar duplicate feedback 1. Import history (CSV/JSON restore + merge policy)
2. CSV/JSON export in History 2. Favorites / pin scans
@@ -1,23 +1,71 @@
package com.clean.scanner.data.scanner package com.clean.scanner.data.scanner
import android.graphics.Rect
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import com.clean.scanner.domain.ScanResult import com.clean.scanner.domain.ScanResult
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.common.InputImage
data class DetectionPoint(
val x: Float,
val y: Float
)
data class DetectionBox(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float,
val corners: List<DetectionPoint> = emptyList()
)
class MlKitBarcodeAnalyzer( class MlKitBarcodeAnalyzer(
private val isAnalysisEnabled: () -> Boolean = { true }, private val isAnalysisEnabled: () -> Boolean = { true },
private val onDetectionStateChanged: (
hasPotential: Boolean,
hasReadable: Boolean,
boxes: List<DetectionBox>,
sourceWidth: Int,
sourceHeight: Int
) -> Unit = { _, _, _, _, _ -> },
private val onDetected: (ScanResult) -> Unit private val onDetected: (ScanResult) -> Unit
) : ImageAnalysis.Analyzer, AutoCloseable { ) : ImageAnalysis.Analyzer, AutoCloseable {
private val scanner = BarcodeScanning.getClient() private val scanner = BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.enableAllPotentialBarcodes()
.build()
)
@Volatile @Volatile
private var processing = false private var processing = false
@Volatile
private var lastHasPotential = false
@Volatile
private var lastHasReadable = false
@Volatile
private var lastBoxes: List<DetectionBox> = emptyList()
@Volatile
private var lastSourceWidth = 0
@Volatile
private var lastSourceHeight = 0
override fun analyze(imageProxy: ImageProxy) { override fun analyze(imageProxy: ImageProxy) {
if (!isAnalysisEnabled() || processing) { if (!isAnalysisEnabled()) {
publishDetectionState(
hasPotential = false,
hasReadable = false,
boxes = emptyList(),
sourceWidth = 0,
sourceHeight = 0
)
imageProxy.close()
return
}
if (processing) {
imageProxy.close() imageProxy.close()
return return
} }
@@ -26,15 +74,58 @@ class MlKitBarcodeAnalyzer(
imageProxy.close() imageProxy.close()
return return
} }
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
val sourceWidth = if (rotationDegrees == 90 || rotationDegrees == 270) imageProxy.height else imageProxy.width
val sourceHeight = if (rotationDegrees == 90 || rotationDegrees == 270) imageProxy.width else imageProxy.height
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
processing = true processing = true
scanner.process(image) scanner.process(image)
.addOnSuccessListener { barcodes -> .addOnSuccessListener { barcodes ->
val first = barcodes.firstOrNull() ?: return@addOnSuccessListener val readable = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }
val raw = first.rawValue ?: return@addOnSuccessListener val boxes = barcodes.mapNotNull { barcode ->
val type = first.valueType.toHumanType() val bounds = barcode.boundingBox ?: return@mapNotNull null
onDetected(ScanResult(content = raw, type = type)) val normalized = normalizeBoundingBox(
rect = bounds,
width = imageProxy.width,
height = imageProxy.height,
rotationDegrees = rotationDegrees
) ?: 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
)
}?.filterNotNull() ?: emptyList()
normalized.copy(corners = normalizedCorners)
}
publishDetectionState(
hasPotential = barcodes.isNotEmpty(),
hasReadable = readable != null,
boxes = boxes,
sourceWidth = sourceWidth,
sourceHeight = sourceHeight
)
if (readable != null) {
onDetected(
ScanResult(
content = readable.rawValue.orEmpty(),
type = readable.valueType.toHumanType()
)
)
}
}
.addOnFailureListener {
publishDetectionState(
hasPotential = false,
hasReadable = false,
boxes = emptyList(),
sourceWidth = 0,
sourceHeight = 0
)
} }
.addOnCompleteListener { .addOnCompleteListener {
processing = false processing = false
@@ -61,4 +152,119 @@ class MlKitBarcodeAnalyzer(
override fun close() { override fun close() {
scanner.close() scanner.close()
} }
private fun publishDetectionState(
hasPotential: Boolean,
hasReadable: Boolean,
boxes: List<DetectionBox>,
sourceWidth: Int,
sourceHeight: Int
) {
if (
lastHasPotential == hasPotential &&
lastHasReadable == hasReadable &&
lastBoxes == boxes &&
lastSourceWidth == sourceWidth &&
lastSourceHeight == sourceHeight
) return
lastHasPotential = hasPotential
lastHasReadable = hasReadable
lastBoxes = boxes
lastSourceWidth = sourceWidth
lastSourceHeight = sourceHeight
onDetectionStateChanged(hasPotential, hasReadable, boxes, sourceWidth, sourceHeight)
}
private fun normalizeBoundingBox(
rect: Rect,
width: Int,
height: Int,
rotationDegrees: 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
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)
)
}
private fun normalizePoint(
x: Float,
y: Float,
width: Int,
height: Int,
rotationDegrees: Int
): DetectionPoint? {
if (width <= 0 || height <= 0) return null
var outX = x
var outY = y
var outWidth = width.toFloat()
var outHeight = height.toFloat()
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 (outWidth <= 0f || outHeight <= 0f) return null
return DetectionPoint(
x = (outX / outWidth).coerceIn(0f, 1f),
y = (outY / outHeight).coerceIn(0f, 1f)
)
}
} }
@@ -6,6 +6,7 @@ import android.view.ScaleGestureDetector
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.core.UseCaseGroup
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -19,6 +20,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clean.scanner.data.scanner.DetectionBox
import com.clean.scanner.data.scanner.MlKitBarcodeAnalyzer import com.clean.scanner.data.scanner.MlKitBarcodeAnalyzer
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -32,19 +34,39 @@ fun CameraPreview(
analysisEnabled: Boolean, analysisEnabled: Boolean,
torchEnabled: Boolean, torchEnabled: Boolean,
onTorchAvailabilityChanged: (Boolean) -> Unit, onTorchAvailabilityChanged: (Boolean) -> Unit,
onDetectionStateChanged: (
hasPotential: Boolean,
hasReadable: Boolean,
boxes: List<DetectionBox>,
sourceWidth: Int,
sourceHeight: Int
) -> Unit = { _, _, _, _, _ -> },
onScan: (String, String) -> Unit onScan: (String, String) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
val mainExecutor = remember(context) { ContextCompat.getMainExecutor(context) }
val previewView = remember { PreviewView(context) } val previewView = remember { PreviewView(context) }
val cameraRef = remember { mutableStateOf<androidx.camera.core.Camera?>(null) } val cameraRef = remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
val zoomRatio = remember { mutableStateOf(1f) } val zoomRatio = remember { mutableStateOf(1f) }
val latestAnalysisEnabled = rememberUpdatedState(analysisEnabled) val latestAnalysisEnabled = rememberUpdatedState(analysisEnabled)
val latestOnScan = rememberUpdatedState(onScan) val latestOnScan = rememberUpdatedState(onScan)
val latestOnDetectionStateChanged = rememberUpdatedState(onDetectionStateChanged)
val analyzer = remember { val analyzer = remember {
MlKitBarcodeAnalyzer( MlKitBarcodeAnalyzer(
isAnalysisEnabled = { latestAnalysisEnabled.value }, isAnalysisEnabled = { latestAnalysisEnabled.value },
onDetectionStateChanged = { hasPotential, hasReadable, boxes, sourceWidth, sourceHeight ->
mainExecutor.execute {
latestOnDetectionStateChanged.value(
hasPotential,
hasReadable,
boxes,
sourceWidth,
sourceHeight
)
}
},
onDetected = { result -> latestOnScan.value(result.content, result.type) } onDetected = { result -> latestOnScan.value(result.content, result.type) }
) )
} }
@@ -87,7 +109,18 @@ fun CameraPreview(
setAnalyzer(cameraExecutor, analyzer) setAnalyzer(cameraExecutor, analyzer)
} }
val camera = provider.bindToLifecycle( val camera = previewView.viewPort?.let { viewPort ->
val group = UseCaseGroup.Builder()
.setViewPort(viewPort)
.addUseCase(preview)
.addUseCase(imageAnalysis)
.build()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
group
)
} ?: provider.bindToLifecycle(
lifecycleOwner, lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA, CameraSelector.DEFAULT_BACK_CAMERA,
preview, preview,
@@ -107,6 +140,7 @@ fun CameraPreview(
modifier = modifier, modifier = modifier,
factory = { factory = {
previewView.apply { previewView.apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
setOnTouchListener { _, event -> setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) { if (event.actionMasked == MotionEvent.ACTION_DOWN) {
performClick() performClick()
@@ -22,20 +22,25 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ViewList
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.FlashOff
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.ViewModule
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -49,6 +54,7 @@ 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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
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
@@ -56,10 +62,12 @@ 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
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.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
import com.clean.scanner.R import com.clean.scanner.R
import com.clean.scanner.data.scanner.DetectionBox
import com.clean.scanner.domain.ScanResult import com.clean.scanner.domain.ScanResult
import com.clean.scanner.ui.BatchScanRecord import com.clean.scanner.ui.BatchScanRecord
import com.clean.scanner.ui.components.CameraPreview import com.clean.scanner.ui.components.CameraPreview
@@ -68,10 +76,12 @@ import com.clean.scanner.util.Intents
import com.clean.scanner.util.ScanContentParsers import com.clean.scanner.util.ScanContentParsers
import com.clean.scanner.util.UrlRiskScorer import com.clean.scanner.util.UrlRiskScorer
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode 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.math.max
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -110,8 +120,21 @@ 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 hasPotentialInView by remember { mutableStateOf(false) }
var hasReadableInView by remember { mutableStateOf(false) }
var detectionBoxes by remember { mutableStateOf<List<DetectionBox>>(emptyList()) }
var detectionSourceWidth by remember { mutableStateOf(0) }
var detectionSourceHeight by remember { mutableStateOf(0) }
val activity = context as? Activity val activity = context as? Activity
val imageScanner = remember { BarcodeScanning.getClient() } val imageScanner = remember {
BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.enableAllPotentialBarcodes()
.build()
)
}
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
@@ -138,12 +161,21 @@ fun ScannerScreen(
imageScanner.process(image) imageScanner.process(image)
.addOnSuccessListener { barcodes -> .addOnSuccessListener { barcodes ->
val first = barcodes.firstOrNull() val candidates = barcodes.mapNotNull { barcode ->
val raw = first?.rawValue val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
if (raw.isNullOrBlank()) { ScanResult(content = raw, type = barcode.valueType.toHumanType())
showNoCodeInImage = true }.distinctBy { "${it.type}|${it.content}" }
when (candidates.size) {
0 -> showNoCodeInImage = true
1 -> {
if (barcodes.size > 1) {
imageScanCandidates = candidates
} else { } else {
onScan(ScanResult(content = raw, type = first.valueType.toHumanType())) onScan(candidates.first())
}
}
else -> imageScanCandidates = candidates
} }
} }
.addOnFailureListener { .addOnFailureListener {
@@ -194,18 +226,80 @@ fun ScannerScreen(
analysisEnabled = analysisEnabled, analysisEnabled = analysisEnabled,
torchEnabled = torchEnabled, torchEnabled = torchEnabled,
onTorchAvailabilityChanged = { torchAvailable = it }, onTorchAvailabilityChanged = { torchAvailable = it },
onDetectionStateChanged = { hasPotential, hasReadable, boxes, sourceWidth, sourceHeight ->
hasPotentialInView = hasPotential
hasReadableInView = hasReadable
detectionBoxes = boxes
detectionSourceWidth = sourceWidth
detectionSourceHeight = sourceHeight
},
onScan = { content, type -> onScan = { content, type ->
onScan(ScanResult(content = content, type = type)) onScan(ScanResult(content = content, type = type))
} }
) )
if (detectionBoxes.isNotEmpty()) {
Canvas(
modifier = Modifier
.fillMaxSize()
) {
val sourceW = detectionSourceWidth.toFloat()
val sourceH = detectionSourceHeight.toFloat()
if (sourceW <= 0f || sourceH <= 0f) return@Canvas
val scale = max(size.width / sourceW, size.height / sourceH)
val scaledW = sourceW * scale
val scaledH = sourceH * scale
val offsetX = (size.width - scaledW) / 2f
val offsetY = (size.height - scaledH) / 2f
val boxColor = if (hasReadableInView) Color(0xFF4AE3A3) else Color(0xFFFFC857)
detectionBoxes.forEach { box ->
val left = offsetX + (box.left * sourceW * scale)
val top = offsetY + (box.top * sourceH * scale)
val right = offsetX + (box.right * sourceW * scale)
val bottom = offsetY + (box.bottom * sourceH * scale)
val mappedCorners = box.corners.map { p ->
androidx.compose.ui.geometry.Offset(
x = offsetX + (p.x * sourceW * scale),
y = offsetY + (p.y * sourceH * scale)
)
}
if (mappedCorners.size >= 4) {
val outline = Path().apply {
moveTo(mappedCorners.first().x, mappedCorners.first().y)
mappedCorners.drop(1).forEach { pt -> lineTo(pt.x, pt.y) }
close()
}
drawPath(
path = outline,
color = boxColor.copy(alpha = 0.96f),
style = Stroke(width = 4f)
)
} else if (right > left && bottom > top) {
drawRoundRect(
color = boxColor.copy(alpha = 0.95f),
topLeft = androidx.compose.ui.geometry.Offset(left, top),
size = androidx.compose.ui.geometry.Size(right - left, bottom - top),
cornerRadius = CornerRadius(14f, 14f),
style = Stroke(width = 4f)
)
}
}
}
}
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .align(Alignment.Center)
.fillMaxWidth(0.7f) .fillMaxWidth(0.7f)
.height(220.dp) .height(220.dp)
) { ) {
val guideColor = Color(0xFF7CE6C6) val guideColor = when {
hasReadableInView -> Color(0xFF4AE3A3)
hasPotentialInView -> Color(0xFFFFC857)
else -> Color(0xFF7CE6C6)
}
val cx = size.width / 2f val cx = size.width / 2f
val cy = size.height / 2f val cy = size.height / 2f
drawRoundRect( drawRoundRect(
@@ -225,7 +319,11 @@ fun ScannerScreen(
} }
Text( Text(
text = stringResource(R.string.pinch_to_zoom_hint), text = when {
hasReadableInView -> stringResource(R.string.live_readable_detected)
hasPotentialInView -> stringResource(R.string.live_potential_detected)
else -> stringResource(R.string.pinch_to_zoom_hint)
},
color = Color.White, color = Color.White,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
@@ -262,16 +360,21 @@ fun ScannerScreen(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (torchAvailable) { if (torchAvailable) {
OverlayToggle( OverlayIconToggle(
checked = torchEnabled, checked = torchEnabled,
onCheckedChange = { torchEnabled = it }, onCheckedChange = { torchEnabled = it },
label = stringResource(R.string.flashlight) label = stringResource(R.string.flashlight),
checkedImageVector = Icons.Default.FlashOn,
uncheckedImageVector = Icons.Default.FlashOff,
showLabel = false
) )
} }
OverlayToggle( OverlayIconToggle(
checked = batchMode, checked = batchMode,
onCheckedChange = onBatchModeChange, onCheckedChange = onBatchModeChange,
label = stringResource(R.string.batch_mode) label = stringResource(R.string.batch_mode),
checkedImageVector = Icons.Default.ViewModule,
uncheckedImageVector = Icons.AutoMirrored.Filled.ViewList
) )
} }
@@ -426,6 +529,56 @@ 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()
)
}
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { imageScanCandidates = emptyList() }) {
Text(stringResource(R.string.cancel))
}
}
)
}
if (showImageScanFailed) { if (showImageScanFailed) {
AlertDialog( AlertDialog(
onDismissRequest = { showImageScanFailed = false }, onDismissRequest = { showImageScanFailed = false },
@@ -441,12 +594,16 @@ fun ScannerScreen(
} }
@Composable @Composable
private fun OverlayToggle( private fun OverlayIconToggle(
checked: Boolean, checked: Boolean,
onCheckedChange: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit,
label: String label: String,
checkedImageVector: androidx.compose.ui.graphics.vector.ImageVector,
uncheckedImageVector: androidx.compose.ui.graphics.vector.ImageVector,
showLabel: Boolean = true
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.background( .background(
color = Color.Black.copy(alpha = 0.35f), color = Color.Black.copy(alpha = 0.35f),
@@ -454,8 +611,23 @@ private fun OverlayToggle(
) )
.padding(horizontal = 10.dp, vertical = 8.dp) .padding(horizontal = 10.dp, vertical = 8.dp)
) { ) {
Text(text = label, color = Color.White) IconToggleButton(
Switch(checked = checked, onCheckedChange = onCheckedChange) checked = checked,
onCheckedChange = onCheckedChange
) {
Icon(
imageVector = if (checked) checkedImageVector else uncheckedImageVector,
contentDescription = label,
tint = if (checked) Color(0xFF4AE3A3) else Color.White
)
}
if (showLabel) {
Text(
text = label,
color = Color.White,
textAlign = TextAlign.Center
)
}
} }
} }
@@ -2,16 +2,15 @@ package com.clean.scanner.util
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.provider.CalendarContract import android.provider.CalendarContract
import android.provider.ContactsContract import android.provider.ContactsContract
import android.provider.Settings import android.provider.Settings
import androidx.core.content.ContextCompat.startActivity import androidx.core.net.toUri
object Intents { object Intents {
fun openUrl(context: Context, url: String) { fun openUrl(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val intent = Intent(Intent.ACTION_VIEW, url.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(context, intent, null) context.startActivity(intent)
} }
fun shareText(context: Context, text: String) { fun shareText(context: Context, text: String) {
@@ -24,27 +23,28 @@ object Intents {
.putExtra(Intent.EXTRA_TEXT, text) .putExtra(Intent.EXTRA_TEXT, text)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val chooser = Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val chooser = Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(context, chooser, null) context.startActivity(chooser)
} }
fun dialPhone(context: Context, number: String) { fun dialPhone(context: Context, number: String) {
val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${number.trim()}")) val intent = Intent(Intent.ACTION_DIAL, "tel:${number.trim()}".toUri())
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(context, intent, null) context.startActivity(intent)
} }
@Suppress("SpellCheckingInspection")
fun sendSms(context: Context, number: String, message: String?) { fun sendSms(context: Context, number: String, message: String?) {
val base = number.trim() val base = number.trim()
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:$base")) val intent = Intent(Intent.ACTION_SENDTO, "smsto:$base".toUri())
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (!message.isNullOrBlank()) { if (!message.isNullOrBlank()) {
intent.putExtra("sms_body", message) intent.putExtra("sms_body", message)
} }
startActivity(context, intent, null) context.startActivity(intent)
} }
fun sendEmail(context: Context, email: String, subject: String?, body: String? = null) { fun sendEmail(context: Context, email: String, subject: String?, body: String? = null) {
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${email.trim()}")) val intent = Intent(Intent.ACTION_SENDTO, "mailto:${email.trim()}".toUri())
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (!subject.isNullOrBlank()) { if (!subject.isNullOrBlank()) {
intent.putExtra(Intent.EXTRA_SUBJECT, subject) intent.putExtra(Intent.EXTRA_SUBJECT, subject)
@@ -52,12 +52,12 @@ object Intents {
if (!body.isNullOrBlank()) { if (!body.isNullOrBlank()) {
intent.putExtra(Intent.EXTRA_TEXT, body) intent.putExtra(Intent.EXTRA_TEXT, body)
} }
startActivity(context, intent, null) context.startActivity(intent)
} }
fun openWifiSettings(context: Context) { fun openWifiSettings(context: Context) {
val intent = Intent(Settings.ACTION_WIFI_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val intent = Intent(Settings.ACTION_WIFI_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(context, intent, null) context.startActivity(intent)
} }
fun addContact(context: Context, rawContent: String) { fun addContact(context: Context, rawContent: String) {
@@ -66,7 +66,7 @@ object Intents {
putExtra(ContactsContract.Intents.Insert.NOTES, rawContent) putExtra(ContactsContract.Intents.Insert.NOTES, rawContent)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
startActivity(context, intent, null) context.startActivity(intent)
} }
fun addCalendarEvent(context: Context, rawContent: String) { fun addCalendarEvent(context: Context, rawContent: String) {
@@ -76,6 +76,6 @@ object Intents {
putExtra(CalendarContract.Events.DESCRIPTION, rawContent) putExtra(CalendarContract.Events.DESCRIPTION, rawContent)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
startActivity(context, intent, null) context.startActivity(intent)
} }
} }
+4
View File
@@ -33,6 +33,8 @@
<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="live_potential_detected">Code erkannt. Ruhig halten oder näher rangehen.</string>
<string name="live_readable_detected">Lesbarer Code erkannt.</string>
<string name="share_history">Historie teilen</string> <string name="share_history">Historie teilen</string>
<string name="share_txt">TXT</string> <string name="share_txt">TXT</string>
<string name="share_csv">CSV</string> <string name="share_csv">CSV</string>
@@ -43,6 +45,8 @@
<string name="clear_batch">Stapel leeren</string> <string name="clear_batch">Stapel leeren</string>
<string name="share_batch">Stapel teilen</string> <string name="share_batch">Stapel teilen</string>
<string name="no_code_found_in_image">Im gewählten Bild wurde kein QR- oder Barcode gefunden.</string> <string name="no_code_found_in_image">Im gewählten Bild wurde kein QR- oder Barcode gefunden.</string>
<string name="image_scan_pick_title">%1$d Codes im Bild gefunden</string>
<string name="image_scan_pick_subtitle">Wähle ein Ergebnis aus:</string>
<string name="image_scan_failed">Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen.</string> <string name="image_scan_failed">Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen.</string>
<string name="already_scanned">Bereits gescannt</string> <string name="already_scanned">Bereits gescannt</string>
<string name="view_history">Historie anzeigen</string> <string name="view_history">Historie anzeigen</string>
+4
View File
@@ -33,6 +33,8 @@
<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="live_potential_detected">Code spotted. Hold steady or move closer.</string>
<string name="live_readable_detected">Readable code detected.</string>
<string name="share_history">Share history</string> <string name="share_history">Share history</string>
<string name="share_txt">TXT</string> <string name="share_txt">TXT</string>
<string name="share_csv">CSV</string> <string name="share_csv">CSV</string>
@@ -43,6 +45,8 @@
<string name="clear_batch">Clear batch</string> <string name="clear_batch">Clear batch</string>
<string name="share_batch">Share batch</string> <string name="share_batch">Share batch</string>
<string name="no_code_found_in_image">No QR or barcode found in the selected image.</string> <string name="no_code_found_in_image">No QR or barcode found in the selected image.</string>
<string name="image_scan_pick_title">Found %1$d codes in image</string>
<string name="image_scan_pick_subtitle">Choose a result to use:</string>
<string name="image_scan_failed">Could not read this image. Try another one.</string> <string name="image_scan_failed">Could not read this image. Try another one.</string>
<string name="already_scanned">Already scanned</string> <string name="already_scanned">Already scanned</string>
<string name="view_history">View history</string> <string name="view_history">View history</string>
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
id("com.android.application") version "9.0.0" apply false id("com.android.application") version "9.0.0" apply false
id("com.google.devtools.ksp") version "2.3.5" apply false id("com.google.devtools.ksp") version "2.3.5" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false
} }