Skip to content

Commit e54d0bf

Browse files
committed
feat: improved time picker, multiple choice questions, camera setup
1 parent 59464b8 commit e54d0bf

File tree

14 files changed

+182
-81
lines changed

14 files changed

+182
-81
lines changed

pretixscan/composeApp/src/commonMain/composeResources/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,4 +224,8 @@
224224
<string name="badge_printing_selected_printer_not_available">The selected printer is currently unavailable. Please
225225
check that the device is online and connected to the system.
226226
</string>
227+
<string name="settings_section_camera">Camera</string>
228+
<string name="settings_preferred_camera_label">Preferred camera</string>
229+
<string name="settings_camera_auto">Auto</string>
230+
<string name="no_available_cameras">No available cameras</string>
227231
</resources>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package eu.pretix.desktop.app.ui
2+
3+
import androidx.compose.material3.HorizontalDivider
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.ui.Modifier
6+
import androidx.compose.ui.graphics.Color
7+
import androidx.compose.ui.unit.dp
8+
9+
@Composable
10+
fun ListDivider(currentIndex: Int? = null, lastIndex: Int? = null) {
11+
if (lastIndex != null && currentIndex != null && currentIndex < lastIndex) {
12+
// show divider between rows except the last row
13+
HorizontalDivider(Modifier, thickness = 1.dp, color = Color.Gray)
14+
} else if (lastIndex == null && currentIndex == null) {
15+
// always show when no index is set
16+
HorizontalDivider(Modifier, thickness = 1.dp, color = Color.Gray)
17+
}
18+
}

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/cache/DataStoreConfig.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package eu.pretix.desktop.cache
22

33
import androidx.datastore.core.DataStore
44
import androidx.datastore.preferences.core.*
5+
import eu.pretix.desktop.webcam.data.VideoSource
56
import eu.pretix.libpretixsync.api.PretixApi
67
import kotlinx.coroutines.flow.first
78
import kotlinx.serialization.builtins.ListSerializer
@@ -364,7 +365,7 @@ class DataStoreConfig(private val dataStore: DataStore<Preferences>) {
364365
// ============================================================
365366

366367
suspend fun getPreferredCameraName(): String =
367-
dataStore.data.first()[PreferenceKeys.PREFERRED_CAMERA_NAME] ?: "-"
368+
dataStore.data.first()[PreferenceKeys.PREFERRED_CAMERA_NAME] ?: VideoSource.NO_CAMERA_NAME
368369

369370
suspend fun setPreferredCameraName(value: String) {
370371
dataStore.edit { it[PreferenceKeys.PREFERRED_CAMERA_NAME] = value }

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/webcam/data/VideoSource.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ interface VideoSource {
2121
fun collectAvailableImageData(): StateFlow<ImageData?>
2222

2323
fun takeScreenshot(): BufferedImage?
24+
25+
companion object {
26+
const val NO_CAMERA_NAME = "-"
27+
}
2428
}
2529

2630
sealed class VideoState {
@@ -41,6 +45,10 @@ data class ImageData(
4145

4246
class DefaultVideoSource : VideoSource {
4347

48+
init {
49+
configureWebCam()
50+
}
51+
4452
private var currentVideo: MutableStateFlow<Webcam?> = MutableStateFlow(null)
4553
private val videoEventFlow: MutableStateFlow<VideoState> = MutableStateFlow(VideoState.Closed)
4654
private val availableImageDataFlow: MutableStateFlow<ImageData?> = MutableStateFlow(null)

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/webcam/data/WebCamViewModel.kt

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class WebCamViewModel(
3030
val availableImageData: StateFlow<ImageData?> = videoSource.collectAvailableImageData()
3131
private val coroutineScope = CoroutineScope(Dispatchers.IO + Job())
3232
private val noSelectedVideo = Video(
33-
name = "-",
33+
name = VideoSource.NO_CAMERA_NAME,
3434
availableResolutions = listOf(),
3535
fps = .0,
3636
)
@@ -43,14 +43,13 @@ class WebCamViewModel(
4343
val uiState: StateFlow<CameraState> = _uiState
4444

4545
fun load() {
46-
configureWebCam()
4746
observeVideoInput()
4847
}
4948

5049

5150
fun observeVideoInput() = coroutineScope.launch {
5251
videoSource.getAvailableWebcam().collectLatest { videos ->
53-
val newVideos = listOf(noSelectedVideo) + videos.map { webcam ->
52+
val newVideos = videos.map { webcam ->
5453
Video(
5554
name = webcam.name,
5655
availableResolutions = webcam.device.resolutions.map { it.toResolution() },
@@ -61,12 +60,15 @@ class WebCamViewModel(
6160
_uiState.update { state ->
6261
state.copy(availableVideos = newVideos)
6362
}
64-
if (_uiState.value.preselectCamera && _uiState.value.selectedVideo?.name == "-") {
65-
val newCamera = newVideos.firstOrNull { it.name == appConfig.preferredCameraName }
66-
?: newVideos.firstOrNull { it.name != "-" }
67-
if (newCamera != null) {
68-
selectVideo(newCamera)
63+
if (_uiState.value.preselectCamera && _uiState.value.selectedVideo?.name == VideoSource.NO_CAMERA_NAME) {
64+
val newCamera = when {
65+
appConfig.preferredCameraName != VideoSource.NO_CAMERA_NAME -> {
66+
newVideos.firstOrNull { it.name == appConfig.preferredCameraName }
67+
?: newVideos.firstOrNull()
68+
}
69+
else -> newVideos.firstOrNull()
6970
}
71+
newCamera?.let { selectVideo(it) }
7072
}
7173
}
7274
}
@@ -81,14 +83,11 @@ class WebCamViewModel(
8183

8284
fun selectVideo(video: Video) {
8385
if (video.name == _uiState.value.selectedVideo?.name) return
84-
if (video.name == "-") {
86+
if (video.name == VideoSource.NO_CAMERA_NAME) {
8587
videoSource.close()
8688
_uiState.update { it.copy(selectedVideo = noSelectedVideo) }
8789
return
8890
}
89-
if (appConfig.preferredCameraName != video.name) {
90-
appConfig.preferredCameraName = video.name
91-
}
9291
videoSource.open(video.name)
9392
_uiState.update {
9493
it.copy(selectedVideo = video, preselectCamera = false)

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/webcam/presentation/WebCam.kt

Lines changed: 43 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import eu.pretix.desktop.app.ui.Logo
2121
import eu.pretix.desktop.app.ui.asColor
2222
import eu.pretix.desktop.webcam.data.ImageData
2323
import eu.pretix.desktop.webcam.data.Video
24+
import eu.pretix.desktop.webcam.data.VideoSource
2425
import eu.pretix.desktop.webcam.data.WebCamViewModel
2526
import kotlinx.coroutines.delay
2627
import org.jetbrains.compose.resources.painterResource
@@ -72,13 +73,21 @@ fun WebCam(onPhotoTaken: (String?) -> Unit) {
7273

7374
Column {
7475
Spacer(modifier = Modifier.weight(1f))
75-
IconButton(onClick = {
76-
val path = viewModel.savePhoto()
77-
onPhotoTaken(path)
78-
}) {
79-
Image(
80-
painter = painterResource(Res.drawable.ic_photo_camera_white_24),
81-
contentDescription = stringResource(Res.string.take_a_photo)
76+
if (selectedVideo != null && selectedVideo.name != VideoSource.NO_CAMERA_NAME) {
77+
IconButton(onClick = {
78+
val path = viewModel.savePhoto()
79+
onPhotoTaken(path)
80+
}) {
81+
Image(
82+
painter = painterResource(Res.drawable.ic_photo_camera_white_24),
83+
contentDescription = stringResource(Res.string.take_a_photo)
84+
)
85+
}
86+
} else {
87+
Text(
88+
text = stringResource(Res.string.no_available_cameras),
89+
style = MaterialTheme.typography.titleLarge,
90+
color = MaterialTheme.colorScheme.onSurface
8291
)
8392
}
8493
}
@@ -105,45 +114,35 @@ fun Toolbar(
105114
Spacer(Modifier.weight(1f))
106115

107116
if (availableDeviceNames != null) {
108-
Box(contentAlignment = Alignment.TopStart) {
109-
Button(modifier = Modifier.padding(horizontal = 16.dp), onClick = { expanded = true }) {
110-
Row {
111-
if (selectedDevice != "-") {
112-
Text(selectedDevice ?: "")
113-
} else {
114-
Text(stringResource(Res.string.select_camera))
117+
if (availableDeviceNames.isNotEmpty() && selectedDevice != VideoSource.NO_CAMERA_NAME) {
118+
Box(contentAlignment = Alignment.TopStart) {
119+
Button(modifier = Modifier.padding(horizontal = 16.dp), onClick = { expanded = true }) {
120+
Row {
121+
Text(selectedDevice ?: stringResource(Res.string.select_camera))
122+
Icon(
123+
Icons.Default.ArrowDropDown,
124+
contentDescription = null,
125+
tint = CustomColor.White.asColor()
126+
)
115127
}
116-
Icon(
117-
Icons.Default.ArrowDropDown,
118-
contentDescription = null,
119-
tint = CustomColor.White.asColor()
120-
)
121128
}
122-
}
123-
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
124-
availableDeviceNames.forEachIndexed { _, name ->
125-
DropdownMenuItem(
126-
text = {
127-
if (name == "-") {
128-
Text(stringResource(Res.string.none_camera))
129-
} else {
130-
Text(name)
129+
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
130+
availableDeviceNames.forEach { name ->
131+
DropdownMenuItem(
132+
text = { Text(name) },
133+
onClick = {
134+
onDeviceSelect(name)
135+
expanded = false
136+
},
137+
leadingIcon = {
138+
if (name == selectedDevice) {
139+
Icon(Icons.Default.Check, contentDescription = null)
140+
} else {
141+
Icon(Icons.Default.Face, contentDescription = null)
142+
}
131143
}
132-
},
133-
onClick = {
134-
onDeviceSelect(name)
135-
expanded = false
136-
},
137-
leadingIcon = {
138-
if (name == selectedDevice) {
139-
Icon(Icons.Default.Check, contentDescription = null)
140-
} else if (name == "-") {
141-
Icon(Icons.Default.Close, contentDescription = null)
142-
} else {
143-
Icon(Icons.Default.Face, contentDescription = null)
144-
}
145-
}
146-
)
144+
)
145+
}
147146
}
148147
}
149148
}
@@ -158,7 +157,7 @@ private fun VideoSurface(
158157
selectedVideo: Video?,
159158
availableImageData: ImageData?,
160159
) {
161-
if (selectedVideo?.name == "-") return
160+
if (selectedVideo?.name == VideoSource.NO_CAMERA_NAME) return
162161
if (availableImageData == null) return
163162
val imageRatio = availableImageData.image.width.toFloat() / availableImageData.image.height
164163
Box(

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState
1010
import androidx.compose.foundation.rememberScrollbarAdapter
1111
import androidx.compose.foundation.selection.selectable
1212
import androidx.compose.foundation.selection.selectableGroup
13-
import androidx.compose.material.Divider
13+
import androidx.compose.material3.Divider
1414
import androidx.compose.material3.Checkbox
1515
import androidx.compose.material3.CircularProgressIndicator
16+
import androidx.compose.material3.HorizontalDivider
1617
import androidx.compose.material3.Text
1718
import androidx.compose.runtime.Composable
1819
import androidx.compose.runtime.LaunchedEffect
@@ -24,6 +25,7 @@ import androidx.compose.ui.graphics.Color
2425
import androidx.compose.ui.semantics.Role
2526
import androidx.compose.ui.text.font.FontWeight
2627
import androidx.compose.ui.unit.dp
28+
import eu.pretix.desktop.app.ui.ListDivider
2729
import eu.pretix.desktop.app.ui.SelectListRow
2830
import eu.pretix.libpretixsync.setup.RemoteEvent
2931
import eu.pretix.scan.tickets.presentation.formatDateForDisplay
@@ -137,9 +139,7 @@ fun SelectEventList(
137139
Text(dateText)
138140
}
139141
}
140-
if (index < list.lastIndex) {
141-
Divider(color = Color.Gray, thickness = 1.dp)
142-
}
142+
ListDivider(index, list.lastIndex)
143143
}
144144
}
145145

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState
1111
import androidx.compose.foundation.rememberScrollbarAdapter
1212
import androidx.compose.foundation.selection.selectable
1313
import androidx.compose.foundation.selection.selectableGroup
14-
import androidx.compose.material.Divider
14+
import androidx.compose.material3.Divider
1515
import androidx.compose.material3.Checkbox
1616
import androidx.compose.material3.CircularProgressIndicator
17+
import androidx.compose.material3.HorizontalDivider
1718
import androidx.compose.material3.Text
1819
import androidx.compose.runtime.Composable
1920
import androidx.compose.runtime.collectAsState
@@ -24,6 +25,7 @@ import androidx.compose.ui.graphics.Color
2425
import androidx.compose.ui.semantics.Role
2526
import androidx.compose.ui.text.font.FontWeight
2627
import androidx.compose.ui.unit.dp
28+
import eu.pretix.desktop.app.ui.ListDivider
2729
import eu.pretix.desktop.app.ui.SelectListRow
2830
import eu.pretix.libpretixsync.sqldelight.CheckInList
2931
import org.jetbrains.compose.resources.stringResource
@@ -105,9 +107,7 @@ fun SelectCheckInList(
105107
Text(item.name ?: "", fontWeight = FontWeight.Bold)
106108
}
107109
}
108-
if (index < list.lastIndex) {
109-
Divider(color = Color.Gray, thickness = 1.dp)
110-
}
110+
ListDivider(index, list.lastIndex)
111111
}
112112
}
113113

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/settings/data/ConfigurableSettings.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ data class ConfigurableSettings(
1414
val offlineMode: Boolean = false,
1515
val uiReduceMotion: Boolean = false,
1616
val uiHideNames: Boolean = false,
17+
val cameras: List<String> = emptyList(),
18+
val preferredCamera: String? = null,
1719
)

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/settings/presentation/SettingsScreen.kt

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import eu.pretix.desktop.app.ui.*
2424
import eu.pretix.desktop.cache.getLogDirectory
2525
import eu.pretix.desktop.cache.getUserDataFolder
2626
import eu.pretix.desktop.cache.openPathInFileBrowser
27+
import eu.pretix.desktop.webcam.data.VideoSource
2728
import kotlinx.coroutines.launch
2829
import org.jetbrains.compose.resources.stringResource
2930
import org.koin.compose.viewmodel.koinViewModel
@@ -240,6 +241,29 @@ fun SettingsScreen(
240241
}
241242
}
242243

244+
item {
245+
Section(stringResource(Res.string.settings_section_camera)) {
246+
Setting {
247+
Column(
248+
horizontalAlignment = Alignment.Start
249+
) {
250+
Text(
251+
stringResource(Res.string.settings_preferred_camera_label)
252+
)
253+
FieldSpinner(
254+
selectedValue = form.preferredCamera,
255+
availableOptions = listOf(SelectableValue(VideoSource.NO_CAMERA_NAME, stringResource(Res.string.settings_camera_auto))) + form.cameras.map { SelectableValue(it, it) },
256+
onSelect = {
257+
coroutineScope.launch {
258+
viewModel.setPreferredCamera(it?.value)
259+
}
260+
},
261+
)
262+
}
263+
}
264+
}
265+
}
266+
243267
item {
244268
Section(stringResource(Res.string.settings_label_about)) {
245269
Setting {
@@ -337,7 +361,7 @@ fun Section(heading: String, content: @Composable () -> Unit) {
337361
fontWeight = FontWeight.Medium
338362
)
339363
content()
340-
HorizontalDivider()
364+
ListDivider()
341365
}
342366
}
343367

0 commit comments

Comments
 (0)