Increment version code and name to 4 and 0.8.0-alpha3 respectively. Update SickEntryDto to change sickTypeId from Int to String. Enhance WeekOverviewDto and WeekDayDto with new fields for non-working details. Refactor TimeClockApp and AdminScreens for improved state management and UI updates. Introduce new dropdown components for selecting year and title type in SettingsScreens. Update mock data for consistency in status display.

This commit is contained in:
Torsten Schulz (local)
2026-05-15 08:08:25 +02:00
parent 8d4f8775d2
commit 95b611fd04
11 changed files with 672 additions and 145 deletions

View File

@@ -23,8 +23,8 @@ android {
applicationId = "de.tsschulz.timeclock" applicationId = "de.tsschulz.timeclock"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 3 versionCode = 4
versionName = "0.8.0-alpha2" versionName = "0.8.0-alpha3"
buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"") buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
} }

View File

@@ -24,7 +24,7 @@ data class SickEntryDto(
val id: String, val id: String,
val startDate: String? = null, val startDate: String? = null,
val endDate: String? = null, val endDate: String? = null,
val sickTypeId: Int? = null, val sickTypeId: String? = null,
val sickTypeName: String? = null, val sickTypeName: String? = null,
) )

View File

@@ -77,6 +77,9 @@ data class WeekOverviewDto(
val weekEnd: String? = null, val weekEnd: String? = null,
val weekTotal: String? = null, val weekTotal: String? = null,
val totalAll: String? = null, val totalAll: String? = null,
val nonWorkingTotal: String? = null,
val nonWorkingDays: Int = 0,
val nonWorkingDetails: List<NonWorkingDetailDto> = emptyList(),
val days: List<WeekDayDto> = emptyList(), val days: List<WeekDayDto> = emptyList(),
) )
@@ -88,6 +91,9 @@ data class WeekDayDto(
val workTime: String? = null, val workTime: String? = null,
val totalWorkTime: String? = null, val totalWorkTime: String? = null,
val netWorkTime: String? = null, val netWorkTime: String? = null,
val holiday: WeekDayHolidayDto? = null,
val vacation: WeekDayVacationDto? = null,
val sick: WeekDaySickDto? = null,
val status: String? = null, val status: String? = null,
val statusText: String? = null, val statusText: String? = null,
val workBlocks: List<WorkBlockDto> = emptyList(), val workBlocks: List<WorkBlockDto> = emptyList(),
@@ -99,3 +105,28 @@ data class WorkBlockDto(
val totalWorkTime: String? = null, val totalWorkTime: String? = null,
val netWorkTime: String? = null, val netWorkTime: String? = null,
) )
@Serializable
data class WeekDayHolidayDto(
val hours: Double = 0.0,
val description: String? = null,
)
@Serializable
data class WeekDayVacationDto(
val hours: Double = 0.0,
val halfDay: Boolean = false,
)
@Serializable
data class WeekDaySickDto(
val hours: Double = 0.0,
val type: String? = null,
)
@Serializable
data class NonWorkingDetailDto(
val date: String? = null,
val type: String? = null,
val hours: Double = 0.0,
)

View File

@@ -1,12 +1,15 @@
package de.tsschulz.timeclock.ui package de.tsschulz.timeclock.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -14,12 +17,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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
import de.tsschulz.timeclock.data.api.WeekDayDto import de.tsschulz.timeclock.data.api.WeekDayDto
import de.tsschulz.timeclock.data.api.WeekOverviewDto import de.tsschulz.timeclock.data.api.WeekOverviewDto
import de.tsschulz.timeclock.data.api.WorkBlockDto
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.tsschulz.timeclock.ui.admin.AdminViewModel import de.tsschulz.timeclock.ui.admin.AdminViewModel
import de.tsschulz.timeclock.ui.admin.HolidaysAdminScreen import de.tsschulz.timeclock.ui.admin.HolidaysAdminScreen
@@ -49,6 +54,7 @@ import de.tsschulz.timeclock.ui.settings.ProfileScreen
import de.tsschulz.timeclock.ui.settings.SettingsViewModel import de.tsschulz.timeclock.ui.settings.SettingsViewModel
import de.tsschulz.timeclock.ui.settings.TimewishScreen import de.tsschulz.timeclock.ui.settings.TimewishScreen
import de.tsschulz.timeclock.ui.theme.TcColors import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing import de.tsschulz.timeclock.ui.theme.TcSpacing
import de.tsschulz.timeclock.ui.time.EntriesScreen import de.tsschulz.timeclock.ui.time.EntriesScreen
import de.tsschulz.timeclock.ui.time.StatsScreen import de.tsschulz.timeclock.ui.time.StatsScreen
@@ -224,34 +230,46 @@ private fun DemoScreen(
) )
} }
AppRoute.Export -> TcCard { Text("Export ist in der Android-App deaktiviert.", color = TcColors.TextMuted) } AppRoute.Export -> TcCard { Text("Export ist in der Android-App deaktiviert.", color = TcColors.TextMuted) }
AppRoute.Profile -> ProfileScreen( AppRoute.Profile -> {
LaunchedEffect(route) { settingsViewModel.loadProfile() }
ProfileScreen(
state = settingsState, state = settingsState,
isTablet = isTablet, isTablet = isTablet,
onSave = { name, stateId, weekWorkdays, dailyHours, titleType -> onSave = { name, stateId, weekWorkdays, dailyHours, titleType ->
settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType) settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType)
}, },
) )
}
AppRoute.Password -> PasswordScreen( AppRoute.Password -> PasswordScreen(
state = settingsState, state = settingsState,
onChange = { old, new, confirm -> settingsViewModel.changePassword(old, new, confirm) }, onChange = { old, new, confirm -> settingsViewModel.changePassword(old, new, confirm) },
) )
AppRoute.Timewish -> TimewishScreen( AppRoute.Timewish -> {
LaunchedEffect(route) { settingsViewModel.loadTimewishes() }
TimewishScreen(
state = settingsState, state = settingsState,
isTablet = isTablet, isTablet = isTablet,
onCreate = { day, type, hours, start, end -> settingsViewModel.createTimewish(day, type, hours, start, end) }, onCreate = { day, type, hours, start, end -> settingsViewModel.createTimewish(day, type, hours, start, end) },
onDelete = { settingsViewModel.deleteTimewish(it) }, onDelete = { settingsViewModel.deleteTimewish(it) },
) )
AppRoute.Permissions -> PermissionsScreen( }
AppRoute.Permissions -> {
LaunchedEffect(route) { settingsViewModel.loadWatchers() }
PermissionsScreen(
state = settingsState, state = settingsState,
isTablet = isTablet, isTablet = isTablet,
onAdd = { settingsViewModel.addWatcher(it) }, onAdd = { settingsViewModel.addWatcher(it) },
onDelete = { settingsViewModel.deleteWatcher(it) }, onDelete = { settingsViewModel.deleteWatcher(it) },
) )
AppRoute.Invite -> InviteScreen( }
AppRoute.Invite -> {
LaunchedEffect(route) { settingsViewModel.loadInvites() }
InviteScreen(
state = settingsState, state = settingsState,
isTablet = isTablet, isTablet = isTablet,
onSend = { settingsViewModel.sendInvite(it) }, onSend = { settingsViewModel.sendInvite(it) },
) )
}
AppRoute.Holidays -> HolidaysAdminScreen( AppRoute.Holidays -> HolidaysAdminScreen(
state = adminState, state = adminState,
isTablet = isTablet, isTablet = isTablet,
@@ -298,55 +316,203 @@ private fun WeekOverviewScreen(
@Composable @Composable
private fun WeekTablet(week: WeekOverviewDto) { private fun WeekTablet(week: WeekOverviewDto) {
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Xl), modifier = Modifier.fillMaxWidth()) { WeekTable(week, compact = false)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg)) {
week.days.forEach { day -> WeekDayCard(day) }
}
TcCard(modifier = Modifier.weight(1f)) {
SectionTitle("Wochensumme")
DetailRow("Arbeitszeit", week.weekTotal ?: "0:00")
DetailRow("Gesamt", week.totalAll ?: week.weekTotal ?: "0:00")
val today = week.days.firstOrNull { it.isToday } ?: week.days.firstOrNull()
today?.let {
SectionTitle("Aktueller Tag")
DetailRow("Tag", it.name ?: "")
DetailRow("Datum", it.date.toDisplayDate())
DetailRow("Status", it.statusText ?: "")
DetailRow("Arbeitszeit", it.netWorkTime ?: it.totalWorkTime ?: "")
}
}
}
} }
@Composable @Composable
private fun WeekPhone(week: WeekOverviewDto) { private fun WeekPhone(week: WeekOverviewDto) {
week.days.forEach { day -> WeekDayCard(day) } WeekTable(week, compact = true)
}
@Composable
private fun WeekTable(week: WeekOverviewDto, compact: Boolean) {
TcCard { TcCard {
SectionTitle("Wochensumme") if (!compact) {
DetailRow("Arbeitszeit", week.weekTotal ?: "0:00") WeekHeaderRow()
DetailRow("Gesamt", week.totalAll ?: week.weekTotal ?: "0:00") }
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
week.days.forEach { day -> WeekDayRow(day, compact) }
WeekSummaryRows(week, compact)
}
} }
} }
@Composable @Composable
private fun WeekDayCard(day: WeekDayDto) { private fun WeekHeaderRow() {
TcCard { Row(
SectionTitle("${day.name ?: "Tag"} ${day.date.toDisplayDate()}") modifier = Modifier
val blocks = day.workBlocks .fillMaxWidth()
if (blocks.isNotEmpty()) { .background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
blocks.forEachIndexed { index, block -> .padding(horizontal = TcSpacing.Md, vertical = TcSpacing.Sm),
val label = if (blocks.size > 1) "Arbeitszeit ${index + 1}" else "Arbeitszeit" horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
DetailRow(label, block.workTime ?: day.workTime ?: "") verticalAlignment = Alignment.CenterVertically,
DetailRow("Netto", block.netWorkTime ?: block.totalWorkTime ?: "") ) {
WeekCell("Tag", weight = 1.0f, header = true)
WeekCell("Datum", weight = 1.0f, header = true)
WeekCell("Zeiten", weight = 2.4f, header = true)
WeekCell("Arbeitszeit", weight = 1.4f, header = true)
WeekCell("Status", weight = 1.2f, header = true)
}
}
@Composable
private fun WeekDayRow(day: WeekDayDto, compact: Boolean) {
val bg = if (day.isToday) TcColors.ActiveMenu else TcColors.Background
if (compact) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(bg, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(day.name ?: "Tag", color = TcColors.Text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(day.date.toDisplayDate(), color = TcColors.TextMuted, fontSize = 14.sp)
}
WeekDetailBlock("Zeiten", day.timeLines())
WeekDetailBlock("Arbeitszeit", day.workTotalLines())
day.statusText?.let { WeekStatusBadge(it) }
} }
} else { } else {
DetailRow("Arbeitszeit", day.workTime ?: "") Row(
DetailRow("Netto", day.netWorkTime ?: day.totalWorkTime ?: "") modifier = Modifier
.fillMaxWidth()
.background(bg, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
verticalAlignment = Alignment.Top,
) {
WeekCell(day.name ?: "Tag", weight = 1.0f, strong = true)
WeekCell(day.date.toDisplayDate(), weight = 1.0f)
WeekMultilineCell(day.timeLines(), weight = 2.4f)
WeekMultilineCell(day.workTotalLines(), weight = 1.4f)
WeekCell(day.statusText ?: "", weight = 1.2f)
} }
DetailRow("Status", day.statusText ?: "")
} }
} }
@Composable
private fun WeekSummaryRows(week: WeekOverviewDto, compact: Boolean) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Card, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
WeekSummaryLine("Wochensumme", week.weekTotal ?: "0:00", compact)
if (week.nonWorkingDays > 0) {
WeekSummaryLine(
"Arbeitsfreie Tage (${week.nonWorkingDays})",
week.nonWorkingTotal ?: "0:00",
compact,
week.nonWorkingDetails.joinToString { "${it.date.toDisplayDate()}: ${it.type ?: "frei"} (${it.hours.toHourLabel()}h)" },
)
}
WeekSummaryLine("Gesamtsumme", week.totalAll ?: week.weekTotal ?: "0:00", compact)
}
}
@Composable
private fun WeekSummaryLine(label: String, value: String, compact: Boolean, detail: String? = null) {
if (compact) {
Column {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Text(value, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
}
detail?.let { Text(it, color = TcColors.TextMuted, fontSize = 12.sp) }
}
} else {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md)) {
Text(label, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(5.4f))
Text(value, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.4f))
Text(detail.orEmpty(), color = TcColors.TextMuted, fontSize = 12.sp, modifier = Modifier.weight(1.2f))
}
}
}
@Composable
private fun WeekDetailBlock(label: String, lines: List<String>) {
Text(label, color = TcColors.TextMuted, fontSize = 13.sp, fontWeight = FontWeight.Medium)
lines.ifEmpty { listOf("") }.forEach { line ->
Text(line, color = TcColors.Text, fontSize = 14.sp)
}
}
@Composable
private fun WeekStatusBadge(text: String) {
Text(
text = text,
color = TcColors.Text,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.background(TcColors.Button, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.ButtonBorder, RoundedCornerShape(TcRadius.Medium))
.padding(horizontal = TcSpacing.Sm, vertical = 4.dp),
)
}
@Composable
private fun RowScope.WeekCell(text: String, weight: Float, header: Boolean = false, strong: Boolean = false) {
Text(
text = text,
color = if (header) TcColors.TextMuted else TcColors.Text,
fontSize = if (header) 13.sp else 14.sp,
fontWeight = if (header || strong) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier.weight(weight),
)
}
@Composable
private fun RowScope.WeekMultilineCell(lines: List<String>, weight: Float) {
Column(modifier = Modifier.weight(weight), verticalArrangement = Arrangement.spacedBy(4.dp)) {
lines.ifEmpty { listOf("") }.forEach {
Text(it, color = TcColors.Text, fontSize = 14.sp)
}
}
}
private fun WeekDayDto.timeLines(): List<String> {
val blocks = workBlocks
return if (blocks.isNotEmpty()) {
blocks.flatMapIndexed { index, block ->
listOf("${if (blocks.size > 1) "Arbeitszeit ${index + 1}" else "Arbeitszeit"}: ${block.workTime ?: workTime ?: ""}")
}
} else if (!workTime.isNullOrBlank()) {
listOf("Arbeitszeit: $workTime")
} else {
emptyList()
}
}
private fun WeekDayDto.workTotalLines(): List<String> {
val lines = mutableListOf<String>()
val blocks = workBlocks
if (blocks.isNotEmpty()) {
blocks.forEach { block -> lines.addAll(block.totalLines()) }
if (blocks.size > 1 && !netWorkTime.isNullOrBlank()) lines.add("Gesamt: $netWorkTime")
} else {
totalWorkTime?.let { lines.add(it) }
netWorkTime?.let { lines.add("= $it") }
}
holiday?.let { lines.add("+ ${it.hours.toHourLabel()}:00 ${it.description ?: "Feiertag"}") }
vacation?.let { lines.add("+ ${it.hours.toHourLabel()}:00 Urlaub") }
sick?.let { lines.add("${it.hours.toHourLabel()}:00 (${it.type.toSickLabel()})") }
return lines
}
private fun WorkBlockDto.totalLines(): List<String> =
buildList {
totalWorkTime?.let { add(it) }
netWorkTime?.let { add("= $it") }
}
@Composable @Composable
private fun CalendarDemo(isTablet: Boolean) { private fun CalendarDemo(isTablet: Boolean) {
if (isTablet) { if (isTablet) {
@@ -408,6 +574,19 @@ private fun String?.toDisplayDate(): String {
}.getOrDefault(this) }.getOrDefault(this)
} }
private fun Double.toHourLabel(): String =
if (this % 1.0 == 0.0) toInt().toString() else toString()
private fun String?.toSickLabel(): String =
when (this) {
"self" -> "Krank"
"child" -> "Kind krank"
"parents" -> "Eltern krank"
"partner" -> "Partner krank"
null -> "Krank"
else -> this
}
@Composable @Composable
private fun SectionTitle(text: String) { private fun SectionTitle(text: String) {
Text( Text(

View File

@@ -1,5 +1,6 @@
package de.tsschulz.timeclock.ui.admin package de.tsschulz.timeclock.ui.admin
import android.app.DatePickerDialog
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.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -19,6 +20,7 @@ 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
@@ -34,6 +36,8 @@ import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing import de.tsschulz.timeclock.ui.theme.TcSpacing
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable @Composable
fun HolidaysAdminScreen( fun HolidaysAdminScreen(
@@ -42,7 +46,7 @@ fun HolidaysAdminScreen(
onCreate: (String, Double, String, List<String>) -> Unit, onCreate: (String, Double, String, List<String>) -> Unit,
onDelete: (String) -> Unit, onDelete: (String) -> Unit,
) { ) {
var date by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } var date by rememberSaveable { mutableStateOf(LocalDate.now().toGermanDate()) }
var hours by rememberSaveable { mutableStateOf("8") } var hours by rememberSaveable { mutableStateOf("8") }
var description by rememberSaveable { mutableStateOf("") } var description by rememberSaveable { mutableStateOf("") }
var stateIds by rememberSaveable { mutableStateOf("") } var stateIds by rememberSaveable { mutableStateOf("") }
@@ -61,8 +65,11 @@ fun HolidaysAdminScreen(
stateIds = stateIds, stateIds = stateIds,
onStateIds = { stateIds = it }, onStateIds = { stateIds = it },
onCreate = { onCreate = {
onCreate(date, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds)) normalizeHolidayDate(date)?.let { isoDate ->
onCreate(isoDate, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds))
date = LocalDate.parse(isoDate).toGermanDate()
description = "" description = ""
}
}, },
modifier = Modifier.weight(0.9f), modifier = Modifier.weight(0.9f),
) )
@@ -83,8 +90,11 @@ fun HolidaysAdminScreen(
stateIds = stateIds, stateIds = stateIds,
onStateIds = { stateIds = it }, onStateIds = { stateIds = it },
onCreate = { onCreate = {
onCreate(date, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds)) normalizeHolidayDate(date)?.let { isoDate ->
onCreate(isoDate, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds))
date = LocalDate.parse(isoDate).toGermanDate()
description = "" description = ""
}
}, },
) )
HolidayList("Zukünftige Feiertage", state.futureHolidays, onDelete) HolidayList("Zukünftige Feiertage", state.futureHolidays, onDelete)
@@ -129,15 +139,9 @@ private fun HolidayForm(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
FormCard("Feiertag hinzufügen", modifier) { FormCard("Feiertag hinzufügen", modifier) {
TcTextField("Datum", date, onDate, placeholder = "YYYY-MM-DD") HolidayDateInput(date, onDate)
TcTextField("Freie Stunden", hours, onHours, placeholder = "8") TcTextField("Freie Stunden", hours, onHours, placeholder = "8")
TcTextField("Beschreibung", description, onDescription, placeholder = "z.B. Tag der Deutschen Einheit") TcTextField("Beschreibung", description, onDescription, placeholder = "z.B. Tag der Deutschen Einheit")
TcTextField(
label = "Bundesland-IDs",
value = stateIds,
onValueChange = onStateIds,
placeholder = "Leer lassen für Bundesfeiertag",
)
if (state.holidayStates.isNotEmpty()) { if (state.holidayStates.isNotEmpty()) {
Text("Bundesländer", color = TcColors.TextMuted, fontSize = 13.sp) Text("Bundesländer", color = TcColors.TextMuted, fontSize = 13.sp)
FlowRow( FlowRow(
@@ -160,6 +164,36 @@ private fun HolidayForm(
} }
} }
@Composable
private fun HolidayDateInput(value: String, onValueChange: (String) -> Unit) {
val context = LocalContext.current
val selectedDate = parseHolidayDate(value) ?: LocalDate.now()
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalAlignment = Alignment.Bottom) {
TcTextField(
label = "Datum",
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
placeholder = "TT.MM.JJJJ oder YYYY-MM-DD",
)
TcButton(
text = "Auswählen",
variant = ButtonVariant.Default,
onClick = {
DatePickerDialog(
context,
{ _, year, month, day ->
onValueChange(LocalDate.of(year, month + 1, day).toGermanDate())
},
selectedDate.year,
selectedDate.monthValue - 1,
selectedDate.dayOfMonth,
).show()
},
)
}
}
@Composable @Composable
private fun HolidayList(title: String, holidays: List<HolidayDto>, onDelete: (String) -> Unit) { private fun HolidayList(title: String, holidays: List<HolidayDto>, onDelete: (String) -> Unit) {
ListCard(title, holidays) { holiday -> HolidayRow(holiday, onDelete) } ListCard(title, holidays) { holiday -> HolidayRow(holiday, onDelete) }
@@ -269,6 +303,20 @@ private fun toggleStateId(raw: String, id: String): String {
return ids.joinToString(",") return ids.joinToString(",")
} }
private val germanDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY)
private fun LocalDate.toGermanDate(): String = format(germanDateFormatter)
private fun parseHolidayDate(raw: String): LocalDate? {
val value = raw.trim()
if (value.isBlank()) return null
return runCatching { LocalDate.parse(value) }
.recoverCatching { LocalDate.parse(value, germanDateFormatter) }
.getOrNull()
}
private fun normalizeHolidayDate(raw: String): String? = parseHolidayDate(raw)?.toString()
private fun String?.toDisplayDate(): String { private fun String?.toDisplayDate(): String {
if (this.isNullOrBlank()) return "-" if (this.isNullOrBlank()) return "-"
return runCatching { return runCatching {

View File

@@ -50,6 +50,7 @@ import java.time.LocalDate
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlin.math.roundToInt
@Composable @Composable
fun VacationScreen(state: BookingUiState, isTablet: Boolean, onCreate: (Int, String, String?) -> Unit, onDelete: (String) -> Unit) { fun VacationScreen(state: BookingUiState, isTablet: Boolean, onCreate: (Int, String, String?) -> Unit, onDelete: (String) -> Unit) {
@@ -58,13 +59,21 @@ fun VacationScreen(state: BookingUiState, isTablet: Boolean, onCreate: (Int, Str
var end by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } var end by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
Phase4Frame(loading = state.vacationLoading, error = state.vacationError) { 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") FieldLabel("Umfang")
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) { Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
TcButton("Zeitraum", variant = if (type == "0") ButtonVariant.Primary else ButtonVariant.Default, onClick = { type = "0" }) 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" }) TcButton("Halber Tag", variant = if (type == "1") ButtonVariant.Primary else ButtonVariant.Default, onClick = {
type = "1"
end = start
})
}
DateField("Urlaubsbeginn", start, {
start = it
if (type == "1") end = it
})
if (type != "1") {
DateField("Urlaubsende", end, { end = it })
} }
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 = { TcButton("Urlaub eintragen", variant = ButtonVariant.Primary, onClick = {
val typeValue = type.toIntOrNull() ?: 0 val typeValue = type.toIntOrNull() ?: 0
onCreate(typeValue, start, if (typeValue == 1) start else end) onCreate(typeValue, start, if (typeValue == 1) start else end)
@@ -86,9 +95,9 @@ fun SickScreen(state: BookingUiState, isTablet: Boolean, onCreate: (String, Stri
} }
Phase4Frame(loading = state.sickLoading, error = state.sickError) { 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") DateField("Erster Krankheitstag", start, { start = it })
TcTextField("Letzter Krankheitstag", end, { end = it }, placeholder = "YYYY-MM-DD") DateField("Letzter Krankheitstag", end, { end = it })
TcTextField("Krankheitstyp-ID", typeId, { typeId = it }, placeholder = state.sickTypes.joinToString { "${it.id}=${it.name}" }) FieldLabel("Krankheitstyp")
FlowRow(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
state.sickTypes.forEach { type -> state.sickTypes.forEach { type ->
TcButton( TcButton(
@@ -162,11 +171,16 @@ 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 selectedYear by rememberSaveable { mutableStateOf(state.workdaysYear) }
LaunchedEffect(state.workdaysYear) {
selectedYear = state.workdaysYear
}
Phase4Frame(loading = state.workdaysLoading, error = state.workdaysError) { Phase4Frame(loading = state.workdaysLoading, error = state.workdaysError) {
FormCard("Arbeitstage", isTablet = false) { FormCard("Arbeitstage", isTablet = false) {
TcTextField("Jahr", year, { year = it }, placeholder = "2026") YearDropdown(selectedYear) { year ->
TcButton("Jahr laden", variant = ButtonVariant.Primary, onClick = { year.toIntOrNull()?.let(onYear) }) selectedYear = year
onYear(year)
}
} }
WorkdaysCard(state.workdays) WorkdaysCard(state.workdays)
} }
@@ -311,6 +325,32 @@ private fun ActionDropdown(value: String, onValueChange: (String) -> Unit) {
} }
} }
@Composable
private fun YearDropdown(value: Int, onValueChange: (Int) -> Unit) {
var expanded by rememberSaveable { mutableStateOf(false) }
val years = 2000..2100
FieldLabel("Jahr")
Box(modifier = Modifier.fillMaxWidth()) {
TcButton(
text = value.toString(),
variant = ButtonVariant.Default,
modifier = Modifier.fillMaxWidth(),
onClick = { expanded = true },
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
years.forEach { year ->
DropdownMenuItem(
text = { Text(year.toString(), color = TcColors.Text) },
onClick = {
onValueChange(year)
expanded = false
},
)
}
}
}
}
@Composable @Composable
private fun FieldLabel(label: String) { private fun FieldLabel(label: String) {
Text( Text(
@@ -432,11 +472,14 @@ private fun CalendarCell(day: CalendarDayDto) {
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium)) .border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Sm), .padding(TcSpacing.Sm),
) { ) {
Text(day.day.toString(), color = TcColors.Text, fontWeight = FontWeight.SemiBold) Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.Bottom) {
Text(day.toWeekdayLabel(), color = TcColors.TextMuted, fontSize = 11.sp)
Text(day.day.toString(), color = TcColors.Text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
day.holiday?.let { Text(it, color = TcColors.Danger, fontSize = 11.sp) } day.holiday?.let { Text(it, color = TcColors.Danger, fontSize = 11.sp) }
if (day.sick) Text("Krank", color = TcColors.Secondary, fontSize = 11.sp) if (day.sick) Text("Krank", color = TcColors.Secondary, fontSize = 11.sp)
day.vacation?.let { Text(if (it == "half") "Urlaub 1/2" else "Urlaub", color = TcColors.Primary, fontSize = 11.sp) } day.vacation?.let { Text(if (it == "half") "Urlaub 1/2" else "Urlaub", color = TcColors.Primary, fontSize = 11.sp) }
day.workedHours?.let { Text("${it}h", color = TcColors.TextMuted, fontSize = 11.sp) } day.workedHours?.let { Text(it.toHourMinuteLabel(), color = TcColors.TextMuted, fontSize = 11.sp) }
} }
} }
@@ -472,4 +515,16 @@ private fun String.toActionLabel(): String =
private fun WorklogEntryDto.toWorklogLabel(): String = private fun WorklogEntryDto.toWorklogLabel(): String =
"${time ?: "—"} - ${(action ?: "—").toActionLabel()}" "${time ?: "—"} - ${(action ?: "—").toActionLabel()}"
private fun CalendarDayDto.toWeekdayLabel(): String =
runCatching {
LocalDate.parse(date).format(DateTimeFormatter.ofPattern("EEE", Locale.GERMANY))
}.getOrDefault("")
private fun Double.toHourMinuteLabel(): String {
val totalMinutes = (this * 60).roundToInt().coerceAtLeast(0)
val hours = totalMinutes / 60
val minutes = totalMinutes % 60
return "%d:%02d".format(hours, minutes)
}
private val Color333 = androidx.compose.ui.graphics.Color(0xFF333333) private val Color333 = androidx.compose.ui.graphics.Color(0xFF333333)

View File

@@ -40,13 +40,14 @@ val adminSections = userSections + MenuSection(
) )
val mockStatusRows = listOf( val mockStatusRows = listOf(
StatusRow(label = "Heute", isHeading = true), StatusRow(label = "Derzeit gearbeitet", value = "04:37:18 h"),
StatusRow(label = "Status", value = "Arbeit läuft"), StatusRow(label = "Offen", value = "03:23 h"),
StatusRow(label = "Beginn", value = "08:12"), StatusRow(label = "Normales Arbeitsende", value = "16:42 Uhr"),
StatusRow(label = "Arbeitszeit", value = "04:37:18"), StatusRow(label = "Überstunden (Woche)", value = "00:18 h"),
StatusRow(label = "Offen", value = "03:23"), StatusRow(label = "Wochenarbeitszeit", value = "20:37 h"),
StatusRow(label = "Reguläres Ende", value = "16:42"), StatusRow(label = "Bereinigtes Arbeitsende (heute)", isHeading = true),
StatusRow(label = "- Generell", value = "16:42 Uhr"),
) )
val mockPrimaryAction = StatusAction("Pause starten", ButtonVariant.Success) val mockPrimaryAction = StatusAction("Arbeit beenden", ButtonVariant.Danger)
val mockSecondaryAction = StatusAction("Gehen", ButtonVariant.Secondary) val mockSecondaryAction = StatusAction("Pause beginnen", ButtonVariant.Secondary)

View File

@@ -1,14 +1,18 @@
package de.tsschulz.timeclock.ui.settings package de.tsschulz.timeclock.ui.settings
import android.app.DatePickerDialog
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.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -18,11 +22,13 @@ 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
import de.tsschulz.timeclock.data.api.InvitationDto import de.tsschulz.timeclock.data.api.InvitationDto
import de.tsschulz.timeclock.data.api.ProfileDto import de.tsschulz.timeclock.data.api.ProfileDto
import de.tsschulz.timeclock.data.api.StateDto
import de.tsschulz.timeclock.data.api.TimewishDto import de.tsschulz.timeclock.data.api.TimewishDto
import de.tsschulz.timeclock.data.api.WatcherDto import de.tsschulz.timeclock.data.api.WatcherDto
import de.tsschulz.timeclock.ui.components.TcButton import de.tsschulz.timeclock.ui.components.TcButton
@@ -35,6 +41,8 @@ import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing import de.tsschulz.timeclock.ui.theme.TcSpacing
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable @Composable
fun ProfileScreen( fun ProfileScreen(
@@ -47,7 +55,7 @@ fun ProfileScreen(
var stateId by rememberSaveable { mutableStateOf("") } var stateId by rememberSaveable { mutableStateOf("") }
var weekWorkdays by rememberSaveable { mutableStateOf("5") } var weekWorkdays by rememberSaveable { mutableStateOf("5") }
var dailyHours by rememberSaveable { mutableStateOf("8.0") } var dailyHours by rememberSaveable { mutableStateOf("8.0") }
var preferredTitleType by rememberSaveable { mutableStateOf("0") } var preferredTitleType by rememberSaveable { mutableStateOf(2) }
LaunchedEffect(profile) { LaunchedEffect(profile) {
profile?.let { profile?.let {
@@ -55,7 +63,7 @@ fun ProfileScreen(
stateId = it.stateId.orEmpty() stateId = it.stateId.orEmpty()
weekWorkdays = (it.weekWorkdays ?: 5).toString() weekWorkdays = (it.weekWorkdays ?: 5).toString()
dailyHours = (it.dailyHours ?: 8.0).toString() dailyHours = (it.dailyHours ?: 8.0).toString()
preferredTitleType = (it.preferredTitleType ?: 0).toString() preferredTitleType = it.preferredTitleType ?: 2
} }
} }
@@ -63,17 +71,22 @@ fun ProfileScreen(
ResponsiveSettings(isTablet) { ResponsiveSettings(isTablet) {
FormCard("Persönliche Daten") { FormCard("Persönliche Daten") {
TcTextField("Name", fullName, { fullName = it }) TcTextField("Name", fullName, { fullName = it })
TcTextField("Bundesland-ID", stateId, { stateId = it }, placeholder = state.states.joinToString { "${it.id}=${it.name}" }) StateDropdown(
states = state.states,
selectedId = stateId,
selectedName = profile?.stateName,
onSelect = { stateId = it.orEmpty() },
)
TcTextField("Arbeitstage pro Woche", weekWorkdays, { weekWorkdays = it }, placeholder = "5") TcTextField("Arbeitstage pro Woche", weekWorkdays, { weekWorkdays = it }, placeholder = "5")
TcTextField("Stunden pro Tag", dailyHours, { dailyHours = it }, placeholder = "8.0") TcTextField("Stunden pro Tag", dailyHours, { dailyHours = it }, placeholder = "8.0")
TcTextField("Titeltyp", preferredTitleType, { preferredTitleType = it }, placeholder = "0") TitleTypeDropdown(preferredTitleType, { preferredTitleType = it })
TcButton("Speichern", variant = ButtonVariant.Primary, onClick = { TcButton("Speichern", variant = ButtonVariant.Primary, onClick = {
onSave( onSave(
fullName, fullName,
stateId.ifBlank { null }, stateId.ifBlank { null },
weekWorkdays.toIntOrNull() ?: 5, weekWorkdays.toIntOrNull() ?: 5,
dailyHours.toDoubleOrNull() ?: 8.0, dailyHours.toDoubleOrNull() ?: 8.0,
preferredTitleType.toIntOrNull() ?: 0, preferredTitleType,
) )
}) })
} }
@@ -113,8 +126,8 @@ fun TimewishScreen(
onCreate: (Int, Int, Double?, String, String?) -> Unit, onCreate: (Int, Int, Double?, String, String?) -> Unit,
onDelete: (String) -> Unit, onDelete: (String) -> Unit,
) { ) {
var day by rememberSaveable { mutableStateOf("1") } var day by rememberSaveable { mutableStateOf(1) }
var wishtype by rememberSaveable { mutableStateOf("1") } var wishtype by rememberSaveable { mutableStateOf(2) }
var hours by rememberSaveable { mutableStateOf("8.0") } var hours by rememberSaveable { mutableStateOf("8.0") }
var startDate by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } var startDate by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var endDate by rememberSaveable { mutableStateOf("") } var endDate by rememberSaveable { mutableStateOf("") }
@@ -122,16 +135,21 @@ fun TimewishScreen(
SettingsFrame(state) { SettingsFrame(state) {
ResponsiveSettings(isTablet) { ResponsiveSettings(isTablet) {
FormCard("Zeitwunsch eintragen") { FormCard("Zeitwunsch eintragen") {
TcTextField("Wochentag", day, { day = it }, placeholder = "1") WeekdayDropdown(day, { day = it })
TcTextField("Wunschtyp", wishtype, { wishtype = it }, placeholder = "1") WishtypeDropdown(wishtype) {
wishtype = it
hours = if (it == 2) "8.0" else "0"
}
if (wishtype == 2) {
TcTextField("Stunden", hours, { hours = it }, placeholder = "8.0") TcTextField("Stunden", hours, { hours = it }, placeholder = "8.0")
TcTextField("Gueltig ab", startDate, { startDate = it }, placeholder = "YYYY-MM-DD") }
TcTextField("Gueltig bis", endDate, { endDate = it }, placeholder = "YYYY-MM-DD") SettingsDateField("Gültig ab", startDate, { startDate = it })
SettingsDateField("Gültig bis (optional)", endDate, { endDate = it }, allowBlank = true)
TcButton("Zeitwunsch speichern", variant = ButtonVariant.Primary, onClick = { TcButton("Zeitwunsch speichern", variant = ButtonVariant.Primary, onClick = {
onCreate( onCreate(
day.toIntOrNull() ?: 1, day,
wishtype.toIntOrNull() ?: 1, wishtype,
hours.toDoubleOrNull(), if (wishtype == 2) hours.toDoubleOrNull() else null,
startDate, startDate,
endDate.ifBlank { null }, endDate.ifBlank { null },
) )
@@ -191,6 +209,189 @@ fun PermissionsScreen(
} }
} }
@Composable
private fun TitleTypeDropdown(value: Int, onValueChange: (Int) -> Unit) {
var expanded by rememberSaveable { mutableStateOf(false) }
FieldLabel("Anzeige in Seitentitel")
Box(modifier = Modifier.fillMaxWidth()) {
TcButton(
text = titleTypeLabel(value),
variant = ButtonVariant.Default,
modifier = Modifier.fillMaxWidth(),
onClick = { expanded = true },
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
(0..7).forEach { type ->
DropdownMenuItem(
text = { Text(titleTypeLabel(type), color = TcColors.Text) },
onClick = {
onValueChange(type)
expanded = false
},
)
}
}
}
}
@Composable
private fun WeekdayDropdown(value: Int, onValueChange: (Int) -> Unit) {
var expanded by rememberSaveable { mutableStateOf(false) }
FieldLabel("Wochentag")
Box(modifier = Modifier.fillMaxWidth()) {
TcButton(
text = weekdayLabel(value),
variant = ButtonVariant.Default,
modifier = Modifier.fillMaxWidth(),
onClick = { expanded = true },
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
(1..7).forEach { day ->
DropdownMenuItem(
text = { Text(weekdayLabel(day), color = TcColors.Text) },
onClick = {
onValueChange(day)
expanded = false
},
)
}
}
}
}
@Composable
private fun WishtypeDropdown(value: Int, onValueChange: (Int) -> Unit) {
var expanded by rememberSaveable { mutableStateOf(false) }
FieldLabel("Typ")
Box(modifier = Modifier.fillMaxWidth()) {
TcButton(
text = wishtypeLabel(value),
variant = ButtonVariant.Default,
modifier = Modifier.fillMaxWidth(),
onClick = { expanded = true },
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
(0..2).forEach { type ->
DropdownMenuItem(
text = { Text(wishtypeLabel(type), color = TcColors.Text) },
onClick = {
onValueChange(type)
expanded = false
},
)
}
}
}
}
@Composable
private fun SettingsDateField(
label: String,
value: String,
onValueChange: (String) -> Unit,
allowBlank: Boolean = false,
) {
val context = LocalContext.current
val parsed = runCatching { LocalDate.parse(value) }.getOrDefault(LocalDate.now())
FieldLabel(label)
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalAlignment = Alignment.CenterVertically) {
TcButton(
text = if (allowBlank && value.isBlank()) "Kein Enddatum" else parsed.toGermanDate(),
variant = ButtonVariant.Default,
modifier = Modifier.weight(1f),
onClick = {
DatePickerDialog(
context,
{ _, year, month, day ->
onValueChange(LocalDate.of(year, month + 1, day).toString())
},
parsed.year,
parsed.monthValue - 1,
parsed.dayOfMonth,
).show()
},
)
if (allowBlank) {
TcButton("Leeren", variant = ButtonVariant.Default, onClick = { onValueChange("") })
}
}
}
@Composable
private fun StateDropdown(
states: List<StateDto>,
selectedId: String,
selectedName: String?,
onSelect: (String?) -> Unit,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val selected = states.firstOrNull { it.id == selectedId }
FieldLabel("Bundesland")
Box(modifier = Modifier.fillMaxWidth()) {
TcButton(
text = selected?.name ?: selectedName?.takeIf { selectedId.isNotBlank() } ?: "Kein Bundesland ausgewählt",
variant = ButtonVariant.Default,
modifier = Modifier.fillMaxWidth(),
onClick = { expanded = true },
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("Kein Bundesland ausgewählt", color = TcColors.Text) },
onClick = {
onSelect(null)
expanded = false
},
)
states.forEach { state ->
DropdownMenuItem(
text = { Text(state.name, color = TcColors.Text) },
onClick = {
onSelect(state.id)
expanded = false
},
)
}
}
}
}
private fun titleTypeLabel(value: Int): String =
when (value) {
0 -> "Restzeit für Tag ohne Korrektur"
1 -> "Restzeit für Tag, korrigiert nach Wochenarbeitszeit"
2 -> "Restzeit für Tag, korrigiert nach Gesamtarbeitszeit"
3 -> "Heute gearbeitete Zeit"
4 -> "Uhrzeit für Arbeitsende ohne Korrektur"
5 -> "Uhrzeit für Arbeitsende, korrigiert nach Wochenarbeitszeit"
6 -> "Uhrzeit für Arbeitsende, korrigiert nach Gesamtarbeitszeit"
7 -> "Nur App-Name"
else -> "Restzeit für Tag, korrigiert nach Gesamtarbeitszeit"
}
private fun weekdayLabel(value: Int): String =
when (value) {
1 -> "Montag"
2 -> "Dienstag"
3 -> "Mittwoch"
4 -> "Donnerstag"
5 -> "Freitag"
6 -> "Samstag"
7 -> "Sonntag"
else -> "Montag"
}
private fun wishtypeLabel(value: Int): String =
when (value) {
0 -> "Kein Wunsch"
1 -> "Frei"
2 -> "Arbeit"
else -> "Arbeit"
}
private val germanDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY)
private fun LocalDate.toGermanDate(): String = format(germanDateFormatter)
@Composable @Composable
private fun SettingsFrame(state: SettingsUiState, content: @Composable () -> Unit) { private fun SettingsFrame(state: SettingsUiState, content: @Composable () -> Unit) {
if (state.loading) TcLoading("Lade Daten...") if (state.loading) TcLoading("Lade Daten...")
@@ -295,6 +496,16 @@ private fun SectionTitle(title: String) {
) )
} }
@Composable
private fun FieldLabel(label: String) {
Text(
text = label,
color = TcColors.Text,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
)
}
@Composable @Composable
private fun Detail(label: String, value: String) { private fun Detail(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {

View File

@@ -37,9 +37,6 @@ class SettingsViewModel(
fun loadPhase5() { fun loadPhase5() {
loadProfile() loadProfile()
loadTimewishes()
loadInvites()
loadWatchers()
} }
fun loadProfile() = launchLoad { fun loadProfile() = launchLoad {

View File

@@ -22,16 +22,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
data class TimeUiState( data class TimeUiState(
val loading: Boolean = false, val loading: Boolean = false,
val clockInProgress: Boolean = false, val clockInProgress: Boolean = false,
val error: String? = null, val error: String? = null,
val dashboard: TimeDashboard? = null, val dashboard: TimeDashboard? = null,
val statusRows: List<StatusRow> = listOf(StatusRow(label = "Heute", isHeading = true), StatusRow("Status", "")), val statusRows: List<StatusRow> = listOf(StatusRow("Derzeit gearbeitet", "")),
val primaryAction: StatusAction? = null, val primaryAction: StatusAction? = null,
val secondaryAction: StatusAction? = null, val secondaryAction: StatusAction? = null,
val weekLoading: Boolean = false, val weekLoading: Boolean = false,
@@ -169,46 +166,54 @@ class TimeViewModel(
private fun TimeDashboard.toStatusRows(): List<StatusRow> { private fun TimeDashboard.toStatusRows(): List<StatusRow> {
val stats = stats val stats = stats
return buildList { return buildList {
add(StatusRow(label = "Heute", isHeading = true)) add(StatusRow("Derzeit gearbeitet", stats.currentlyWorked.toHoursLabel()))
add(StatusRow("Status", state.toStatusText())) add(StatusRow("Offen", stats.open.toHoursLabel()))
if (runningStartTime != null) add(StatusRow("Beginn", runningStartTime.toDisplayTime())) add(StatusRow("Normales Arbeitsende", stats.regularEnd.toClockLabel()))
if (currentPauseStart != null) add(StatusRow("Pause seit", currentPauseStart.toDisplayTime())) addOvertimeRow("Woche", stats.overtime)
add(StatusRow("Arbeitszeit", stats.currentlyWorked ?: "")) addOvertimeRow("Gesamt", stats.totalOvertime)
add(StatusRow("Offen", stats.open ?: "")) add(StatusRow("Wochenarbeitszeit", stats.weekWorktime.toHoursLabel()))
add(StatusRow("Woche", stats.weekWorktime ?: "")) add(StatusRow("Arbeitsfreie Stunden", stats.nonWorkingHours.toHoursLabel()))
add(StatusRow("Überstunden", stats.overtime ?: "")) add(StatusRow("Offen für Woche", stats.openForWeek.toHoursLabel()))
add(StatusRow("Gesamt", stats.totalOvertime ?: "")) add(StatusRow("Bereinigtes Arbeitsende (heute)", isHeading = true))
add(StatusRow("Arbeitsende", stats.adjustedEndTodayGeneral ?: stats.regularEnd ?: "")) add(StatusRow("- Generell", stats.adjustedEndTodayGeneral.toClockLabel()))
add(StatusRow("- Woche", stats.adjustedEndTodayWeek.toClockLabel()))
} }
} }
private fun TimeDashboard.primaryAction(): StatusAction? = private fun TimeDashboard.primaryAction(): StatusAction? =
when (state) { when (state) {
null, "stop work" -> StatusAction("Kommen", ButtonVariant.Success, "start work") null, "stop work" -> StatusAction("Arbeit beginnen", ButtonVariant.Success, "start work")
"start work", "stop pause" -> StatusAction("Pause starten", ButtonVariant.Default, "start pause") "start work", "stop pause" -> StatusAction("Arbeit beenden", ButtonVariant.Danger, "stop work")
"start pause" -> StatusAction("Pause beenden", ButtonVariant.Success, "stop pause") "start pause" -> null
else -> StatusAction("Kommen", ButtonVariant.Success, "start work") else -> StatusAction("Arbeit beginnen", ButtonVariant.Success, "start work")
} }
private fun TimeDashboard.secondaryAction(): StatusAction? = private fun TimeDashboard.secondaryAction(): StatusAction? =
when (state) { when (state) {
"start work", "stop pause" -> StatusAction("Gehen", ButtonVariant.Secondary, "stop work") "start work", "stop pause" -> StatusAction("Pause beginnen", ButtonVariant.Secondary, "start pause")
"start pause" -> StatusAction("Pause beenden", ButtonVariant.Secondary, "stop pause")
else -> null else -> null
} }
private fun String?.toStatusText(): String = private fun MutableList<StatusRow>.addOvertimeRow(scope: String, value: String?) {
when (this) { val raw = value?.takeIf { it.isNotBlank() && it != "" } ?: ""
null, "stop work" -> "Nicht eingestempelt" val isNegative = raw.startsWith("-")
"start work" -> "Arbeit läuft" val label = if (isNegative) "Fehlzeit ($scope)" else "Überstunden ($scope)"
"start pause" -> "Pause läuft" val displayValue = if (isNegative) raw.removePrefix("-").toHoursLabel() else raw.toHoursLabel()
"stop pause" -> "Arbeit läuft" add(StatusRow(label, displayValue))
else -> this
} }
private fun String.toDisplayTime(): String = private fun String?.toHoursLabel(): String {
runCatching { val value = this?.takeIf { it.isNotBlank() } ?: return ""
OffsetDateTime.parse(this).toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm", Locale.GERMANY)) if (value == "" || value.contains("erreicht")) return value
}.getOrDefault(this) return if (value.endsWith(" h")) value else "$value h"
}
private fun String?.toClockLabel(): String {
val value = this?.takeIf { it.isNotBlank() } ?: return ""
if (value == "" || value.contains("erreicht") || value.endsWith(" Uhr")) return value
return "$value Uhr"
}
class Factory( class Factory(
private val application: Application, private val application: Application,