nicer crosshair, icons instead of text buttons, share history

This commit is contained in:
Hadrian Burkhardt
2026-02-11 03:23:42 +01:00
parent d9378fa78e
commit c0e9b52897
14 changed files with 275 additions and 83 deletions
+34 -19
View File
@@ -1,35 +1,50 @@
# Clean Scanner (MVP) # Clean Scanner (MVP)
Offline-first, ad-free QR/barcode scanner built with Kotlin + Compose + CameraX + ML Kit. Offline-first, ad-free QR/barcode scanner built with Kotlin, Jetpack Compose, CameraX, and on-device ML Kit.
## Architektur ## Architektur
- `ui/`: Compose-Screens + ViewModels (MVVM presentation layer) - `ui/`: Compose screens/components + ViewModels (MVVM)
- `data/`: Scanner-Analyzer, Room entities/DAO, Repository - `data/`: ML Kit analyzer, Room entities/DAO, repository
- `domain/`: app models (`ScanResult`, `ScanRecord`, `UrlRiskResult`) - `domain/`: app models (`ScanResult`, `ScanRecord`, `UrlRiskResult`)
- `settings/`: DataStore preferences (history toggle, warnings toggle) - `settings/`: DataStore preferences (history + warnings toggles)
- `util/`: URL risk scorer, clipboard, intents - `util/`: URL risk scoring, clipboard, intents
## Datenschutz ## Datenschutz
- Keine Werbung - Keine Werbung
- Keine Tracker/Analytics/Crashlytics - Keine Tracker/Analytics/Crashlytics
- Kein Backend/keine Webrequests - Kein Backend, keine Servercalls
- Keine `INTERNET`-Permission im Manifest - Keine `INTERNET`-Permission im Manifest
## MVP Features ## MVP Features
- Startscreen mit Scan-Button und lokalem Historie-Toggle (Default: OFF) - Home: Scan-Button, lokaler Historie-Toggle (Default: OFF), Datenschutz-Dialog
- Scanner mit CameraX live preview, Taschenlampe, debounce/no double scan - Scanner: CameraX Live-Preview, Fadenkreuz-Overlay, Taschenlampe, Debounce gegen Doppelscans
- Ergebnis-Bottom-Sheet mit Copy/Share/Open/Scan again - Ergebnis-Bottom-Sheet: Typ/Inhalt + Copy/Share/Open/Scan again
- Lokale URL-Risikoheuristik mit Warn-Dialog ab Score `>= 3` - URL-Sicherheitswarnung bei lokalem `riskScore >= 3` (kein Blocken, nur Hinweis)
- Historie-Liste inkl. Suche, Swipe-delete, Alles-löschen - Historie: Suche, Swipe-to-delete, Alles-löschen, Detailansicht mit Volltext
- Einstellungen: Historie an/aus, Warnungen an/aus, About - Einstellungen: Historie an/aus (mit optionalem Löschen), Warnungen an/aus, About-Infos
## Run ## Voraussetzungen
1. In Android Studio: Open this folder as project. - Android Studio (aktuell stabil)
2. Let Gradle sync dependencies. - JDK 17+
3. Run app on emulator/device (API 24+). - Android SDK für `compileSdk = 35`
## Build & Run
1. Projekt in Android Studio öffnen.
2. Gradle Sync ausführen.
3. App auf Emulator/Device (API 24+) starten.
CLI:
```bash
./gradlew :app:assembleDebug
./gradlew :app:installDebug
```
## Tests ## Tests
- URL Risk Scorer Tests: `app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt` (11 cases) - Unit tests:
## Hinweis ```bash
In dieser Umgebung war kein `gradle`/`gradlew` verfügbar, daher konnte ich Builds/Tests hier nicht lokal ausführen. ./gradlew testDebugUnitTest
```
- URL-Risk-Scorer tests: `app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt`
+1
View File
@@ -74,6 +74,7 @@ dependencies {
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("com.google.android.material:material:1.12.0") implementation("com.google.android.material:material:1.12.0")
implementation("androidx.navigation:navigation-compose:2.8.2") implementation("androidx.navigation:navigation-compose:2.8.2")
@@ -6,13 +6,14 @@ import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import com.clean.scanner.ui.CleanScannerAppRoot import com.clean.scanner.ui.CleanScannerAppRoot
import com.clean.scanner.ui.theme.CleanScannerTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val container = (application as CleanScannerApp).appContainer val container = (application as CleanScannerApp).appContainer
setContent { setContent {
MaterialTheme { CleanScannerTheme {
Surface(color = MaterialTheme.colorScheme.background) { Surface(color = MaterialTheme.colorScheme.background) {
CleanScannerAppRoot(container) CleanScannerAppRoot(container)
} }
@@ -1,5 +1,7 @@
package com.clean.scanner.ui package com.clean.scanner.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -9,17 +11,17 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.clean.scanner.AppContainer import com.clean.scanner.AppContainer
import com.clean.scanner.R import com.clean.scanner.R
import com.clean.scanner.ui.screens.HistoryScreen import com.clean.scanner.ui.screens.HistoryScreen
import com.clean.scanner.ui.screens.HomeScreen
import com.clean.scanner.ui.screens.ScannerScreen import com.clean.scanner.ui.screens.ScannerScreen
import com.clean.scanner.ui.screens.SettingsScreen import com.clean.scanner.ui.screens.SettingsScreen
private enum class RootTab { Home, History, Settings } private enum class RootTab { Scanner, History, Settings }
@Composable @Composable
fun CleanScannerAppRoot(container: AppContainer) { fun CleanScannerAppRoot(container: AppContainer) {
@@ -28,17 +30,16 @@ fun CleanScannerAppRoot(container: AppContainer) {
val appState by appViewModel.uiState.collectAsStateWithLifecycle() val appState by appViewModel.uiState.collectAsStateWithLifecycle()
val scannerState by scannerViewModel.uiState.collectAsStateWithLifecycle() val scannerState by scannerViewModel.uiState.collectAsStateWithLifecycle()
var activeTab by remember { mutableStateOf(RootTab.Home) } var activeTab by remember { mutableStateOf(RootTab.Scanner) }
var showScanner by remember { mutableStateOf(false) }
Scaffold( Scaffold(
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
NavigationBarItem( NavigationBarItem(
selected = activeTab == RootTab.Home, selected = activeTab == RootTab.Scanner,
onClick = { onClick = {
activeTab = RootTab.Home activeTab = RootTab.Scanner
showScanner = false scannerViewModel.resumeScanning()
}, },
label = { Text(stringResource(R.string.scan)) }, label = { Text(stringResource(R.string.scan)) },
icon = {} icon = {}
@@ -47,7 +48,6 @@ fun CleanScannerAppRoot(container: AppContainer) {
selected = activeTab == RootTab.History, selected = activeTab == RootTab.History,
onClick = { onClick = {
activeTab = RootTab.History activeTab = RootTab.History
showScanner = false
}, },
label = { Text(stringResource(R.string.history)) }, label = { Text(stringResource(R.string.history)) },
icon = {} icon = {}
@@ -56,7 +56,6 @@ fun CleanScannerAppRoot(container: AppContainer) {
selected = activeTab == RootTab.Settings, selected = activeTab == RootTab.Settings,
onClick = { onClick = {
activeTab = RootTab.Settings activeTab = RootTab.Settings
showScanner = false
}, },
label = { Text(stringResource(R.string.settings)) }, label = { Text(stringResource(R.string.settings)) },
icon = {} icon = {}
@@ -64,9 +63,9 @@ fun CleanScannerAppRoot(container: AppContainer) {
} }
} }
) { padding -> ) { padding ->
androidx.compose.foundation.layout.Box(modifier = androidx.compose.ui.Modifier.padding(padding)) { Box(modifier = Modifier.padding(padding)) {
when { when (activeTab) {
showScanner -> ScannerScreen( RootTab.Scanner -> ScannerScreen(
analysisEnabled = scannerState.analysisEnabled, analysisEnabled = scannerState.analysisEnabled,
lastResult = scannerState.lastResult, lastResult = scannerState.lastResult,
warningsEnabled = appState.warningsEnabled, warningsEnabled = appState.warningsEnabled,
@@ -74,16 +73,7 @@ fun CleanScannerAppRoot(container: AppContainer) {
onScanAgain = scannerViewModel::resumeScanning onScanAgain = scannerViewModel::resumeScanning
) )
activeTab == RootTab.Home -> HomeScreen( RootTab.History -> HistoryScreen(
historyEnabled = appState.historyEnabled,
onHistoryToggle = { appViewModel.setHistoryEnabled(it, false) },
onScanClick = {
showScanner = true
scannerViewModel.resumeScanning()
}
)
activeTab == RootTab.History -> HistoryScreen(
query = appState.searchQuery, query = appState.searchQuery,
history = appState.history, history = appState.history,
onQueryChange = appViewModel::setQuery, onQueryChange = appViewModel::setQuery,
@@ -91,7 +81,7 @@ fun CleanScannerAppRoot(container: AppContainer) {
onClearAll = appViewModel::clearHistory onClearAll = appViewModel::clearHistory
) )
activeTab == RootTab.Settings -> SettingsScreen( RootTab.Settings -> SettingsScreen(
historyEnabled = appState.historyEnabled, historyEnabled = appState.historyEnabled,
warningsEnabled = appState.warningsEnabled, warningsEnabled = appState.warningsEnabled,
onHistoryToggle = appViewModel::setHistoryEnabled, onHistoryToggle = appViewModel::setHistoryEnabled,
@@ -1,6 +1,8 @@
package com.clean.scanner.ui.components package com.clean.scanner.ui.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview import androidx.camera.core.Preview
@@ -13,12 +15,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clean.scanner.data.scanner.MlKitBarcodeAnalyzer import com.clean.scanner.data.scanner.MlKitBarcodeAnalyzer
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.max
import kotlin.math.min
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
@Composable @Composable
@@ -34,6 +38,21 @@ fun CameraPreview(
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
val previewView = remember { PreviewView(context) } val previewView = remember { PreviewView(context) }
val cameraRef = remember { mutableStateOf<androidx.camera.core.Camera?>(null) } val cameraRef = remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
val zoomRatio = remember { mutableStateOf(1f) }
val scaleGestureDetector = remember {
ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
val camera = cameraRef.value ?: return false
val zoomState = camera.cameraInfo.zoomState.value ?: return false
val nextZoom = zoomRatio.value * detector.scaleFactor
val clampedZoom = max(zoomState.minZoomRatio, min(nextZoom, zoomState.maxZoomRatio))
zoomRatio.value = clampedZoom
camera.cameraControl.setZoomRatio(clampedZoom)
return true
}
})
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
@@ -70,6 +89,7 @@ fun CameraPreview(
onTorchAvailabilityChanged(camera.cameraInfo.hasFlashUnit()) onTorchAvailabilityChanged(camera.cameraInfo.hasFlashUnit())
cameraRef.value = camera cameraRef.value = camera
zoomRatio.value = camera.cameraInfo.zoomState.value?.zoomRatio ?: 1f
} }
LaunchedEffect(torchEnabled) { LaunchedEffect(torchEnabled) {
@@ -78,6 +98,16 @@ fun CameraPreview(
AndroidView( AndroidView(
modifier = modifier, modifier = modifier,
factory = { previewView } factory = {
previewView.apply {
setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
performClick()
}
scaleGestureDetector.onTouchEvent(event)
true
}
}
}
) )
} }
@@ -1,7 +1,9 @@
package com.clean.scanner.ui.screens package com.clean.scanner.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -19,10 +21,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clean.scanner.R import com.clean.scanner.R
import com.clean.scanner.domain.ScanRecord import com.clean.scanner.domain.ScanRecord
import com.clean.scanner.util.Intents
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
@@ -34,7 +38,9 @@ fun HistoryScreen(
onDelete: (Long) -> Unit, onDelete: (Long) -> Unit,
onClearAll: () -> Unit onClearAll: () -> Unit
) { ) {
val context = LocalContext.current
val showDeleteAll = remember { mutableStateOf(false) } val showDeleteAll = remember { mutableStateOf(false) }
val selectedItem = remember { mutableStateOf<ScanRecord?>(null) }
if (showDeleteAll.value) { if (showDeleteAll.value) {
AlertDialog( AlertDialog(
@@ -55,6 +61,20 @@ fun HistoryScreen(
) )
} }
val detail = selectedItem.value
if (detail != null) {
AlertDialog(
onDismissRequest = { selectedItem.value = null },
title = { Text(text = detail.type) },
text = { Text(text = detail.content) },
confirmButton = {
TextButton(onClick = { selectedItem.value = null }) {
Text(text = stringResource(R.string.confirm))
}
}
)
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -68,13 +88,28 @@ fun HistoryScreen(
label = { Text(stringResource(R.string.search)) } label = { Text(stringResource(R.string.search)) }
) )
Row(modifier = Modifier.fillMaxWidth()) {
TextButton(
onClick = {
val exportText = buildHistoryExportText(history)
Intents.shareText(context, exportText)
},
enabled = history.isNotEmpty()
) {
Text(stringResource(R.string.share_history))
}
TextButton(onClick = { showDeleteAll.value = true }) { TextButton(onClick = { showDeleteAll.value = true }) {
Text(stringResource(R.string.delete_all)) Text(stringResource(R.string.delete_all))
} }
}
LazyColumn { LazyColumn {
items(history, key = { it.id }) { item -> items(history, key = { it.id }) { item ->
HistoryRow(item = item, onDelete = onDelete) HistoryRow(
item = item,
onDelete = onDelete,
onOpenDetails = { selectedItem.value = item }
)
} }
} }
} }
@@ -82,7 +117,11 @@ fun HistoryScreen(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun HistoryRow(item: ScanRecord, onDelete: (Long) -> Unit) { private fun HistoryRow(
item: ScanRecord,
onDelete: (Long) -> Unit,
onOpenDetails: () -> Unit
) {
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { confirmValueChange = {
if (it == SwipeToDismissBoxValue.EndToStart || it == SwipeToDismissBoxValue.StartToEnd) { if (it == SwipeToDismissBoxValue.EndToStart || it == SwipeToDismissBoxValue.StartToEnd) {
@@ -100,6 +139,7 @@ private fun HistoryRow(item: ScanRecord, onDelete: (Long) -> Unit) {
content = { content = {
Column(modifier = Modifier Column(modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onOpenDetails() }
.padding(vertical = 12.dp)) { .padding(vertical = 12.dp)) {
Text(text = item.type) Text(text = item.type)
Text(text = item.content, maxLines = 2) Text(text = item.content, maxLines = 2)
@@ -108,3 +148,12 @@ private fun HistoryRow(item: ScanRecord, onDelete: (Long) -> Unit) {
} }
) )
} }
private fun buildHistoryExportText(history: List<ScanRecord>): String {
if (history.isEmpty()) return ""
val formatter = DateFormat.getDateTimeInstance()
return history.joinToString(separator = "\n\n") { item ->
val time = formatter.format(Date(item.timestamp))
"$time\n${item.type}\n${item.content}"
}
}
@@ -7,11 +7,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -24,6 +28,21 @@ fun HomeScreen(
onHistoryToggle: (Boolean) -> Unit, onHistoryToggle: (Boolean) -> Unit,
onScanClick: () -> Unit onScanClick: () -> Unit
) { ) {
val showPrivacyDialog = remember { mutableStateOf(false) }
if (showPrivacyDialog.value) {
AlertDialog(
onDismissRequest = { showPrivacyDialog.value = false },
title = { Text(text = stringResource(R.string.privacy)) },
text = { Text(text = stringResource(R.string.privacy_text)) },
confirmButton = {
TextButton(onClick = { showPrivacyDialog.value = false }) {
Text(text = stringResource(R.string.confirm))
}
}
)
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -47,13 +66,12 @@ fun HomeScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
TextButton(onClick = { showPrivacyDialog.value = true }) {
Text( Text(
text = stringResource(R.string.privacy), text = stringResource(R.string.privacy),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.privacy_text),
textAlign = TextAlign.Start textAlign = TextAlign.Start
) )
} }
}
} }
@@ -7,17 +7,27 @@ import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -31,8 +41,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -96,12 +109,42 @@ fun ScannerScreen(
} }
) )
Box( Canvas(
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .align(Alignment.Center)
.fillMaxWidth(0.7f) .fillMaxWidth(0.7f)
.height(220.dp) .height(220.dp)
.background(Color.Transparent) ) {
val guideColor = Color(0xFF7CE6C6)
val cx = size.width / 2f
val cy = size.height / 2f
drawRoundRect(
color = guideColor.copy(alpha = 0.10f),
cornerRadius = CornerRadius(26f, 26f)
)
drawRoundRect(
color = guideColor.copy(alpha = 0.90f),
cornerRadius = CornerRadius(26f, 26f),
style = Stroke(width = 4f)
)
drawCircle(
color = guideColor.copy(alpha = 0.90f),
radius = 8f,
center = androidx.compose.ui.geometry.Offset(cx, cy)
)
}
Text(
text = stringResource(R.string.pinch_to_zoom_hint),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 40.dp)
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(18.dp)
)
.padding(horizontal = 14.dp, vertical = 8.dp)
) )
if (torchAvailable) { if (torchAvailable) {
@@ -126,7 +169,7 @@ fun ScannerScreen(
} }
if (lastResult != null) { if (lastResult != null) {
ModalBottomSheet(onDismissRequest = {}) { ModalBottomSheet(onDismissRequest = onScanAgain) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -135,8 +178,17 @@ fun ScannerScreen(
) { ) {
Text(text = "${stringResource(R.string.content_type)}: ${lastResult.type}") Text(text = "${stringResource(R.string.content_type)}: ${lastResult.type}")
Text(text = "${stringResource(R.string.content_value)}: ${lastResult.content}") Text(text = "${stringResource(R.string.content_value)}: ${lastResult.content}")
Button(onClick = { ClipboardUtil.copy(context, lastResult.content) }) { Row(
Text(stringResource(R.string.copy)) modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(onClick = { ClipboardUtil.copy(context, lastResult.content) }) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(R.string.copy)
)
} }
if (lastResult.type == "URL") { if (lastResult.type == "URL") {
Button(onClick = { Button(onClick = {
@@ -152,11 +204,12 @@ fun ScannerScreen(
Text(stringResource(R.string.open)) Text(stringResource(R.string.open))
} }
} }
Button(onClick = { Intents.shareText(context, lastResult.content) }) { IconButton(onClick = { Intents.shareText(context, lastResult.content) }) {
Text(stringResource(R.string.share)) Icon(
imageVector = Icons.Default.Share,
contentDescription = stringResource(R.string.share)
)
} }
Button(onClick = onScanAgain) {
Text(stringResource(R.string.scan_again))
} }
} }
} }
@@ -0,0 +1,31 @@
package com.clean.scanner.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()
@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
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
+2
View File
@@ -31,4 +31,6 @@
<string name="content_type">Typ</string> <string name="content_type">Typ</string>
<string name="content_value">Inhalt</string> <string name="content_value">Inhalt</string>
<string name="request_camera">Kamera erlauben</string> <string name="request_camera">Kamera erlauben</string>
<string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string>
<string name="share_history">Historie teilen</string>
</resources> </resources>
+2
View File
@@ -31,4 +31,6 @@
<string name="content_type">Type</string> <string name="content_type">Type</string>
<string name="content_value">Content</string> <string name="content_value">Content</string>
<string name="request_camera">Allow camera</string> <string name="request_camera">Allow camera</string>
<string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string>
<string name="share_history">Share history</string>
</resources> </resources>
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
id("com.android.application") version "8.7.0" apply false id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false
id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false
} }
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored Executable → Regular
View File