From 333f90c79264df152907157bf755fc54f9ea60fb Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 14 May 2026 22:51:48 +0200 Subject: [PATCH] Refactor TimefixService to improve query handling and enhance error management in MainActivity. Update AndroidManifest to specify application class and increment Gradle version. Enhance BookingViewModel for better state management and loading indicators. Update UI components for improved user experience in booking screens. --- backend/src/services/TimefixService.js | 6 +- mobile-app/build.gradle.kts | 2 +- .../composeApp/src/main/AndroidManifest.xml | 1 + .../de/tsschulz/timeclock/MainActivity.kt | 47 ++-- .../timeclock/TimeClockApplication.kt | 20 ++ .../timeclock/data/api/BookingDtos.kt | 3 + .../data/api/FlexibleStringSerializer.kt | 35 +++ .../de/tsschulz/timeclock/ui/TimeClockApp.kt | 58 +++-- .../timeclock/ui/booking/BookingScreens.kt | 205 ++++++++++++++++-- .../timeclock/ui/booking/BookingViewModel.kt | 75 +++++-- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 11 files changed, 376 insertions(+), 78 deletions(-) create mode 100644 mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/TimeClockApplication.kt create mode 100644 mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/FlexibleStringSerializer.kt diff --git a/backend/src/services/TimefixService.js b/backend/src/services/TimefixService.js index 27937b9..625a11e 100644 --- a/backend/src/services/TimefixService.js +++ b/backend/src/services/TimefixService.js @@ -33,15 +33,15 @@ class TimefixService { }); // Hole auch die Timefixes für diese Einträge - const entryIds = entries.map(e => e.id); + const entryIds = entries.map(e => e.id).filter(Boolean); if (entryIds.length === 0) { return []; } const timefixesForEntries = await sequelize.query( - `SELECT worklog_id, fix_type, fix_date_time FROM timefix WHERE worklog_id IN (?)`, + `SELECT worklog_id, fix_type, fix_date_time FROM timefix WHERE worklog_id IN (:entryIds)`, { - replacements: [entryIds], + replacements: { entryIds }, type: sequelize.QueryTypes.SELECT } ); diff --git a/mobile-app/build.gradle.kts b/mobile-app/build.gradle.kts index 7cd342d..9bd2d9c 100644 --- a/mobile-app/build.gradle.kts +++ b/mobile-app/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.android.application") version "9.1.1" apply false + id("com.android.application") version "9.2.1" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false } diff --git a/mobile-app/composeApp/src/main/AndroidManifest.xml b/mobile-app/composeApp/src/main/AndroidManifest.xml index 3df365b..50a6859 100644 --- a/mobile-app/composeApp/src/main/AndroidManifest.xml +++ b/mobile-app/composeApp/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="Stechuhr" + android:name=".TimeClockApplication" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.TimeClock"> diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/MainActivity.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/MainActivity.kt index 56df0e8..31f1d2c 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/MainActivity.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/MainActivity.kt @@ -2,6 +2,8 @@ package de.tsschulz.timeclock import android.graphics.Color import android.os.Bundle +import android.util.Log +import android.widget.TextView import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.core.view.WindowCompat @@ -18,23 +20,36 @@ class MainActivity : ComponentActivity() { @Suppress("DEPRECATION") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + Log.i(TAG, "MainActivity.onCreate") window.statusBarColor = Color.rgb(240, 255, 236) window.navigationBarColor = Color.WHITE - setContent { - val authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory(application)) - val timeViewModel: TimeViewModel = viewModel(factory = TimeViewModel.Factory(application)) - val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application)) - val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application)) - val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application)) - TimeClockTheme { - TimeClockApp( - authViewModel = authViewModel, - timeViewModel = timeViewModel, - bookingViewModel = bookingViewModel, - settingsViewModel = settingsViewModel, - adminViewModel = adminViewModel, - ) + try { + setContent { + val authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory(application)) + val timeViewModel: TimeViewModel = viewModel(factory = TimeViewModel.Factory(application)) + val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application)) + val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application)) + val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application)) + TimeClockTheme { + TimeClockApp( + authViewModel = authViewModel, + timeViewModel = timeViewModel, + bookingViewModel = bookingViewModel, + settingsViewModel = settingsViewModel, + adminViewModel = adminViewModel, + ) + } } + } catch (e: Throwable) { + Log.e(TAG, "Failed to create Compose content", e) + setContentView( + TextView(this).apply { + text = "Die App konnte nicht gestartet werden.\n${e.message.orEmpty()}" + setTextColor(Color.BLACK) + setBackgroundColor(Color.WHITE) + setPadding(32, 32, 32, 32) + }, + ) } window.decorView.post { val controller = WindowCompat.getInsetsController(window, window.decorView) @@ -42,4 +57,8 @@ class MainActivity : ComponentActivity() { controller.isAppearanceLightNavigationBars = true } } + + private companion object { + const val TAG = "TimeClockStartup" + } } diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/TimeClockApplication.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/TimeClockApplication.kt new file mode 100644 index 0000000..8250a02 --- /dev/null +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/TimeClockApplication.kt @@ -0,0 +1,20 @@ +package de.tsschulz.timeclock + +import android.app.Application +import android.util.Log + +class TimeClockApplication : Application() { + override fun onCreate() { + super.onCreate() + Log.i(TAG, "Application started") + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + Log.e(TAG, "Uncaught exception on ${thread.name}", throwable) + previousHandler?.uncaughtException(thread, throwable) + } + } + + private companion object { + const val TAG = "TimeClockStartup" + } +} diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/BookingDtos.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/BookingDtos.kt index b5002ae..32a1476 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/BookingDtos.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/BookingDtos.kt @@ -56,8 +56,11 @@ data class TimefixDto( @Serializable data class WorklogEntryDto( val id: String, + @Serializable(with = FlexibleStringSerializer::class) val time: String? = null, + @Serializable(with = FlexibleStringSerializer::class) val action: String? = null, + @Serializable(with = FlexibleStringSerializer::class) val tstamp: String? = null, ) diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/FlexibleStringSerializer.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/FlexibleStringSerializer.kt new file mode 100644 index 0000000..1d03ad3 --- /dev/null +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/FlexibleStringSerializer.kt @@ -0,0 +1,35 @@ +package de.tsschulz.timeclock.data.api + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +object FlexibleStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("FlexibleString", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String? { + val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString() + return when (val element = jsonDecoder.decodeJsonElement()) { + JsonNull -> null + is JsonPrimitive -> element.contentOrNull + else -> null + } + } + + @OptIn(ExperimentalSerializationApi::class) + override fun serialize(encoder: Encoder, value: String?) { + if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeString(value) + } + } +} diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt index 0fabf28..839a1a2 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt @@ -94,7 +94,6 @@ fun TimeClockApp( val sections = if (user.isAdmin) adminSections else userSections LaunchedEffect(user.id) { timeViewModel.start() } - LaunchedEffect(user.id) { bookingViewModel.loadPhase4() } LaunchedEffect(user.id) { settingsViewModel.loadPhase5() } LaunchedEffect(user.id, user.isAdmin) { adminViewModel.loadPhase6(user.isAdmin) } @@ -169,27 +168,42 @@ private fun DemoScreen( onWeekOffset = onWeekOffset, isTablet = isTablet, ) - AppRoute.Timefix -> TimefixScreen( - state = bookingState, - isTablet = isTablet, - onDate = { bookingViewModel.setTimefixDate(it) }, - onCreate = { id, date, time, action -> bookingViewModel.createTimefix(id, date, time, action) }, - onDelete = { bookingViewModel.deleteTimefix(it) }, - ) - AppRoute.Vacation -> VacationScreen( - state = bookingState, - isTablet = isTablet, - onCreate = { type, start, end -> bookingViewModel.createVacation(type, start, end) }, - onDelete = { bookingViewModel.deleteVacation(it) }, - ) - AppRoute.Sick -> SickScreen( - state = bookingState, - isTablet = isTablet, - onCreate = { type, start, end -> bookingViewModel.createSick(type, start, end) }, - onDelete = { bookingViewModel.deleteSick(it) }, - ) - AppRoute.Workdays -> WorkdaysScreen(state = bookingState, onYear = { bookingViewModel.loadWorkdays(it) }) - AppRoute.Calendar -> CalendarScreen(state = bookingState, onMonth = { bookingViewModel.changeCalendarMonth(it) }) + AppRoute.Timefix -> { + LaunchedEffect(route) { bookingViewModel.loadTimefix() } + TimefixScreen( + state = bookingState, + isTablet = isTablet, + onDate = { bookingViewModel.setTimefixDate(it) }, + onCreate = { id, date, time, action -> bookingViewModel.createTimefix(id, date, time, action) }, + onDelete = { bookingViewModel.deleteTimefix(it) }, + ) + } + AppRoute.Vacation -> { + LaunchedEffect(route) { bookingViewModel.loadVacations() } + VacationScreen( + state = bookingState, + isTablet = isTablet, + onCreate = { type, start, end -> bookingViewModel.createVacation(type, start, end) }, + onDelete = { bookingViewModel.deleteVacation(it) }, + ) + } + AppRoute.Sick -> { + LaunchedEffect(route) { bookingViewModel.loadSick() } + SickScreen( + state = bookingState, + isTablet = isTablet, + onCreate = { type, start, end -> bookingViewModel.createSick(type, start, end) }, + onDelete = { bookingViewModel.deleteSick(it) }, + ) + } + AppRoute.Workdays -> { + LaunchedEffect(route) { bookingViewModel.loadWorkdays(bookingState.workdaysYear) } + WorkdaysScreen(state = bookingState, onYear = { bookingViewModel.loadWorkdays(it) }) + } + AppRoute.Calendar -> { + LaunchedEffect(route) { bookingViewModel.loadCalendar(bookingState.calendarYear, bookingState.calendarMonth) } + CalendarScreen(state = bookingState, onMonth = { bookingViewModel.changeCalendarMonth(it) }) + } AppRoute.Entries -> { LaunchedEffect(Unit) { timeViewModel.loadEntries() } EntriesScreen( diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingScreens.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingScreens.kt index 51049a8..0c2941c 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingScreens.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingScreens.kt @@ -1,7 +1,10 @@ package de.tsschulz.timeclock.ui.booking +import android.app.DatePickerDialog +import android.app.TimePickerDialog import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -13,13 +16,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -49,9 +56,13 @@ fun VacationScreen(state: BookingUiState, isTablet: Boolean, onCreate: (Int, Str var type by rememberSaveable { mutableStateOf("0") } var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } var end by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } - Phase4Frame(state) { + Phase4Frame(loading = state.vacationLoading, error = state.vacationError) { FormCard("Urlaub eintragen", isTablet) { TcTextField("Umfang (0 Zeitraum, 1 Halber Tag)", type, { type = it }, placeholder = "0") + Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) { + TcButton("Zeitraum", variant = if (type == "0") ButtonVariant.Primary else ButtonVariant.Default, onClick = { type = "0" }) + TcButton("Halber Tag", variant = if (type == "1") ButtonVariant.Primary else ButtonVariant.Default, onClick = { type = "1" }) + } TcTextField("Urlaubsbeginn", start, { start = it }, placeholder = "YYYY-MM-DD") TcTextField("Urlaubsende", end, { end = it }, placeholder = "YYYY-MM-DD") TcButton("Urlaub eintragen", variant = ButtonVariant.Primary, onClick = { @@ -70,11 +81,23 @@ fun SickScreen(state: BookingUiState, isTablet: Boolean, onCreate: (String, Stri var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } var end by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } var typeId by rememberSaveable { mutableStateOf(state.sickTypes.firstOrNull()?.id?.toString().orEmpty()) } - Phase4Frame(state) { + LaunchedEffect(state.sickTypes) { + if (typeId.isBlank()) typeId = state.sickTypes.firstOrNull()?.id.orEmpty() + } + Phase4Frame(loading = state.sickLoading, error = state.sickError) { FormCard("Erkrankung eintragen", isTablet) { TcTextField("Erster Krankheitstag", start, { start = it }, placeholder = "YYYY-MM-DD") TcTextField("Letzter Krankheitstag", end, { end = it }, placeholder = "YYYY-MM-DD") TcTextField("Krankheitstyp-ID", typeId, { typeId = it }, placeholder = state.sickTypes.joinToString { "${it.id}=${it.name}" }) + FlowRow(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) { + state.sickTypes.forEach { type -> + TcButton( + text = type.name, + variant = if (type.id == typeId) ButtonVariant.Primary else ButtonVariant.Default, + onClick = { typeId = type.id }, + ) + } + } TcButton("Erkrankung eintragen", variant = ButtonVariant.Primary, onClick = { if (typeId.isNotBlank()) onCreate(typeId, start, end.ifBlank { start }) }) @@ -85,6 +108,7 @@ fun SickScreen(state: BookingUiState, isTablet: Boolean, onCreate: (String, Stri } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun TimefixScreen( state: BookingUiState, @@ -94,16 +118,38 @@ fun TimefixScreen( onDelete: (String) -> Unit, ) { var worklogId by rememberSaveable { mutableStateOf("") } + var newDate by rememberSaveable { mutableStateOf(state.timefixDate) } var time by rememberSaveable { mutableStateOf("08:00") } var action by rememberSaveable { mutableStateOf("start work") } - Phase4Frame(state) { + LaunchedEffect(state.worklogEntries) { + val selectedStillExists = state.worklogEntries.any { it.id == worklogId } + if (worklogId.isBlank() || !selectedStillExists) { + val first = state.worklogEntries.firstOrNull() + worklogId = first?.id.orEmpty() + first?.time?.let { time = it } + first?.action?.let { action = it } + } + } + LaunchedEffect(state.timefixDate) { + if (newDate.isBlank()) newDate = state.timefixDate + } + Phase4Frame(loading = state.timefixLoading, error = state.timefixError) { FormCard("Zeitkorrektur", isTablet) { - TcTextField("Datum", state.timefixDate, onDate, placeholder = "YYYY-MM-DD") - TcTextField("Worklog-ID", worklogId, { worklogId = it }, placeholder = state.worklogEntries.firstOrNull()?.id?.toString() ?: "") - TcTextField("Neue Uhrzeit", time, { time = it }, placeholder = "HH:MM") - TcTextField("Aktion", action, { action = it }, placeholder = "start work") + DateField("Datum des Original-Eintrags", state.timefixDate, onDate) + WorklogDropdown( + entries = state.worklogEntries, + selectedId = worklogId, + onSelect = { entry -> + worklogId = entry.id + entry.time?.let { time = it } + entry.action?.let { action = it } + }, + ) + DateField("Neues Datum", newDate, { newDate = it }) + TimeField("Neue Uhrzeit", time, { time = it }) + ActionDropdown(action, { action = it }) TcButton("Korrektur erstellen", variant = ButtonVariant.Primary, onClick = { - if (worklogId.isNotBlank()) onCreate(worklogId, state.timefixDate, time, action) + if (worklogId.isNotBlank()) onCreate(worklogId, newDate, time, action) }) if (state.worklogEntries.isEmpty()) { Text("Für dieses Datum sind keine Worklog-Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 13.sp) @@ -117,7 +163,7 @@ fun TimefixScreen( @Composable fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) { var year by rememberSaveable { mutableStateOf(state.workdaysYear.toString()) } - Phase4Frame(state) { + Phase4Frame(loading = state.workdaysLoading, error = state.workdaysError) { FormCard("Arbeitstage", isTablet = false) { TcTextField("Jahr", year, { year = it }, placeholder = "2026") TcButton("Jahr laden", variant = ButtonVariant.Primary, onClick = { year.toIntOrNull()?.let(onYear) }) @@ -128,7 +174,7 @@ fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) { @Composable fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) { - Phase4Frame(state) { + Phase4Frame(loading = state.calendarLoading, error = state.calendarError) { TcCard { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { TcButton("←", variant = ButtonVariant.Secondary, onClick = { onMonth(-1) }) @@ -147,9 +193,9 @@ fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) { } @Composable -private fun Phase4Frame(state: BookingUiState, content: @Composable () -> Unit) { - if (state.loading) TcLoading("Lade Daten...") - state.error?.let { TcError(it) } +private fun Phase4Frame(loading: Boolean, error: String?, content: @Composable () -> Unit) { + if (loading) TcLoading("Lade Daten...") + error?.let { TcError(it) } content() } @@ -161,6 +207,121 @@ private fun FormCard(title: String, isTablet: Boolean, content: @Composable Colu } } +@Composable +private fun DateField(label: String, value: String, onValueChange: (String) -> Unit) { + val context = LocalContext.current + val parsed = runCatching { LocalDate.parse(value) }.getOrDefault(LocalDate.now()) + FieldLabel(label) + TcButton( + text = parsed.format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY)), + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { + DatePickerDialog( + context, + { _, year, month, day -> + onValueChange(LocalDate.of(year, month + 1, day).toString()) + }, + parsed.year, + parsed.monthValue - 1, + parsed.dayOfMonth, + ).show() + }, + ) +} + +@Composable +private fun TimeField(label: String, value: String, onValueChange: (String) -> Unit) { + val context = LocalContext.current + val parts = value.split(":") + val hour = parts.getOrNull(0)?.toIntOrNull() ?: 8 + val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0 + FieldLabel(label) + TcButton( + text = "%02d:%02d".format(hour, minute), + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { + TimePickerDialog( + context, + { _, selectedHour, selectedMinute -> + onValueChange("%02d:%02d".format(selectedHour, selectedMinute)) + }, + hour, + minute, + true, + ).show() + }, + ) +} + +@Composable +private fun WorklogDropdown( + entries: List, + selectedId: String, + onSelect: (WorklogEntryDto) -> Unit, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + val selected = entries.firstOrNull { it.id == selectedId } + FieldLabel("Eintrag") + Box(modifier = Modifier.fillMaxWidth()) { + TcButton( + text = selected?.toWorklogLabel() ?: if (entries.isEmpty()) "Keine Einträge für dieses Datum" else "Bitte wählen", + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { if (entries.isNotEmpty()) expanded = true }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + entries.forEach { entry -> + DropdownMenuItem( + text = { Text(entry.toWorklogLabel(), color = TcColors.Text) }, + onClick = { + onSelect(entry) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun ActionDropdown(value: String, onValueChange: (String) -> Unit) { + var expanded by rememberSaveable { mutableStateOf(false) } + val actions = listOf("start work", "stop work", "start pause", "stop pause") + FieldLabel("Neuer Eintrags-Typ") + Box(modifier = Modifier.fillMaxWidth()) { + TcButton( + text = value.toActionLabel(), + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { expanded = true }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + actions.forEach { action -> + DropdownMenuItem( + text = { Text(action.toActionLabel(), color = TcColors.Text) }, + onClick = { + onValueChange(action) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun FieldLabel(label: String) { + Text( + text = label, + color = Color333, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + modifier = Modifier.padding(bottom = TcSpacing.Sm), + ) +} + @Composable private fun ListCard(title: String, items: List, row: @Composable (T) -> Unit) { TcCard { @@ -191,14 +352,14 @@ private fun SickRow(item: SickEntryDto, onDelete: (String) -> Unit) = DataRow( @Composable private fun TimefixRow(item: TimefixDto, onDelete: (String) -> Unit) = DataRow( - title = item.newAction ?: "Zeitkorrektur", + title = item.newAction?.toActionLabel() ?: "Zeitkorrektur", details = "${item.originalDate.toDisplayDate()} ${item.originalTime ?: "—"} → ${item.newDate.toDisplayDate()} ${item.newTime ?: "—"}", onDelete = { onDelete(item.id) }, ) @Composable private fun WorklogRow(item: WorklogEntryDto) = DataRow( - title = "#${item.id} ${item.action ?: "—"}", + title = item.action?.toActionLabel() ?: "Eintrag", details = "${item.time ?: "—"} (${item.tstamp ?: "—"})", ) @@ -298,3 +459,17 @@ private fun String?.toDisplayDate(): String { LocalDate.parse(this.substring(0, 10)).format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY)) }.getOrDefault(this) } + +private fun String.toActionLabel(): String = + when (this) { + "start work" -> "Arbeit beginnen" + "stop work" -> "Arbeit beenden" + "start pause" -> "Pause beginnen" + "stop pause" -> "Pause beenden" + else -> this + } + +private fun WorklogEntryDto.toWorklogLabel(): String = + "${time ?: "—"} - ${(action ?: "—").toActionLabel()}" + +private val Color333 = androidx.compose.ui.graphics.Color(0xFF333333) diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingViewModel.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingViewModel.kt index 27d24fa..b9b658a 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingViewModel.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingViewModel.kt @@ -25,6 +25,16 @@ import java.time.YearMonth data class BookingUiState( val loading: Boolean = false, val error: String? = null, + val vacationLoading: Boolean = false, + val vacationError: String? = null, + val sickLoading: Boolean = false, + val sickError: String? = null, + val timefixLoading: Boolean = false, + val timefixError: String? = null, + val workdaysLoading: Boolean = false, + val workdaysError: String? = null, + val calendarLoading: Boolean = false, + val calendarError: String? = null, val vacations: List = emptyList(), val sickEntries: List = emptyList(), val sickTypes: List = emptyList(), @@ -46,13 +56,14 @@ class BookingViewModel( fun loadPhase4() { loadVacations() - loadSick() - loadTimefix() - loadWorkdays(_uiState.value.workdaysYear) - loadCalendar(_uiState.value.calendarYear, _uiState.value.calendarMonth) } - fun loadVacations() = launchLoad { copy(vacations = repository.getVacations()) } + fun loadVacations() = launchSection( + start = { copy(vacationLoading = true, vacationError = null) }, + success = { vacations -> copy(vacationLoading = false, vacationError = null, vacations = vacations) }, + failure = { message -> copy(vacationLoading = false, vacationError = message) }, + block = { repository.getVacations() }, + ) fun createVacation(type: Int, start: String, end: String?) = launchMutation { repository.createVacation(type, start, end) loadVacations() @@ -62,9 +73,12 @@ class BookingViewModel( loadVacations() } - fun loadSick() = launchLoad { - copy(sickEntries = repository.getSickEntries(), sickTypes = repository.getSickTypes()) - } + fun loadSick() = launchSection( + start = { copy(sickLoading = true, sickError = null) }, + success = { result -> copy(sickLoading = false, sickError = null, sickEntries = result.first, sickTypes = result.second) }, + failure = { message -> copy(sickLoading = false, sickError = message) }, + block = { repository.getSickEntries() to repository.getSickTypes() }, + ) fun createSick(typeId: String, start: String, end: String?) = launchMutation { repository.createSick(typeId, start, end) loadSick() @@ -79,9 +93,12 @@ class BookingViewModel( loadTimefix(date) } - fun loadTimefix(date: String = _uiState.value.timefixDate) = launchLoad { - copy(timefixes = repository.getTimefixes(), worklogEntries = repository.getWorklogEntries(date)) - } + fun loadTimefix(date: String = _uiState.value.timefixDate) = launchSection( + start = { copy(timefixLoading = true, timefixError = null) }, + success = { result -> copy(timefixLoading = false, timefixError = null, timefixes = result.first, worklogEntries = result.second) }, + failure = { message -> copy(timefixLoading = false, timefixError = message) }, + block = { repository.getTimefixes() to repository.getWorklogEntries(date) }, + ) fun createTimefix(worklogId: String, date: String, time: String, action: String) = launchMutation { repository.createTimefix(worklogId, date, time, action) loadTimefix(date) @@ -91,25 +108,39 @@ class BookingViewModel( loadTimefix() } - fun loadWorkdays(year: Int) = launchLoad { - copy(workdaysYear = year, workdays = repository.getWorkdays(year)) - } + fun loadWorkdays(year: Int) = launchSection( + start = { copy(workdaysLoading = true, workdaysError = null, workdaysYear = year) }, + success = { workdays -> copy(workdaysLoading = false, workdaysError = null, workdays = workdays) }, + failure = { message -> copy(workdaysLoading = false, workdaysError = message) }, + block = { repository.getWorkdays(year) }, + ) fun changeCalendarMonth(delta: Int) { val current = YearMonth.of(_uiState.value.calendarYear, _uiState.value.calendarMonth).plusMonths(delta.toLong()) loadCalendar(current.year, current.monthValue) } - fun loadCalendar(year: Int, month: Int) = launchLoad { - copy(calendarYear = year, calendarMonth = month, calendar = repository.getCalendar(year, month)) - } + fun loadCalendar(year: Int, month: Int) = launchSection( + start = { copy(calendarLoading = true, calendarError = null, calendarYear = year, calendarMonth = month) }, + success = { calendar -> copy(calendarLoading = false, calendarError = null, calendar = calendar) }, + failure = { message -> copy(calendarLoading = false, calendarError = message) }, + block = { repository.getCalendar(year, month) }, + ) - private fun launchLoad(reducer: suspend BookingUiState.() -> BookingUiState) { + private fun launchSection( + start: BookingUiState.() -> BookingUiState, + success: BookingUiState.(T) -> BookingUiState, + failure: BookingUiState.(String) -> BookingUiState, + block: suspend () -> T, + ) { viewModelScope.launch { - _uiState.update { it.copy(loading = true, error = null) } - runCatching { _uiState.value.reducer() } - .onSuccess { next -> _uiState.value = next.copy(loading = false, error = null) } - .onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Daten konnten nicht geladen werden") } } + _uiState.update { it.start().copy(error = null) } + runCatching { block() } + .onSuccess { value -> _uiState.update { it.success(value) } } + .onFailure { e -> + val message = e.message ?: "Daten konnten nicht geladen werden" + _uiState.update { it.failure(message) } + } } } diff --git a/mobile-app/gradle/wrapper/gradle-wrapper.properties b/mobile-app/gradle/wrapper/gradle-wrapper.properties index 37f78a6..c61a118 100644 --- a/mobile-app/gradle/wrapper/gradle-wrapper.properties +++ b/mobile-app/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME