event ticketing whitelist
This commit is contained in:
@@ -70,12 +70,17 @@ fun CleanScannerAppRoot(container: AppContainer) {
|
|||||||
lastResult = scannerState.lastResult,
|
lastResult = scannerState.lastResult,
|
||||||
batchMode = scannerState.batchMode,
|
batchMode = scannerState.batchMode,
|
||||||
batchResults = scannerState.batchResults,
|
batchResults = scannerState.batchResults,
|
||||||
|
eventTicketWhitelistCount = scannerState.eventTicketWhitelistCount,
|
||||||
duplicateFeedbackNonce = scannerState.duplicateFeedbackNonce,
|
duplicateFeedbackNonce = scannerState.duplicateFeedbackNonce,
|
||||||
scanFeedbackNonce = scannerState.scanFeedbackNonce,
|
scanFeedbackNonce = scannerState.scanFeedbackNonce,
|
||||||
warningsEnabled = appState.warningsEnabled,
|
warningsEnabled = appState.warningsEnabled,
|
||||||
scanFeedbackEnabled = appState.scanFeedbackEnabled,
|
scanFeedbackEnabled = appState.scanFeedbackEnabled,
|
||||||
useCaseView = appState.useCaseView,
|
useCaseView = appState.useCaseView,
|
||||||
onScan = scannerViewModel::onScan,
|
onScan = scannerViewModel::onScan,
|
||||||
|
onEvaluateEventTicketScan = scannerViewModel::evaluateEventTicketScan,
|
||||||
|
onAuditDuplicateTicket = scannerViewModel::auditDuplicateTicketScan,
|
||||||
|
onAuditUnregisteredTicket = scannerViewModel::auditUnregisteredTicketScan,
|
||||||
|
onReplaceEventTicketWhitelist = scannerViewModel::replaceEventTicketWhitelist,
|
||||||
onScanAgain = scannerViewModel::resumeScanning,
|
onScanAgain = scannerViewModel::resumeScanning,
|
||||||
onBatchModeChange = scannerViewModel::setBatchMode,
|
onBatchModeChange = scannerViewModel::setBatchMode,
|
||||||
onClearBatchResults = scannerViewModel::clearBatchResults,
|
onClearBatchResults = scannerViewModel::clearBatchResults,
|
||||||
|
|||||||
@@ -21,11 +21,19 @@ data class ScannerUiState(
|
|||||||
val lastScanTimestamp: Long = 0L,
|
val lastScanTimestamp: Long = 0L,
|
||||||
val batchMode: Boolean = false,
|
val batchMode: Boolean = false,
|
||||||
val batchResults: List<BatchScanRecord> = emptyList(),
|
val batchResults: List<BatchScanRecord> = emptyList(),
|
||||||
|
val eventTicketWhitelistCount: Int = 0,
|
||||||
val recentScanKeys: List<String> = emptyList(),
|
val recentScanKeys: List<String> = emptyList(),
|
||||||
val duplicateFeedbackNonce: Int = 0,
|
val duplicateFeedbackNonce: Int = 0,
|
||||||
val scanFeedbackNonce: Int = 0
|
val scanFeedbackNonce: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class EventTicketScanDecision {
|
||||||
|
Accept,
|
||||||
|
Unregistered,
|
||||||
|
DuplicateAlert,
|
||||||
|
Ignore
|
||||||
|
}
|
||||||
|
|
||||||
class ScannerViewModel(
|
class ScannerViewModel(
|
||||||
private val saveScan: suspend (content: String, type: String) -> Unit,
|
private val saveScan: suspend (content: String, type: String) -> Unit,
|
||||||
private val nowProvider: () -> Long = { System.currentTimeMillis() }
|
private val nowProvider: () -> Long = { System.currentTimeMillis() }
|
||||||
@@ -33,12 +41,17 @@ class ScannerViewModel(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val GENERAL_DEBOUNCE_MS = 800L
|
const val GENERAL_DEBOUNCE_MS = 800L
|
||||||
const val SAME_CODE_HOLDOFF_MS = 2500L
|
const val SAME_CODE_HOLDOFF_MS = 2500L
|
||||||
|
const val EVENT_TICKET_HOLDOFF_MS = 30_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ScannerUiState())
|
private val _uiState = MutableStateFlow(ScannerUiState())
|
||||||
val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow()
|
||||||
private val recentScanKeySet = LinkedHashSet<String>(200)
|
private val recentScanKeySet = LinkedHashSet<String>(200)
|
||||||
private val batchKeySet = LinkedHashSet<String>(100)
|
private val batchKeySet = LinkedHashSet<String>(100)
|
||||||
|
private val eventTicketSeenKeys = HashSet<String>()
|
||||||
|
private val eventTicketWhitelistIds = HashSet<String>()
|
||||||
|
private val eventTicketDuplicateCooldowns = HashMap<String, Long>()
|
||||||
|
private val eventTicketRecentlyAccepted = HashMap<String, Long>()
|
||||||
private var lastAcceptedKey: String? = null
|
private var lastAcceptedKey: String? = null
|
||||||
private var lastAcceptedTimestamp: Long = 0L
|
private var lastAcceptedTimestamp: Long = 0L
|
||||||
|
|
||||||
@@ -112,9 +125,61 @@ class ScannerViewModel(
|
|||||||
|
|
||||||
fun clearBatchResults() {
|
fun clearBatchResults() {
|
||||||
batchKeySet.clear()
|
batchKeySet.clear()
|
||||||
|
eventTicketSeenKeys.clear()
|
||||||
|
eventTicketDuplicateCooldowns.clear()
|
||||||
|
eventTicketRecentlyAccepted.clear()
|
||||||
_uiState.value = _uiState.value.copy(batchResults = emptyList())
|
_uiState.value = _uiState.value.copy(batchResults = emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun auditDuplicateTicketScan(result: ScanResult) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
saveScan(result.content, "Duplicate ticket (${result.type})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun auditUnregisteredTicketScan(result: ScanResult) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
saveScan(result.content, "Unregistered ticket (${result.type})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replaceEventTicketWhitelist(ids: Set<String>) {
|
||||||
|
eventTicketWhitelistIds.clear()
|
||||||
|
eventTicketWhitelistIds.addAll(ids.map(::normalizeWhitelistId).filter { it.isNotBlank() })
|
||||||
|
_uiState.value = _uiState.value.copy(eventTicketWhitelistCount = eventTicketWhitelistIds.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun evaluateEventTicketScan(result: ScanResult): EventTicketScanDecision {
|
||||||
|
val key = "${result.type}|${result.content}"
|
||||||
|
val normalizedContent = normalizeWhitelistId(result.content)
|
||||||
|
val now = nowProvider()
|
||||||
|
if (eventTicketWhitelistIds.isNotEmpty() && normalizedContent !in eventTicketWhitelistIds) {
|
||||||
|
return EventTicketScanDecision.Unregistered
|
||||||
|
}
|
||||||
|
if (key !in eventTicketSeenKeys) {
|
||||||
|
eventTicketSeenKeys.add(key)
|
||||||
|
eventTicketRecentlyAccepted[key] = now
|
||||||
|
return EventTicketScanDecision.Accept
|
||||||
|
}
|
||||||
|
|
||||||
|
val acceptedAt = eventTicketRecentlyAccepted[key] ?: 0L
|
||||||
|
if (now - acceptedAt < EVENT_TICKET_HOLDOFF_MS) {
|
||||||
|
return EventTicketScanDecision.Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
val cooldownUntil = eventTicketDuplicateCooldowns[key] ?: 0L
|
||||||
|
if (now < cooldownUntil) {
|
||||||
|
return EventTicketScanDecision.Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
eventTicketDuplicateCooldowns[key] = now + EVENT_TICKET_HOLDOFF_MS
|
||||||
|
return EventTicketScanDecision.DuplicateAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeWhitelistId(value: String): String {
|
||||||
|
return value.trim().lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
class Factory(private val container: AppContainer) : ViewModelProvider.Factory {
|
class Factory(private val container: AppContainer) : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.FlashOff
|
|||||||
import androidx.compose.material.icons.filled.FlashOn
|
import androidx.compose.material.icons.filled.FlashOn
|
||||||
import androidx.compose.material.icons.filled.PersonAdd
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
import androidx.compose.material.icons.filled.ViewModule
|
import androidx.compose.material.icons.filled.ViewModule
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -49,6 +50,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
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
|
||||||
@@ -75,6 +77,7 @@ import com.clean.scanner.data.scanner.DetectionBox
|
|||||||
import com.clean.scanner.data.scanner.DetectionPoint
|
import com.clean.scanner.data.scanner.DetectionPoint
|
||||||
import com.clean.scanner.domain.ScanResult
|
import com.clean.scanner.domain.ScanResult
|
||||||
import com.clean.scanner.ui.BatchScanRecord
|
import com.clean.scanner.ui.BatchScanRecord
|
||||||
|
import com.clean.scanner.ui.EventTicketScanDecision
|
||||||
import com.clean.scanner.ui.UseCaseView
|
import com.clean.scanner.ui.UseCaseView
|
||||||
import com.clean.scanner.ui.components.CameraPreview
|
import com.clean.scanner.ui.components.CameraPreview
|
||||||
import com.clean.scanner.ui.capabilities
|
import com.clean.scanner.ui.capabilities
|
||||||
@@ -87,6 +90,9 @@ import com.google.mlkit.vision.barcode.BarcodeScanning
|
|||||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||||
import com.google.mlkit.vision.barcode.common.Barcode
|
import com.google.mlkit.vision.barcode.common.Barcode
|
||||||
import com.google.mlkit.vision.common.InputImage
|
import com.google.mlkit.vision.common.InputImage
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
internal data class GalleryScanCandidate(
|
internal data class GalleryScanCandidate(
|
||||||
@@ -101,12 +107,17 @@ fun ScannerScreen(
|
|||||||
lastResult: ScanResult?,
|
lastResult: ScanResult?,
|
||||||
batchMode: Boolean,
|
batchMode: Boolean,
|
||||||
batchResults: List<BatchScanRecord>,
|
batchResults: List<BatchScanRecord>,
|
||||||
|
eventTicketWhitelistCount: Int,
|
||||||
duplicateFeedbackNonce: Int,
|
duplicateFeedbackNonce: Int,
|
||||||
scanFeedbackNonce: Int,
|
scanFeedbackNonce: Int,
|
||||||
warningsEnabled: Boolean,
|
warningsEnabled: Boolean,
|
||||||
scanFeedbackEnabled: Boolean,
|
scanFeedbackEnabled: Boolean,
|
||||||
useCaseView: UseCaseView,
|
useCaseView: UseCaseView,
|
||||||
onScan: (ScanResult) -> Unit,
|
onScan: (ScanResult) -> Unit,
|
||||||
|
onEvaluateEventTicketScan: (ScanResult) -> EventTicketScanDecision,
|
||||||
|
onAuditDuplicateTicket: (ScanResult) -> Unit,
|
||||||
|
onAuditUnregisteredTicket: (ScanResult) -> Unit,
|
||||||
|
onReplaceEventTicketWhitelist: (Set<String>) -> Unit,
|
||||||
onScanAgain: () -> Unit,
|
onScanAgain: () -> Unit,
|
||||||
onBatchModeChange: (Boolean) -> Unit,
|
onBatchModeChange: (Boolean) -> Unit,
|
||||||
onClearBatchResults: () -> Unit,
|
onClearBatchResults: () -> Unit,
|
||||||
@@ -115,9 +126,9 @@ fun ScannerScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val capabilities = remember(useCaseView) { useCaseView.capabilities() }
|
val capabilities = remember(useCaseView) { useCaseView.capabilities() }
|
||||||
|
val forceBatchMode = useCaseView == UseCaseView.EventTicketing
|
||||||
val showBatchModeToggle = remember(useCaseView) {
|
val showBatchModeToggle = remember(useCaseView) {
|
||||||
when (useCaseView) {
|
when (useCaseView) {
|
||||||
UseCaseView.EventTicketing,
|
|
||||||
UseCaseView.InventoryOperations,
|
UseCaseView.InventoryOperations,
|
||||||
UseCaseView.FieldWorkServiceTeams,
|
UseCaseView.FieldWorkServiceTeams,
|
||||||
UseCaseView.OfflineLowConnectivity,
|
UseCaseView.OfflineLowConnectivity,
|
||||||
@@ -125,12 +136,14 @@ fun ScannerScreen(
|
|||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val isBatchModeActive = forceBatchMode || batchMode
|
||||||
val duplicateSnackbarHostState = remember { SnackbarHostState() }
|
val duplicateSnackbarHostState = remember { SnackbarHostState() }
|
||||||
val toneGenerator = remember { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) }
|
val toneGenerator = remember { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) }
|
||||||
|
|
||||||
LaunchedEffect(showBatchModeToggle, batchMode) {
|
LaunchedEffect(forceBatchMode, showBatchModeToggle, batchMode) {
|
||||||
if (!showBatchModeToggle && batchMode) {
|
when {
|
||||||
onBatchModeChange(false)
|
forceBatchMode && !batchMode -> onBatchModeChange(true)
|
||||||
|
!forceBatchMode && !showBatchModeToggle && batchMode -> onBatchModeChange(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +160,10 @@ fun ScannerScreen(
|
|||||||
var torchAvailable by remember { mutableStateOf(false) }
|
var torchAvailable by remember { mutableStateOf(false) }
|
||||||
var showRiskWarning by remember { mutableStateOf(false) }
|
var showRiskWarning by remember { mutableStateOf(false) }
|
||||||
var pendingOpenUrl by remember { mutableStateOf<String?>(null) }
|
var pendingOpenUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showDuplicateTicketAlert by remember { mutableStateOf(false) }
|
||||||
|
var showUnregisteredTicketAlert by remember { mutableStateOf(false) }
|
||||||
|
var duplicateTicketAlertContent by remember { mutableStateOf<String?>(null) }
|
||||||
|
var unregisteredTicketAlertContent by remember { mutableStateOf<String?>(null) }
|
||||||
var showImageScanFailed by remember { mutableStateOf(false) }
|
var showImageScanFailed by remember { mutableStateOf(false) }
|
||||||
var imageScanCandidates by remember { mutableStateOf<List<GalleryScanCandidate>>(emptyList()) }
|
var imageScanCandidates by remember { mutableStateOf<List<GalleryScanCandidate>>(emptyList()) }
|
||||||
var imageScanPreviewUri by remember { mutableStateOf<Uri?>(null) }
|
var imageScanPreviewUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
@@ -155,6 +172,8 @@ fun ScannerScreen(
|
|||||||
var detectionBoxes by remember { mutableStateOf<List<DetectionBox>>(emptyList()) }
|
var detectionBoxes by remember { mutableStateOf<List<DetectionBox>>(emptyList()) }
|
||||||
var detectionSourceWidth by remember { mutableIntStateOf(0) }
|
var detectionSourceWidth by remember { mutableIntStateOf(0) }
|
||||||
var detectionSourceHeight by remember { mutableIntStateOf(0) }
|
var detectionSourceHeight by remember { mutableIntStateOf(0) }
|
||||||
|
var lastHandledScanFeedbackNonce by remember { mutableIntStateOf(scanFeedbackNonce) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val activity = context as? Activity
|
val activity = context as? Activity
|
||||||
val imageScanner = remember {
|
val imageScanner = remember {
|
||||||
BarcodeScanning.getClient(
|
BarcodeScanning.getClient(
|
||||||
@@ -220,6 +239,34 @@ fun ScannerScreen(
|
|||||||
showImageScanFailed = true
|
showImageScanFailed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val whitelistPicker = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent()
|
||||||
|
) { uri ->
|
||||||
|
if (uri == null) return@rememberLauncherForActivityResult
|
||||||
|
scope.launch {
|
||||||
|
val ids = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { reader ->
|
||||||
|
parseWhitelistIds(reader.readText())
|
||||||
|
} ?: emptySet()
|
||||||
|
}.getOrElse { emptySet() }
|
||||||
|
}
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.whitelist_import_empty),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
} else {
|
||||||
|
onReplaceEventTicketWhitelist(ids)
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.whitelist_import_success, ids.size),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (!cameraGranted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
if (!cameraGranted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
@@ -227,6 +274,8 @@ fun ScannerScreen(
|
|||||||
|
|
||||||
LaunchedEffect(duplicateFeedbackNonce) {
|
LaunchedEffect(duplicateFeedbackNonce) {
|
||||||
if (duplicateFeedbackNonce > 0) {
|
if (duplicateFeedbackNonce > 0) {
|
||||||
|
if (useCaseView == UseCaseView.EventTicketing) return@LaunchedEffect
|
||||||
|
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
context,
|
context,
|
||||||
context.getString(R.string.already_scanned),
|
context.getString(R.string.already_scanned),
|
||||||
@@ -244,10 +293,13 @@ fun ScannerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(scanFeedbackNonce, scanFeedbackEnabled) {
|
LaunchedEffect(scanFeedbackNonce, scanFeedbackEnabled) {
|
||||||
if (scanFeedbackEnabled && scanFeedbackNonce > 0) {
|
if (scanFeedbackEnabled && scanFeedbackNonce > lastHandledScanFeedbackNonce) {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP, 120)
|
toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP, 120)
|
||||||
}
|
}
|
||||||
|
if (scanFeedbackNonce > lastHandledScanFeedbackNonce) {
|
||||||
|
lastHandledScanFeedbackNonce = scanFeedbackNonce
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
@@ -307,6 +359,26 @@ fun ScannerScreen(
|
|||||||
val insideAim = centerX in aimLeft..aimRight && centerY in aimTop..aimBottom
|
val insideAim = centerX in aimLeft..aimRight && centerY in aimTop..aimBottom
|
||||||
if (!insideAim) return@CameraPreview
|
if (!insideAim) return@CameraPreview
|
||||||
|
|
||||||
|
if (forceBatchMode) {
|
||||||
|
val scanResult = ScanResult(content = content, type = type)
|
||||||
|
when (onEvaluateEventTicketScan(scanResult)) {
|
||||||
|
EventTicketScanDecision.Accept -> Unit
|
||||||
|
EventTicketScanDecision.Unregistered -> {
|
||||||
|
onAuditUnregisteredTicket(scanResult)
|
||||||
|
unregisteredTicketAlertContent = content
|
||||||
|
showUnregisteredTicketAlert = true
|
||||||
|
return@CameraPreview
|
||||||
|
}
|
||||||
|
EventTicketScanDecision.DuplicateAlert -> {
|
||||||
|
onAuditDuplicateTicket(scanResult)
|
||||||
|
duplicateTicketAlertContent = content
|
||||||
|
showDuplicateTicketAlert = true
|
||||||
|
return@CameraPreview
|
||||||
|
}
|
||||||
|
EventTicketScanDecision.Ignore -> return@CameraPreview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onScan(ScanResult(content = content, type = type))
|
onScan(ScanResult(content = content, type = type))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -394,7 +466,7 @@ fun ScannerScreen(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(bottom = if (batchMode) 190.dp else 56.dp)
|
.padding(bottom = if (isBatchModeActive) 190.dp else 56.dp)
|
||||||
.background(
|
.background(
|
||||||
color = Color.Black.copy(alpha = 0.35f),
|
color = Color.Black.copy(alpha = 0.35f),
|
||||||
shape = RoundedCornerShape(18.dp)
|
shape = RoundedCornerShape(18.dp)
|
||||||
@@ -420,6 +492,24 @@ fun ScannerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (useCaseView == UseCaseView.EventTicketing) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { whitelistPicker.launch("*/*") },
|
||||||
|
modifier = Modifier
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.UploadFile,
|
||||||
|
contentDescription = stringResource(R.string.import_whitelist),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(useCaseView.titleRes),
|
text = stringResource(useCaseView.titleRes),
|
||||||
@@ -434,6 +524,21 @@ fun ScannerScreen(
|
|||||||
)
|
)
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
)
|
)
|
||||||
|
if (useCaseView == UseCaseView.EventTicketing) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.whitelist_loaded_count, eventTicketWhitelistCount),
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(top = 56.dp, start = 64.dp, end = 64.dp)
|
||||||
|
.background(
|
||||||
|
color = Color.Black.copy(alpha = 0.35f),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -462,7 +567,7 @@ fun ScannerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batchMode && showBatchModeToggle) {
|
if (isBatchModeActive && (showBatchModeToggle || forceBatchMode)) {
|
||||||
Box(modifier = Modifier.align(Alignment.BottomCenter)) {
|
Box(modifier = Modifier.align(Alignment.BottomCenter)) {
|
||||||
BatchResultsPanel(
|
BatchResultsPanel(
|
||||||
results = batchResults,
|
results = batchResults,
|
||||||
@@ -476,7 +581,7 @@ fun ScannerScreen(
|
|||||||
hostState = duplicateSnackbarHostState,
|
hostState = duplicateSnackbarHostState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(bottom = if (batchMode) 12.dp else 80.dp)
|
.padding(bottom = if (isBatchModeActive) 12.dp else 80.dp)
|
||||||
)
|
)
|
||||||
} else if (!galleryOpen) {
|
} else if (!galleryOpen) {
|
||||||
PermissionContent(
|
PermissionContent(
|
||||||
@@ -498,7 +603,7 @@ fun ScannerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastResult != null && !batchMode) {
|
if (lastResult != null && !isBatchModeActive) {
|
||||||
val parsedContact = remember(lastResult.content) { ScanContentParsers.parseContact(lastResult.content) }
|
val parsedContact = remember(lastResult.content) { ScanContentParsers.parseContact(lastResult.content) }
|
||||||
val parsedEvent = remember(lastResult.content) { ScanContentParsers.parseCalendarEvent(lastResult.content) }
|
val parsedEvent = remember(lastResult.content) { ScanContentParsers.parseCalendarEvent(lastResult.content) }
|
||||||
|
|
||||||
@@ -634,6 +739,51 @@ fun ScannerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDuplicateTicketAlert && useCaseView == UseCaseView.EventTicketing) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDuplicateTicketAlert = false },
|
||||||
|
title = { Text(text = stringResource(R.string.duplicate_ticket_alert_title), color = Color(0xFFB00020)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(text = stringResource(R.string.duplicate_ticket_alert_message))
|
||||||
|
if (!duplicateTicketAlertContent.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.duplicate_ticket_alert_code, duplicateTicketAlertContent!!),
|
||||||
|
color = Color(0xFFB00020)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showDuplicateTicketAlert = false }) {
|
||||||
|
Text(stringResource(R.string.confirm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showUnregisteredTicketAlert && useCaseView == UseCaseView.EventTicketing) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showUnregisteredTicketAlert = false },
|
||||||
|
title = { Text(text = stringResource(R.string.unregistered_ticket_alert_title), color = Color(0xFFB00020)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(text = stringResource(R.string.unregistered_ticket_alert_message))
|
||||||
|
if (!unregisteredTicketAlertContent.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.unregistered_ticket_alert_code, unregisteredTicketAlertContent!!),
|
||||||
|
color = Color(0xFFB00020)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showUnregisteredTicketAlert = false }) {
|
||||||
|
Text(stringResource(R.string.confirm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (imageScanPreviewUri != null) {
|
if (imageScanPreviewUri != null) {
|
||||||
GalleryScanPreviewDialog(
|
GalleryScanPreviewDialog(
|
||||||
imageUri = imageScanPreviewUri,
|
imageUri = imageScanPreviewUri,
|
||||||
@@ -663,3 +813,13 @@ fun ScannerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseWhitelistIds(raw: String): Set<String> {
|
||||||
|
if (raw.isBlank()) return emptySet()
|
||||||
|
return raw
|
||||||
|
.split('\n', '\r', ',', ';', '\t')
|
||||||
|
.asSequence()
|
||||||
|
.map { it.trim().lowercase() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
<string name="share_csv">CSV</string>
|
<string name="share_csv">CSV</string>
|
||||||
<string name="share_json">JSON</string>
|
<string name="share_json">JSON</string>
|
||||||
<string name="scan_from_image">Aus Bild scannen</string>
|
<string name="scan_from_image">Aus Bild scannen</string>
|
||||||
|
<string name="import_whitelist">Whitelist importieren</string>
|
||||||
|
<string name="whitelist_loaded_count">Geladene registrierte IDs: %1$d</string>
|
||||||
|
<string name="whitelist_import_success">%1$d IDs importiert.</string>
|
||||||
|
<string name="whitelist_import_empty">IDs konnten aus dieser Datei nicht importiert werden.</string>
|
||||||
<string name="batch_mode">Stapelmodus</string>
|
<string name="batch_mode">Stapelmodus</string>
|
||||||
<string name="batch_captures_count">Stapel-Scans: %1$d</string>
|
<string name="batch_captures_count">Stapel-Scans: %1$d</string>
|
||||||
<string name="clear_batch">Stapel leeren</string>
|
<string name="clear_batch">Stapel leeren</string>
|
||||||
@@ -50,6 +54,12 @@
|
|||||||
<string name="image_scan_pick_subtitle">Wähle ein Ergebnis aus:</string>
|
<string name="image_scan_pick_subtitle">Wähle ein Ergebnis aus:</string>
|
||||||
<string name="image_scan_failed">Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen.</string>
|
<string name="image_scan_failed">Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen.</string>
|
||||||
<string name="already_scanned">Bereits gescannt</string>
|
<string name="already_scanned">Bereits gescannt</string>
|
||||||
|
<string name="duplicate_ticket_alert_title">Doppeltes Ticket erkannt</string>
|
||||||
|
<string name="duplicate_ticket_alert_message">Dieser Ticket-/Code wurde bereits gescannt. Bitte Eintritt sofort prüfen, um Betrug zu verhindern.</string>
|
||||||
|
<string name="duplicate_ticket_alert_code">Code: %1$s</string>
|
||||||
|
<string name="unregistered_ticket_alert_title">Nicht registriertes Ticket erkannt</string>
|
||||||
|
<string name="unregistered_ticket_alert_message">Dieser Ticket-/Code ist nicht in der importierten Whitelist. Bitte Registrierung prüfen.</string>
|
||||||
|
<string name="unregistered_ticket_alert_code">Code: %1$s</string>
|
||||||
<string name="view_history">Historie anzeigen</string>
|
<string name="view_history">Historie anzeigen</string>
|
||||||
<string name="call_number">Nummer anrufen</string>
|
<string name="call_number">Nummer anrufen</string>
|
||||||
<string name="send_sms">SMS senden</string>
|
<string name="send_sms">SMS senden</string>
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
<string name="share_csv">CSV</string>
|
<string name="share_csv">CSV</string>
|
||||||
<string name="share_json">JSON</string>
|
<string name="share_json">JSON</string>
|
||||||
<string name="scan_from_image">Scan from image</string>
|
<string name="scan_from_image">Scan from image</string>
|
||||||
|
<string name="import_whitelist">Import whitelist</string>
|
||||||
|
<string name="whitelist_loaded_count">Registered IDs loaded: %1$d</string>
|
||||||
|
<string name="whitelist_import_success">Imported %1$d IDs.</string>
|
||||||
|
<string name="whitelist_import_empty">Could not import IDs from this file.</string>
|
||||||
<string name="batch_mode">Batch mode</string>
|
<string name="batch_mode">Batch mode</string>
|
||||||
<string name="batch_captures_count">Batch captures: %1$d</string>
|
<string name="batch_captures_count">Batch captures: %1$d</string>
|
||||||
<string name="clear_batch">Clear batch</string>
|
<string name="clear_batch">Clear batch</string>
|
||||||
@@ -50,6 +54,12 @@
|
|||||||
<string name="image_scan_pick_subtitle">Choose a result to use:</string>
|
<string name="image_scan_pick_subtitle">Choose a result to use:</string>
|
||||||
<string name="image_scan_failed">Could not read this image. Try another one.</string>
|
<string name="image_scan_failed">Could not read this image. Try another one.</string>
|
||||||
<string name="already_scanned">Already scanned</string>
|
<string name="already_scanned">Already scanned</string>
|
||||||
|
<string name="duplicate_ticket_alert_title">Duplicate ticket detected</string>
|
||||||
|
<string name="duplicate_ticket_alert_message">This ticket/code was scanned before. Verify entry immediately to prevent fraud.</string>
|
||||||
|
<string name="duplicate_ticket_alert_code">Code: %1$s</string>
|
||||||
|
<string name="unregistered_ticket_alert_title">Unregistered ticket detected</string>
|
||||||
|
<string name="unregistered_ticket_alert_message">This ticket/code is not in the imported whitelist. Verify attendee registration.</string>
|
||||||
|
<string name="unregistered_ticket_alert_code">Code: %1$s</string>
|
||||||
<string name="view_history">View history</string>
|
<string name="view_history">View history</string>
|
||||||
<string name="call_number">Call number</string>
|
<string name="call_number">Call number</string>
|
||||||
<string name="send_sms">Send SMS</string>
|
<string name="send_sms">Send SMS</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user