diff --git a/README.md b/README.md index 4e79c02..0bc81b6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ Offline-first, ad-free QR/barcode scanner built with Kotlin, Jetpack Compose, Ca ## MVP Features - 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 - URL-Sicherheitswarnung bei lokalem `riskScore >= 3` (kein Blocken, nur Hinweis) - Historie: Suche, Swipe-to-delete, Alles-löschen, Detailansicht mit Volltext diff --git a/ROADMAP.md b/ROADMAP.md index 9cae2d3..a8e188a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,9 @@ - [x] Settings for scan feedback - 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) 1. Import history @@ -30,8 +33,8 @@ 3. Tagging - Add tags/folders and filter chips in History. -4. Improved image scan -- Support multi-code detection from one image and let user pick result. +4. [x] Improved image scan +- Multi-code detection from one image with result picker. ## Bigger Features (1-3 weeks) @@ -50,5 +53,5 @@ ## Suggested Next 2 -1. Banner/snackbar duplicate feedback -2. CSV/JSON export in History +1. Import history (CSV/JSON restore + merge policy) +2. Favorites / pin scans diff --git a/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt b/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt index db32d8c..3badaa1 100644 --- a/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt +++ b/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt @@ -1,23 +1,71 @@ package com.clean.scanner.data.scanner +import android.graphics.Rect import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy import com.clean.scanner.domain.ScanResult 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.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 = emptyList() +) + class MlKitBarcodeAnalyzer( private val isAnalysisEnabled: () -> Boolean = { true }, + private val onDetectionStateChanged: ( + hasPotential: Boolean, + hasReadable: Boolean, + boxes: List, + sourceWidth: Int, + sourceHeight: Int + ) -> Unit = { _, _, _, _, _ -> }, private val onDetected: (ScanResult) -> Unit ) : ImageAnalysis.Analyzer, AutoCloseable { - private val scanner = BarcodeScanning.getClient() + private val scanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS) + .enableAllPotentialBarcodes() + .build() + ) @Volatile private var processing = false + @Volatile + private var lastHasPotential = false + @Volatile + private var lastHasReadable = false + @Volatile + private var lastBoxes: List = emptyList() + @Volatile + private var lastSourceWidth = 0 + @Volatile + private var lastSourceHeight = 0 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() return } @@ -26,15 +74,58 @@ class MlKitBarcodeAnalyzer( imageProxy.close() 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) processing = true scanner.process(image) .addOnSuccessListener { barcodes -> - val first = barcodes.firstOrNull() ?: return@addOnSuccessListener - val raw = first.rawValue ?: return@addOnSuccessListener - val type = first.valueType.toHumanType() - onDetected(ScanResult(content = raw, type = type)) + val readable = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() } + val boxes = barcodes.mapNotNull { barcode -> + val bounds = barcode.boundingBox ?: return@mapNotNull null + 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 { processing = false @@ -61,4 +152,119 @@ class MlKitBarcodeAnalyzer( override fun close() { scanner.close() } + + private fun publishDetectionState( + hasPotential: Boolean, + hasReadable: Boolean, + boxes: List, + 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) + ) + } } diff --git a/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt b/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt index 609361c..e00526e 100644 --- a/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt +++ b/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt @@ -6,6 +6,7 @@ import android.view.ScaleGestureDetector import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview +import androidx.camera.core.UseCaseGroup import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.runtime.Composable @@ -19,6 +20,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat +import com.clean.scanner.data.scanner.DetectionBox import com.clean.scanner.data.scanner.MlKitBarcodeAnalyzer import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -32,19 +34,39 @@ fun CameraPreview( analysisEnabled: Boolean, torchEnabled: Boolean, onTorchAvailabilityChanged: (Boolean) -> Unit, + onDetectionStateChanged: ( + hasPotential: Boolean, + hasReadable: Boolean, + boxes: List, + sourceWidth: Int, + sourceHeight: Int + ) -> Unit = { _, _, _, _, _ -> }, onScan: (String, String) -> Unit ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } + val mainExecutor = remember(context) { ContextCompat.getMainExecutor(context) } val previewView = remember { PreviewView(context) } val cameraRef = remember { mutableStateOf(null) } val zoomRatio = remember { mutableStateOf(1f) } val latestAnalysisEnabled = rememberUpdatedState(analysisEnabled) val latestOnScan = rememberUpdatedState(onScan) + val latestOnDetectionStateChanged = rememberUpdatedState(onDetectionStateChanged) val analyzer = remember { MlKitBarcodeAnalyzer( 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) } ) } @@ -87,7 +109,18 @@ fun CameraPreview( 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, CameraSelector.DEFAULT_BACK_CAMERA, preview, @@ -107,6 +140,7 @@ fun CameraPreview( modifier = modifier, factory = { previewView.apply { + scaleType = PreviewView.ScaleType.FILL_CENTER setOnTouchListener { _, event -> if (event.actionMasked == MotionEvent.ACTION_DOWN) { performClick() diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt index 3655ecd..2213a5a 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt @@ -22,20 +22,25 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape 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.FlashOff +import androidx.compose.material.icons.filled.FlashOn import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.ViewModule import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -49,6 +54,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.hapticfeedback.HapticFeedbackType 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.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import com.clean.scanner.R +import com.clean.scanner.data.scanner.DetectionBox import com.clean.scanner.domain.ScanResult import com.clean.scanner.ui.BatchScanRecord 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.UrlRiskScorer 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.common.InputImage import java.text.DateFormat import java.util.Date +import kotlin.math.max @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -110,8 +120,21 @@ fun ScannerScreen( var pendingOpenUrl by remember { mutableStateOf(null) } var showImageScanFailed by remember { mutableStateOf(false) } var showNoCodeInImage by remember { mutableStateOf(false) } + var imageScanCandidates by remember { mutableStateOf>(emptyList()) } + var hasPotentialInView by remember { mutableStateOf(false) } + var hasReadableInView by remember { mutableStateOf(false) } + var detectionBoxes by remember { mutableStateOf>(emptyList()) } + var detectionSourceWidth by remember { mutableStateOf(0) } + var detectionSourceHeight by remember { mutableStateOf(0) } 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( contract = ActivityResultContracts.RequestPermission() @@ -138,12 +161,21 @@ fun ScannerScreen( imageScanner.process(image) .addOnSuccessListener { barcodes -> - val first = barcodes.firstOrNull() - val raw = first?.rawValue - if (raw.isNullOrBlank()) { - showNoCodeInImage = true - } else { - onScan(ScanResult(content = raw, type = first.valueType.toHumanType())) + val candidates = barcodes.mapNotNull { barcode -> + val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + ScanResult(content = raw, type = barcode.valueType.toHumanType()) + }.distinctBy { "${it.type}|${it.content}" } + + when (candidates.size) { + 0 -> showNoCodeInImage = true + 1 -> { + if (barcodes.size > 1) { + imageScanCandidates = candidates + } else { + onScan(candidates.first()) + } + } + else -> imageScanCandidates = candidates } } .addOnFailureListener { @@ -194,18 +226,80 @@ fun ScannerScreen( analysisEnabled = analysisEnabled, torchEnabled = torchEnabled, onTorchAvailabilityChanged = { torchAvailable = it }, + onDetectionStateChanged = { hasPotential, hasReadable, boxes, sourceWidth, sourceHeight -> + hasPotentialInView = hasPotential + hasReadableInView = hasReadable + detectionBoxes = boxes + detectionSourceWidth = sourceWidth + detectionSourceHeight = sourceHeight + }, onScan = { content, 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( modifier = Modifier .align(Alignment.Center) .fillMaxWidth(0.7f) .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 cy = size.height / 2f drawRoundRect( @@ -225,7 +319,11 @@ fun ScannerScreen( } 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, textAlign = TextAlign.Center, modifier = Modifier @@ -262,16 +360,21 @@ fun ScannerScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (torchAvailable) { - OverlayToggle( + OverlayIconToggle( checked = torchEnabled, 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, 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) { AlertDialog( onDismissRequest = { showImageScanFailed = false }, @@ -441,12 +594,16 @@ fun ScannerScreen( } @Composable -private fun OverlayToggle( +private fun OverlayIconToggle( checked: Boolean, 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( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .background( color = Color.Black.copy(alpha = 0.35f), @@ -454,8 +611,23 @@ private fun OverlayToggle( ) .padding(horizontal = 10.dp, vertical = 8.dp) ) { - Text(text = label, color = Color.White) - Switch(checked = checked, onCheckedChange = onCheckedChange) + IconToggleButton( + 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 + ) + } } } diff --git a/app/src/main/java/com/clean/scanner/util/Intents.kt b/app/src/main/java/com/clean/scanner/util/Intents.kt index e29a446..8a0cfab 100644 --- a/app/src/main/java/com/clean/scanner/util/Intents.kt +++ b/app/src/main/java/com/clean/scanner/util/Intents.kt @@ -2,16 +2,15 @@ package com.clean.scanner.util import android.content.Context import android.content.Intent -import android.net.Uri import android.provider.CalendarContract import android.provider.ContactsContract import android.provider.Settings -import androidx.core.content.ContextCompat.startActivity +import androidx.core.net.toUri object Intents { fun openUrl(context: Context, url: String) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(context, intent, null) + val intent = Intent(Intent.ACTION_VIEW, url.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) } fun shareText(context: Context, text: String) { @@ -24,27 +23,28 @@ object Intents { .putExtra(Intent.EXTRA_TEXT, text) .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) { - 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) - startActivity(context, intent, null) + context.startActivity(intent) } + @Suppress("SpellCheckingInspection") fun sendSms(context: Context, number: String, message: String?) { 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) if (!message.isNullOrBlank()) { intent.putExtra("sms_body", message) } - startActivity(context, intent, null) + context.startActivity(intent) } 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) if (!subject.isNullOrBlank()) { intent.putExtra(Intent.EXTRA_SUBJECT, subject) @@ -52,12 +52,12 @@ object Intents { if (!body.isNullOrBlank()) { intent.putExtra(Intent.EXTRA_TEXT, body) } - startActivity(context, intent, null) + context.startActivity(intent) } fun openWifiSettings(context: Context) { 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) { @@ -66,7 +66,7 @@ object Intents { putExtra(ContactsContract.Intents.Insert.NOTES, rawContent) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - startActivity(context, intent, null) + context.startActivity(intent) } fun addCalendarEvent(context: Context, rawContent: String) { @@ -76,6 +76,6 @@ object Intents { putExtra(CalendarContract.Events.DESCRIPTION, rawContent) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - startActivity(context, intent, null) + context.startActivity(intent) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c96f595..f04889e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -33,6 +33,8 @@ Inhalt Kamera erlauben Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen + Code erkannt. Ruhig halten oder näher rangehen. + Lesbarer Code erkannt. Historie teilen TXT CSV @@ -43,6 +45,8 @@ Stapel leeren Stapel teilen Im gewählten Bild wurde kein QR- oder Barcode gefunden. + %1$d Codes im Bild gefunden + Wähle ein Ergebnis aus: Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen. Bereits gescannt Historie anzeigen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e8ffc3..e4df298 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,8 @@ Content Allow camera Pinch to zoom for small codes + Code spotted. Hold steady or move closer. + Readable code detected. Share history TXT CSV @@ -43,6 +45,8 @@ Clear batch Share batch No QR or barcode found in the selected image. + Found %1$d codes in image + Choose a result to use: Could not read this image. Try another one. Already scanned View history diff --git a/build.gradle.kts b/build.gradle.kts index 801cd3f..577fa7a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ plugins { id("com.android.application") version "9.0.0" 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 }