event ticketing whitelist

This commit is contained in:
Hadrian Burkhardt
2026-03-03 16:11:28 +01:00
parent 3d7620954f
commit a0646273bc
5 changed files with 259 additions and 9 deletions
@@ -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()
}
+10
View File
@@ -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>
+10
View File
@@ -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>