Skip to content

Commit 37006d9

Browse files
committed
feat: implement autoscan to support scanning with a handheld scanner, auto-dismiss (timer), dismiss with space key, continuous scanning (clear search box)
1 parent 5aedbbf commit 37006d9

File tree

9 files changed

+197
-18
lines changed

9 files changed

+197
-18
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package eu.pretix.desktop.app.ui
2+
3+
import androidx.compose.ui.Modifier
4+
5+
/**
6+
* Modifier that listens for alphanumeric key events globally and triggers a callback.
7+
* Used to automatically focus the search field when a handheld scanner inputs data.
8+
*/
9+
expect fun Modifier.autoScanEventListener(
10+
onAlphanumericKey: (String) -> Unit
11+
): Modifier

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/main/presentation/MainScreen.kt

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import androidx.compose.runtime.collectAsState
99
import androidx.compose.runtime.getValue
1010
import androidx.compose.runtime.rememberCoroutineScope
1111
import androidx.compose.ui.Alignment
12+
import androidx.compose.ui.Modifier
1213
import androidx.navigation.NavHostController
1314
import eu.pretix.desktop.app.navigation.Route
1415
import eu.pretix.desktop.app.ui.ScreenContentRoot
16+
import eu.pretix.desktop.app.ui.autoScanEventListener
1517
import eu.pretix.scan.main.presentation.selectevent.SelectEventDialog
1618
import eu.pretix.scan.main.presentation.selectlist.SelectCheckInListDialog
1719
import eu.pretix.scan.main.presentation.selectlist.SelectCheckInListForMultiEventDialog
@@ -76,7 +78,13 @@ fun MainScreen(
7678

7779
is MainUiState.ReadyToScan -> {
7880
val data = (uiState as MainUiState.ReadyToScan<MainUiStateData>).data
79-
Column {
81+
Column(
82+
modifier = Modifier.autoScanEventListener(
83+
onAlphanumericKey = { _ ->
84+
// Event propagates to SearchTextField which auto-focuses on mount
85+
}
86+
)
87+
) {
8088
MainToolbar(
8189
viewModel = viewModel,
8290
eventSelection = data.eventSelection,
@@ -89,30 +97,48 @@ fun MainScreen(
8997
)
9098

9199
ScreenContentRoot {
92-
TicketSearchBar(onSelectedSearchResult = {
93-
coroutineScope.launch {
94-
viewModel.onHandleSearchResult(it)
100+
TicketSearchBar(
101+
onSelectedSearchResult = {
102+
coroutineScope.launch {
103+
viewModel.onHandleSearchResult(it)
104+
}
105+
},
106+
onDirectScan = { secret ->
107+
coroutineScope.launch {
108+
viewModel.onHandleDirectScan(secret)
109+
}
95110
}
96-
})
111+
)
97112
}
98113

99114
}
100115
}
101116

102117
is MainUiState.HandlingTicket -> {
103118
val data = (uiState as MainUiState.HandlingTicket<MainUiStateData>).data
104-
Column {
119+
Column(
120+
modifier = Modifier.autoScanEventListener { _ ->
121+
// Event propagates to SearchTextField which maintains focus
122+
}
123+
) {
105124
MainToolbar(
106125
viewModel = viewModel,
107126
eventSelection = data.eventSelection
108127
)
109128

110129
ScreenContentRoot {
111-
TicketSearchBar(onSelectedSearchResult = {
112-
coroutineScope.launch {
113-
viewModel.onHandleSearchResult(it)
130+
TicketSearchBar(
131+
onSelectedSearchResult = {
132+
coroutineScope.launch {
133+
viewModel.onHandleSearchResult(it)
134+
}
135+
},
136+
onDirectScan = { secret ->
137+
coroutineScope.launch {
138+
viewModel.onHandleDirectScan(secret)
139+
}
114140
}
115-
})
141+
)
116142
}
117143
}
118144
TicketHandlingDialog(

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/main/presentation/MainViewModel.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ class MainViewModel(
153153
}
154154
}
155155

156+
suspend fun onHandleDirectScan(secret: String) {
157+
log.info("AutoScan: Handling direct scan for ticket")
158+
val currentState = _uiState.value
159+
if (currentState is MainUiState.ReadyToScan) {
160+
_uiState.update {
161+
MainUiState.HandlingTicket(currentState.data.secret(secret))
162+
}
163+
}
164+
}
165+
156166
fun onHandleTicketHandlingDismissed() {
157167
val currentState = _uiState.value
158168
if (currentState is MainUiState.HandlingTicket) {

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/SearchTextField.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,21 @@ import androidx.compose.ui.text.input.ImeAction
2020
import org.jetbrains.compose.resources.stringResource
2121
import pretixscan.composeapp.generated.resources.Res
2222
import pretixscan.composeapp.generated.resources.text_action_clear
23+
import java.util.logging.Logger
24+
25+
private val log = Logger.getLogger("SearchTextField")
2326

2427
@Composable
2528
fun SearchTextField(
2629
modifier: Modifier = Modifier,
2730
value: String = "",
2831
hint: String = "",
29-
onSearch: (String) -> Unit = {}
32+
onSearch: (String) -> Unit = {},
33+
onDirectScan: (String) -> Unit = {}
3034
) {
3135
val focusRequester = remember { FocusRequester() }
36+
// Barcode pattern from old implementation: alphanumeric plus =+/ with at least 5 chars
37+
val barcodePattern = remember { Regex("[a-zA-Z0-9=+/]{5,}") }
3238

3339
LaunchedEffect(Unit) {
3440
focusRequester.requestFocus()
@@ -50,7 +56,19 @@ fun SearchTextField(
5056
},
5157
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
5258
keyboardActions = KeyboardActions(onSearch = {
53-
onSearch(text)
59+
// Check if this looks like a barcode scan
60+
if (text.matches(barcodePattern)) {
61+
log.info("AutoScan: Barcode pattern detected, triggering direct scan")
62+
// Direct check-in for barcode
63+
onDirectScan(text)
64+
text = ""
65+
// Clear search results by notifying ViewModel
66+
onSearch("")
67+
} else {
68+
log.info("AutoScan: Regular search (not barcode pattern)")
69+
// Normal search
70+
onSearch(text)
71+
}
5472
}),
5573
maxLines = 1,
5674
singleLine = true,

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialog.kt

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package eu.pretix.scan.tickets.presentation
22

33
import androidx.compose.foundation.background
44
import androidx.compose.foundation.border
5+
import androidx.compose.foundation.focusable
56
import androidx.compose.foundation.layout.*
67
import androidx.compose.foundation.shape.RoundedCornerShape
78
import androidx.compose.material3.CircularProgressIndicator
@@ -10,7 +11,10 @@ import androidx.compose.runtime.*
1011
import androidx.compose.ui.Alignment
1112
import androidx.compose.ui.Modifier
1213
import androidx.compose.ui.draw.clip
14+
import androidx.compose.ui.focus.FocusRequester
15+
import androidx.compose.ui.focus.focusRequester
1316
import androidx.compose.ui.graphics.Color
17+
import androidx.compose.ui.input.key.*
1418
import androidx.compose.ui.unit.dp
1519
import com.composables.core.*
1620
import eu.pretix.scan.tickets.data.ResultState
@@ -19,6 +23,9 @@ import kotlinx.coroutines.Dispatchers
1923
import kotlinx.coroutines.launch
2024
import org.jetbrains.compose.ui.tooling.preview.Preview
2125
import org.koin.compose.viewmodel.koinViewModel
26+
import java.util.logging.Logger
27+
28+
private val log = Logger.getLogger("TicketHandlingDialog")
2229

2330
@Preview
2431
@Composable
@@ -28,12 +35,46 @@ fun TicketHandlingDialog(secret: String?, onDismiss: () -> Unit) {
2835
val uiState by viewModel.uiState.collectAsState()
2936
val coroutineScope = rememberCoroutineScope()
3037
val dialogState = rememberDialogState(initiallyVisible = true)
38+
var remainingTimeProgress by remember { mutableStateOf(1.0f) }
39+
val focusRequester = remember { FocusRequester() }
3140

3241
LaunchedEffect(secret) {
3342
viewModel.resetTicketHandlingState()
3443
viewModel.handleTicket(secret)
3544
}
3645

46+
// Request focus when transitioning to success states so Space key works
47+
// Keyed on resultState so it re-runs after questions/unpaid dialogs
48+
LaunchedEffect(uiState.resultState) {
49+
if (uiState.resultState == ResultState.SUCCESS ||
50+
uiState.resultState == ResultState.SUCCESS_EXIT) {
51+
log.info("AutoScan: Requesting focus for success state: ${uiState.resultState}")
52+
focusRequester.requestFocus()
53+
}
54+
}
55+
56+
// Auto-dismiss countdown for successful scans (30 seconds)
57+
LaunchedEffect(uiState.resultState) {
58+
if (uiState.resultState == ResultState.SUCCESS || uiState.resultState == ResultState.SUCCESS_EXIT) {
59+
val totalDuration = 30000L // 30 seconds in milliseconds
60+
val updateInterval = 100L // Update every 100ms for smooth animation
61+
var elapsed = 0L
62+
63+
while (elapsed < totalDuration) {
64+
kotlinx.coroutines.delay(updateInterval)
65+
elapsed += updateInterval
66+
remainingTimeProgress = 1.0f - (elapsed.toFloat() / totalDuration.toFloat())
67+
}
68+
69+
// Time's up, dismiss the dialog
70+
log.info("AutoScan: 30s auto-dismiss timer expired")
71+
onDismiss()
72+
} else {
73+
// Reset progress for non-success states
74+
remainingTimeProgress = 1.0f
75+
}
76+
}
77+
3778
DisposableEffect(Unit) {
3879
onDispose {
3980
viewModel.resetTicketHandlingState()
@@ -56,7 +97,21 @@ fun TicketHandlingDialog(secret: String?, onDismiss: () -> Unit) {
5697
.padding(20.dp)
5798
.clip(RoundedCornerShape(12.dp))
5899
.border(1.dp, Color(0xFFE4E4E4), RoundedCornerShape(12.dp))
59-
.background(Color.White),
100+
.background(Color.White)
101+
.focusRequester(focusRequester)
102+
.focusable()
103+
.onKeyEvent { keyEvent ->
104+
// Handle Space key to manually dismiss dialog
105+
// Enter key is NOT handled here to allow continuous scanning
106+
if (keyEvent.type == KeyEventType.KeyDown &&
107+
keyEvent.key == Key.Spacebar) {
108+
log.info("AutoScan: Space key pressed, dismissing dialog")
109+
onDismiss()
110+
true // Consume Space key
111+
} else {
112+
false // Let other keys (including Enter) propagate for continuous scanning
113+
}
114+
},
60115
) {
61116
when (uiState.resultState) {
62117
ResultState.EMPTY -> {}
@@ -101,7 +156,8 @@ fun TicketHandlingDialog(secret: String?, onDismiss: () -> Unit) {
101156
CoroutineScope(Dispatchers.IO).launch {
102157
viewModel.printBadges()
103158
}
104-
}
159+
},
160+
remainingTimeProgress = remainingTimeProgress
105161
)
106162
}
107163

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketSearchBar.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import pretixscan.composeapp.generated.resources.searchfield_prompt
1919
@Composable
2020
fun TicketSearchBar(
2121
modifier: Modifier = Modifier,
22-
onSelectedSearchResult: (TicketCheckProvider.SearchResult) -> Unit
22+
onSelectedSearchResult: (TicketCheckProvider.SearchResult) -> Unit,
23+
onDirectScan: (String) -> Unit = {}
2324
) {
2425
val viewModel = koinViewModel<TicketSearchBarViewModel>()
2526

@@ -35,7 +36,8 @@ fun TicketSearchBar(
3536
modifier = Modifier
3637
.fillMaxWidth()
3738
.padding(16.dp),
38-
onSearch = viewModel::onSearchTextChange
39+
onSearch = viewModel::onSearchTextChange,
40+
onDirectScan = onDirectScan
3941
)
4042
}
4143

@@ -54,7 +56,11 @@ fun TicketSearchBar(
5456
} else {
5557
SearchResultsView(
5658
searchSuggestions,
57-
onSelectedSearchResult = onSelectedSearchResult
59+
onSelectedSearchResult = { result ->
60+
// Clear search before handling result (matches old implementation)
61+
viewModel.clearSearch()
62+
onSelectedSearchResult(result)
63+
}
5864
)
5965
}
6066
}

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketSearchBarViewModel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ class TicketSearchBarViewModel(
5050
fun onSearchTextChange(text: String) {
5151
_searchText.value = text
5252
}
53+
54+
fun clearSearch() {
55+
_searchText.value = ""
56+
}
5357
}
5458

5559

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketSuccess.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
44
import androidx.compose.foundation.background
55
import androidx.compose.foundation.layout.*
66
import androidx.compose.material3.Button
7+
import androidx.compose.material3.LinearProgressIndicator
78
import androidx.compose.material3.MaterialTheme
89
import androidx.compose.material3.Text
910
import androidx.compose.runtime.Composable
@@ -25,7 +26,12 @@ import pretixscan.composeapp.generated.resources.settings_label_print_badges
2526

2627
@Preview
2728
@Composable
28-
fun TicketSuccess(modifier: Modifier = Modifier, data: ResultStateData, onPrintBadges: () -> Unit) {
29+
fun TicketSuccess(
30+
modifier: Modifier = Modifier,
31+
data: ResultStateData,
32+
onPrintBadges: () -> Unit,
33+
remainingTimeProgress: Float = 1.0f
34+
) {
2935

3036
Column(
3137
modifier = Modifier.background(data.resultState.color()),
@@ -93,6 +99,16 @@ fun TicketSuccess(modifier: Modifier = Modifier, data: ResultStateData, onPrintB
9399
) {
94100
Text(data.orderCodeAndPositionId ?: "", style = MaterialTheme.typography.bodyLarge)
95101
}
102+
103+
// Countdown progress indicator
104+
LinearProgressIndicator(
105+
progress = { remainingTimeProgress },
106+
modifier = Modifier
107+
.fillMaxWidth()
108+
.height(4.dp),
109+
color = CustomColor.White.asColor().copy(alpha = 0.8f),
110+
trackColor = data.resultState.color().copy(alpha = 0.3f)
111+
)
96112
}
97113

98114
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package eu.pretix.desktop.app.ui
2+
3+
import androidx.compose.ui.Modifier
4+
import androidx.compose.ui.input.key.*
5+
import java.util.logging.Logger
6+
7+
private val log = Logger.getLogger("AutoScanEventListener")
8+
9+
/**
10+
* Desktop implementation of auto-scan event listener.
11+
* Monitors for alphanumeric key presses to ensure search field can capture scanner input.
12+
* Does not consume events, allowing them to propagate to focused elements.
13+
*/
14+
actual fun Modifier.autoScanEventListener(
15+
onAlphanumericKey: (String) -> Unit
16+
): Modifier = this.onPreviewKeyEvent { keyEvent ->
17+
// Only handle key down events
18+
if (keyEvent.type != KeyEventType.KeyDown) {
19+
return@onPreviewKeyEvent false
20+
}
21+
22+
// Check if the key is alphanumeric
23+
val char = keyEvent.utf16CodePoint.toChar()
24+
if (char.isLetterOrDigit()) {
25+
log.info("AutoScan: Alphanumeric key detected")
26+
// Notify callback but don't consume - let event propagate to search field
27+
onAlphanumericKey(char.toString())
28+
false // Don't consume, allow normal event handling
29+
} else {
30+
false
31+
}
32+
}

0 commit comments

Comments
 (0)