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:
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,8 +20,10 @@ 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
|
||||
try {
|
||||
setContent {
|
||||
val authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory(application))
|
||||
val timeViewModel: TimeViewModel = viewModel(factory = TimeViewModel.Factory(application))
|
||||
@@ -36,10 +40,25 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
controller.isAppearanceLightStatusBars = true
|
||||
controller.isAppearanceLightNavigationBars = true
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "TimeClockStartup"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
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 -> VacationScreen(
|
||||
}
|
||||
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 -> SickScreen(
|
||||
}
|
||||
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 -> WorkdaysScreen(state = bookingState, onYear = { bookingViewModel.loadWorkdays(it) })
|
||||
AppRoute.Calendar -> CalendarScreen(state = bookingState, onMonth = { bookingViewModel.changeCalendarMonth(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(
|
||||
|
||||
@@ -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<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
|
||||
private fun <T> ListCard(title: String, items: List<T>, 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)
|
||||
|
||||
@@ -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<VacationDto> = emptyList(),
|
||||
val sickEntries: List<SickEntryDto> = emptyList(),
|
||||
val sickTypes: List<SickTypeDto> = 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 <T> 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user