nicer visual aperance

This commit is contained in:
Hadrian Burkhardt
2026-05-10 10:31:51 +02:00
parent 120d1672a3
commit 1eb389ea5e
12 changed files with 1821 additions and 171 deletions
@@ -2,8 +2,14 @@ package de.softwareapp_hb.privateqrscanner.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -20,6 +26,7 @@ import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.ui.screens.HistoryScreen
import de.softwareapp_hb.privateqrscanner.ui.screens.ScannerScreen
import de.softwareapp_hb.privateqrscanner.ui.screens.SettingsScreen
import de.softwareapp_hb.privateqrscanner.ui.theme.PrivateQrColors
private enum class RootTab { Scanner, History, Settings }
@@ -33,8 +40,16 @@ fun CleanScannerAppRoot(container: AppContainer) {
var activeTab by remember { mutableStateOf(RootTab.Scanner) }
Scaffold(
containerColor = PrivateQrColors.AppBackground,
bottomBar = {
NavigationBar {
val navColors = NavigationBarItemDefaults.colors(
selectedIconColor = PrivateQrColors.Teal700,
selectedTextColor = PrivateQrColors.Teal700,
indicatorColor = PrivateQrColors.Mint,
unselectedIconColor = PrivateQrColors.TextSecondary,
unselectedTextColor = PrivateQrColors.TextSecondary
)
NavigationBar(containerColor = PrivateQrColors.Surface) {
NavigationBarItem(
selected = activeTab == RootTab.Scanner,
onClick = {
@@ -42,7 +57,8 @@ fun CleanScannerAppRoot(container: AppContainer) {
scannerViewModel.resumeScanning()
},
label = { Text(stringResource(R.string.scan)) },
icon = {}
icon = { Icon(Icons.Default.QrCodeScanner, contentDescription = null) },
colors = navColors
)
NavigationBarItem(
selected = activeTab == RootTab.History,
@@ -50,7 +66,8 @@ fun CleanScannerAppRoot(container: AppContainer) {
activeTab = RootTab.History
},
label = { Text(stringResource(R.string.history)) },
icon = {}
icon = { Icon(Icons.Default.History, contentDescription = null) },
colors = navColors
)
NavigationBarItem(
selected = activeTab == RootTab.Settings,
@@ -58,7 +75,8 @@ fun CleanScannerAppRoot(container: AppContainer) {
activeTab = RootTab.Settings
},
label = { Text(stringResource(R.string.settings)) },
icon = {}
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
colors = navColors
)
}
}
@@ -47,7 +47,8 @@ fun UseCaseView.capabilities(): UseCaseCapabilities {
allowBatchMode = false,
allowCopy = true,
allowShare = true,
allowOpenUrl = true
allowOpenUrl = true,
allowHistoryExport = true
)
UseCaseView.EventTicketing -> UseCaseCapabilities(
@@ -1,17 +1,36 @@
package de.softwareapp_hb.privateqrscanner.ui.screens
import androidx.compose.foundation.Image
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
@@ -20,14 +39,22 @@ import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.domain.ScanRecord
import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import de.softwareapp_hb.privateqrscanner.ui.capabilities
import de.softwareapp_hb.privateqrscanner.ui.theme.PrivateQrColors
import de.softwareapp_hb.privateqrscanner.util.HistoryExportFormatter
import de.softwareapp_hb.privateqrscanner.util.Intents
import java.text.DateFormat
@@ -90,55 +117,97 @@ fun HistoryScreen(
)
}
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Top
.background(PrivateQrColors.AppBackground)
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.search)) }
)
Row(modifier = Modifier.fillMaxWidth()) {
if (capabilities.allowHistoryExport) {
TextButton(
onClick = {
val exportText = HistoryExportFormatter.formatText(history)
Intents.shareContent(context, exportText, "text/plain")
},
enabled = history.isNotEmpty()
) {
Text(stringResource(R.string.share_txt))
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
) {
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 = {
val exportCsv = HistoryExportFormatter.formatCsv(history)
Intents.shareContent(context, exportCsv, "text/csv")
},
enabled = history.isNotEmpty()
onClick = { showDeleteAll.value = true },
enabled = history.isNotEmpty(),
colors = ButtonDefaults.textButtonColors(
contentColor = Color(0xFFBE123C),
disabledContentColor = PrivateQrColors.TextSecondary.copy(alpha = 0.45f)
)
) {
Text(stringResource(R.string.share_csv))
}
TextButton(
onClick = {
val exportJson = HistoryExportFormatter.formatJson(history)
Intents.shareContent(context, exportJson, "application/json")
},
enabled = history.isNotEmpty()
) {
Text(stringResource(R.string.share_json))
Text(stringResource(R.string.delete_all), fontWeight = FontWeight.Bold)
}
}
TextButton(onClick = { showDeleteAll.value = true }) {
Text(stringResource(R.string.delete_all))
}
Spacer(modifier = Modifier.height(4.dp))
}
LazyColumn {
if (history.isEmpty()) {
item {
EmptyHistoryCard()
}
} else {
items(history, key = { it.id }) { item ->
HistoryRow(
item = item,
@@ -147,6 +216,139 @@ fun HistoryScreen(
)
}
}
item {
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun HistoryHeader() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_legacy),
contentDescription = null,
modifier = Modifier.size(64.dp)
)
Column {
Text(
text = stringResource(R.string.app_name),
color = PrivateQrColors.TextPrimary,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.ExtraBold
)
Text(
text = "Optional history stays on your device",
color = PrivateQrColors.TextSecondary,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(18.dp))
Card(
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
shape = RoundedCornerShape(30.dp)
) {
Column(
modifier = Modifier
.background(
Brush.linearGradient(
colors = listOf(PrivateQrColors.Navy, PrivateQrColors.Teal900)
)
)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Review past scans locally",
color = PrivateQrColors.Surface,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.ExtraBold
)
Text(
text = "Search, export, or delete saved scans whenever you choose.",
color = PrivateQrColors.Mint,
style = MaterialTheme.typography.titleMedium
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PrivacyPill("No ads")
PrivacyPill("No tracking")
PrivacyPill("No account")
}
}
}
}
@Composable
private fun PrivacyPill(text: String) {
Box(
modifier = Modifier
.background(
color = PrivateQrColors.Mint.copy(alpha = 0.12f),
shape = RoundedCornerShape(16.dp)
)
.padding(horizontal = 14.dp, vertical = 9.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = PrivateQrColors.Mint,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.ExtraBold
)
}
}
@Composable
private fun ExportButton(
text: String,
enabled: Boolean,
onClick: () -> Unit
) {
TextButton(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.textButtonColors(
containerColor = PrivateQrColors.Mint,
contentColor = PrivateQrColors.Teal700,
disabledContainerColor = PrivateQrColors.Mint.copy(alpha = 0.45f),
disabledContentColor = PrivateQrColors.Teal700.copy(alpha = 0.45f)
),
shape = RoundedCornerShape(15.dp)
) {
Text(text = text, fontWeight = FontWeight.ExtraBold)
}
}
@Composable
private fun EmptyHistoryCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface),
shape = RoundedCornerShape(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "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.",
color = PrivateQrColors.TextSecondary,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@@ -172,25 +374,81 @@ private fun HistoryRow(
state = dismissState,
backgroundContent = {},
content = {
Column(modifier = Modifier
.fillMaxWidth()
.clickable { onOpenDetails() }
.padding(vertical = 12.dp)) {
Text(text = item.type)
Text(
text = if (item.isBase64Encoded()) {
stringResource(R.string.base64_encoded_inline, item.content)
} else {
item.content
},
maxLines = 2
)
Text(text = DateFormat.getDateTimeInstance().format(Date(item.timestamp)))
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenDetails() },
colors = CardDefaults.cardColors(containerColor = PrivateQrColors.Surface),
shape = RoundedCornerShape(24.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier.padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(52.dp)
.background(
color = PrivateQrColors.Mint.copy(alpha = 0.55f),
shape = RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = iconForType(item.type),
contentDescription = null,
tint = PrivateQrColors.Teal700
)
}
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = item.type,
color = PrivateQrColors.TextPrimary,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.ExtraBold
)
Text(
text = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
.format(Date(item.timestamp)),
color = PrivateQrColors.TextSecondary,
style = MaterialTheme.typography.labelMedium,
maxLines = 1
)
}
Text(
text = if (item.isBase64Encoded()) {
stringResource(R.string.base64_encoded_inline, item.content)
} else {
item.content
},
color = PrivateQrColors.TextSecondary,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
)
}
private fun iconForType(type: String): ImageVector {
return when {
type.equals("URL", ignoreCase = true) -> Icons.Default.Link
type.equals("Email", ignoreCase = true) -> Icons.Default.Email
type.equals("WiFi", ignoreCase = true) -> Icons.Default.Wifi
else -> Icons.Default.History
}
}
private fun ScanRecord.isBase64Encoded(): Boolean {
return type.contains("base64", ignoreCase = true)
}
@@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import de.softwareapp_hb.privateqrscanner.ui.theme.PrivateQrColors
import de.softwareapp_hb.privateqrscanner.util.ParsedContact
import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers
import de.softwareapp_hb.privateqrscanner.util.UrlRiskScorer
@@ -60,39 +61,59 @@ internal fun ResultVisualCard(
val fields = remember(result) { buildResultFields(result) }
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF2F7FF)),
shape = RoundedCornerShape(14.dp)
colors = CardDefaults.cardColors(containerColor = PrivateQrColors.SoftSurface),
shape = RoundedCornerShape(26.dp)
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
if (!result.isBase64Encoded && result.type == "WiFi") {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = null,
tint = Color(0xFF1D4ED8)
tint = PrivateQrColors.Teal700
)
Text(
text = "Wi-Fi",
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.headlineSmall
)
}
} else {
Text(
text = result.displayType,
style = MaterialTheme.typography.titleMedium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = result.displayType,
style = MaterialTheme.typography.headlineSmall,
color = PrivateQrColors.TextPrimary
)
if (!result.isBase64Encoded && result.type == "URL") {
Text(
text = "Local check",
color = PrivateQrColors.Teal700,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.background(
color = PrivateQrColors.Mint,
shape = RoundedCornerShape(50)
)
.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
}
if (result.isBase64Encoded) {
Text(
text = stringResource(R.string.base64_encoded_notice),
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF4F6277)
color = PrivateQrColors.TextSecondary
)
}
if (fields.isEmpty()) {
@@ -106,7 +127,7 @@ internal fun ResultVisualCard(
Text(
text = field.label,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4F6277)
color = PrivateQrColors.TextSecondary
)
val isClickableUrl = result.type == "URL" &&
field.label == "Link" &&
@@ -114,7 +135,7 @@ internal fun ResultVisualCard(
Text(
text = field.value,
style = MaterialTheme.typography.bodyMedium,
color = if (isClickableUrl) Color(0xFF1D4ED8) else Color.Unspecified,
color = if (isClickableUrl) Color(0xFF1D4ED8) else PrivateQrColors.TextPrimary,
textDecoration = if (isClickableUrl) TextDecoration.Underline else null,
modifier = if (isClickableUrl) {
Modifier.clickable { onOpenUrl(field.value) }
@@ -127,6 +148,30 @@ internal fun ResultVisualCard(
}
}
}
if (!result.isBase64Encoded && result.type == "URL") {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
color = Color(0xFFECFDF5),
shape = RoundedCornerShape(18.dp)
)
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "",
color = PrivateQrColors.Teal700,
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Checked on device before opening",
color = PrivateQrColors.Teal700,
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
}
@@ -34,9 +34,11 @@ import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material.icons.filled.ViewModule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -57,6 +59,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
@@ -81,6 +84,7 @@ import de.softwareapp_hb.privateqrscanner.ui.EventTicketScanDecision
import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import com.clean.scanner.ui.components.CameraPreview
import de.softwareapp_hb.privateqrscanner.ui.capabilities
import de.softwareapp_hb.privateqrscanner.ui.theme.PrivateQrColors
import de.softwareapp_hb.privateqrscanner.util.ClipboardUtil
import de.softwareapp_hb.privateqrscanner.util.Intents
import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers
@@ -317,14 +321,15 @@ fun ScannerScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(PrivateQrColors.Deep)
.onSizeChanged { containerSize = it }
) {
val density = LocalDensity.current
val viewW = containerSize.width.toFloat()
val viewH = containerSize.height.toFloat()
val galleryOpen = imageScanPreviewUri != null
val aimW = viewW * 0.62f
val aimH = with(density) { 200.dp.toPx() }
val aimW = viewW * 0.70f
val aimH = with(density) { 230.dp.toPx() }
val aimLeft = (viewW - aimW) / 2f
val aimTop = (viewH - aimH) / 2f
val aimRight = aimLeft + aimW
@@ -385,6 +390,20 @@ fun ScannerScreen(
}
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
PrivateQrColors.Deep.copy(alpha = 0.72f),
PrivateQrColors.Teal900.copy(alpha = 0.34f),
PrivateQrColors.Deep.copy(alpha = 0.78f)
)
)
)
)
if (detectionBoxes.isNotEmpty()) {
Canvas(
modifier = Modifier
@@ -421,15 +440,15 @@ fun ScannerScreen(
drawPath(
path = outline,
color = boxColor.copy(alpha = 0.96f),
style = Stroke(width = 4f)
style = Stroke(width = 5.5f)
)
} else if (right > left && bottom > top) {
drawRoundRect(
color = boxColor.copy(alpha = 0.95f),
topLeft = Offset(left, top),
size = Size(right - left, bottom - top),
cornerRadius = CornerRadius(14f, 14f),
style = Stroke(width = 4f)
cornerRadius = CornerRadius(22f, 22f),
style = Stroke(width = 5.5f)
)
}
}
@@ -439,22 +458,22 @@ fun ScannerScreen(
Canvas(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.62f)
.height(200.dp)
.fillMaxWidth(0.70f)
.height(230.dp)
) {
val guideColor = when {
hasReadableInView -> Color(0xFF4AE3A3)
hasPotentialInView -> Color(0xFFFFC857)
else -> Color(0xFF7CE6C6)
hasPotentialInView -> PrivateQrColors.Warning
else -> PrivateQrColors.Teal300
}
drawRoundRect(
color = guideColor.copy(alpha = 0.08f),
cornerRadius = CornerRadius(22f, 22f)
color = guideColor.copy(alpha = 0.12f),
cornerRadius = CornerRadius(32f, 32f)
)
drawRoundRect(
color = guideColor.copy(alpha = 0.90f),
cornerRadius = CornerRadius(22f, 22f),
style = Stroke(width = 3.5f)
cornerRadius = CornerRadius(32f, 32f),
style = Stroke(width = 5f)
)
}
@@ -465,15 +484,16 @@ fun ScannerScreen(
else -> stringResource(R.string.aim_center_hint)
},
color = Color.White,
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = if (isBatchModeActive) 190.dp else 56.dp)
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(18.dp)
color = Color.Black.copy(alpha = 0.45f),
shape = RoundedCornerShape(24.dp)
)
.padding(horizontal = 14.dp, vertical = 8.dp)
.padding(horizontal = 18.dp, vertical = 10.dp)
)
if (capabilities.allowScanFromImage) {
@@ -483,8 +503,8 @@ fun ScannerScreen(
.align(Alignment.TopEnd)
.padding(top = 12.dp, end = 12.dp)
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(14.dp)
color = Color.Black.copy(alpha = 0.38f),
shape = RoundedCornerShape(18.dp)
)
) {
Icon(
@@ -501,8 +521,8 @@ fun ScannerScreen(
.align(Alignment.TopEnd)
.padding(top = 12.dp, end = if (capabilities.allowScanFromImage) 64.dp else 12.dp)
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(14.dp)
color = Color.Black.copy(alpha = 0.38f),
shape = RoundedCornerShape(18.dp)
)
) {
Icon(
@@ -516,15 +536,16 @@ fun ScannerScreen(
Text(
text = stringResource(useCaseView.titleRes),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 16.dp, start = 64.dp, end = 64.dp)
.background(
color = Color.Black.copy(alpha = 0.4f),
shape = RoundedCornerShape(14.dp)
color = Color.Black.copy(alpha = 0.52f),
shape = RoundedCornerShape(22.dp)
)
.padding(horizontal = 12.dp, vertical = 6.dp)
.padding(horizontal = 18.dp, vertical = 10.dp)
)
if (useCaseView == UseCaseView.EventTicketing) {
Text(
@@ -613,12 +634,16 @@ fun ScannerScreen(
if (lastResult.isBase64Encoded) null else ScanContentParsers.parseCalendarEvent(lastResult.content)
}
ModalBottomSheet(onDismissRequest = onScanAgain) {
ModalBottomSheet(
onDismissRequest = onScanAgain,
containerColor = PrivateQrColors.Surface,
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
.padding(start = 20.dp, top = 4.dp, end = 20.dp, bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
ResultVisualCard(
result = lastResult,
@@ -642,31 +667,50 @@ fun ScannerScreen(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (capabilities.allowAddContact && parsedContact != null) {
IconButton(onClick = {
Intents.addContact(context, parsedContact, lastResult.content)
}) {
IconButton(
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)
contentDescription = stringResource(R.string.add_contact),
tint = PrivateQrColors.TextPrimary
)
}
}
if (capabilities.allowCopy) {
IconButton(onClick = { ClipboardUtil.copy(context, lastResult.content) }) {
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)
contentDescription = stringResource(R.string.copy),
tint = PrivateQrColors.TextPrimary
)
}
}
if (capabilities.allowShare) {
IconButton(onClick = { Intents.shareText(context, lastResult.content) }) {
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)
contentDescription = stringResource(R.string.share),
tint = PrivateQrColors.TextPrimary
)
}
}
@@ -677,9 +721,14 @@ fun ScannerScreen(
when (lastResult.type) {
"Phone" -> {
if (capabilities.allowDialPhone) {
Button(onClick = {
Intents.dialPhone(context, ScanContentParsers.extractPhoneNumber(lastResult.content))
}) {
Button(
onClick = {
Intents.dialPhone(context, ScanContentParsers.extractPhoneNumber(lastResult.content))
},
colors = ButtonDefaults.buttonColors(
containerColor = PrivateQrColors.Teal700
)
) {
Text(stringResource(R.string.call_number))
}
}
@@ -687,10 +736,15 @@ fun ScannerScreen(
"SMS" -> {
if (capabilities.allowSendSms) {
Button(onClick = {
val smsData = ScanContentParsers.parseSms(lastResult.content)
Intents.sendSms(context, smsData.first, smsData.second)
}) {
Button(
onClick = {
val smsData = ScanContentParsers.parseSms(lastResult.content)
Intents.sendSms(context, smsData.first, smsData.second)
},
colors = ButtonDefaults.buttonColors(
containerColor = PrivateQrColors.Teal700
)
) {
Text(stringResource(R.string.send_sms))
}
}
@@ -698,9 +752,14 @@ fun ScannerScreen(
"Email" -> {
if (capabilities.allowSendEmail) {
Button(onClick = {
Intents.sendEmail(context, ScanContentParsers.extractEmail(lastResult.content), null)
}) {
Button(
onClick = {
Intents.sendEmail(context, ScanContentParsers.extractEmail(lastResult.content), null)
},
colors = ButtonDefaults.buttonColors(
containerColor = PrivateQrColors.Teal700
)
) {
Text(stringResource(R.string.send_email))
}
}
@@ -708,7 +767,12 @@ fun ScannerScreen(
"WiFi" -> {
if (capabilities.allowOpenWifiSettings) {
Button(onClick = { Intents.openWifiSettings(context) }) {
Button(
onClick = { Intents.openWifiSettings(context) },
colors = ButtonDefaults.buttonColors(
containerColor = PrivateQrColors.Teal700
)
) {
Text(stringResource(R.string.open_wifi_settings))
}
}
@@ -716,9 +780,14 @@ fun ScannerScreen(
"Calendar" -> {
if (capabilities.allowAddCalendarEvent) {
Button(onClick = {
Intents.addCalendarEvent(context, parsedEvent, lastResult.content)
}) {
Button(
onClick = {
Intents.addCalendarEvent(context, parsedEvent, lastResult.content)
},
colors = ButtonDefaults.buttonColors(
containerColor = PrivateQrColors.Teal700
)
) {
Text(stringResource(R.string.add_calendar_event))
}
}
@@ -1,25 +1,44 @@
package de.softwareapp_hb.privateqrscanner.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import de.softwareapp_hb.privateqrscanner.ui.theme.PrivateQrColors
import de.softwareapp_hb.privateqrscanner.util.InAppReviewRequester
@Composable
@@ -93,50 +112,216 @@ fun SettingsScreen(
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Top
.background(PrivateQrColors.AppBackground)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
Text(text = stringResource(R.string.save_history))
Switch(
checked = historyEnabled,
onCheckedChange = { enabled ->
if (!enabled && historyEnabled) {
showDeleteConfirm.value = true
} else {
onHistoryToggle(enabled, false)
}
}
SettingsHeader()
Text(
text = stringResource(R.string.settings),
color = PrivateQrColors.TextPrimary,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.ExtraBold
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = stringResource(R.string.security_warnings))
Switch(checked = warningsEnabled, onCheckedChange = onWarningsToggle)
Spacer(modifier = Modifier.height(16.dp))
Text(text = stringResource(R.string.scan_feedback))
Switch(checked = scanFeedbackEnabled, onCheckedChange = onScanFeedbackToggle)
Spacer(modifier = Modifier.height(16.dp))
Text(text = stringResource(R.string.active_use_case_view))
Text(text = stringResource(selectedUseCaseView.titleRes))
TextButton(onClick = { showUseCasePicker.value = true }) {
Text(stringResource(R.string.select_use_case_view))
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
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(text = stringResource(R.string.about))
Text(text = stringResource(R.string.version))
Text(text = stringResource(R.string.licenses))
Text(text = stringResource(R.string.contact))
TextButton(onClick = { showPrivacyPolicy.value = true }) {
Text(text = stringResource(R.string.privacy_policy))
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
)
TextButton(
onClick = { showUseCasePicker.value = true },
colors = ButtonDefaults.textButtonColors(contentColor = PrivateQrColors.Teal700)
) {
Text(stringResource(R.string.select_use_case_view), 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))
TextButton(onClick = { InAppReviewRequester.requestReview(context) }) {
Text(text = stringResource(R.string.review_app))
}
}
@Composable
private fun SettingsHeader() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_legacy),
contentDescription = null,
modifier = Modifier.size(64.dp)
)
Column {
Text(
text = stringResource(R.string.app_name),
color = PrivateQrColors.TextPrimary,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.ExtraBold
)
Text(
text = "Local privacy controls",
color = PrivateQrColors.TextSecondary,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(18.dp))
Card(
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
shape = RoundedCornerShape(30.dp)
) {
Column(
modifier = Modifier
.background(
Brush.linearGradient(
colors = listOf(PrivateQrColors.Navy, PrivateQrColors.Teal900)
)
)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Control what stays saved",
color = PrivateQrColors.Surface,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.ExtraBold
)
Text(
text = "History, warnings, and feedback are optional device-local settings.",
color = PrivateQrColors.Mint,
style = MaterialTheme.typography.titleMedium
)
}
}
}
@Composable
private fun SettingsToggleRow(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(74.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
private fun SettingsDivider() {
HorizontalDivider(color = PrivateQrColors.Divider)
}
@Composable
private fun InfoLine(text: String) {
Text(
text = text,
color = PrivateQrColors.TextSecondary,
style = MaterialTheme.typography.bodyMedium
)
}
@@ -0,0 +1,21 @@
package de.softwareapp_hb.privateqrscanner.ui.theme
import androidx.compose.ui.graphics.Color
object PrivateQrColors {
val Deep = Color(0xFF07111F)
val Navy = Color(0xFF0B1220)
val Teal900 = Color(0xFF123B3F)
val Teal800 = Color(0xFF155E63)
val Teal700 = Color(0xFF0F766E)
val Teal300 = Color(0xFF2DD4BF)
val Mint = Color(0xFFDFF7F2)
val AppBackground = Color(0xFFF6FBFA)
val Surface = Color(0xFFFFFFFF)
val SoftSurface = Color(0xFFF2F7FF)
val TextPrimary = Color(0xFF0B1220)
val TextSecondary = Color(0xFF607080)
val Divider = Color(0xFFDCE8E6)
val Success = Color(0xFF10B981)
val Warning = Color(0xFFFFC857)
}
@@ -1,28 +1,41 @@
package de.softwareapp_hb.privateqrscanner.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
private val LightColors = lightColorScheme(
primary = PrivateQrColors.Teal700,
onPrimary = PrivateQrColors.Surface,
secondary = PrivateQrColors.Teal300,
onSecondary = PrivateQrColors.Navy,
background = PrivateQrColors.AppBackground,
onBackground = PrivateQrColors.TextPrimary,
surface = PrivateQrColors.Surface,
onSurface = PrivateQrColors.TextPrimary,
surfaceVariant = PrivateQrColors.Mint,
onSurfaceVariant = PrivateQrColors.TextSecondary
)
private val DarkColors = darkColorScheme(
primary = PrivateQrColors.Teal300,
onPrimary = PrivateQrColors.Navy,
secondary = PrivateQrColors.Mint,
onSecondary = PrivateQrColors.Navy,
background = PrivateQrColors.Deep,
onBackground = PrivateQrColors.Surface,
surface = PrivateQrColors.Navy,
onSurface = PrivateQrColors.Surface,
surfaceVariant = PrivateQrColors.Teal900,
onSurfaceVariant = PrivateQrColors.Mint
)
@Composable
fun CleanScannerTheme(content: @Composable () -> Unit) {
val darkTheme = isSystemInDarkTheme()
val context = LocalContext.current
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} else {
if (darkTheme) DarkColors else LightColors
}
val colorScheme = if (darkTheme) DarkColors else LightColors
MaterialTheme(
colorScheme = colorScheme,