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"
minSdk = 26
targetSdk = 36
versionCode = 3
versionName = "0.8.0-alpha2"
versionCode = 4
versionName = "0.8.0-alpha3"
buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
}

View File

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

View File

@@ -77,6 +77,9 @@ data class WeekOverviewDto(
val weekEnd: String? = null,
val weekTotal: String? = null,
val totalAll: String? = null,
val nonWorkingTotal: String? = null,
val nonWorkingDays: Int = 0,
val nonWorkingDetails: List<NonWorkingDetailDto> = emptyList(),
val days: List<WeekDayDto> = emptyList(),
)
@@ -88,6 +91,9 @@ data class WeekDayDto(
val workTime: String? = null,
val totalWorkTime: String? = null,
val netWorkTime: String? = null,
val holiday: WeekDayHolidayDto? = null,
val vacation: WeekDayVacationDto? = null,
val sick: WeekDaySickDto? = null,
val status: String? = null,
val statusText: String? = null,
val workBlocks: List<WorkBlockDto> = emptyList(),
@@ -99,3 +105,28 @@ data class WorkBlockDto(
val totalWorkTime: 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
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -14,12 +17,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.data.api.WeekDayDto
import de.tsschulz.timeclock.data.api.WeekOverviewDto
import de.tsschulz.timeclock.data.api.WorkBlockDto
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.tsschulz.timeclock.ui.admin.AdminViewModel
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.TimewishScreen
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.time.EntriesScreen
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.Profile -> ProfileScreen(
state = settingsState,
isTablet = isTablet,
onSave = { name, stateId, weekWorkdays, dailyHours, titleType ->
settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType)
},
)
AppRoute.Profile -> {
LaunchedEffect(route) { settingsViewModel.loadProfile() }
ProfileScreen(
state = settingsState,
isTablet = isTablet,
onSave = { name, stateId, weekWorkdays, dailyHours, titleType ->
settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType)
},
)
}
AppRoute.Password -> PasswordScreen(
state = settingsState,
onChange = { old, new, confirm -> settingsViewModel.changePassword(old, new, confirm) },
)
AppRoute.Timewish -> TimewishScreen(
state = settingsState,
isTablet = isTablet,
onCreate = { day, type, hours, start, end -> settingsViewModel.createTimewish(day, type, hours, start, end) },
onDelete = { settingsViewModel.deleteTimewish(it) },
)
AppRoute.Permissions -> PermissionsScreen(
state = settingsState,
isTablet = isTablet,
onAdd = { settingsViewModel.addWatcher(it) },
onDelete = { settingsViewModel.deleteWatcher(it) },
)
AppRoute.Invite -> InviteScreen(
state = settingsState,
isTablet = isTablet,
onSend = { settingsViewModel.sendInvite(it) },
)
AppRoute.Timewish -> {
LaunchedEffect(route) { settingsViewModel.loadTimewishes() }
TimewishScreen(
state = settingsState,
isTablet = isTablet,
onCreate = { day, type, hours, start, end -> settingsViewModel.createTimewish(day, type, hours, start, end) },
onDelete = { settingsViewModel.deleteTimewish(it) },
)
}
AppRoute.Permissions -> {
LaunchedEffect(route) { settingsViewModel.loadWatchers() }
PermissionsScreen(
state = settingsState,
isTablet = isTablet,
onAdd = { settingsViewModel.addWatcher(it) },
onDelete = { settingsViewModel.deleteWatcher(it) },
)
}
AppRoute.Invite -> {
LaunchedEffect(route) { settingsViewModel.loadInvites() }
InviteScreen(
state = settingsState,
isTablet = isTablet,
onSend = { settingsViewModel.sendInvite(it) },
)
}
AppRoute.Holidays -> HolidaysAdminScreen(
state = adminState,
isTablet = isTablet,
@@ -298,55 +316,203 @@ private fun WeekOverviewScreen(
@Composable
private fun WeekTablet(week: WeekOverviewDto) {
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Xl), modifier = Modifier.fillMaxWidth()) {
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 ?: "")
}
}
}
WeekTable(week, compact = false)
}
@Composable
private fun WeekPhone(week: WeekOverviewDto) {
week.days.forEach { day -> WeekDayCard(day) }
WeekTable(week, compact = true)
}
@Composable
private fun WeekTable(week: WeekOverviewDto, compact: Boolean) {
TcCard {
SectionTitle("Wochensumme")
DetailRow("Arbeitszeit", week.weekTotal ?: "0:00")
DetailRow("Gesamt", week.totalAll ?: week.weekTotal ?: "0:00")
if (!compact) {
WeekHeaderRow()
}
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
week.days.forEach { day -> WeekDayRow(day, compact) }
WeekSummaryRows(week, compact)
}
}
}
@Composable
private fun WeekDayCard(day: WeekDayDto) {
TcCard {
SectionTitle("${day.name ?: "Tag"} ${day.date.toDisplayDate()}")
val blocks = day.workBlocks
if (blocks.isNotEmpty()) {
blocks.forEachIndexed { index, block ->
val label = if (blocks.size > 1) "Arbeitszeit ${index + 1}" else "Arbeitszeit"
DetailRow(label, block.workTime ?: day.workTime ?: "")
DetailRow("Netto", block.netWorkTime ?: block.totalWorkTime ?: "")
}
} else {
DetailRow("Arbeitszeit", day.workTime ?: "")
DetailRow("Netto", day.netWorkTime ?: day.totalWorkTime ?: "")
}
DetailRow("Status", day.statusText ?: "")
private fun WeekHeaderRow() {
Row(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
.padding(horizontal = TcSpacing.Md, vertical = TcSpacing.Sm),
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
) {
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 {
Row(
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)
}
}
}
@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
private fun CalendarDemo(isTablet: Boolean) {
if (isTablet) {
@@ -408,6 +574,19 @@ private fun String?.toDisplayDate(): String {
}.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
private fun SectionTitle(text: String) {
Text(

View File

@@ -1,5 +1,6 @@
package de.tsschulz.timeclock.ui.admin
import android.app.DatePickerDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
@@ -19,6 +20,7 @@ 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
@@ -34,6 +36,8 @@ import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun HolidaysAdminScreen(
@@ -42,7 +46,7 @@ fun HolidaysAdminScreen(
onCreate: (String, Double, String, List<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 description by rememberSaveable { mutableStateOf("") }
var stateIds by rememberSaveable { mutableStateOf("") }
@@ -61,8 +65,11 @@ fun HolidaysAdminScreen(
stateIds = stateIds,
onStateIds = { stateIds = it },
onCreate = {
onCreate(date, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds))
description = ""
normalizeHolidayDate(date)?.let { isoDate ->
onCreate(isoDate, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds))
date = LocalDate.parse(isoDate).toGermanDate()
description = ""
}
},
modifier = Modifier.weight(0.9f),
)
@@ -83,8 +90,11 @@ fun HolidaysAdminScreen(
stateIds = stateIds,
onStateIds = { stateIds = it },
onCreate = {
onCreate(date, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds))
description = ""
normalizeHolidayDate(date)?.let { isoDate ->
onCreate(isoDate, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds))
date = LocalDate.parse(isoDate).toGermanDate()
description = ""
}
},
)
HolidayList("Zukünftige Feiertage", state.futureHolidays, onDelete)
@@ -129,15 +139,9 @@ private fun HolidayForm(
modifier: Modifier = 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("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()) {
Text("Bundesländer", color = TcColors.TextMuted, fontSize = 13.sp)
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
private fun HolidayList(title: String, holidays: List<HolidayDto>, onDelete: (String) -> Unit) {
ListCard(title, holidays) { holiday -> HolidayRow(holiday, onDelete) }
@@ -269,6 +303,20 @@ private fun toggleStateId(raw: String, id: String): String {
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 {
if (this.isNullOrBlank()) return "-"
return runCatching {

View File

@@ -50,6 +50,7 @@ import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.math.roundToInt
@Composable
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()) }
Phase4Frame(loading = state.vacationLoading, error = state.vacationError) {
FormCard("Urlaub eintragen", isTablet) {
TcTextField("Umfang (0 Zeitraum, 1 Halber Tag)", type, { type = it }, placeholder = "0")
FieldLabel("Umfang")
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" })
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 = {
val typeValue = type.toIntOrNull() ?: 0
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) {
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}" })
DateField("Erster Krankheitstag", start, { start = it })
DateField("Letzter Krankheitstag", end, { end = it })
FieldLabel("Krankheitstyp")
FlowRow(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
state.sickTypes.forEach { type ->
TcButton(
@@ -162,11 +171,16 @@ fun TimefixScreen(
@Composable
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) {
FormCard("Arbeitstage", isTablet = false) {
TcTextField("Jahr", year, { year = it }, placeholder = "2026")
TcButton("Jahr laden", variant = ButtonVariant.Primary, onClick = { year.toIntOrNull()?.let(onYear) })
YearDropdown(selectedYear) { year ->
selectedYear = year
onYear(year)
}
}
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
private fun FieldLabel(label: String) {
Text(
@@ -432,11 +472,14 @@ private fun CalendarCell(day: CalendarDayDto) {
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.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) }
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.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 =
"${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)

View File

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

View File

@@ -1,14 +1,18 @@
package de.tsschulz.timeclock.ui.settings
import android.app.DatePickerDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -18,11 +22,13 @@ 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
import de.tsschulz.timeclock.data.api.InvitationDto
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.WatcherDto
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.TcSpacing
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun ProfileScreen(
@@ -47,7 +55,7 @@ fun ProfileScreen(
var stateId by rememberSaveable { mutableStateOf("") }
var weekWorkdays by rememberSaveable { mutableStateOf("5") }
var dailyHours by rememberSaveable { mutableStateOf("8.0") }
var preferredTitleType by rememberSaveable { mutableStateOf("0") }
var preferredTitleType by rememberSaveable { mutableStateOf(2) }
LaunchedEffect(profile) {
profile?.let {
@@ -55,7 +63,7 @@ fun ProfileScreen(
stateId = it.stateId.orEmpty()
weekWorkdays = (it.weekWorkdays ?: 5).toString()
dailyHours = (it.dailyHours ?: 8.0).toString()
preferredTitleType = (it.preferredTitleType ?: 0).toString()
preferredTitleType = it.preferredTitleType ?: 2
}
}
@@ -63,17 +71,22 @@ fun ProfileScreen(
ResponsiveSettings(isTablet) {
FormCard("Persönliche Daten") {
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("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 = {
onSave(
fullName,
stateId.ifBlank { null },
weekWorkdays.toIntOrNull() ?: 5,
dailyHours.toDoubleOrNull() ?: 8.0,
preferredTitleType.toIntOrNull() ?: 0,
preferredTitleType,
)
})
}
@@ -113,8 +126,8 @@ fun TimewishScreen(
onCreate: (Int, Int, Double?, String, String?) -> Unit,
onDelete: (String) -> Unit,
) {
var day by rememberSaveable { mutableStateOf("1") }
var wishtype by rememberSaveable { mutableStateOf("1") }
var day by rememberSaveable { mutableStateOf(1) }
var wishtype by rememberSaveable { mutableStateOf(2) }
var hours by rememberSaveable { mutableStateOf("8.0") }
var startDate by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var endDate by rememberSaveable { mutableStateOf("") }
@@ -122,16 +135,21 @@ fun TimewishScreen(
SettingsFrame(state) {
ResponsiveSettings(isTablet) {
FormCard("Zeitwunsch eintragen") {
TcTextField("Wochentag", day, { day = it }, placeholder = "1")
TcTextField("Wunschtyp", wishtype, { wishtype = it }, placeholder = "1")
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")
WeekdayDropdown(day, { day = it })
WishtypeDropdown(wishtype) {
wishtype = it
hours = if (it == 2) "8.0" else "0"
}
if (wishtype == 2) {
TcTextField("Stunden", hours, { hours = it }, placeholder = "8.0")
}
SettingsDateField("Gültig ab", startDate, { startDate = it })
SettingsDateField("Gültig bis (optional)", endDate, { endDate = it }, allowBlank = true)
TcButton("Zeitwunsch speichern", variant = ButtonVariant.Primary, onClick = {
onCreate(
day.toIntOrNull() ?: 1,
wishtype.toIntOrNull() ?: 1,
hours.toDoubleOrNull(),
day,
wishtype,
if (wishtype == 2) hours.toDoubleOrNull() else null,
startDate,
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
private fun SettingsFrame(state: SettingsUiState, content: @Composable () -> Unit) {
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
private fun Detail(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {

View File

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

View File

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