Skip to content

Commit e0b6506

Browse files
committed
feat: improve auto scan and questions
- feat: trim search field in case of invisible characters (e.g. copy paste from the web) - feat(autoscan): enable continuous scanning independent of focus state - fix(questions): show during checkin answers, validation display for question inputs
1 parent d5473c4 commit e0b6506

File tree

30 files changed

+1066
-150
lines changed

30 files changed

+1066
-150
lines changed

pretixscan/composeApp/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ kotlin {
103103
desktopTest.dependencies {
104104
implementation(compose.desktop.uiTestJUnit4)
105105
implementation(compose.desktop.currentOs)
106+
implementation(libs.mockk)
107+
implementation(libs.koin.test)
108+
implementation(libs.koin.test.junit4)
106109
}
107110

108111

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package eu.pretix.desktop.app.scan
2+
3+
import androidx.compose.runtime.Composable
4+
import eu.pretix.scan.main.presentation.MainUiState
5+
import eu.pretix.scan.main.presentation.MainUiStateData
6+
import kotlinx.coroutines.flow.StateFlow
7+
8+
@Composable
9+
expect fun GlobalScanSetup(
10+
stateFlow: StateFlow<MainUiState<MainUiStateData>>,
11+
onHandleDirectScan: suspend (String) -> Unit
12+
)

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/app/ui/AutoScanEventListener.kt

Lines changed: 0 additions & 11 deletions
This file was deleted.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package eu.pretix.desktop.app.ui
2+
3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.Row
6+
import androidx.compose.foundation.layout.padding
7+
import androidx.compose.material.icons.Icons
8+
import androidx.compose.material.icons.filled.Delete
9+
import androidx.compose.material3.Button
10+
import androidx.compose.material3.Icon
11+
import androidx.compose.material3.Text
12+
import androidx.compose.runtime.Composable
13+
import androidx.compose.ui.Alignment
14+
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.graphics.Color
16+
import androidx.compose.ui.text.font.FontWeight
17+
import androidx.compose.ui.unit.dp
18+
import eu.pretix.scan.tickets.presentation.QuestionImagePreview
19+
import org.jetbrains.compose.resources.stringResource
20+
import pretixscan.composeapp.generated.resources.Res
21+
import pretixscan.composeapp.generated.resources.delete_photo
22+
import pretixscan.composeapp.generated.resources.question_input_invalid
23+
import pretixscan.composeapp.generated.resources.question_input_required
24+
import pretixscan.composeapp.generated.resources.take_a_photo
25+
26+
@Composable
27+
fun FiledFileUpload(
28+
label: String? = null,
29+
required: Boolean = false,
30+
validation: FieldValidationState?,
31+
selectedFilePath: String?,
32+
onSelectFile: () -> Unit = {},
33+
onDeleteFile: () -> Unit = {},
34+
) {
35+
Column(
36+
horizontalAlignment = Alignment.Start
37+
) {
38+
if (label != null) {
39+
RequiredTextLabel(label = label, required = required, fontWeight = FontWeight.SemiBold)
40+
}
41+
Row {
42+
Column(
43+
modifier = Modifier.weight(2f)
44+
.padding(end = 16.dp)
45+
) {
46+
Button(onClick = onSelectFile) {
47+
Text(stringResource(Res.string.take_a_photo))
48+
}
49+
if (selectedFilePath != null) {
50+
Button(
51+
onClick = onDeleteFile) {
52+
Icon(
53+
Icons.Filled.Delete,
54+
contentDescription = stringResource(Res.string.delete_photo)
55+
)
56+
Text(stringResource(Res.string.delete_photo))
57+
}
58+
}
59+
}
60+
61+
if (selectedFilePath != null) {
62+
Box(modifier = Modifier.weight(1f)) {
63+
QuestionImagePreview(filePath = selectedFilePath)
64+
}
65+
}
66+
}
67+
68+
if (validation != null) {
69+
when (validation) {
70+
FieldValidationState.INVALID -> {
71+
Text(
72+
stringResource(Res.string.question_input_invalid),
73+
color = Color.Red,
74+
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp)
75+
)
76+
}
77+
78+
FieldValidationState.MISSING -> {
79+
Text(
80+
stringResource(Res.string.question_input_required),
81+
color = Color.Red,
82+
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp)
83+
)
84+
}
85+
}
86+
}
87+
}
88+
}

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

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,13 @@ import androidx.compose.desktop.ui.tooling.preview.Preview
44
import androidx.compose.foundation.layout.Arrangement
55
import androidx.compose.foundation.layout.Column
66
import androidx.compose.material3.CircularProgressIndicator
7-
import androidx.compose.runtime.Composable
8-
import androidx.compose.runtime.collectAsState
9-
import androidx.compose.runtime.getValue
10-
import androidx.compose.runtime.rememberCoroutineScope
7+
import androidx.compose.runtime.*
118
import androidx.compose.ui.Alignment
129
import androidx.compose.ui.Modifier
1310
import androidx.navigation.NavHostController
1411
import eu.pretix.desktop.app.navigation.Route
12+
import eu.pretix.desktop.app.scan.GlobalScanSetup
1513
import eu.pretix.desktop.app.ui.ScreenContentRoot
16-
import eu.pretix.desktop.app.ui.autoScanEventListener
1714
import eu.pretix.scan.main.presentation.selectevent.SelectEventDialog
1815
import eu.pretix.scan.main.presentation.selectlist.SelectCheckInListDialog
1916
import eu.pretix.scan.main.presentation.selectlist.SelectCheckInListForMultiEventDialog
@@ -32,6 +29,14 @@ fun MainScreen(
3229
val uiState by viewModel.uiState.collectAsState()
3330
val coroutineScope = rememberCoroutineScope()
3431

32+
GlobalScanSetup(
33+
stateFlow = viewModel.uiState,
34+
onHandleDirectScan = { secret ->
35+
coroutineScope.launch {
36+
viewModel.onHandleDirectScan(secret)
37+
}
38+
}
39+
)
3540

3641
when (uiState) {
3742
MainUiState.SelectEvent -> {
@@ -78,13 +83,7 @@ fun MainScreen(
7883

7984
is MainUiState.ReadyToScan -> {
8085
val data = (uiState as MainUiState.ReadyToScan<MainUiStateData>).data
81-
Column(
82-
modifier = Modifier.autoScanEventListener(
83-
onAlphanumericKey = { _ ->
84-
// Event propagates to SearchTextField which auto-focuses on mount
85-
}
86-
)
87-
) {
86+
Column {
8887
MainToolbar(
8988
viewModel = viewModel,
9089
eventSelection = data.eventSelection,
@@ -116,11 +115,7 @@ fun MainScreen(
116115

117116
is MainUiState.HandlingTicket -> {
118117
val data = (uiState as MainUiState.HandlingTicket<MainUiStateData>).data
119-
Column(
120-
modifier = Modifier.autoScanEventListener { _ ->
121-
// Event propagates to SearchTextField which maintains focus
122-
}
123-
) {
118+
Column {
124119
MainToolbar(
125120
viewModel = viewModel,
126121
eventSelection = data.eventSelection
@@ -143,7 +138,9 @@ fun MainScreen(
143138
}
144139
TicketHandlingDialog(
145140
secret = data.secret,
146-
onDismiss = viewModel::onHandleTicketHandlingDismissed
141+
scanTimestamp = data.scanTimestamp,
142+
onDismiss = viewModel::onHandleTicketHandlingDismissed,
143+
onResultStateChanged = viewModel::onTicketResultDetermined
147144
)
148145
}
149146
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package eu.pretix.scan.main.presentation
22

33
import eu.pretix.desktop.cache.EventSelection
4+
import eu.pretix.scan.tickets.data.ResultState
45
import org.joda.time.DateTime
56

67
// Presentation model for multi-event selection
@@ -28,7 +29,12 @@ sealed class MainUiState<out T> {
2829
) : MainUiState<Nothing>()
2930
}
3031

31-
data class MainUiStateData(val eventSelection: EventSelection, val secret: String? = null)
32+
data class MainUiStateData(
33+
val eventSelection: EventSelection,
34+
val secret: String? = null,
35+
val scanTimestamp: Long = 0L,
36+
val resultState: ResultState? = null
37+
)
3238

3339
fun MainUiStateData.secret(secret: String?): MainUiStateData {
3440
return this.copy(secret = secret)

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

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import eu.pretix.desktop.cache.Version
99
import eu.pretix.libpretixsync.check.TicketCheckProvider
1010
import eu.pretix.libpretixsync.setup.RemoteEvent
1111
import eu.pretix.libpretixsync.sqldelight.CheckInList
12+
import eu.pretix.scan.tickets.data.ResultState
13+
import eu.pretix.scan.tickets.data.requiresUserInteraction
1214
import kotlinx.coroutines.flow.*
1315
import java.util.logging.Logger
1416

@@ -156,9 +158,37 @@ class MainViewModel(
156158
suspend fun onHandleDirectScan(secret: String) {
157159
log.info("AutoScan: Handling direct scan for ticket")
158160
val currentState = _uiState.value
159-
if (currentState is MainUiState.ReadyToScan) {
160-
_uiState.update {
161-
MainUiState.HandlingTicket(currentState.data.secret(secret))
161+
162+
when (currentState) {
163+
is MainUiState.ReadyToScan -> {
164+
_uiState.update {
165+
MainUiState.HandlingTicket(
166+
currentState.data
167+
.secret(secret)
168+
.copy(scanTimestamp = System.currentTimeMillis())
169+
)
170+
}
171+
}
172+
is MainUiState.HandlingTicket -> {
173+
val currentResultState = currentState.data.resultState
174+
175+
if (currentResultState?.requiresUserInteraction() == true) {
176+
log.info("AutoScan: Ignoring new scan - current dialog requires user interaction (state: $currentResultState)")
177+
return
178+
}
179+
180+
log.info("AutoScan: New scan while handling auto-dismissible dialog, interrupting")
181+
_uiState.update { MainUiState.ReadyToScan(currentState.data.secret(null)) }
182+
_uiState.update {
183+
MainUiState.HandlingTicket(
184+
currentState.data
185+
.secret(secret)
186+
.copy(scanTimestamp = System.currentTimeMillis())
187+
)
188+
}
189+
}
190+
else -> {
191+
log.info("AutoScan: Ignoring scan in state: ${currentState::class.simpleName}")
162192
}
163193
}
164194
}
@@ -172,6 +202,17 @@ class MainViewModel(
172202
}
173203
}
174204

205+
fun onTicketResultDetermined(resultState: ResultState) {
206+
val currentState = _uiState.value
207+
if (currentState is MainUiState.HandlingTicket) {
208+
_uiState.update {
209+
MainUiState.HandlingTicket(
210+
currentState.data.copy(resultState = resultState)
211+
)
212+
}
213+
}
214+
}
215+
175216
suspend fun selectMultipleEvents(events: List<RemoteEvent>) {
176217
if (events.isEmpty()) return
177218

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ fun SelectEventDialog(
102102
Spacer(modifier = Modifier.height(16.dp))
103103
}
104104

105-
// Event List - now supports both modes
105+
// Event List
106106
SelectEventList(
107107
advancedMode = advancedMode,
108108
selectedEvent = selectedEvent,

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/data/ResultState.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ fun ResultState.color(): Color {
2929
}
3030
}
3131

32+
fun ResultState.requiresUserInteraction(): Boolean {
33+
return this == ResultState.DIALOG_UNPAID || this == ResultState.DIALOG_QUESTIONS
34+
}
35+
36+
fun ResultState.isAutoDismissible(): Boolean {
37+
return this == ResultState.SUCCESS || this == ResultState.SUCCESS_EXIT
38+
}
39+
3240
data class ResultStateData(
3341
val resultState: ResultState,
3442
val resultText: String? = null,

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,8 @@ import com.vanniktech.locale.Country
33

44

55
fun Country.readableName(): String {
6-
return name.lowercase().replace(Regex("""(?:^|_+)(\p{L})""")) { (if (it.value.startsWith("_")) " " else "") + it.groupValues[1].uppercase() } // LIKE_THIS -> "Like This"
6+
if (name.length <= 4 && name.all { it.isUpperCase() || it == '_' }) {
7+
return name
8+
}
9+
return name.lowercase().replace(Regex("""(?:^|_+)(\p{L})""")) { (if (it.value.startsWith("_")) " " else "") + it.groupValues[1].uppercase() }
710
}

0 commit comments

Comments
 (0)