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, query = appState.searchQuery,
history = appState.history, history = appState.history,
useCaseView = appState.useCaseView, useCaseView = appState.useCaseView,
historyEnabled = appState.historyEnabled,
warningsEnabled = appState.warningsEnabled,
scanFeedbackEnabled = appState.scanFeedbackEnabled,
onQueryChange = appViewModel::setQuery, onQueryChange = appViewModel::setQuery,
onDelete = appViewModel::deleteHistoryItem, onDelete = appViewModel::deleteHistoryItem,
onClearAll = appViewModel::clearHistory onClearAll = appViewModel::clearHistory,
onHistoryToggle = appViewModel::setHistoryEnabled,
onWarningsToggle = appViewModel::setWarningsEnabled,
onScanFeedbackToggle = appViewModel::setScanFeedbackEnabled
) )
RootTab.Settings -> SettingsScreen( RootTab.Settings -> SettingsScreen(
@@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Box
import androidx.compose.foundation.layout.BoxWithConstraints
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
@@ -31,6 +32,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -65,9 +68,15 @@ fun HistoryScreen(
query: String, query: String,
history: List<ScanRecord>, history: List<ScanRecord>,
useCaseView: UseCaseView, useCaseView: UseCaseView,
historyEnabled: Boolean,
warningsEnabled: Boolean,
scanFeedbackEnabled: Boolean,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
onDelete: (Long) -> Unit, onDelete: (Long) -> Unit,
onClearAll: () -> Unit onClearAll: () -> Unit,
onHistoryToggle: (Boolean, Boolean) -> Unit,
onWarningsToggle: (Boolean) -> Unit,
onScanFeedbackToggle: (Boolean) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val capabilities = useCaseView.capabilities() val capabilities = useCaseView.capabilities()
@@ -117,10 +126,93 @@ fun HistoryScreen(
) )
} }
LazyColumn( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(PrivateQrColors.AppBackground) .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), .padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
@@ -135,27 +227,7 @@ fun HistoryScreen(
fontWeight = FontWeight.ExtraBold fontWeight = FontWeight.ExtraBold
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( HistorySearchField(query = query, onQueryChange = onQueryChange)
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)) Spacer(modifier = Modifier.height(10.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -222,6 +294,163 @@ fun HistoryScreen(
} }
} }
} }
}
}
@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 @Composable
private fun HistoryHeader() { private fun HistoryHeader() {
@@ -243,7 +472,7 @@ private fun HistoryHeader() {
fontWeight = FontWeight.ExtraBold fontWeight = FontWeight.ExtraBold
) )
Text( Text(
text = "Optional history stays on your device", text = stringResource(R.string.history_header_subtitle),
color = PrivateQrColors.TextSecondary, color = PrivateQrColors.TextSecondary,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@@ -266,20 +495,20 @@ private fun HistoryHeader() {
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Text(
text = "Review past scans locally", text = stringResource(R.string.history_hero_title),
color = PrivateQrColors.Surface, color = PrivateQrColors.Surface,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.ExtraBold fontWeight = FontWeight.ExtraBold
) )
Text( Text(
text = "Search, export, or delete saved scans whenever you choose.", text = stringResource(R.string.history_hero_subtitle),
color = PrivateQrColors.Mint, color = PrivateQrColors.Mint,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PrivacyPill("No ads") PrivacyPill(stringResource(R.string.privacy_pill_no_ads))
PrivacyPill("No tracking") PrivacyPill(stringResource(R.string.privacy_pill_no_tracking))
PrivacyPill("No account") PrivacyPill(stringResource(R.string.privacy_pill_no_account))
} }
} }
} }
@@ -338,13 +567,13 @@ private fun EmptyHistoryCard() {
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Text( Text(
text = "No saved scans yet", text = stringResource(R.string.no_saved_scans_yet),
color = PrivateQrColors.TextPrimary, color = PrivateQrColors.TextPrimary,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.ExtraBold fontWeight = FontWeight.ExtraBold
) )
Text( 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, color = PrivateQrColors.TextSecondary,
style = MaterialTheme.typography.bodyMedium 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.ContentCopy
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton import androidx.compose.material3.IconToggleButton
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
@@ -25,10 +28,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
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.unit.dp import androidx.compose.ui.unit.dp
import de.softwareapp_hb.privateqrscanner.R import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.ui.BatchScanRecord 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.ClipboardUtil
import de.softwareapp_hb.privateqrscanner.util.Intents import de.softwareapp_hb.privateqrscanner.util.Intents
import java.text.DateFormat import java.text.DateFormat
@@ -91,22 +96,46 @@ internal fun BatchResultsPanel(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background( .background(
color = Color.Black.copy(alpha = 0.42f), color = PrivateQrColors.Deep.copy(alpha = 0.88f),
shape = RoundedCornerShape(14.dp) shape = RoundedCornerShape(28.dp)
) )
.padding(12.dp), .padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = stringResource(R.string.batch_captures_count, results.size), 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 -> results.take(3).forEach { item ->
val contentText = if (item.result.isBase64Encoded) { val contentText = if (item.result.isBase64Encoded) {
stringResource(R.string.base64_encoded_inline, item.result.content) stringResource(R.string.base64_encoded_inline, item.result.content)
} else { } else {
item.result.content item.result.content
} }
HorizontalDivider(color = Color.White.copy(alpha = 0.12f))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -116,11 +145,15 @@ internal fun BatchResultsPanel(
Text( Text(
text = "${item.result.displayType}: $contentText", text = "${item.result.displayType}: $contentText",
color = Color.White.copy(alpha = 0.92f), color = Color.White.copy(alpha = 0.92f),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1 maxLines = 1
) )
Text( Text(
text = timeFormat.format(Date(item.timestamp)), 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 { Row {
@@ -143,18 +176,43 @@ internal fun BatchResultsPanel(
} }
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(
TextButton(onClick = onClear, enabled = results.isNotEmpty()) { modifier = Modifier
Text(stringResource(R.string.clear_batch)) .fillMaxWidth()
} .background(Color(0xFF831843).copy(alpha = 0.42f), RoundedCornerShape(18.dp))
if (allowShare) { .padding(horizontal = 14.dp, vertical = 12.dp),
TextButton( horizontalArrangement = Arrangement.spacedBy(10.dp),
onClick = { Intents.shareText(context, buildBatchExport(results)) }, verticalAlignment = Alignment.CenterVertically
enabled = results.isNotEmpty()
) { ) {
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") { if (!result.isBase64Encoded && result.type == "URL") {
Text( Text(
text = "Local check", text = stringResource(R.string.result_local_check),
color = PrivateQrColors.Teal700, color = PrivateQrColors.Teal700,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
modifier = Modifier modifier = Modifier
@@ -166,7 +166,7 @@ internal fun ResultVisualCard(
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Text( Text(
text = "Checked on device before opening", text = stringResource(R.string.result_checked_on_device),
color = PrivateQrColors.Teal700, color = PrivateQrColors.Teal700,
style = MaterialTheme.typography.labelLarge 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.automirrored.filled.ViewList
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.FlashOff 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.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.text.style.TextAlign
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp 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( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
) { granted -> ) { granted ->
@@ -647,20 +662,12 @@ fun ScannerScreen(
) { ) {
ResultVisualCard( ResultVisualCard(
result = lastResult, result = lastResult,
onOpenUrl = { url -> onOpenUrl = ::requestOpenUrl
val risk = UrlRiskScorer.score(url)
val risky = warningsEnabled && risk.score >= 3
if (risky) {
pendingOpenUrl = url
showRiskWarning = true
} else {
Intents.openUrl(context, url)
}
}
) )
val hasQuickActions = capabilities.allowCopy || val hasQuickActions = capabilities.allowCopy ||
capabilities.allowShare || capabilities.allowShare ||
(capabilities.allowAddContact && parsedContact != null) (capabilities.allowAddContact && parsedContact != null) ||
(capabilities.allowOpenUrl && !lastResult.isBase64Encoded && lastResult.type == "URL")
if (hasQuickActions) { if (hasQuickActions) {
Row( Row(
@@ -670,49 +677,34 @@ fun ScannerScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
if (capabilities.allowAddContact && parsedContact != null) { if (capabilities.allowAddContact && parsedContact != null) {
IconButton( ResultActionPill(
onClick = { Intents.addContact(context, parsedContact, lastResult.content) }, text = stringResource(R.string.add_contact),
modifier = Modifier.background(
color = PrivateQrColors.AppBackground,
shape = RoundedCornerShape(50)
)
) {
Icon(
imageVector = Icons.Default.PersonAdd, imageVector = Icons.Default.PersonAdd,
contentDescription = stringResource(R.string.add_contact), onClick = { Intents.addContact(context, parsedContact, lastResult.content) },
tint = PrivateQrColors.TextPrimary primary = true
) )
} }
}
if (capabilities.allowCopy) { if (capabilities.allowCopy) {
IconButton( ResultActionPill(
onClick = { ClipboardUtil.copy(context, lastResult.content) }, text = stringResource(R.string.copy),
modifier = Modifier.background(
color = PrivateQrColors.AppBackground,
shape = RoundedCornerShape(50)
)
) {
Icon(
imageVector = Icons.Default.ContentCopy, imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(R.string.copy), onClick = { ClipboardUtil.copy(context, lastResult.content) }
tint = PrivateQrColors.TextPrimary
) )
} }
}
if (capabilities.allowShare) { if (capabilities.allowShare) {
IconButton( ResultActionPill(
onClick = { Intents.shareText(context, lastResult.content) }, text = stringResource(R.string.share),
modifier = Modifier.background(
color = PrivateQrColors.AppBackground,
shape = RoundedCornerShape(50)
)
) {
Icon(
imageVector = Icons.Default.Share, imageVector = Icons.Default.Share,
contentDescription = stringResource(R.string.share), onClick = { Intents.shareText(context, lastResult.content) }
tint = PrivateQrColors.TextPrimary
) )
} }
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() } .filter { it.isNotBlank() }
.toSet() .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.clickable
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.BoxWithConstraints
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
@@ -57,7 +58,6 @@ fun SettingsScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val showDeleteConfirm = remember { mutableStateOf(false) } val showDeleteConfirm = remember { mutableStateOf(false) }
val showUseCasePicker = remember { mutableStateOf(false) }
val showPrivacyPolicy = remember { mutableStateOf(false) } val showPrivacyPolicy = remember { mutableStateOf(false) }
if (showDeleteConfirm.value) { 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) { if (showPrivacyPolicy.value) {
PrivacyPolicyDialog(onDismiss = { showPrivacyPolicy.value = false }) PrivacyPolicyDialog(onDismiss = { showPrivacyPolicy.value = false })
} }
Column( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(PrivateQrColors.AppBackground) .background(PrivateQrColors.AppBackground)
) {
val wideLayout = maxWidth >= 600.dp
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(20.dp), .padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp) verticalArrangement = Arrangement.spacedBy(18.dp)
) { ) {
SettingsHeader() SettingsHeader()
Text( if (wideLayout) {
text = stringResource(R.string.settings), Row(
color = PrivateQrColors.TextPrimary, modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.headlineMedium, horizontalArrangement = Arrangement.spacedBy(24.dp),
fontWeight = FontWeight.ExtraBold 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( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface), colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface),
@@ -149,7 +177,7 @@ fun SettingsScreen(
checked = historyEnabled, checked = historyEnabled,
onCheckedChange = { enabled -> onCheckedChange = { enabled ->
if (!enabled && historyEnabled) { if (!enabled && historyEnabled) {
showDeleteConfirm.value = true onRequestDisableHistory()
} else { } else {
onHistoryToggle(enabled, false) onHistoryToggle(enabled, false)
} }
@@ -169,34 +197,39 @@ fun SettingsScreen(
) )
} }
} }
}
Card( @Composable
modifier = Modifier.fillMaxWidth(), private fun UseCaseSelectionCard(
colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface), selectedUseCaseView: UseCaseView,
shape = RoundedCornerShape(28.dp), onUseCaseViewSelected: (UseCaseView) -> Unit,
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text( Text(
text = stringResource(R.string.active_use_case_view), text = stringResource(R.string.select_use_case_view),
color = PrivateQrColors.TextPrimary, color = PrivateQrColors.TextPrimary,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.ExtraBold fontWeight = FontWeight.ExtraBold
) )
Text( UseCaseView.entries.forEach { candidate ->
text = stringResource(selectedUseCaseView.titleRes), UseCasePickerOption(
color = PrivateQrColors.TextSecondary, candidate = candidate,
style = MaterialTheme.typography.bodyLarge 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( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface), colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface),
@@ -215,13 +248,13 @@ fun SettingsScreen(
InfoLine(stringResource(R.string.contact)) InfoLine(stringResource(R.string.contact))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TextButton( TextButton(
onClick = { showPrivacyPolicy.value = true }, onClick = onPrivacyClick,
colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700) colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700)
) { ) {
Text(text = stringResource(R.string.privacy_policy), fontWeight = FontWeight.Bold) Text(text = stringResource(R.string.privacy_policy), fontWeight = FontWeight.Bold)
} }
TextButton( TextButton(
onClick = { InAppReviewRequester.requestReview(context) }, onClick = onReviewClick,
colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700) colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700)
) { ) {
Text(text = stringResource(R.string.review_app), fontWeight = FontWeight.Bold) Text(text = stringResource(R.string.review_app), fontWeight = FontWeight.Bold)
@@ -229,9 +262,6 @@ fun SettingsScreen(
} }
} }
} }
Spacer(modifier = Modifier.height(12.dp))
}
} }
@Composable @Composable
@@ -309,7 +339,7 @@ private fun SettingsHeader() {
fontWeight = FontWeight.ExtraBold fontWeight = FontWeight.ExtraBold
) )
Text( Text(
text = "Local privacy controls", text = stringResource(R.string.settings_header_subtitle),
color = PrivateQrColors.TextSecondary, color = PrivateQrColors.TextSecondary,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@@ -332,13 +362,13 @@ private fun SettingsHeader() {
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Text(
text = "Control what stays saved", text = stringResource(R.string.settings_hero_title),
color = PrivateQrColors.Surface, color = PrivateQrColors.Surface,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.ExtraBold fontWeight = FontWeight.ExtraBold
) )
Text( Text(
text = "History, warnings, and feedback are optional device-local settings.", text = stringResource(R.string.settings_hero_subtitle),
color = PrivateQrColors.Mint, color = PrivateQrColors.Mint,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
+17 -1
View File
@@ -45,6 +45,15 @@
<string name="share_txt">TXT</string> <string name="share_txt">TXT</string>
<string name="share_csv">CSV</string> <string name="share_csv">CSV</string>
<string name="share_json">JSON</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="scan_from_image">Aus Bild scannen</string>
<string name="import_whitelist">Whitelist importieren</string> <string name="import_whitelist">Whitelist importieren</string>
<string name="whitelist_loaded_count">Geladene registrierte IDs: %1$d</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="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="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 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_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>
@@ -80,4 +89,11 @@
<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_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="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> </resources>
+17 -1
View File
@@ -45,6 +45,15 @@
<string name="share_txt">TXT</string> <string name="share_txt">TXT</string>
<string name="share_csv">CSV</string> <string name="share_csv">CSV</string>
<string name="share_json">JSON</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="scan_from_image">Scan from image</string>
<string name="import_whitelist">Import whitelist</string> <string name="import_whitelist">Import whitelist</string>
<string name="whitelist_loaded_count">Registered IDs loaded: %1$d</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="share_batch">Share batch</string>
<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 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_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>
@@ -80,4 +89,11 @@
<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_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="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> </resources>