view alignment

This commit is contained in:
Hadrian Burkhardt
2026-05-10 11:05:06 +02:00
parent d822e54f91
commit 5d5284d76e
6 changed files with 228 additions and 36 deletions
@@ -48,6 +48,12 @@ fun UseCaseView.capabilities(): UseCaseCapabilities {
allowCopy = true, allowCopy = true,
allowShare = true, allowShare = true,
allowOpenUrl = true, allowOpenUrl = true,
allowAddContact = true,
allowDialPhone = true,
allowSendSms = true,
allowSendEmail = true,
allowOpenWifiSettings = true,
allowAddCalendarEvent = true,
allowHistoryExport = true allowHistoryExport = true
) )
@@ -9,16 +9,23 @@ import android.os.Build
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.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth 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.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
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
@@ -46,6 +53,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.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.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.DetectionBox
import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint
import de.softwareapp_hb.privateqrscanner.domain.ScanResult import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import de.softwareapp_hb.privateqrscanner.ui.theme.PrivateQrColors
import de.softwareapp_hb.privateqrscanner.util.readableBarcodePayload import de.softwareapp_hb.privateqrscanner.util.readableBarcodePayload
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScanner
@@ -104,6 +113,7 @@ internal fun GalleryScanPreviewDialog(
val context = LocalContext.current val context = LocalContext.current
val bitmap = remember(imageUri) { imageUri?.let { loadBitmapFromUri(context, it) } } val bitmap = remember(imageUri) { imageUri?.let { loadBitmapFromUri(context, it) } }
var liveCandidates by remember(imageUri, candidates) { mutableStateOf(candidates) } var liveCandidates by remember(imageUri, candidates) { mutableStateOf(candidates) }
var selectedIndex by remember(imageUri) { mutableIntStateOf(0) }
var zoom by remember(imageUri) { mutableFloatStateOf(1f) } var zoom by remember(imageUri) { mutableFloatStateOf(1f) }
var pan by remember(imageUri) { mutableStateOf(Offset.Zero) } var pan by remember(imageUri) { mutableStateOf(Offset.Zero) }
var viewportSize by remember { mutableStateOf(IntSize.Zero) } var viewportSize by remember { mutableStateOf(IntSize.Zero) }
@@ -130,6 +140,14 @@ internal fun GalleryScanPreviewDialog(
onDispose { scanner.close() } onDispose { scanner.close() }
} }
LaunchedEffect(liveCandidates.size) {
selectedIndex = if (liveCandidates.isEmpty()) {
0
} else {
selectedIndex.coerceIn(0, liveCandidates.lastIndex)
}
}
LaunchedEffect(bitmap, viewportSize, zoom, pan, scanTick) { LaunchedEffect(bitmap, viewportSize, zoom, pan, scanTick) {
val bmp = bitmap ?: return@LaunchedEffect val bmp = bitmap ?: return@LaunchedEffect
val vw = viewportSize.width.toFloat() val vw = viewportSize.width.toFloat()
@@ -204,14 +222,33 @@ internal fun GalleryScanPreviewDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, 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 = { text = {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState()), .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) { if (bitmap != null) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -228,7 +265,7 @@ internal fun GalleryScanPreviewDialog(
scanTick++ scanTick++
} }
} }
.background(Color.Black.copy(alpha = 0.32f), RoundedCornerShape(12.dp)), .background(PrivateQrColors.Navy, RoundedCornerShape(24.dp)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Image( Image(
@@ -267,7 +304,7 @@ internal fun GalleryScanPreviewDialog(
liveCandidates.forEachIndexed { index, candidate -> liveCandidates.forEachIndexed { index, candidate ->
val box = candidate.box ?: return@forEachIndexed 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 -> val points = box.corners.map { p ->
imageToScreen(p.x * imageW, p.y * imageH) imageToScreen(p.x * imageW, p.y * imageH)
} }
@@ -322,20 +359,45 @@ internal fun GalleryScanPreviewDialog(
} }
} }
if (liveCandidates.isEmpty()) { if (liveCandidates.isNotEmpty()) {
Text(text = stringResource(R.string.no_code_found_in_image))
} else {
Text(text = stringResource(R.string.image_scan_pick_subtitle))
liveCandidates.forEachIndexed { index, candidate -> liveCandidates.forEachIndexed { index, candidate ->
TextButton( val selected = index == selectedIndex
onClick = { onPick(candidate) }, Row(
modifier = Modifier.fillMaxWidth() 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(
text = "${index + 1}. ${candidate.result.displayType}", text = "${index + 1}",
textAlign = TextAlign.Start, color = PrivateQrColors.Teal700,
modifier = Modifier.fillMaxWidth() 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(
text = if (candidate.result.isBase64Encoded) { text = if (candidate.result.isBase64Encoded) {
@@ -343,10 +405,11 @@ internal fun GalleryScanPreviewDialog(
} else { } else {
candidate.result.content candidate.result.content
}, },
color = PrivateQrColors.TextSecondary,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
) )
} }
} }
@@ -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 = { dismissButton = {
TextButton(onClick = onDismiss) { TextButton(
onClick = onDismiss,
colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700)
) {
Text(stringResource(R.string.cancel)) Text(stringResource(R.string.cancel))
} }
} }
@@ -801,15 +801,44 @@ fun ScannerScreen(
if (showRiskWarning && pendingOpenUrl != null) { if (showRiskWarning && pendingOpenUrl != null) {
AlertDialog( AlertDialog(
onDismissRequest = { showRiskWarning = false }, 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 = { confirmButton = {
TextButton(onClick = { TextButton(
Intents.openUrl(context, pendingOpenUrl!!) onClick = {
showRiskWarning = false Intents.openUrl(context, pendingOpenUrl!!)
}) { Text(stringResource(R.string.open_anyway)) } showRiskWarning = false
},
colors = ButtonDefaults.textButtonColors(
containerColor = PrivateQrColors.Teal700,
contentColor = PrivateQrColors.Surface
),
shape = RoundedCornerShape(18.dp)
) {
Text(stringResource(R.string.open_anyway))
}
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showRiskWarning = false }) { TextButton(
onClick = { showRiskWarning = false },
colors = ButtonDefaults.textButtonColors(
contentColor = PrivateQrColors.Teal700
)
) {
Text(stringResource(R.string.cancel)) Text(stringResource(R.string.cancel))
} }
} }
@@ -2,7 +2,10 @@ package de.softwareapp_hb.privateqrscanner.ui.screens
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -80,19 +83,27 @@ fun SettingsScreen(
if (showUseCasePicker.value) { if (showUseCasePicker.value) {
AlertDialog( AlertDialog(
onDismissRequest = { showUseCasePicker.value = false }, 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 = { text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
UseCaseView.entries.forEach { candidate -> UseCaseView.entries.forEach { candidate ->
TextButton( UseCasePickerOption(
candidate = candidate,
selected = candidate == selectedUseCaseView,
onClick = { onClick = {
onUseCaseViewSelected(candidate) onUseCaseViewSelected(candidate)
showUseCasePicker.value = false 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 @Composable
private fun SettingsHeader() { private fun SettingsHeader() {
Row( Row(
+5 -1
View File
@@ -16,7 +16,8 @@
<string name="open">Öffnen</string> <string name="open">Öffnen</string>
<string name="cancel">Abbrechen</string> <string name="cancel">Abbrechen</string>
<string name="open_anyway">Trotzdem öffnen</string> <string name="open_anyway">Trotzdem öffnen</string>
<string name="risk_warning">Diese URL wirkt ungewöhnlich. Prüfe sie, bevor du öffnest.</string> <string name="risk_warning_title">Diese URL wirkt ungewöhnlich</string>
<string name="risk_warning">Prüfe den Link vor dem Öffnen. Die Warnung wird auf deinem Gerät berechnet, ohne den Scan irgendwohin zu senden.</string>
<string name="delete_all">Alles löschen</string> <string name="delete_all">Alles löschen</string>
<string name="confirm_delete_all">Alle Historie-Einträge löschen?</string> <string name="confirm_delete_all">Alle Historie-Einträge löschen?</string>
<string name="confirm">Bestätigen</string> <string name="confirm">Bestätigen</string>
@@ -56,6 +57,7 @@
<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_title">%1$d Codes im Bild gefunden</string>
<string name="image_scan_pick_subtitle">Wähle ein Ergebnis aus:</string> <string name="image_scan_pick_subtitle">Wähle ein Ergebnis aus:</string>
<string name="image_scan_use_selected">Ausgewähltes verwenden</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="duplicate_ticket_alert_title">Doppeltes Ticket erkannt</string> <string name="duplicate_ticket_alert_title">Doppeltes Ticket erkannt</string>
@@ -76,4 +78,6 @@
<string name="select_use_case_view">Use-Case-Ansicht wählen</string> <string name="select_use_case_view">Use-Case-Ansicht wählen</string>
<string name="use_case_everyday_personal">Alltägliche private Nutzung</string> <string name="use_case_everyday_personal">Alltägliche private Nutzung</string>
<string name="use_case_event_ticketing">Events &amp; Ticketing</string> <string name="use_case_event_ticketing">Events &amp; Ticketing</string>
<string name="use_case_everyday_description">Vollständiger privater Scanner mit lokalem Verlauf und üblichen Ergebnisaktionen.</string>
<string name="use_case_event_ticketing_description">Batch-Scanning, Duplikaterkennung, Whitelist-Import und Batch-Teilen.</string>
</resources> </resources>
+5 -1
View File
@@ -16,7 +16,8 @@
<string name="open">Open</string> <string name="open">Open</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="open_anyway">Open anyway</string> <string name="open_anyway">Open anyway</string>
<string name="risk_warning">This URL looks unusual. Check it before opening.</string> <string name="risk_warning_title">This URL looks unusual</string>
<string name="risk_warning">Check the link before opening. The warning is calculated on your device, without sending the scan anywhere.</string>
<string name="delete_all">Delete all</string> <string name="delete_all">Delete all</string>
<string name="confirm_delete_all">Delete all history entries?</string> <string name="confirm_delete_all">Delete all history entries?</string>
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>
@@ -56,6 +57,7 @@
<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_title">Found %1$d codes in image</string>
<string name="image_scan_pick_subtitle">Choose a result to use:</string> <string name="image_scan_pick_subtitle">Choose a result to use:</string>
<string name="image_scan_use_selected">Use selected</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="duplicate_ticket_alert_title">Duplicate ticket detected</string> <string name="duplicate_ticket_alert_title">Duplicate ticket detected</string>
@@ -76,4 +78,6 @@
<string name="select_use_case_view">Select use-case view</string> <string name="select_use_case_view">Select use-case view</string>
<string name="use_case_everyday_personal">Everyday personal use</string> <string name="use_case_everyday_personal">Everyday personal use</string>
<string name="use_case_event_ticketing">Event &amp; ticketing</string> <string name="use_case_event_ticketing">Event &amp; ticketing</string>
<string name="use_case_everyday_description">Full personal scanner with local history and common result actions.</string>
<string name="use_case_event_ticketing_description">Batch scanning, duplicate detection, whitelist import, and batch sharing.</string>
</resources> </resources>