diff --git a/app/src/main/java/com/clean/scanner/ui/UseCaseView.kt b/app/src/main/java/com/clean/scanner/ui/UseCaseView.kt index 813ac5d..b6e4576 100644 --- a/app/src/main/java/com/clean/scanner/ui/UseCaseView.kt +++ b/app/src/main/java/com/clean/scanner/ui/UseCaseView.kt @@ -48,6 +48,12 @@ fun UseCaseView.capabilities(): UseCaseCapabilities { allowCopy = true, allowShare = true, allowOpenUrl = true, + allowAddContact = true, + allowDialPhone = true, + allowSendSms = true, + allowSendEmail = true, + allowOpenWifiSettings = true, + allowAddCalendarEvent = true, allowHistoryExport = true ) diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt index 0073425..3542cee 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt @@ -9,16 +9,23 @@ import android.os.Build import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -46,6 +53,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntSize @@ -54,6 +62,7 @@ import de.softwareapp_hb.privateqrscanner.R import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint import de.softwareapp_hb.privateqrscanner.domain.ScanResult +import de.softwareapp_hb.privateqrscanner.ui.theme.PrivateQrColors import de.softwareapp_hb.privateqrscanner.util.readableBarcodePayload import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanner @@ -104,6 +113,7 @@ internal fun GalleryScanPreviewDialog( val context = LocalContext.current val bitmap = remember(imageUri) { imageUri?.let { loadBitmapFromUri(context, it) } } var liveCandidates by remember(imageUri, candidates) { mutableStateOf(candidates) } + var selectedIndex by remember(imageUri) { mutableIntStateOf(0) } var zoom by remember(imageUri) { mutableFloatStateOf(1f) } var pan by remember(imageUri) { mutableStateOf(Offset.Zero) } var viewportSize by remember { mutableStateOf(IntSize.Zero) } @@ -130,6 +140,14 @@ internal fun GalleryScanPreviewDialog( onDispose { scanner.close() } } + LaunchedEffect(liveCandidates.size) { + selectedIndex = if (liveCandidates.isEmpty()) { + 0 + } else { + selectedIndex.coerceIn(0, liveCandidates.lastIndex) + } + } + LaunchedEffect(bitmap, viewportSize, zoom, pan, scanTick) { val bmp = bitmap ?: return@LaunchedEffect val vw = viewportSize.width.toFloat() @@ -204,14 +222,33 @@ internal fun GalleryScanPreviewDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.image_scan_pick_title, liveCandidates.size)) }, + containerColor = PrivateQrColors.Surface, + shape = RoundedCornerShape(30.dp), + title = { + Text( + text = stringResource(R.string.image_scan_pick_title, liveCandidates.size), + color = PrivateQrColors.TextPrimary, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold + ) + }, text = { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + Text( + text = if (liveCandidates.isEmpty()) { + stringResource(R.string.no_code_found_in_image) + } else { + stringResource(R.string.image_scan_pick_subtitle) + }, + color = PrivateQrColors.TextSecondary, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold + ) if (bitmap != null) { Box( modifier = Modifier @@ -228,7 +265,7 @@ internal fun GalleryScanPreviewDialog( scanTick++ } } - .background(Color.Black.copy(alpha = 0.32f), RoundedCornerShape(12.dp)), + .background(PrivateQrColors.Navy, RoundedCornerShape(24.dp)), contentAlignment = Alignment.Center ) { Image( @@ -267,7 +304,7 @@ internal fun GalleryScanPreviewDialog( liveCandidates.forEachIndexed { index, candidate -> val box = candidate.box ?: return@forEachIndexed - val color = Color(0xFF4AE3A3).copy(alpha = 0.96f) + val color = PrivateQrColors.Teal300.copy(alpha = 0.96f) val points = box.corners.map { p -> imageToScreen(p.x * imageW, p.y * imageH) } @@ -322,20 +359,45 @@ internal fun GalleryScanPreviewDialog( } } - if (liveCandidates.isEmpty()) { - Text(text = stringResource(R.string.no_code_found_in_image)) - } else { - Text(text = stringResource(R.string.image_scan_pick_subtitle)) + if (liveCandidates.isNotEmpty()) { liveCandidates.forEachIndexed { index, candidate -> - TextButton( - onClick = { onPick(candidate) }, - modifier = Modifier.fillMaxWidth() + val selected = index == selectedIndex + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = if (selected) Color(0xFFECFDF5) else PrivateQrColors.AppBackground, + shape = RoundedCornerShape(24.dp) + ) + .border( + width = 2.dp, + color = if (selected) PrivateQrColors.Teal300 else Color.Transparent, + shape = RoundedCornerShape(24.dp) + ) + .clickable { selectedIndex = index } + .padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .size(54.dp) + .background(PrivateQrColors.Mint, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { Text( - text = "${index + 1}. ${candidate.result.displayType}", - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() + text = "${index + 1}", + color = PrivateQrColors.Teal700, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = candidate.result.displayType, + color = PrivateQrColors.TextPrimary, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold ) Text( text = if (candidate.result.isBase64Encoded) { @@ -343,10 +405,11 @@ internal fun GalleryScanPreviewDialog( } else { candidate.result.content }, + color = PrivateQrColors.TextSecondary, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, maxLines = 2, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() + overflow = TextOverflow.Ellipsis ) } } @@ -354,9 +417,29 @@ internal fun GalleryScanPreviewDialog( } } }, - confirmButton = {}, + confirmButton = { + val selected = liveCandidates.getOrNull(selectedIndex) + TextButton( + onClick = { + if (selected != null) onPick(selected) + }, + enabled = selected != null, + colors = ButtonDefaults.textButtonColors( + containerColor = PrivateQrColors.Teal700, + contentColor = PrivateQrColors.Surface, + disabledContainerColor = PrivateQrColors.TextSecondary.copy(alpha = 0.18f), + disabledContentColor = PrivateQrColors.TextSecondary + ), + shape = RoundedCornerShape(18.dp) + ) { + Text(stringResource(R.string.image_scan_use_selected)) + } + }, dismissButton = { - TextButton(onClick = onDismiss) { + TextButton( + onClick = onDismiss, + colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700) + ) { Text(stringResource(R.string.cancel)) } } 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 fcdffe8..7a54c39 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 @@ -801,15 +801,44 @@ fun ScannerScreen( if (showRiskWarning && pendingOpenUrl != null) { AlertDialog( onDismissRequest = { showRiskWarning = false }, - text = { Text(stringResource(R.string.risk_warning)) }, + containerColor = PrivateQrColors.Surface, + shape = RoundedCornerShape(28.dp), + title = { + Text( + text = stringResource(R.string.risk_warning_title), + color = PrivateQrColors.TextPrimary, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = stringResource(R.string.risk_warning), + color = PrivateQrColors.TextSecondary, + style = MaterialTheme.typography.bodyLarge + ) + }, confirmButton = { - TextButton(onClick = { - Intents.openUrl(context, pendingOpenUrl!!) - showRiskWarning = false - }) { Text(stringResource(R.string.open_anyway)) } + TextButton( + onClick = { + Intents.openUrl(context, pendingOpenUrl!!) + showRiskWarning = false + }, + colors = ButtonDefaults.textButtonColors( + containerColor = PrivateQrColors.Teal700, + contentColor = PrivateQrColors.Surface + ), + shape = RoundedCornerShape(18.dp) + ) { + Text(stringResource(R.string.open_anyway)) + } }, dismissButton = { - TextButton(onClick = { showRiskWarning = false }) { + TextButton( + onClick = { showRiskWarning = false }, + colors = ButtonDefaults.textButtonColors( + contentColor = PrivateQrColors.Teal700 + ) + ) { Text(stringResource(R.string.cancel)) } } diff --git a/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt index eb290fb..ad05d55 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt @@ -2,7 +2,10 @@ package de.softwareapp_hb.privateqrscanner.ui.screens import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -80,19 +83,27 @@ fun SettingsScreen( if (showUseCasePicker.value) { AlertDialog( onDismissRequest = { showUseCasePicker.value = false }, - title = { Text(stringResource(R.string.select_use_case_view)) }, + containerColor = PrivateQrColors.Surface, + shape = RoundedCornerShape(30.dp), + title = { + Text( + text = stringResource(R.string.select_use_case_view), + color = PrivateQrColors.TextPrimary, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold + ) + }, text = { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { UseCaseView.entries.forEach { candidate -> - TextButton( + UseCasePickerOption( + candidate = candidate, + selected = candidate == selectedUseCaseView, onClick = { onUseCaseViewSelected(candidate) showUseCasePicker.value = false - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(candidate.titleRes)) - } + } + ) } } }, @@ -223,6 +234,61 @@ fun SettingsScreen( } } +@Composable +private fun UseCasePickerOption( + candidate: UseCaseView, + selected: Boolean, + onClick: () -> Unit +) { + val borderColor = if (selected) PrivateQrColors.Teal300 else Color.Transparent + val backgroundColor = if (selected) Color(0xFFECFDF5) else PrivateQrColors.AppBackground + Row( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(24.dp)) + .border(2.dp, borderColor, RoundedCornerShape(24.dp)) + .clickable(onClick = onClick) + .padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(58.dp) + .background(PrivateQrColors.Mint, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = if (selected) "✓" else "◎", + color = PrivateQrColors.Teal700, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(candidate.titleRes), + color = PrivateQrColors.TextPrimary, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold + ) + Text( + text = stringResource(candidate.descriptionRes()), + color = PrivateQrColors.TextSecondary, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +private fun UseCaseView.descriptionRes(): Int { + return when (this) { + UseCaseView.EverydayPersonal -> R.string.use_case_everyday_description + UseCaseView.EventTicketing -> R.string.use_case_event_ticketing_description + } +} + @Composable private fun SettingsHeader() { Row( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0ad7721..6f0bf17 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -16,7 +16,8 @@ Öffnen Abbrechen Trotzdem öffnen - Diese URL wirkt ungewöhnlich. Prüfe sie, bevor du öffnest. + Diese URL wirkt ungewöhnlich + Prüfe den Link vor dem Öffnen. Die Warnung wird auf deinem Gerät berechnet, ohne den Scan irgendwohin zu senden. Alles löschen Alle Historie-Einträge löschen? Bestätigen @@ -56,6 +57,7 @@ Im gewählten Bild wurde kein QR- oder Barcode gefunden. %1$d Codes im Bild gefunden Wähle ein Ergebnis aus: + Ausgewähltes verwenden Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen. Bereits gescannt Doppeltes Ticket erkannt @@ -76,4 +78,6 @@ Use-Case-Ansicht wählen Alltägliche private Nutzung Events & Ticketing + Vollständiger privater Scanner mit lokalem Verlauf und üblichen Ergebnisaktionen. + Batch-Scanning, Duplikaterkennung, Whitelist-Import und Batch-Teilen. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0565a5..b401e75 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,7 +16,8 @@ Open Cancel Open anyway - This URL looks unusual. Check it before opening. + This URL looks unusual + Check the link before opening. The warning is calculated on your device, without sending the scan anywhere. Delete all Delete all history entries? Confirm @@ -56,6 +57,7 @@ No QR or barcode found in the selected image. Found %1$d codes in image Choose a result to use: + Use selected Could not read this image. Try another one. Already scanned Duplicate ticket detected @@ -76,4 +78,6 @@ Select use-case view Everyday personal use Event & ticketing + Full personal scanner with local history and common result actions. + Batch scanning, duplicate detection, whitelist import, and batch sharing.