nicer crosshair, icons instead of text buttons, share history
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -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)) }
|
||||||
)
|
)
|
||||||
|
|
||||||
TextButton(onClick = { showDeleteAll.value = true }) {
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(stringResource(R.string.delete_all))
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val exportText = buildHistoryExportText(history)
|
||||||
|
Intents.shareText(context, exportText)
|
||||||
|
},
|
||||||
|
enabled = history.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.share_history))
|
||||||
|
}
|
||||||
|
TextButton(onClick = { showDeleteAll.value = true }) {
|
||||||
|
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))
|
||||||
|
|
||||||
Text(
|
TextButton(onClick = { showPrivacyDialog.value = true }) {
|
||||||
text = stringResource(R.string.privacy),
|
Text(
|
||||||
style = MaterialTheme.typography.titleMedium
|
text = stringResource(R.string.privacy),
|
||||||
)
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Text(
|
textAlign = TextAlign.Start
|
||||||
text = stringResource(R.string.privacy_text),
|
)
|
||||||
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,28 +178,38 @@ 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()
|
||||||
if (lastResult.type == "URL") {
|
.horizontalScroll(rememberScrollState()),
|
||||||
Button(onClick = {
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
val risk = UrlRiskScorer.score(lastResult.content)
|
) {
|
||||||
val risky = warningsEnabled && risk.score >= 3
|
IconButton(onClick = { ClipboardUtil.copy(context, lastResult.content) }) {
|
||||||
if (risky) {
|
Icon(
|
||||||
pendingOpenUrl = lastResult.content
|
imageVector = Icons.Default.ContentCopy,
|
||||||
showRiskWarning = true
|
contentDescription = stringResource(R.string.copy)
|
||||||
} else {
|
)
|
||||||
Intents.openUrl(context, lastResult.content)
|
}
|
||||||
}
|
if (lastResult.type == "URL") {
|
||||||
}) {
|
Button(onClick = {
|
||||||
Text(stringResource(R.string.open))
|
val risk = UrlRiskScorer.score(lastResult.content)
|
||||||
|
val risky = warningsEnabled && risk.score >= 3
|
||||||
|
if (risky) {
|
||||||
|
pendingOpenUrl = lastResult.content
|
||||||
|
showRiskWarning = true
|
||||||
|
} else {
|
||||||
|
Intents.openUrl(context, lastResult.content)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.open))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = { Intents.shareText(context, lastResult.content) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Share,
|
||||||
|
contentDescription = stringResource(R.string.share)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Button(onClick = { Intents.shareText(context, lastResult.content) }) {
|
|
||||||
Text(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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user