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.

This commit is contained in:
Torsten Schulz (local)
2026-05-14 22:51:48 +02:00
parent 5b6adab4cd
commit 333f90c792
11 changed files with 376 additions and 78 deletions

View File

@@ -33,15 +33,15 @@ class TimefixService {
}); });
// Hole auch die Timefixes für diese Einträge // 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) { if (entryIds.length === 0) {
return []; return [];
} }
const timefixesForEntries = await sequelize.query( 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 type: sequelize.QueryTypes.SELECT
} }
); );

View File

@@ -1,5 +1,5 @@
plugins { 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.compose") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false
} }

View File

@@ -5,6 +5,7 @@
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="Stechuhr" android:label="Stechuhr"
android:name=".TimeClockApplication"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.TimeClock"> android:theme="@style/Theme.TimeClock">

View File

@@ -2,6 +2,8 @@ package de.tsschulz.timeclock
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.widget.TextView
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@@ -18,23 +20,36 @@ class MainActivity : ComponentActivity() {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.i(TAG, "MainActivity.onCreate")
window.statusBarColor = Color.rgb(240, 255, 236) window.statusBarColor = Color.rgb(240, 255, 236)
window.navigationBarColor = Color.WHITE window.navigationBarColor = Color.WHITE
setContent { try {
val authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory(application)) setContent {
val timeViewModel: TimeViewModel = viewModel(factory = TimeViewModel.Factory(application)) val authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory(application))
val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application)) val timeViewModel: TimeViewModel = viewModel(factory = TimeViewModel.Factory(application))
val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application)) val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application))
val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application)) val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application))
TimeClockTheme { val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application))
TimeClockApp( TimeClockTheme {
authViewModel = authViewModel, TimeClockApp(
timeViewModel = timeViewModel, authViewModel = authViewModel,
bookingViewModel = bookingViewModel, timeViewModel = timeViewModel,
settingsViewModel = settingsViewModel, bookingViewModel = bookingViewModel,
adminViewModel = adminViewModel, 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 { window.decorView.post {
val controller = WindowCompat.getInsetsController(window, window.decorView) val controller = WindowCompat.getInsetsController(window, window.decorView)
@@ -42,4 +57,8 @@ class MainActivity : ComponentActivity() {
controller.isAppearanceLightNavigationBars = true controller.isAppearanceLightNavigationBars = true
} }
} }
private companion object {
const val TAG = "TimeClockStartup"
}
} }

View File

@@ -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"
}
}

View File

@@ -56,8 +56,11 @@ data class TimefixDto(
@Serializable @Serializable
data class WorklogEntryDto( data class WorklogEntryDto(
val id: String, val id: String,
@Serializable(with = FlexibleStringSerializer::class)
val time: String? = null, val time: String? = null,
@Serializable(with = FlexibleStringSerializer::class)
val action: String? = null, val action: String? = null,
@Serializable(with = FlexibleStringSerializer::class)
val tstamp: String? = null, val tstamp: String? = null,
) )

View File

@@ -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<String?> {
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)
}
}
}

View File

@@ -94,7 +94,6 @@ fun TimeClockApp(
val sections = if (user.isAdmin) adminSections else userSections val sections = if (user.isAdmin) adminSections else userSections
LaunchedEffect(user.id) { timeViewModel.start() } LaunchedEffect(user.id) { timeViewModel.start() }
LaunchedEffect(user.id) { bookingViewModel.loadPhase4() }
LaunchedEffect(user.id) { settingsViewModel.loadPhase5() } LaunchedEffect(user.id) { settingsViewModel.loadPhase5() }
LaunchedEffect(user.id, user.isAdmin) { adminViewModel.loadPhase6(user.isAdmin) } LaunchedEffect(user.id, user.isAdmin) { adminViewModel.loadPhase6(user.isAdmin) }
@@ -169,27 +168,42 @@ private fun DemoScreen(
onWeekOffset = onWeekOffset, onWeekOffset = onWeekOffset,
isTablet = isTablet, isTablet = isTablet,
) )
AppRoute.Timefix -> TimefixScreen( AppRoute.Timefix -> {
state = bookingState, LaunchedEffect(route) { bookingViewModel.loadTimefix() }
isTablet = isTablet, TimefixScreen(
onDate = { bookingViewModel.setTimefixDate(it) }, state = bookingState,
onCreate = { id, date, time, action -> bookingViewModel.createTimefix(id, date, time, action) }, isTablet = isTablet,
onDelete = { bookingViewModel.deleteTimefix(it) }, onDate = { bookingViewModel.setTimefixDate(it) },
) onCreate = { id, date, time, action -> bookingViewModel.createTimefix(id, date, time, action) },
AppRoute.Vacation -> VacationScreen( onDelete = { bookingViewModel.deleteTimefix(it) },
state = bookingState, )
isTablet = isTablet, }
onCreate = { type, start, end -> bookingViewModel.createVacation(type, start, end) }, AppRoute.Vacation -> {
onDelete = { bookingViewModel.deleteVacation(it) }, LaunchedEffect(route) { bookingViewModel.loadVacations() }
) VacationScreen(
AppRoute.Sick -> SickScreen( state = bookingState,
state = bookingState, isTablet = isTablet,
isTablet = isTablet, onCreate = { type, start, end -> bookingViewModel.createVacation(type, start, end) },
onCreate = { type, start, end -> bookingViewModel.createSick(type, start, end) }, onDelete = { bookingViewModel.deleteVacation(it) },
onDelete = { bookingViewModel.deleteSick(it) }, )
) }
AppRoute.Workdays -> WorkdaysScreen(state = bookingState, onYear = { bookingViewModel.loadWorkdays(it) }) AppRoute.Sick -> {
AppRoute.Calendar -> CalendarScreen(state = bookingState, onMonth = { bookingViewModel.changeCalendarMonth(it) }) 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 -> { AppRoute.Entries -> {
LaunchedEffect(Unit) { timeViewModel.loadEntries() } LaunchedEffect(Unit) { timeViewModel.loadEntries() }
EntriesScreen( EntriesScreen(

View File

@@ -1,7 +1,10 @@
package de.tsschulz.timeclock.ui.booking package de.tsschulz.timeclock.ui.booking
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope 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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
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
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 type by rememberSaveable { mutableStateOf("0") }
var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var end 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) { FormCard("Urlaub eintragen", isTablet) {
TcTextField("Umfang (0 Zeitraum, 1 Halber Tag)", type, { type = it }, placeholder = "0") 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("Urlaubsbeginn", start, { start = it }, placeholder = "YYYY-MM-DD")
TcTextField("Urlaubsende", end, { end = it }, placeholder = "YYYY-MM-DD") TcTextField("Urlaubsende", end, { end = it }, placeholder = "YYYY-MM-DD")
TcButton("Urlaub eintragen", variant = ButtonVariant.Primary, onClick = { 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 start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var end 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()) } 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) { FormCard("Erkrankung eintragen", isTablet) {
TcTextField("Erster Krankheitstag", start, { start = it }, placeholder = "YYYY-MM-DD") TcTextField("Erster Krankheitstag", start, { start = it }, placeholder = "YYYY-MM-DD")
TcTextField("Letzter Krankheitstag", end, { end = 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}" }) 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 = { TcButton("Erkrankung eintragen", variant = ButtonVariant.Primary, onClick = {
if (typeId.isNotBlank()) onCreate(typeId, start, end.ifBlank { start }) 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 @Composable
fun TimefixScreen( fun TimefixScreen(
state: BookingUiState, state: BookingUiState,
@@ -94,16 +118,38 @@ fun TimefixScreen(
onDelete: (String) -> Unit, onDelete: (String) -> Unit,
) { ) {
var worklogId by rememberSaveable { mutableStateOf("") } var worklogId by rememberSaveable { mutableStateOf("") }
var newDate by rememberSaveable { mutableStateOf(state.timefixDate) }
var time by rememberSaveable { mutableStateOf("08:00") } var time by rememberSaveable { mutableStateOf("08:00") }
var action by rememberSaveable { mutableStateOf("start work") } 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) { FormCard("Zeitkorrektur", isTablet) {
TcTextField("Datum", state.timefixDate, onDate, placeholder = "YYYY-MM-DD") DateField("Datum des Original-Eintrags", state.timefixDate, onDate)
TcTextField("Worklog-ID", worklogId, { worklogId = it }, placeholder = state.worklogEntries.firstOrNull()?.id?.toString() ?: "") WorklogDropdown(
TcTextField("Neue Uhrzeit", time, { time = it }, placeholder = "HH:MM") entries = state.worklogEntries,
TcTextField("Aktion", action, { action = it }, placeholder = "start work") 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 = { 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()) { if (state.worklogEntries.isEmpty()) {
Text("Für dieses Datum sind keine Worklog-Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 13.sp) Text("Für dieses Datum sind keine Worklog-Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 13.sp)
@@ -117,7 +163,7 @@ fun TimefixScreen(
@Composable @Composable
fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) { fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) {
var year by rememberSaveable { mutableStateOf(state.workdaysYear.toString()) } var year by rememberSaveable { mutableStateOf(state.workdaysYear.toString()) }
Phase4Frame(state) { Phase4Frame(loading = state.workdaysLoading, error = state.workdaysError) {
FormCard("Arbeitstage", isTablet = false) { FormCard("Arbeitstage", isTablet = false) {
TcTextField("Jahr", year, { year = it }, placeholder = "2026") TcTextField("Jahr", year, { year = it }, placeholder = "2026")
TcButton("Jahr laden", variant = ButtonVariant.Primary, onClick = { year.toIntOrNull()?.let(onYear) }) TcButton("Jahr laden", variant = ButtonVariant.Primary, onClick = { year.toIntOrNull()?.let(onYear) })
@@ -128,7 +174,7 @@ fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) {
@Composable @Composable
fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) { fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) {
Phase4Frame(state) { Phase4Frame(loading = state.calendarLoading, error = state.calendarError) {
TcCard { TcCard {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
TcButton("", variant = ButtonVariant.Secondary, onClick = { onMonth(-1) }) TcButton("", variant = ButtonVariant.Secondary, onClick = { onMonth(-1) })
@@ -147,9 +193,9 @@ fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) {
} }
@Composable @Composable
private fun Phase4Frame(state: BookingUiState, content: @Composable () -> Unit) { private fun Phase4Frame(loading: Boolean, error: String?, content: @Composable () -> Unit) {
if (state.loading) TcLoading("Lade Daten...") if (loading) TcLoading("Lade Daten...")
state.error?.let { TcError(it) } error?.let { TcError(it) }
content() 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<WorklogEntryDto>,
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 @Composable
private fun <T> ListCard(title: String, items: List<T>, row: @Composable (T) -> Unit) { private fun <T> ListCard(title: String, items: List<T>, row: @Composable (T) -> Unit) {
TcCard { TcCard {
@@ -191,14 +352,14 @@ private fun SickRow(item: SickEntryDto, onDelete: (String) -> Unit) = DataRow(
@Composable @Composable
private fun TimefixRow(item: TimefixDto, onDelete: (String) -> Unit) = DataRow( 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 ?: "—"}", details = "${item.originalDate.toDisplayDate()} ${item.originalTime ?: "—"}${item.newDate.toDisplayDate()} ${item.newTime ?: "—"}",
onDelete = { onDelete(item.id) }, onDelete = { onDelete(item.id) },
) )
@Composable @Composable
private fun WorklogRow(item: WorklogEntryDto) = DataRow( private fun WorklogRow(item: WorklogEntryDto) = DataRow(
title = "#${item.id} ${item.action ?: "—"}", title = item.action?.toActionLabel() ?: "Eintrag",
details = "${item.time ?: "—"} (${item.tstamp ?: "—"})", 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)) LocalDate.parse(this.substring(0, 10)).format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY))
}.getOrDefault(this) }.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)

View File

@@ -25,6 +25,16 @@ import java.time.YearMonth
data class BookingUiState( data class BookingUiState(
val loading: Boolean = false, val loading: Boolean = false,
val error: String? = null, 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<VacationDto> = emptyList(), val vacations: List<VacationDto> = emptyList(),
val sickEntries: List<SickEntryDto> = emptyList(), val sickEntries: List<SickEntryDto> = emptyList(),
val sickTypes: List<SickTypeDto> = emptyList(), val sickTypes: List<SickTypeDto> = emptyList(),
@@ -46,13 +56,14 @@ class BookingViewModel(
fun loadPhase4() { fun loadPhase4() {
loadVacations() 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 { fun createVacation(type: Int, start: String, end: String?) = launchMutation {
repository.createVacation(type, start, end) repository.createVacation(type, start, end)
loadVacations() loadVacations()
@@ -62,9 +73,12 @@ class BookingViewModel(
loadVacations() loadVacations()
} }
fun loadSick() = launchLoad { fun loadSick() = launchSection(
copy(sickEntries = repository.getSickEntries(), sickTypes = repository.getSickTypes()) 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 { fun createSick(typeId: String, start: String, end: String?) = launchMutation {
repository.createSick(typeId, start, end) repository.createSick(typeId, start, end)
loadSick() loadSick()
@@ -79,9 +93,12 @@ class BookingViewModel(
loadTimefix(date) loadTimefix(date)
} }
fun loadTimefix(date: String = _uiState.value.timefixDate) = launchLoad { fun loadTimefix(date: String = _uiState.value.timefixDate) = launchSection(
copy(timefixes = repository.getTimefixes(), worklogEntries = repository.getWorklogEntries(date)) 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 { fun createTimefix(worklogId: String, date: String, time: String, action: String) = launchMutation {
repository.createTimefix(worklogId, date, time, action) repository.createTimefix(worklogId, date, time, action)
loadTimefix(date) loadTimefix(date)
@@ -91,25 +108,39 @@ class BookingViewModel(
loadTimefix() loadTimefix()
} }
fun loadWorkdays(year: Int) = launchLoad { fun loadWorkdays(year: Int) = launchSection(
copy(workdaysYear = year, workdays = repository.getWorkdays(year)) 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) { fun changeCalendarMonth(delta: Int) {
val current = YearMonth.of(_uiState.value.calendarYear, _uiState.value.calendarMonth).plusMonths(delta.toLong()) val current = YearMonth.of(_uiState.value.calendarYear, _uiState.value.calendarMonth).plusMonths(delta.toLong())
loadCalendar(current.year, current.monthValue) loadCalendar(current.year, current.monthValue)
} }
fun loadCalendar(year: Int, month: Int) = launchLoad { fun loadCalendar(year: Int, month: Int) = launchSection(
copy(calendarYear = year, calendarMonth = month, calendar = repository.getCalendar(year, month)) 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 <T> launchSection(
start: BookingUiState.() -> BookingUiState,
success: BookingUiState.(T) -> BookingUiState,
failure: BookingUiState.(String) -> BookingUiState,
block: suspend () -> T,
) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null) } _uiState.update { it.start().copy(error = null) }
runCatching { _uiState.value.reducer() } runCatching { block() }
.onSuccess { next -> _uiState.value = next.copy(loading = false, error = null) } .onSuccess { value -> _uiState.update { it.success(value) } }
.onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Daten konnten nicht geladen werden") } } .onFailure { e ->
val message = e.message ?: "Daten konnten nicht geladen werden"
_uiState.update { it.failure(message) }
}
} }
} }

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME