event ticketing whitelist
This commit is contained in:
@@ -70,12 +70,17 @@ fun CleanScannerAppRoot(container: AppContainer) {
|
||||
lastResult = scannerState.lastResult,
|
||||
batchMode = scannerState.batchMode,
|
||||
batchResults = scannerState.batchResults,
|
||||
eventTicketWhitelistCount = scannerState.eventTicketWhitelistCount,
|
||||
duplicateFeedbackNonce = scannerState.duplicateFeedbackNonce,
|
||||
scanFeedbackNonce = scannerState.scanFeedbackNonce,
|
||||
warningsEnabled = appState.warningsEnabled,
|
||||
scanFeedbackEnabled = appState.scanFeedbackEnabled,
|
||||
useCaseView = appState.useCaseView,
|
||||
onScan = scannerViewModel::onScan,
|
||||
onEvaluateEventTicketScan = scannerViewModel::evaluateEventTicketScan,
|
||||
onAuditDuplicateTicket = scannerViewModel::auditDuplicateTicketScan,
|
||||
onAuditUnregisteredTicket = scannerViewModel::auditUnregisteredTicketScan,
|
||||
onReplaceEventTicketWhitelist = scannerViewModel::replaceEventTicketWhitelist,
|
||||
onScanAgain = scannerViewModel::resumeScanning,
|
||||
onBatchModeChange = scannerViewModel::setBatchMode,
|
||||
onClearBatchResults = scannerViewModel::clearBatchResults,
|
||||
|
||||
@@ -21,11 +21,19 @@ data class ScannerUiState(
|
||||
val lastScanTimestamp: Long = 0L,
|
||||
val batchMode: Boolean = false,
|
||||
val batchResults: List<BatchScanRecord> = emptyList(),
|
||||
val eventTicketWhitelistCount: Int = 0,
|
||||
val recentScanKeys: List<String> = emptyList(),
|
||||
val duplicateFeedbackNonce: Int = 0,
|
||||
val scanFeedbackNonce: Int = 0
|
||||
)
|
||||
|
||||
enum class EventTicketScanDecision {
|
||||
Accept,
|
||||
Unregistered,
|
||||
DuplicateAlert,
|
||||
Ignore
|
||||
}
|
||||
|
||||
class ScannerViewModel(
|
||||
private val saveScan: suspend (content: String, type: String) -> Unit,
|
||||
private val nowProvider: () -> Long = { System.currentTimeMillis() }
|
||||
@@ -33,12 +41,17 @@ class ScannerViewModel(
|
||||
private companion object {
|
||||
const val GENERAL_DEBOUNCE_MS = 800L
|
||||
const val SAME_CODE_HOLDOFF_MS = 2500L
|
||||
const val EVENT_TICKET_HOLDOFF_MS = 30_000L
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(ScannerUiState())
|
||||
val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow()
|
||||
private val recentScanKeySet = LinkedHashSet<String>(200)
|
||||
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 lastAcceptedTimestamp: Long = 0L
|
||||
|
||||
@@ -112,9 +125,61 @@ class ScannerViewModel(
|
||||
|
||||
fun clearBatchResults() {
|
||||
batchKeySet.clear()
|
||||
eventTicketSeenKeys.clear()
|
||||
eventTicketDuplicateCooldowns.clear()
|
||||
eventTicketRecentlyAccepted.clear()
|
||||
_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 {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
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.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
import androidx.compose.material.icons.filled.ViewModule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
@@ -49,6 +50,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.domain.ScanResult
|
||||
import com.clean.scanner.ui.BatchScanRecord
|
||||
import com.clean.scanner.ui.EventTicketScanDecision
|
||||
import com.clean.scanner.ui.UseCaseView
|
||||
import com.clean.scanner.ui.components.CameraPreview
|
||||
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.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.max
|
||||
|
||||
internal data class GalleryScanCandidate(
|
||||
@@ -101,12 +107,17 @@ fun ScannerScreen(
|
||||
lastResult: ScanResult?,
|
||||
batchMode: Boolean,
|
||||
batchResults: List<BatchScanRecord>,
|
||||
eventTicketWhitelistCount: Int,
|
||||
duplicateFeedbackNonce: Int,
|
||||
scanFeedbackNonce: Int,
|
||||
warningsEnabled: Boolean,
|
||||
scanFeedbackEnabled: Boolean,
|
||||
useCaseView: UseCaseView,
|
||||
onScan: (ScanResult) -> Unit,
|
||||
onEvaluateEventTicketScan: (ScanResult) -> EventTicketScanDecision,
|
||||
onAuditDuplicateTicket: (ScanResult) -> Unit,
|
||||
onAuditUnregisteredTicket: (ScanResult) -> Unit,
|
||||
onReplaceEventTicketWhitelist: (Set<String>) -> Unit,
|
||||
onScanAgain: () -> Unit,
|
||||
onBatchModeChange: (Boolean) -> Unit,
|
||||
onClearBatchResults: () -> Unit,
|
||||
@@ -115,9 +126,9 @@ fun ScannerScreen(
|
||||
val context = LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val capabilities = remember(useCaseView) { useCaseView.capabilities() }
|
||||
val forceBatchMode = useCaseView == UseCaseView.EventTicketing
|
||||
val showBatchModeToggle = remember(useCaseView) {
|
||||
when (useCaseView) {
|
||||
UseCaseView.EventTicketing,
|
||||
UseCaseView.InventoryOperations,
|
||||
UseCaseView.FieldWorkServiceTeams,
|
||||
UseCaseView.OfflineLowConnectivity,
|
||||
@@ -125,12 +136,14 @@ fun ScannerScreen(
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
val isBatchModeActive = forceBatchMode || batchMode
|
||||
val duplicateSnackbarHostState = remember { SnackbarHostState() }
|
||||
val toneGenerator = remember { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) }
|
||||
|
||||
LaunchedEffect(showBatchModeToggle, batchMode) {
|
||||
if (!showBatchModeToggle && batchMode) {
|
||||
onBatchModeChange(false)
|
||||
LaunchedEffect(forceBatchMode, showBatchModeToggle, batchMode) {
|
||||
when {
|
||||
forceBatchMode && !batchMode -> onBatchModeChange(true)
|
||||
!forceBatchMode && !showBatchModeToggle && batchMode -> onBatchModeChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +160,10 @@ fun ScannerScreen(
|
||||
var torchAvailable by remember { mutableStateOf(false) }
|
||||
var showRiskWarning by remember { mutableStateOf(false) }
|
||||
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 imageScanCandidates by remember { mutableStateOf<List<GalleryScanCandidate>>(emptyList()) }
|
||||
var imageScanPreviewUri by remember { mutableStateOf<Uri?>(null) }
|
||||
@@ -155,6 +172,8 @@ fun ScannerScreen(
|
||||
var detectionBoxes by remember { mutableStateOf<List<DetectionBox>>(emptyList()) }
|
||||
var detectionSourceWidth 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 imageScanner = remember {
|
||||
BarcodeScanning.getClient(
|
||||
@@ -220,6 +239,34 @@ fun ScannerScreen(
|
||||
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) {
|
||||
if (!cameraGranted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
@@ -227,6 +274,8 @@ fun ScannerScreen(
|
||||
|
||||
LaunchedEffect(duplicateFeedbackNonce) {
|
||||
if (duplicateFeedbackNonce > 0) {
|
||||
if (useCaseView == UseCaseView.EventTicketing) return@LaunchedEffect
|
||||
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.already_scanned),
|
||||
@@ -244,10 +293,13 @@ fun ScannerScreen(
|
||||
}
|
||||
|
||||
LaunchedEffect(scanFeedbackNonce, scanFeedbackEnabled) {
|
||||
if (scanFeedbackEnabled && scanFeedbackNonce > 0) {
|
||||
if (scanFeedbackEnabled && scanFeedbackNonce > lastHandledScanFeedbackNonce) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP, 120)
|
||||
}
|
||||
if (scanFeedbackNonce > lastHandledScanFeedbackNonce) {
|
||||
lastHandledScanFeedbackNonce = scanFeedbackNonce
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
@@ -307,6 +359,26 @@ fun ScannerScreen(
|
||||
val insideAim = centerX in aimLeft..aimRight && centerY in aimTop..aimBottom
|
||||
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))
|
||||
}
|
||||
)
|
||||
@@ -394,7 +466,7 @@ fun ScannerScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = if (batchMode) 190.dp else 56.dp)
|
||||
.padding(bottom = if (isBatchModeActive) 190.dp else 56.dp)
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.35f),
|
||||
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 = stringResource(useCaseView.titleRes),
|
||||
@@ -434,6 +524,21 @@ fun ScannerScreen(
|
||||
)
|
||||
.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(
|
||||
modifier = Modifier
|
||||
@@ -462,7 +567,7 @@ fun ScannerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (batchMode && showBatchModeToggle) {
|
||||
if (isBatchModeActive && (showBatchModeToggle || forceBatchMode)) {
|
||||
Box(modifier = Modifier.align(Alignment.BottomCenter)) {
|
||||
BatchResultsPanel(
|
||||
results = batchResults,
|
||||
@@ -476,7 +581,7 @@ fun ScannerScreen(
|
||||
hostState = duplicateSnackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = if (batchMode) 12.dp else 80.dp)
|
||||
.padding(bottom = if (isBatchModeActive) 12.dp else 80.dp)
|
||||
)
|
||||
} else if (!galleryOpen) {
|
||||
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 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) {
|
||||
GalleryScanPreviewDialog(
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user