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,
@@ -0,0 +1,515 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1080, height=1920, initial-scale=1">
<title>Private QR Scanner Screenshot 1</title>
<style>
:root {
--bg: #07111f;
--surface: #f8fafc;
--surface-2: #eef7f5;
--ink: #0b1220;
--muted: #5e7282;
--teal: #2dd4bf;
--mint: #dff7f2;
--blue: #1d4ed8;
}
* {
box-sizing: border-box;
}
html,
body {
width: 1080px;
height: 1920px;
margin: 0;
overflow: hidden;
font-family: Inter, "DejaVu Sans", Arial, sans-serif;
background: var(--bg);
color: var(--surface);
}
.shot {
position: relative;
width: 1080px;
height: 1920px;
overflow: hidden;
background:
radial-gradient(circle at 72% 17%, rgba(45, 212, 191, 0.22), transparent 27%),
radial-gradient(circle at 22% 70%, rgba(223, 247, 242, 0.10), transparent 24%),
linear-gradient(180deg, #0b1220 0%, #103840 52%, #07111f 100%);
}
.camera {
position: absolute;
inset: 0;
overflow: hidden;
background:
linear-gradient(90deg, rgba(45, 212, 191, 0.09) 1px, transparent 1px) 0 0 / 68px 68px,
linear-gradient(0deg, rgba(45, 212, 191, 0.08) 1px, transparent 1px) 0 0 / 68px 68px,
radial-gradient(circle at 58% 38%, rgba(45, 212, 191, 0.17), transparent 22%),
linear-gradient(150deg, rgba(8, 17, 31, 0.15), rgba(8, 17, 31, 0.78));
}
.camera::before,
.camera::after {
content: "";
position: absolute;
left: -120px;
right: -120px;
pointer-events: none;
}
.camera::before {
top: 314px;
height: 424px;
background: rgba(223, 247, 242, 0.10);
clip-path: polygon(0 63%, 19% 42%, 38% 48%, 58% 33%, 78% 42%, 100% 21%, 100% 100%, 0 100%);
}
.camera::after {
top: 642px;
height: 470px;
background: rgba(2, 6, 23, 0.58);
clip-path: polygon(0 23%, 18% 10%, 36% 28%, 54% 18%, 73% 31%, 100% 13%, 100% 100%, 0 100%);
}
.status {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: center;
height: 92px;
padding: 22px 44px 0;
color: #f8fafc;
font-size: 24px;
font-weight: 760;
}
.system-icons {
display: flex;
align-items: center;
gap: 10px;
}
.signal {
display: flex;
align-items: end;
gap: 4px;
height: 22px;
}
.signal span {
width: 5px;
border-radius: 999px;
background: #f8fafc;
}
.signal span:nth-child(1) { height: 8px; }
.signal span:nth-child(2) { height: 12px; }
.signal span:nth-child(3) { height: 16px; }
.signal span:nth-child(4) { height: 21px; }
.battery {
width: 42px;
height: 20px;
border: 2px solid #f8fafc;
border-radius: 6px;
padding: 3px;
}
.battery::after {
content: "";
display: block;
width: 27px;
height: 10px;
border-radius: 3px;
background: var(--teal);
}
.top-chip {
position: absolute;
top: 118px;
left: 212px;
right: 212px;
z-index: 3;
display: flex;
justify-content: center;
align-items: center;
min-height: 56px;
padding: 11px 18px;
border-radius: 18px;
color: white;
background: rgba(0, 0, 0, 0.45);
font-size: 23px;
font-weight: 700;
text-align: center;
box-shadow: 0 18px 42px rgba(2, 6, 23, 0.24);
}
.gallery {
position: absolute;
top: 112px;
right: 42px;
z-index: 3;
display: grid;
place-items: center;
width: 64px;
height: 64px;
border: 1px solid rgba(248, 250, 252, 0.22);
border-radius: 18px;
background: rgba(0, 0, 0, 0.38);
}
.gallery svg {
width: 32px;
height: 32px;
}
.aim {
position: absolute;
top: 392px;
left: 166px;
z-index: 2;
width: 748px;
height: 480px;
border-radius: 54px;
background: rgba(45, 212, 191, 0.06);
border: 7px solid rgba(45, 212, 191, 0.9);
box-shadow:
inset 0 0 0 2px rgba(223, 247, 242, 0.20),
0 0 72px rgba(45, 212, 191, 0.27);
}
.qr-target {
position: absolute;
top: 478px;
left: 332px;
z-index: 3;
width: 416px;
height: 300px;
border-radius: 26px;
padding: 26px;
background: rgba(248, 250, 252, 0.97);
box-shadow: 0 28px 70px rgba(2, 6, 23, 0.36);
}
.qr-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(5, 1fr);
gap: 13px;
width: 100%;
height: 100%;
}
.qr-grid span {
border-radius: 5px;
background: #07111f;
}
.qr-grid span:nth-child(4),
.qr-grid span:nth-child(10),
.qr-grid span:nth-child(18),
.qr-grid span:nth-child(25),
.qr-grid span:nth-child(32) {
background: var(--teal);
}
.detect-box {
position: absolute;
top: 466px;
left: 314px;
z-index: 4;
width: 452px;
height: 336px;
border: 5px solid #4ae3a3;
border-radius: 32px;
box-shadow: 0 0 0 999px rgba(2, 6, 23, 0.07);
}
.scan-line {
position: absolute;
top: 642px;
left: 214px;
right: 214px;
z-index: 5;
height: 7px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, #2dd4bf, transparent);
box-shadow: 0 0 34px rgba(45, 212, 191, 0.92);
}
.hint {
position: absolute;
left: 238px;
right: 238px;
bottom: 616px;
z-index: 6;
min-height: 58px;
padding: 13px 22px;
border-radius: 24px;
background: rgba(0, 0, 0, 0.45);
color: #f8fafc;
font-size: 25px;
font-weight: 660;
text-align: center;
}
.sheet {
position: absolute;
left: 0;
right: 0;
bottom: 118px;
z-index: 8;
min-height: 612px;
padding: 22px 38px 34px;
border-radius: 42px 42px 0 0;
background: #f8fafc;
color: var(--ink);
box-shadow: 0 -34px 72px rgba(2, 6, 23, 0.35);
}
.handle {
width: 94px;
height: 8px;
margin: 0 auto 26px;
border-radius: 999px;
background: #cbd5e1;
}
.result-card {
padding: 28px;
border-radius: 26px;
background: #f2f7ff;
}
.result-title {
display: flex;
align-items: center;
gap: 14px;
margin: 0 0 24px;
color: #172033;
font-size: 32px;
font-weight: 800;
}
.badge {
display: inline-flex;
align-items: center;
height: 36px;
padding: 0 14px;
border-radius: 999px;
background: var(--mint);
color: #0f766e;
font-size: 18px;
font-weight: 820;
}
.field {
margin-top: 18px;
}
.field-label {
color: #526879;
font-size: 19px;
font-weight: 760;
}
.field-value {
margin-top: 6px;
color: var(--blue);
font-size: 24px;
line-height: 1.28;
font-weight: 640;
text-decoration: underline;
}
.risk {
display: flex;
align-items: center;
gap: 14px;
margin-top: 22px;
padding: 16px 18px;
border-radius: 20px;
background: #ecfdf5;
color: #065f46;
font-size: 22px;
font-weight: 740;
}
.risk svg {
width: 28px;
height: 28px;
flex: 0 0 auto;
}
.actions {
display: flex;
gap: 18px;
margin-top: 26px;
}
.action {
display: grid;
place-items: center;
width: 70px;
height: 70px;
border-radius: 50%;
background: #eef2f7;
color: #1e293b;
}
.action svg {
width: 32px;
height: 32px;
}
.primary {
margin-left: auto;
width: 250px;
border-radius: 22px;
background: #0f766e;
color: #f8fafc;
font-size: 24px;
font-weight: 820;
}
.nav {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 9;
display: grid;
grid-template-columns: repeat(3, 1fr);
height: 118px;
padding: 12px 56px 18px;
background: #fcfffe;
color: #607080;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.nav-item {
display: grid;
place-items: center;
align-content: center;
gap: 7px;
font-size: 20px;
font-weight: 740;
}
.nav-item svg {
width: 28px;
height: 28px;
}
.nav-item.active {
color: #0f766e;
}
</style>
</head>
<body>
<main class="shot">
<div class="camera"></div>
<div class="status">
<div>9:41</div>
<div class="system-icons">
<div class="signal"><span></span><span></span><span></span><span></span></div>
<div class="battery"></div>
</div>
</div>
<div class="top-chip">Everyday personal use</div>
<div class="gallery" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M4 5H20V19H4V5Z" stroke="white" stroke-width="2" stroke-linejoin="round"/>
<path d="M7 16L10.6 12.4L13 14.8L15 12.8L19 16.8" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.5 9.2H15.52" stroke="white" stroke-width="3" stroke-linecap="round"/>
</svg>
</div>
<div class="aim"></div>
<div class="qr-target">
<div class="qr-grid">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</div>
</div>
<div class="detect-box"></div>
<div class="scan-line"></div>
<div class="hint">Readable code detected.</div>
<section class="sheet">
<div class="handle"></div>
<div class="result-card">
<h1 class="result-title">URL <span class="badge">Local check</span></h1>
<div class="field">
<div class="field-label">Link</div>
<div class="field-value">https://example.org/menu</div>
</div>
<div class="field">
<div class="field-label">Risk score</div>
<div class="field-value" style="color:#0f172a;text-decoration:none">0</div>
</div>
<div class="risk">
<svg viewBox="0 0 24 24" fill="none">
<path d="M12 3L19 6V11C19 15.7 16.1 19.9 12 21.8C7.9 19.9 5 15.7 5 11V6L12 3Z" fill="#10B981"/>
<path d="M9 12L11.1 14.1L15.5 9.7" stroke="white" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Checked on device before opening
</div>
</div>
<div class="actions">
<div class="action" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M8 8H6C5.4 8 5 8.4 5 9V19C5 19.6 5.4 20 6 20H16C16.6 20 17 19.6 17 19V17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M8 4H18C18.6 4 19 4.4 19 5V15C19 15.6 18.6 16 18 16H8C7.4 16 7 15.6 7 15V5C7 4.4 7.4 4 8 4Z" stroke="currentColor" stroke-width="2"/>
</svg>
</div>
<div class="action" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M18 8C19.7 8 21 6.7 21 5C21 3.3 19.7 2 18 2C16.3 2 15 3.3 15 5C15 5.2 15 5.4 15.1 5.6L8.6 9.2C8.1 8.5 7.1 8 6 8C4.3 8 3 9.3 3 11C3 12.7 4.3 14 6 14C7.1 14 8.1 13.5 8.6 12.8L15.1 16.4C15 16.6 15 16.8 15 17C15 18.7 16.3 20 18 20C19.7 20 21 18.7 21 17C21 15.3 19.7 14 18 14C16.9 14 15.9 14.5 15.4 15.2L8.9 11.6C9 11.4 9 11.2 9 11C9 10.8 9 10.6 8.9 10.4L15.4 6.8C15.9 7.5 16.9 8 18 8Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
</svg>
</div>
<div class="action primary">Open</div>
</div>
</section>
<nav class="nav">
<div class="nav-item active">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 7V5H9M15 5H19V9M19 15V19H15M9 19H5V15" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
<path d="M8 12H16" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
</svg>
Scan
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 5H19V19H5V5Z" stroke="currentColor" stroke-width="2"/>
<path d="M8 9H16M8 13H16M8 17H13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
History
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24" fill="none">
<path d="M12 15.5C13.9 15.5 15.5 13.9 15.5 12C15.5 10.1 13.9 8.5 12 8.5C10.1 8.5 8.5 10.1 8.5 12C8.5 13.9 10.1 15.5 12 15.5Z" stroke="currentColor" stroke-width="2"/>
<path d="M19 12H21M3 12H5M12 3V5M12 19V21M17 7L18.4 5.6M5.6 18.4L7 17M17 17L18.4 18.4M5.6 5.6L7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Settings
</div>
</nav>
</main>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

@@ -0,0 +1,525 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1080, height=1920, initial-scale=1">
<title>Private QR Scanner Screenshot 2</title>
<style>
:root {
--bg: #f6fbfa;
--surface: #ffffff;
--surface-2: #edf8f5;
--ink: #0b1220;
--muted: #607080;
--line: #dce8e6;
--teal: #0f766e;
--teal-bright: #2dd4bf;
--mint: #dff7f2;
--blue: #1d4ed8;
}
* {
box-sizing: border-box;
}
html,
body {
width: 1080px;
height: 1920px;
margin: 0;
overflow: hidden;
font-family: Inter, "DejaVu Sans", Arial, sans-serif;
background: var(--bg);
color: var(--ink);
}
.shot {
position: relative;
width: 1080px;
height: 1920px;
overflow: hidden;
background:
radial-gradient(circle at 92% -4%, rgba(45, 212, 191, 0.20), transparent 31%),
linear-gradient(180deg, #f6fbfa 0%, #eff8f6 100%);
}
.status {
display: flex;
justify-content: space-between;
align-items: center;
height: 92px;
padding: 22px 44px 0;
color: var(--ink);
font-size: 24px;
font-weight: 760;
}
.system-icons {
display: flex;
align-items: center;
gap: 10px;
}
.signal {
display: flex;
align-items: end;
gap: 4px;
height: 22px;
}
.signal span {
width: 5px;
border-radius: 999px;
background: var(--ink);
}
.signal span:nth-child(1) { height: 8px; }
.signal span:nth-child(2) { height: 12px; }
.signal span:nth-child(3) { height: 16px; }
.signal span:nth-child(4) { height: 21px; }
.battery {
width: 42px;
height: 20px;
border: 2px solid var(--ink);
border-radius: 6px;
padding: 3px;
}
.battery::after {
content: "";
display: block;
width: 27px;
height: 10px;
border-radius: 3px;
background: var(--teal-bright);
}
.content {
padding: 30px 48px 144px;
}
.app-head {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 28px;
}
.app-icon {
width: 80px;
height: 80px;
border-radius: 20px;
box-shadow: 0 14px 28px rgba(15, 118, 110, 0.18);
}
.app-title {
margin: 0;
font-size: 34px;
line-height: 1.08;
font-weight: 850;
letter-spacing: 0;
}
.app-subtitle {
margin-top: 5px;
color: var(--muted);
font-size: 20px;
font-weight: 660;
}
.hero {
margin-bottom: 28px;
padding: 28px;
border-radius: 34px;
background:
radial-gradient(circle at 88% 20%, rgba(45, 212, 191, 0.18), transparent 34%),
linear-gradient(145deg, #0b1220, #123b3f);
color: #f8fafc;
box-shadow: 0 22px 44px rgba(7, 17, 31, 0.18);
}
.hero-row {
display: grid;
grid-template-columns: 1fr 152px;
gap: 20px;
align-items: center;
}
.hero h1 {
margin: 0;
font-size: 47px;
line-height: 1.04;
font-weight: 850;
letter-spacing: 0;
}
.hero p {
margin: 16px 0 0;
color: #cbe7e3;
font-size: 25px;
line-height: 1.25;
font-weight: 560;
}
.safe-card {
display: grid;
place-items: center;
width: 152px;
height: 152px;
border-radius: 30px;
background: rgba(223, 247, 242, 0.12);
border: 1px solid rgba(223, 247, 242, 0.18);
}
.safe-card svg {
width: 86px;
height: 86px;
}
.privacy-strip {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 13px;
margin-top: 26px;
}
.privacy-pill {
display: flex;
align-items: center;
justify-content: center;
min-height: 58px;
border-radius: 18px;
background: rgba(223, 247, 242, 0.12);
color: #dff7f2;
font-size: 20px;
font-weight: 820;
text-align: center;
}
.section-title {
margin: 0 0 18px;
color: #132032;
font-size: 31px;
font-weight: 850;
}
.search {
display: flex;
align-items: center;
gap: 14px;
height: 72px;
margin-bottom: 16px;
padding: 0 22px;
border: 2px solid #cfe0de;
border-radius: 20px;
background: var(--surface);
color: var(--muted);
font-size: 22px;
font-weight: 640;
}
.search svg {
width: 26px;
height: 26px;
color: var(--teal);
}
.export-row {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.export {
height: 50px;
padding: 0 18px;
border: 0;
border-radius: 15px;
background: var(--mint);
color: var(--teal);
font-size: 18px;
font-weight: 840;
}
.delete {
margin-left: auto;
background: #fff1f2;
color: #be123c;
}
.history-list {
display: grid;
gap: 14px;
}
.history-item {
display: grid;
grid-template-columns: 58px 1fr;
gap: 16px;
padding: 19px;
border-radius: 24px;
background: var(--surface);
border: 1px solid rgba(15, 23, 42, 0.06);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.06);
}
.type-icon {
display: grid;
place-items: center;
width: 58px;
height: 58px;
border-radius: 16px;
background: var(--surface-2);
color: var(--teal);
}
.type-icon svg {
width: 30px;
height: 30px;
}
.row-top {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: baseline;
}
.type {
color: #132032;
font-size: 24px;
font-weight: 850;
}
.time {
color: #7b8b98;
font-size: 18px;
font-weight: 680;
white-space: nowrap;
}
.value {
margin-top: 7px;
color: #41566a;
font-size: 21px;
line-height: 1.25;
font-weight: 620;
}
.settings-panel {
margin-top: 30px;
padding: 24px;
border-radius: 30px;
background: var(--surface);
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.07);
}
.setting {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 74px;
border-bottom: 1px solid var(--line);
color: #172033;
font-size: 24px;
font-weight: 780;
}
.setting:last-child {
border-bottom: 0;
}
.switch {
position: relative;
width: 70px;
height: 40px;
border-radius: 999px;
background: var(--teal);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.switch::after {
content: "";
position: absolute;
top: 5px;
right: 5px;
width: 30px;
height: 30px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 4px 8px rgba(15, 23, 42, 0.24);
}
.nav {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 9;
display: grid;
grid-template-columns: repeat(3, 1fr);
height: 118px;
padding: 12px 56px 18px;
background: #fcfffe;
color: #607080;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.nav-item {
display: grid;
place-items: center;
align-content: center;
gap: 7px;
font-size: 20px;
font-weight: 740;
}
.nav-item svg {
width: 28px;
height: 28px;
}
.nav-item.active {
color: var(--teal);
}
</style>
</head>
<body>
<main class="shot">
<div class="status">
<div>9:41</div>
<div class="system-icons">
<div class="signal"><span></span><span></span><span></span><span></span></div>
<div class="battery"></div>
</div>
</div>
<section class="content">
<header class="app-head">
<img class="app-icon" src="./private-qr-scanner-icon.svg" alt="">
<div>
<h1 class="app-title">Private QR Scanner</h1>
<div class="app-subtitle">Optional history stays on your device</div>
</div>
</header>
<section class="hero">
<div class="hero-row">
<div>
<h1>Review past scans locally</h1>
<p>Search, export, or delete saved scans whenever you choose.</p>
</div>
<div class="safe-card" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M12 3L19 6V11C19 15.7 16.1 19.9 12 21.8C7.9 19.9 5 15.7 5 11V6L12 3Z" fill="#2DD4BF"/>
<path d="M8.6 12.2L10.9 14.5L15.8 9.5" stroke="#07111F" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<div class="privacy-strip">
<div class="privacy-pill">No ads</div>
<div class="privacy-pill">No tracking</div>
<div class="privacy-pill">No account</div>
</div>
</section>
<h2 class="section-title">History</h2>
<div class="search">
<svg viewBox="0 0 24 24" fill="none">
<path d="M10.8 18.2C14.9 18.2 18.2 14.9 18.2 10.8C18.2 6.7 14.9 3.4 10.8 3.4C6.7 3.4 3.4 6.7 3.4 10.8C3.4 14.9 6.7 18.2 10.8 18.2Z" stroke="currentColor" stroke-width="2.2"/>
<path d="M16.4 16.4L21 21" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
</svg>
Search saved scans
</div>
<div class="export-row">
<button class="export">TXT</button>
<button class="export">CSV</button>
<button class="export">JSON</button>
<button class="export delete">Delete all</button>
</div>
<div class="history-list">
<article class="history-item">
<div class="type-icon">
<svg viewBox="0 0 24 24" fill="none">
<path d="M10 13A5 5 0 0 0 17.1 13L20 10.1A5 5 0 0 0 12.9 3L11.8 4.1" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M14 11A5 5 0 0 0 6.9 11L4 13.9A5 5 0 0 0 11.1 21L12.2 19.9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<div>
<div class="row-top"><div class="type">URL</div><div class="time">Today, 9:38 AM</div></div>
<div class="value">https://example.org/menu</div>
</div>
</article>
<article class="history-item">
<div class="type-icon">
<svg viewBox="0 0 24 24" fill="none">
<path d="M4 8L12 13L20 8" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 6H19C19.6 6 20 6.4 20 7V17C20 17.6 19.6 18 19 18H5C4.4 18 4 17.6 4 17V7C4 6.4 4.4 6 5 6Z" stroke="currentColor" stroke-width="2.2"/>
</svg>
</div>
<div>
<div class="row-top"><div class="type">Email</div><div class="time">Today, 9:21 AM</div></div>
<div class="value">support@example.org</div>
</div>
</article>
<article class="history-item">
<div class="type-icon">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 12.5C8.9 8.7 15.1 8.7 19 12.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
<path d="M8.2 15.4C10.3 13.4 13.7 13.4 15.8 15.4" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
<path d="M12 19H12.02" stroke="currentColor" stroke-width="3.2" stroke-linecap="round"/>
</svg>
</div>
<div>
<div class="row-top"><div class="type">Wi-Fi</div><div class="time">Yesterday</div></div>
<div class="value">SSID: Guest Network</div>
</div>
</article>
</div>
<section class="settings-panel">
<div class="setting">
<span>Save history (local)</span>
<span class="switch"></span>
</div>
<div class="setting">
<span>Security warnings</span>
<span class="switch"></span>
</div>
<div class="setting">
<span>Scan feedback</span>
<span class="switch"></span>
</div>
</section>
</section>
<nav class="nav">
<div class="nav-item">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 7V5H9M15 5H19V9M19 15V19H15M9 19H5V15" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
<path d="M8 12H16" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
</svg>
Scan
</div>
<div class="nav-item active">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 5H19V19H5V5Z" stroke="currentColor" stroke-width="2"/>
<path d="M8 9H16M8 13H16M8 17H13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
History
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24" fill="none">
<path d="M12 15.5C13.9 15.5 15.5 13.9 15.5 12C15.5 10.1 13.9 8.5 12 8.5C10.1 8.5 8.5 10.1 8.5 12C8.5 13.9 10.1 15.5 12 15.5Z" stroke="currentColor" stroke-width="2"/>
<path d="M19 12H21M3 12H5M12 3V5M12 19V21M17 7L18.4 5.6M5.6 18.4L7 17M17 17L18.4 18.4M5.6 5.6L7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Settings
</div>
</nav>
</main>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB