7in outline

This commit is contained in:
Hadrian Burkhardt
2026-05-10 11:58:19 +02:00
parent ad3cbd2ee4
commit cef84c818f
8 changed files with 672 additions and 297 deletions
@@ -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(
@@ -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<ScanRecord>,
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,10 +126,93 @@ fun HistoryScreen(
)
}
LazyColumn(
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(PrivateQrColors.AppBackground)
) {
val wideLayout = maxWidth >= 600.dp
if (wideLayout) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
Spacer(modifier = Modifier.height(18.dp))
HistoryHeader()
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
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)
) {
@@ -135,27 +227,7 @@ fun HistoryScreen(
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
)
)
HistorySearchField(query = query, onQueryChange = onQueryChange)
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
@@ -221,6 +293,163 @@ fun HistoryScreen(
Spacer(modifier = Modifier.height(24.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<ScanRecord>,
onDelete: (Long) -> Unit,
onOpenDetails: (ScanRecord) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (history.isEmpty()) {
item {
EmptyHistoryCard()
}
} else {
items(history, key = { it.id }) { item ->
HistoryRow(
item = item,
onDelete = onDelete,
onOpenDetails = { onOpenDetails(item) }
)
}
}
}
}
@Composable
private fun LocalControlsCard(
history: List<ScanRecord>,
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
)
@@ -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)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.batch_captures_count, results.size),
color = Color.White
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()
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(stringResource(R.string.share_batch))
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
)
}
}
}
@@ -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
)
@@ -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(
onClick = { Intents.addContact(context, parsedContact, lastResult.content) },
modifier = Modifier.background(
color = PrivateQrColors.AppBackground,
shape = RoundedCornerShape(50)
)
) {
Icon(
ResultActionPill(
text = stringResource(R.string.add_contact),
imageVector = Icons.Default.PersonAdd,
contentDescription = stringResource(R.string.add_contact),
tint = PrivateQrColors.TextPrimary
onClick = { Intents.addContact(context, parsedContact, lastResult.content) },
primary = true
)
}
}
if (capabilities.allowCopy) {
IconButton(
onClick = { ClipboardUtil.copy(context, lastResult.content) },
modifier = Modifier.background(
color = PrivateQrColors.AppBackground,
shape = RoundedCornerShape(50)
)
) {
Icon(
ResultActionPill(
text = stringResource(R.string.copy),
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(R.string.copy),
tint = PrivateQrColors.TextPrimary
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(
ResultActionPill(
text = stringResource(R.string.share),
imageVector = Icons.Default.Share,
contentDescription = stringResource(R.string.share),
tint = PrivateQrColors.TextPrimary
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<String> {
.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)
)
}
}
@@ -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,63 +80,91 @@ 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)
) {
val wideLayout = maxWidth >= 600.dp
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
SettingsHeader()
Text(
text = stringResource(R.string.settings),
color = PrivateQrColors.TextPrimary,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.ExtraBold
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),
@@ -149,7 +177,7 @@ fun SettingsScreen(
checked = historyEnabled,
onCheckedChange = { enabled ->
if (!enabled && historyEnabled) {
showDeleteConfirm.value = true
onRequestDisableHistory()
} else {
onHistoryToggle(enabled, false)
}
@@ -169,34 +197,39 @@ fun SettingsScreen(
)
}
}
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface),
shape = RoundedCornerShape(28.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
@Composable
private fun UseCaseSelectionCard(
selectedUseCaseView: UseCaseView,
onUseCaseViewSelected: (UseCaseView) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = stringResource(R.string.active_use_case_view),
text = stringResource(R.string.select_use_case_view),
color = PrivateQrColors.TextPrimary,
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.ExtraBold
)
Text(
text = stringResource(selectedUseCaseView.titleRes),
color = PrivateQrColors.TextSecondary,
style = MaterialTheme.typography.bodyLarge
UseCaseView.entries.forEach { candidate ->
UseCasePickerOption(
candidate = candidate,
selected = candidate == selectedUseCaseView,
onClick = { onUseCaseViewSelected(candidate) }
)
TextButton(
onClick = { showUseCasePicker.value = true },
colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700)
) {
Text(stringResource(R.string.select_use_case_view), fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
private fun AboutCard(
onPrivacyClick: () -> Unit,
onReviewClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface),
@@ -215,13 +248,13 @@ fun SettingsScreen(
InfoLine(stringResource(R.string.contact))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TextButton(
onClick = { showPrivacyPolicy.value = true },
onClick = onPrivacyClick,
colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700)
) {
Text(text = stringResource(R.string.privacy_policy), fontWeight = FontWeight.Bold)
}
TextButton(
onClick = { InAppReviewRequester.requestReview(context) },
onClick = onReviewClick,
colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700)
) {
Text(text = stringResource(R.string.review_app), fontWeight = FontWeight.Bold)
@@ -229,9 +262,6 @@ fun SettingsScreen(
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
}
@Composable
@@ -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
)
+17 -1
View File
@@ -45,6 +45,15 @@
<string name="share_txt">TXT</string>
<string name="share_csv">CSV</string>
<string name="share_json">JSON</string>
<string name="history_header_subtitle">Optionale Historie bleibt auf deinem Gerät</string>
<string name="history_hero_title">Vergangene Scans lokal prüfen</string>
<string name="history_hero_subtitle">Gespeicherte Scans jederzeit suchen, exportieren oder löschen.</string>
<string name="privacy_pill_no_ads">Keine Werbung</string>
<string name="privacy_pill_no_tracking">Kein Tracking</string>
<string name="privacy_pill_no_account">Kein Konto</string>
<string name="local_controls">Lokale Kontrolle</string>
<string name="no_saved_scans_yet">Noch keine gespeicherten Scans</string>
<string name="empty_history_description">Aktiviere lokale Historie in den Einstellungen, um private Einträge auf diesem Gerät zu speichern.</string>
<string name="scan_from_image">Aus Bild scannen</string>
<string name="import_whitelist">Whitelist importieren</string>
<string name="whitelist_loaded_count">Geladene registrierte IDs: %1$d</string>
@@ -56,7 +65,7 @@
<string name="share_batch">Stapel teilen</string>
<string name="no_code_found_in_image">Im gewählten Bild wurde kein QR- oder Barcode gefunden.</string>
<string name="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 das Ergebnis, das du verwenden möchtest. Erkennung und Auswertung erfolgen lokal.</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="already_scanned">Bereits gescannt</string>
@@ -80,4 +89,11 @@
<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>
<string name="settings_header_subtitle">Einstellungen, die dir Kontrolle geben</string>
<string name="settings_hero_title">Deine Privatsphäre-Einstellungen</string>
<string name="settings_hero_subtitle">Wähle lokale Historie, Sicherheitswarnungen, Feedback und die Scanneransicht, die zu deinem Workflow passt.</string>
<string name="result_local_check">Lokale Prüfung</string>
<string name="result_checked_on_device">Vor dem Öffnen auf dem Gerät geprüft</string>
<string name="result_encoded_data">Codierte Daten</string>
<string name="batch_ticket_privacy_note">Warnungen zu doppelten und nicht registrierten Tickets bleiben auf dem Gerät.</string>
</resources>
+17 -1
View File
@@ -45,6 +45,15 @@
<string name="share_txt">TXT</string>
<string name="share_csv">CSV</string>
<string name="share_json">JSON</string>
<string name="history_header_subtitle">Optional history stays on your device</string>
<string name="history_hero_title">Review past scans locally</string>
<string name="history_hero_subtitle">Search, export, or delete saved scans whenever you choose.</string>
<string name="privacy_pill_no_ads">No ads</string>
<string name="privacy_pill_no_tracking">No tracking</string>
<string name="privacy_pill_no_account">No account</string>
<string name="local_controls">Local controls</string>
<string name="no_saved_scans_yet">No saved scans yet</string>
<string name="empty_history_description">Enable local history in settings to keep a private record on this device.</string>
<string name="scan_from_image">Scan from image</string>
<string name="import_whitelist">Import whitelist</string>
<string name="whitelist_loaded_count">Registered IDs loaded: %1$d</string>
@@ -56,7 +65,7 @@
<string name="share_batch">Share batch</string>
<string name="no_code_found_in_image">No QR or barcode found in the selected image.</string>
<string name="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 the result you want to use. Detection and parsing happen locally.</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="already_scanned">Already scanned</string>
@@ -80,4 +89,11 @@
<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>
<string name="settings_header_subtitle">Settings that keep you in control</string>
<string name="settings_hero_title">Privacy settings are yours</string>
<string name="settings_hero_subtitle">Choose local history, security warnings, feedback, and the scanner view that fits your workflow.</string>
<string name="result_local_check">Local check</string>
<string name="result_checked_on_device">Checked on device before opening</string>
<string name="result_encoded_data">Encoded data</string>
<string name="batch_ticket_privacy_note">Duplicate and unregistered ticket alerts stay on device.</string>
</resources>