From cef84c818fcd8e8a0ec38026fea7639cc6a9b89d Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Sun, 10 May 2026 11:58:19 +0200 Subject: [PATCH] 7in outline --- .../ui/CleanScannerAppRoot.kt | 8 +- .../ui/screens/HistoryScreen.kt | 405 ++++++++++++++---- .../ui/screens/ScannerOverlayComponents.kt | 100 ++++- .../ui/screens/ScannerResultCards.kt | 4 +- .../ui/screens/ScannerScreen.kt | 118 ++--- .../ui/screens/SettingsScreen.kt | 298 +++++++------ app/src/main/res/values-de/strings.xml | 18 +- app/src/main/res/values/strings.xml | 18 +- 8 files changed, 672 insertions(+), 297 deletions(-) diff --git a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/CleanScannerAppRoot.kt b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/CleanScannerAppRoot.kt index 0835fe0..bdf3ad1 100644 --- a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/CleanScannerAppRoot.kt +++ b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/CleanScannerAppRoot.kt @@ -109,9 +109,15 @@ fun CleanScannerAppRoot(container: AppContainer) { query = appState.searchQuery, history = appState.history, useCaseView = appState.useCaseView, + historyEnabled = appState.historyEnabled, + warningsEnabled = appState.warningsEnabled, + scanFeedbackEnabled = appState.scanFeedbackEnabled, onQueryChange = appViewModel::setQuery, onDelete = appViewModel::deleteHistoryItem, - onClearAll = appViewModel::clearHistory + onClearAll = appViewModel::clearHistory, + onHistoryToggle = appViewModel::setHistoryEnabled, + onWarningsToggle = appViewModel::setWarningsEnabled, + onScanFeedbackToggle = appViewModel::setScanFeedbackEnabled ) RootTab.Settings -> SettingsScreen( diff --git a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/HistoryScreen.kt b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/HistoryScreen.kt index c2f5ba0..9c64447 100644 --- a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/HistoryScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -31,6 +32,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text @@ -65,9 +68,15 @@ fun HistoryScreen( query: String, history: List, useCaseView: UseCaseView, + historyEnabled: Boolean, + warningsEnabled: Boolean, + scanFeedbackEnabled: Boolean, onQueryChange: (String) -> Unit, onDelete: (Long) -> Unit, - onClearAll: () -> Unit + onClearAll: () -> Unit, + onHistoryToggle: (Boolean, Boolean) -> Unit, + onWarningsToggle: (Boolean) -> Unit, + onScanFeedbackToggle: (Boolean) -> Unit ) { val context = LocalContext.current val capabilities = useCaseView.capabilities() @@ -117,92 +126,216 @@ fun HistoryScreen( ) } - LazyColumn( + BoxWithConstraints( modifier = Modifier .fillMaxSize() .background(PrivateQrColors.AppBackground) - .padding(horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) ) { - item { - Spacer(modifier = Modifier.height(18.dp)) - HistoryHeader() - Spacer(modifier = Modifier.height(18.dp)) - Text( - text = stringResource(R.string.history), - color = PrivateQrColors.TextPrimary, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.ExtraBold - ) - Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - tint = PrivateQrColors.Teal700 - ) - }, - placeholder = { Text(stringResource(R.string.search)) }, - singleLine = true, - shape = RoundedCornerShape(20.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = PrivateQrColors.Teal300, - unfocusedBorderColor = PrivateQrColors.Divider, - focusedContainerColor = PrivateQrColors.Surface, - unfocusedContainerColor = PrivateQrColors.Surface - ) - ) - Spacer(modifier = Modifier.height(10.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + val wideLayout = maxWidth >= 600.dp + + if (wideLayout) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) ) { - if (capabilities.allowHistoryExport) { - ExportButton( - text = stringResource(R.string.share_txt), - enabled = history.isNotEmpty(), - onClick = { - val exportText = HistoryExportFormatter.formatText(history) - Intents.shareContent(context, exportText, "text/plain") - } - ) - ExportButton( - text = stringResource(R.string.share_csv), - enabled = history.isNotEmpty(), - onClick = { - val exportCsv = HistoryExportFormatter.formatCsv(history) - Intents.shareContent(context, exportCsv, "text/csv") - } - ) - ExportButton( - text = stringResource(R.string.share_json), - enabled = history.isNotEmpty(), - onClick = { - val exportJson = HistoryExportFormatter.formatJson(history) - Intents.shareContent(context, exportJson, "application/json") - } - ) - } - Spacer(modifier = Modifier.weight(1f)) - TextButton( - onClick = { showDeleteAll.value = true }, - enabled = history.isNotEmpty(), - colors = ButtonDefaults.textButtonColors( - contentColor = Color(0xFFBE123C), - disabledContentColor = PrivateQrColors.TextSecondary.copy(alpha = 0.45f) - ) + Spacer(modifier = Modifier.height(18.dp)) + HistoryHeader() + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(24.dp) ) { - Text(stringResource(R.string.delete_all), fontWeight = FontWeight.Bold) + Column( + modifier = Modifier + .weight(1.1f) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.history), + color = PrivateQrColors.TextPrimary, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.ExtraBold + ) + TextButton( + onClick = { showDeleteAll.value = true }, + enabled = history.isNotEmpty(), + colors = ButtonDefaults.textButtonColors( + contentColor = Color(0xFFBE123C), + disabledContentColor = PrivateQrColors.TextSecondary.copy(alpha = 0.45f) + ) + ) { + Text(stringResource(R.string.delete_all), fontWeight = FontWeight.Bold) + } + } + HistorySearchField(query = query, onQueryChange = onQueryChange) + HistoryList( + history = history, + onDelete = onDelete, + onOpenDetails = { selectedItem.value = it }, + modifier = Modifier.weight(1f) + ) + } + + Column( + modifier = Modifier.weight(0.95f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.local_controls), + color = PrivateQrColors.TextPrimary, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.ExtraBold + ) + LocalControlsCard( + history = history, + historyEnabled = historyEnabled, + warningsEnabled = warningsEnabled, + scanFeedbackEnabled = scanFeedbackEnabled, + allowHistoryExport = capabilities.allowHistoryExport, + onHistoryToggle = onHistoryToggle, + onWarningsToggle = onWarningsToggle, + onScanFeedbackToggle = onScanFeedbackToggle + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Spacer(modifier = Modifier.height(18.dp)) + HistoryHeader() + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = stringResource(R.string.history), + color = PrivateQrColors.TextPrimary, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.ExtraBold + ) + Spacer(modifier = Modifier.height(12.dp)) + HistorySearchField(query = query, onQueryChange = onQueryChange) + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (capabilities.allowHistoryExport) { + ExportButton( + text = stringResource(R.string.share_txt), + enabled = history.isNotEmpty(), + onClick = { + val exportText = HistoryExportFormatter.formatText(history) + Intents.shareContent(context, exportText, "text/plain") + } + ) + ExportButton( + text = stringResource(R.string.share_csv), + enabled = history.isNotEmpty(), + onClick = { + val exportCsv = HistoryExportFormatter.formatCsv(history) + Intents.shareContent(context, exportCsv, "text/csv") + } + ) + ExportButton( + text = stringResource(R.string.share_json), + enabled = history.isNotEmpty(), + onClick = { + val exportJson = HistoryExportFormatter.formatJson(history) + Intents.shareContent(context, exportJson, "application/json") + } + ) + } + Spacer(modifier = Modifier.weight(1f)) + TextButton( + onClick = { showDeleteAll.value = true }, + enabled = history.isNotEmpty(), + colors = ButtonDefaults.textButtonColors( + contentColor = Color(0xFFBE123C), + disabledContentColor = PrivateQrColors.TextSecondary.copy(alpha = 0.45f) + ) + ) { + Text(stringResource(R.string.delete_all), fontWeight = FontWeight.Bold) + } + } + Spacer(modifier = Modifier.height(4.dp)) + } + + if (history.isEmpty()) { + item { + EmptyHistoryCard() + } + } else { + items(history, key = { it.id }) { item -> + HistoryRow( + item = item, + onDelete = onDelete, + onOpenDetails = { selectedItem.value = item } + ) + } + } + + item { + Spacer(modifier = Modifier.height(24.dp)) } } - Spacer(modifier = Modifier.height(4.dp)) } + } +} +@Composable +private fun HistorySearchField( + query: String, + onQueryChange: (String) -> Unit +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = PrivateQrColors.Teal700 + ) + }, + placeholder = { Text(stringResource(R.string.search)) }, + singleLine = true, + shape = RoundedCornerShape(20.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrivateQrColors.Teal300, + unfocusedBorderColor = PrivateQrColors.Divider, + focusedContainerColor = PrivateQrColors.Surface, + unfocusedContainerColor = PrivateQrColors.Surface + ) + ) +} + +@Composable +private fun HistoryList( + history: List, + onDelete: (Long) -> Unit, + onOpenDetails: (ScanRecord) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { if (history.isEmpty()) { item { EmptyHistoryCard() @@ -212,15 +345,111 @@ fun HistoryScreen( HistoryRow( item = item, onDelete = onDelete, - onOpenDetails = { selectedItem.value = item } + onOpenDetails = { onOpenDetails(item) } ) } } + } +} - item { - Spacer(modifier = Modifier.height(24.dp)) +@Composable +private fun LocalControlsCard( + history: List, + historyEnabled: Boolean, + warningsEnabled: Boolean, + scanFeedbackEnabled: Boolean, + allowHistoryExport: Boolean, + onHistoryToggle: (Boolean, Boolean) -> Unit, + onWarningsToggle: (Boolean) -> Unit, + onScanFeedbackToggle: (Boolean) -> Unit +) { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface), + shape = RoundedCornerShape(28.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + LocalControlRow( + title = stringResource(R.string.save_history), + checked = historyEnabled, + onCheckedChange = { onHistoryToggle(it, false) } + ) + LocalControlRow( + title = stringResource(R.string.security_warnings), + checked = warningsEnabled, + onCheckedChange = onWarningsToggle + ) + LocalControlRow( + title = stringResource(R.string.scan_feedback), + checked = scanFeedbackEnabled, + onCheckedChange = onScanFeedbackToggle + ) } } + + if (allowHistoryExport) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ExportButton( + text = stringResource(R.string.share_txt), + enabled = history.isNotEmpty(), + onClick = { + Intents.shareContent(context, HistoryExportFormatter.formatText(history), "text/plain") + } + ) + ExportButton( + text = stringResource(R.string.share_csv), + enabled = history.isNotEmpty(), + onClick = { + Intents.shareContent(context, HistoryExportFormatter.formatCsv(history), "text/csv") + } + ) + ExportButton( + text = stringResource(R.string.share_json), + enabled = history.isNotEmpty(), + onClick = { + Intents.shareContent(context, HistoryExportFormatter.formatJson(history), "application/json") + } + ) + } + } +} + +@Composable +private fun LocalControlRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(58.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + color = PrivateQrColors.TextPrimary, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = PrivateQrColors.Surface, + checkedTrackColor = PrivateQrColors.Teal700, + uncheckedThumbColor = PrivateQrColors.Surface, + uncheckedTrackColor = PrivateQrColors.TextSecondary.copy(alpha = 0.35f) + ) + ) + } } @Composable @@ -243,7 +472,7 @@ private fun HistoryHeader() { fontWeight = FontWeight.ExtraBold ) Text( - text = "Optional history stays on your device", + text = stringResource(R.string.history_header_subtitle), color = PrivateQrColors.TextSecondary, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold @@ -266,20 +495,20 @@ private fun HistoryHeader() { verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = "Review past scans locally", + text = stringResource(R.string.history_hero_title), color = PrivateQrColors.Surface, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.ExtraBold ) Text( - text = "Search, export, or delete saved scans whenever you choose.", + text = stringResource(R.string.history_hero_subtitle), color = PrivateQrColors.Mint, style = MaterialTheme.typography.titleMedium ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - PrivacyPill("No ads") - PrivacyPill("No tracking") - PrivacyPill("No account") + PrivacyPill(stringResource(R.string.privacy_pill_no_ads)) + PrivacyPill(stringResource(R.string.privacy_pill_no_tracking)) + PrivacyPill(stringResource(R.string.privacy_pill_no_account)) } } } @@ -338,13 +567,13 @@ private fun EmptyHistoryCard() { verticalArrangement = Arrangement.spacedBy(6.dp) ) { Text( - text = "No saved scans yet", + text = stringResource(R.string.no_saved_scans_yet), color = PrivateQrColors.TextPrimary, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.ExtraBold ) Text( - text = "Enable local history in settings to keep a private record on this device.", + text = stringResource(R.string.empty_history_description), color = PrivateQrColors.TextSecondary, style = MaterialTheme.typography.bodyMedium ) diff --git a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerOverlayComponents.kt b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerOverlayComponents.kt index 0e0c8a3..a696814 100644 --- a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerOverlayComponents.kt +++ b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerOverlayComponents.kt @@ -13,9 +13,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -25,10 +28,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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.unit.dp import de.softwareapp_hb.privateqrscanner.R import de.softwareapp_hb.privateqrscanner.ui.BatchScanRecord +import de.softwareapp_hb.privateqrscanner.ui.theme.PrivateQrColors import de.softwareapp_hb.privateqrscanner.util.ClipboardUtil import de.softwareapp_hb.privateqrscanner.util.Intents import java.text.DateFormat @@ -91,22 +96,46 @@ internal fun BatchResultsPanel( modifier = Modifier .fillMaxWidth() .background( - color = Color.Black.copy(alpha = 0.42f), - shape = RoundedCornerShape(14.dp) + color = PrivateQrColors.Deep.copy(alpha = 0.88f), + shape = RoundedCornerShape(28.dp) ) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text( - text = stringResource(R.string.batch_captures_count, results.size), - color = Color.White - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.batch_captures_count, results.size), + color = Color.White, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold + ) + if (allowShare) { + TextButton( + onClick = { Intents.shareText(context, buildBatchExport(results)) }, + enabled = results.isNotEmpty(), + colors = ButtonDefaults.textButtonColors( + containerColor = PrivateQrColors.Mint, + contentColor = PrivateQrColors.Teal700, + disabledContainerColor = PrivateQrColors.Mint.copy(alpha = 0.32f), + disabledContentColor = PrivateQrColors.Teal700.copy(alpha = 0.45f) + ), + shape = RoundedCornerShape(18.dp) + ) { + Text(stringResource(R.string.share_batch), fontWeight = FontWeight.ExtraBold) + } + } + } results.take(3).forEach { item -> val contentText = if (item.result.isBase64Encoded) { stringResource(R.string.base64_encoded_inline, item.result.content) } else { item.result.content } + HorizontalDivider(color = Color.White.copy(alpha = 0.12f)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -116,11 +145,15 @@ internal fun BatchResultsPanel( Text( text = "${item.result.displayType}: $contentText", color = Color.White.copy(alpha = 0.92f), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, maxLines = 1 ) Text( text = timeFormat.format(Date(item.timestamp)), - color = Color.White.copy(alpha = 0.7f) + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold ) } Row { @@ -143,18 +176,43 @@ internal fun BatchResultsPanel( } } } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = onClear, enabled = results.isNotEmpty()) { - Text(stringResource(R.string.clear_batch)) - } - if (allowShare) { - TextButton( - onClick = { Intents.shareText(context, buildBatchExport(results)) }, - enabled = results.isNotEmpty() - ) { - Text(stringResource(R.string.share_batch)) - } - } + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF831843).copy(alpha = 0.42f), RoundedCornerShape(18.dp)) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "▲", color = Color(0xFFFF7A9A), fontWeight = FontWeight.ExtraBold) + Text( + text = stringResource(R.string.batch_ticket_privacy_note), + color = Color.White, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.weight(1f) + ) + } + TextButton( + onClick = onClear, + enabled = results.isNotEmpty(), + colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Mint) + ) { + Text(stringResource(R.string.clear_batch), fontWeight = FontWeight.Bold) + } + if (results.isEmpty()) { + Text( + text = stringResource(R.string.aim_center_hint), + color = Color.White.copy(alpha = 0.68f), + style = MaterialTheme.typography.bodyMedium + ) + } + if (!allowShare && results.isNotEmpty()) { + Text( + text = stringResource(R.string.batch_ticket_privacy_note), + color = Color.White.copy(alpha = 0.68f), + style = MaterialTheme.typography.bodyMedium + ) } } } diff --git a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerResultCards.kt b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerResultCards.kt index c1a1ca9..639a7d8 100644 --- a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerResultCards.kt +++ b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerResultCards.kt @@ -96,7 +96,7 @@ internal fun ResultVisualCard( ) if (!result.isBase64Encoded && result.type == "URL") { Text( - text = "Local check", + text = stringResource(R.string.result_local_check), color = PrivateQrColors.Teal700, style = MaterialTheme.typography.labelLarge, modifier = Modifier @@ -166,7 +166,7 @@ internal fun ResultVisualCard( style = MaterialTheme.typography.titleMedium ) Text( - text = "Checked on device before opening", + text = stringResource(R.string.result_checked_on_device), color = PrivateQrColors.Teal700, style = MaterialTheme.typography.labelLarge ) diff --git a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerScreen.kt b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerScreen.kt index 941d560..88c2941 100644 --- a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerScreen.kt +++ b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/ScannerScreen.kt @@ -21,9 +21,11 @@ import androidx.compose.foundation.layout.fillMaxSize 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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.ViewList import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.FlashOff @@ -70,6 +72,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -187,6 +191,17 @@ fun ScannerScreen( } } + fun requestOpenUrl(url: String) { + val risk = UrlRiskScorer.score(url) + val risky = warningsEnabled && risk.score >= 3 + if (risky) { + pendingOpenUrl = url + showRiskWarning = true + } else { + Intents.openUrl(context, url) + } + } + val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { granted -> @@ -647,20 +662,12 @@ fun ScannerScreen( ) { ResultVisualCard( result = lastResult, - onOpenUrl = { url -> - val risk = UrlRiskScorer.score(url) - val risky = warningsEnabled && risk.score >= 3 - if (risky) { - pendingOpenUrl = url - showRiskWarning = true - } else { - Intents.openUrl(context, url) - } - } + onOpenUrl = ::requestOpenUrl ) val hasQuickActions = capabilities.allowCopy || capabilities.allowShare || - (capabilities.allowAddContact && parsedContact != null) + (capabilities.allowAddContact && parsedContact != null) || + (capabilities.allowOpenUrl && !lastResult.isBase64Encoded && lastResult.type == "URL") if (hasQuickActions) { Row( @@ -670,49 +677,34 @@ fun ScannerScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { if (capabilities.allowAddContact && parsedContact != null) { - IconButton( + ResultActionPill( + text = stringResource(R.string.add_contact), + imageVector = Icons.Default.PersonAdd, onClick = { Intents.addContact(context, parsedContact, lastResult.content) }, - modifier = Modifier.background( - color = PrivateQrColors.AppBackground, - shape = RoundedCornerShape(50) - ) - ) { - Icon( - imageVector = Icons.Default.PersonAdd, - contentDescription = stringResource(R.string.add_contact), - tint = PrivateQrColors.TextPrimary - ) - } + primary = true + ) } if (capabilities.allowCopy) { - IconButton( - onClick = { ClipboardUtil.copy(context, lastResult.content) }, - modifier = Modifier.background( - color = PrivateQrColors.AppBackground, - shape = RoundedCornerShape(50) - ) - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = stringResource(R.string.copy), - tint = PrivateQrColors.TextPrimary - ) - } + ResultActionPill( + text = stringResource(R.string.copy), + imageVector = Icons.Default.ContentCopy, + onClick = { ClipboardUtil.copy(context, lastResult.content) } + ) } if (capabilities.allowShare) { - IconButton( - onClick = { Intents.shareText(context, lastResult.content) }, - modifier = Modifier.background( - color = PrivateQrColors.AppBackground, - shape = RoundedCornerShape(50) - ) - ) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = stringResource(R.string.share), - tint = PrivateQrColors.TextPrimary - ) - } + ResultActionPill( + text = stringResource(R.string.share), + imageVector = Icons.Default.Share, + onClick = { Intents.shareText(context, lastResult.content) } + ) + } + if (capabilities.allowOpenUrl && !lastResult.isBase64Encoded && lastResult.type == "URL") { + ResultActionPill( + text = stringResource(R.string.open), + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + onClick = { requestOpenUrl(lastResult.content) }, + primary = true + ) } } } @@ -929,3 +921,31 @@ private fun parseWhitelistIds(raw: String): Set { .filter { it.isNotBlank() } .toSet() } + +@Composable +private fun ResultActionPill( + text: String, + imageVector: ImageVector, + onClick: () -> Unit, + primary: Boolean = false +) { + TextButton( + onClick = onClick, + colors = ButtonDefaults.textButtonColors( + containerColor = if (primary) PrivateQrColors.Teal700 else PrivateQrColors.AppBackground, + contentColor = if (primary) PrivateQrColors.Surface else PrivateQrColors.TextPrimary + ), + shape = RoundedCornerShape(18.dp) + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Text( + text = text, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(start = 8.dp) + ) + } +} diff --git a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/SettingsScreen.kt b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/SettingsScreen.kt index ad05d55..5c41caa 100644 --- a/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/de/softwareapp_hb/privateqrscanner/ui/screens/SettingsScreen.kt @@ -6,6 +6,7 @@ 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.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -57,7 +58,6 @@ fun SettingsScreen( ) { val context = LocalContext.current val showDeleteConfirm = remember { mutableStateOf(false) } - val showUseCasePicker = remember { mutableStateOf(false) } val showPrivacyPolicy = remember { mutableStateOf(false) } if (showDeleteConfirm.value) { @@ -80,157 +80,187 @@ fun SettingsScreen( ) } - if (showUseCasePicker.value) { - AlertDialog( - onDismissRequest = { showUseCasePicker.value = false }, - 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(12.dp)) { - UseCaseView.entries.forEach { candidate -> - UseCasePickerOption( - candidate = candidate, - selected = candidate == selectedUseCaseView, - onClick = { - onUseCaseViewSelected(candidate) - showUseCasePicker.value = false - } - ) - } - } - }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = { showUseCasePicker.value = false }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } - if (showPrivacyPolicy.value) { PrivacyPolicyDialog(onDismiss = { showPrivacyPolicy.value = false }) } - Column( + BoxWithConstraints( modifier = Modifier .fillMaxSize() .background(PrivateQrColors.AppBackground) - .verticalScroll(rememberScrollState()) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(18.dp) ) { - SettingsHeader() + val wideLayout = maxWidth >= 600.dp + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + SettingsHeader() + + if (wideLayout) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.Top + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + SettingsToggleCard( + historyEnabled = historyEnabled, + warningsEnabled = warningsEnabled, + scanFeedbackEnabled = scanFeedbackEnabled, + onHistoryToggle = onHistoryToggle, + onWarningsToggle = onWarningsToggle, + onScanFeedbackToggle = onScanFeedbackToggle, + onRequestDisableHistory = { showDeleteConfirm.value = true } + ) + AboutCard( + onPrivacyClick = { showPrivacyPolicy.value = true }, + onReviewClick = { InAppReviewRequester.requestReview(context) } + ) + } + UseCaseSelectionCard( + selectedUseCaseView = selectedUseCaseView, + onUseCaseViewSelected = onUseCaseViewSelected, + modifier = Modifier.weight(0.9f) + ) + } + } else { + SettingsToggleCard( + historyEnabled = historyEnabled, + warningsEnabled = warningsEnabled, + scanFeedbackEnabled = scanFeedbackEnabled, + onHistoryToggle = onHistoryToggle, + onWarningsToggle = onWarningsToggle, + onScanFeedbackToggle = onScanFeedbackToggle, + onRequestDisableHistory = { showDeleteConfirm.value = true } + ) + UseCaseSelectionCard( + selectedUseCaseView = selectedUseCaseView, + onUseCaseViewSelected = onUseCaseViewSelected + ) + AboutCard( + onPrivacyClick = { showPrivacyPolicy.value = true }, + onReviewClick = { InAppReviewRequester.requestReview(context) } + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + } + } +} + +@Composable +private fun SettingsToggleCard( + historyEnabled: Boolean, + warningsEnabled: Boolean, + scanFeedbackEnabled: Boolean, + onHistoryToggle: (Boolean, Boolean) -> Unit, + onWarningsToggle: (Boolean) -> Unit, + onScanFeedbackToggle: (Boolean) -> Unit, + onRequestDisableHistory: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface), + shape = RoundedCornerShape(28.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { + SettingsToggleRow( + title = stringResource(R.string.save_history), + checked = historyEnabled, + onCheckedChange = { enabled -> + if (!enabled && historyEnabled) { + onRequestDisableHistory() + } else { + onHistoryToggle(enabled, false) + } + } + ) + SettingsDivider() + SettingsToggleRow( + title = stringResource(R.string.security_warnings), + checked = warningsEnabled, + onCheckedChange = onWarningsToggle + ) + SettingsDivider() + SettingsToggleRow( + title = stringResource(R.string.scan_feedback), + checked = scanFeedbackEnabled, + onCheckedChange = onScanFeedbackToggle + ) + } + } +} + +@Composable +private fun UseCaseSelectionCard( + selectedUseCaseView: UseCaseView, + onUseCaseViewSelected: (UseCaseView) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { Text( - text = stringResource(R.string.settings), + text = stringResource(R.string.select_use_case_view), color = PrivateQrColors.TextPrimary, - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.ExtraBold ) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface), - shape = RoundedCornerShape(28.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) - ) { - Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { - SettingsToggleRow( - title = stringResource(R.string.save_history), - checked = historyEnabled, - onCheckedChange = { enabled -> - if (!enabled && historyEnabled) { - showDeleteConfirm.value = true - } else { - onHistoryToggle(enabled, false) - } - } - ) - SettingsDivider() - SettingsToggleRow( - title = stringResource(R.string.security_warnings), - checked = warningsEnabled, - onCheckedChange = onWarningsToggle - ) - SettingsDivider() - SettingsToggleRow( - title = stringResource(R.string.scan_feedback), - checked = scanFeedbackEnabled, - onCheckedChange = onScanFeedbackToggle - ) - } + UseCaseView.entries.forEach { candidate -> + UseCasePickerOption( + candidate = candidate, + selected = candidate == selectedUseCaseView, + onClick = { onUseCaseViewSelected(candidate) } + ) } + } +} - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface), - shape = RoundedCornerShape(28.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) - ) { - Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text( - text = stringResource(R.string.active_use_case_view), - color = PrivateQrColors.TextPrimary, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.ExtraBold - ) - Text( - text = stringResource(selectedUseCaseView.titleRes), - color = PrivateQrColors.TextSecondary, - style = MaterialTheme.typography.bodyLarge - ) +@Composable +private fun AboutCard( + onPrivacyClick: () -> Unit, + onReviewClick: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface), + shape = RoundedCornerShape(28.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = stringResource(R.string.about), + color = PrivateQrColors.TextPrimary, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold + ) + InfoLine(stringResource(R.string.version)) + InfoLine(stringResource(R.string.licenses)) + InfoLine(stringResource(R.string.contact)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { TextButton( - onClick = { showUseCasePicker.value = true }, + onClick = onPrivacyClick, colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700) ) { - Text(stringResource(R.string.select_use_case_view), fontWeight = FontWeight.Bold) + Text(text = stringResource(R.string.privacy_policy), fontWeight = FontWeight.Bold) + } + TextButton( + onClick = onReviewClick, + colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700) + ) { + Text(text = stringResource(R.string.review_app), fontWeight = FontWeight.Bold) } } } - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface), - shape = RoundedCornerShape(28.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) - ) { - Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text( - text = stringResource(R.string.about), - color = PrivateQrColors.TextPrimary, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.ExtraBold - ) - InfoLine(stringResource(R.string.version)) - InfoLine(stringResource(R.string.licenses)) - InfoLine(stringResource(R.string.contact)) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - TextButton( - onClick = { showPrivacyPolicy.value = true }, - colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700) - ) { - Text(text = stringResource(R.string.privacy_policy), fontWeight = FontWeight.Bold) - } - TextButton( - onClick = { InAppReviewRequester.requestReview(context) }, - colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700) - ) { - Text(text = stringResource(R.string.review_app), fontWeight = FontWeight.Bold) - } - } - } - } - - Spacer(modifier = Modifier.height(12.dp)) } } @@ -309,7 +339,7 @@ private fun SettingsHeader() { fontWeight = FontWeight.ExtraBold ) Text( - text = "Local privacy controls", + text = stringResource(R.string.settings_header_subtitle), color = PrivateQrColors.TextSecondary, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold @@ -332,13 +362,13 @@ private fun SettingsHeader() { verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = "Control what stays saved", + text = stringResource(R.string.settings_hero_title), color = PrivateQrColors.Surface, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.ExtraBold ) Text( - text = "History, warnings, and feedback are optional device-local settings.", + text = stringResource(R.string.settings_hero_subtitle), color = PrivateQrColors.Mint, style = MaterialTheme.typography.titleMedium ) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6f0bf17..96118d0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -45,6 +45,15 @@ TXT CSV JSON + Optionale Historie bleibt auf deinem Gerät + Vergangene Scans lokal prüfen + Gespeicherte Scans jederzeit suchen, exportieren oder löschen. + Keine Werbung + Kein Tracking + Kein Konto + Lokale Kontrolle + Noch keine gespeicherten Scans + Aktiviere lokale Historie in den Einstellungen, um private Einträge auf diesem Gerät zu speichern. Aus Bild scannen Whitelist importieren Geladene registrierte IDs: %1$d @@ -56,7 +65,7 @@ Stapel teilen Im gewählten Bild wurde kein QR- oder Barcode gefunden. %1$d Codes im Bild gefunden - Wähle ein Ergebnis aus: + Wähle das Ergebnis, das du verwenden möchtest. Erkennung und Auswertung erfolgen lokal. Ausgewähltes verwenden Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen. Bereits gescannt @@ -80,4 +89,11 @@ Events & Ticketing Vollständiger privater Scanner mit lokalem Verlauf und üblichen Ergebnisaktionen. Batch-Scanning, Duplikaterkennung, Whitelist-Import und Batch-Teilen. + Einstellungen, die dir Kontrolle geben + Deine Privatsphäre-Einstellungen + Wähle lokale Historie, Sicherheitswarnungen, Feedback und die Scanneransicht, die zu deinem Workflow passt. + Lokale Prüfung + Vor dem Öffnen auf dem Gerät geprüft + Codierte Daten + Warnungen zu doppelten und nicht registrierten Tickets bleiben auf dem Gerät. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b401e75..bf572bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,6 +45,15 @@ TXT CSV JSON + Optional history stays on your device + Review past scans locally + Search, export, or delete saved scans whenever you choose. + No ads + No tracking + No account + Local controls + No saved scans yet + Enable local history in settings to keep a private record on this device. Scan from image Import whitelist Registered IDs loaded: %1$d @@ -56,7 +65,7 @@ Share batch No QR or barcode found in the selected image. Found %1$d codes in image - Choose a result to use: + Choose the result you want to use. Detection and parsing happen locally. Use selected Could not read this image. Try another one. Already scanned @@ -80,4 +89,11 @@ Event & ticketing Full personal scanner with local history and common result actions. Batch scanning, duplicate detection, whitelist import, and batch sharing. + Settings that keep you in control + Privacy settings are yours + Choose local history, security warnings, feedback, and the scanner view that fits your workflow. + Local check + Checked on device before opening + Encoded data + Duplicate and unregistered ticket alerts stay on device.