Update .gitignore to exclude Android/Gradle files and enhance TimeEntryController and TimefixService for better error handling and performance. Refactor frontend components to use AppBrand for consistent branding across views.

This commit is contained in:
Torsten Schulz (local)
2026-05-14 22:17:29 +02:00
parent 7d5c8cffc7
commit 5b6adab4cd
72 changed files with 5704 additions and 111 deletions

View File

@@ -0,0 +1,65 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
val localProps = rootProject.file("local.properties").takeIf { it.exists() }?.reader()?.use {
Properties().apply { load(it) }
}
val apiBaseUrl: String =
(project.findProperty("timeclock.api.baseUrl") as String?)
?: localProps?.getProperty("timeclock.api.baseUrl")
?: "https://stechuhr3.tsschulz.de/api"
android {
namespace = "de.tsschulz.timeclock"
compileSdk = 36
defaultConfig {
applicationId = "de.tsschulz.timeclock"
minSdk = 26
targetSdk = 36
versionCode = 3
versionName = "0.8.0-alpha2"
buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2026.03.01"))
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-text")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.core:core-ktx:1.18.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
debugImplementation("androidx.compose.ui:ui-tooling")
}

Binary file not shown.

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- HTTP-Cleartext für lokale Dev-URLs (z. B. http://10.0.2.2:3010/api über local.properties) -->
<application android:usesCleartextTraffic="true" />
</manifest>

View File

@@ -0,0 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Stechuhr"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TimeClock">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,45 @@
package de.tsschulz.timeclock
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import de.tsschulz.timeclock.ui.TimeClockApp
import de.tsschulz.timeclock.ui.admin.AdminViewModel
import de.tsschulz.timeclock.ui.auth.AuthViewModel
import de.tsschulz.timeclock.ui.booking.BookingViewModel
import de.tsschulz.timeclock.ui.settings.SettingsViewModel
import de.tsschulz.timeclock.ui.theme.TimeClockTheme
import de.tsschulz.timeclock.ui.time.TimeViewModel
class MainActivity : ComponentActivity() {
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.statusBarColor = Color.rgb(240, 255, 236)
window.navigationBarColor = Color.WHITE
setContent {
val authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory(application))
val timeViewModel: TimeViewModel = viewModel(factory = TimeViewModel.Factory(application))
val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application))
val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application))
val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application))
TimeClockTheme {
TimeClockApp(
authViewModel = authViewModel,
timeViewModel = timeViewModel,
bookingViewModel = bookingViewModel,
settingsViewModel = settingsViewModel,
adminViewModel = adminViewModel,
)
}
}
window.decorView.post {
val controller = WindowCompat.getInsetsController(window, window.decorView)
controller.isAppearanceLightStatusBars = true
controller.isAppearanceLightNavigationBars = true
}
}
}

View File

@@ -0,0 +1,8 @@
package de.tsschulz.timeclock.config
import de.tsschulz.timeclock.BuildConfig
object AppConfig {
/** Basis-URL inkl. `/api`-Suffix (Default aus `BuildConfig`, siehe `composeApp/build.gradle.kts`). */
val apiBaseUrl: String = BuildConfig.API_BASE_URL
}

View File

@@ -0,0 +1,20 @@
package de.tsschulz.timeclock.data.admin
import de.tsschulz.timeclock.data.api.HolidayCreateRequest
import de.tsschulz.timeclock.data.api.HolidayStateDto
import de.tsschulz.timeclock.data.api.HolidaysResponse
import de.tsschulz.timeclock.data.api.RoleUserDto
import de.tsschulz.timeclock.data.api.TimeClockApiClient
class AdminRepository(
private val api: TimeClockApiClient,
) {
suspend fun getHolidayStates(): List<HolidayStateDto> = api.getHolidayStates()
suspend fun getHolidays(): HolidaysResponse = api.getHolidays()
suspend fun createHoliday(date: String, hours: Double, description: String, stateIds: List<String>) =
api.createHoliday(HolidayCreateRequest(date, hours, description, stateIds))
suspend fun deleteHoliday(id: String) = api.deleteHoliday(id)
suspend fun getRoleUsers(): List<RoleUserDto> = api.getRoleUsers()
suspend fun updateUserRole(id: String, role: Int) = api.updateUserRole(id, role)
}

View File

@@ -0,0 +1,47 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.Serializable
@Serializable
data class HolidayStateDto(
val id: String,
val name: String,
)
@Serializable
data class HolidayDto(
val id: String,
val date: String,
val hours: Double = 8.0,
val description: String,
val states: List<String> = emptyList(),
val isFederal: Boolean = true,
)
@Serializable
data class HolidaysResponse(
val future: List<HolidayDto> = emptyList(),
val past: List<HolidayDto> = emptyList(),
)
@Serializable
data class HolidayCreateRequest(
val date: String,
val hours: Double,
val description: String,
val stateIds: List<String> = emptyList(),
)
@Serializable
data class RoleUserDto(
val id: String,
val fullName: String,
val role: Int = 0,
val roleString: String = "user",
val stateName: String? = null,
)
@Serializable
data class RoleUpdateRequest(
val role: Int,
)

View File

@@ -0,0 +1,6 @@
package de.tsschulz.timeclock.data.api
class ApiException(
message: String,
val code: Int,
) : Exception(message)

View File

@@ -0,0 +1,52 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequest(
val email: String,
val password: String,
/** Entspricht dem Web-Login: `0` = keine Stempelaktion. */
val action: String = "0",
)
@Serializable
data class LoginResponse(
val success: Boolean = false,
val token: String? = null,
val user: UserDto? = null,
val message: String? = null,
val error: String? = null,
val actionWarning: String? = null,
)
@Serializable
data class MeResponse(
val success: Boolean = false,
val user: UserDto? = null,
val error: String? = null,
)
@Serializable
data class UserDto(
val id: Int,
@SerialName("full_name") val fullName: String,
val email: String? = null,
val role: Int = 0,
@SerialName("daily_hours") val dailyHours: Double? = null,
@SerialName("week_hours") val weekHours: Double? = null,
@SerialName("week_workdays") val weekWorkdays: Int? = null,
)
@Serializable
data class ErrorBody(
val error: String? = null,
val message: String? = null,
)
@Serializable
data class LogoutResponse(
val success: Boolean = true,
val message: String? = null,
)

View File

@@ -0,0 +1,106 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VacationDto(
val id: String,
val type: String? = null,
val typeValue: Int = 0,
val startDate: String? = null,
val endDate: String? = null,
)
@Serializable
data class VacationCreateRequest(
val vacationType: Int,
val startDate: String,
val endDate: String? = null,
)
@Serializable
data class SickEntryDto(
val id: String,
val startDate: String? = null,
val endDate: String? = null,
val sickTypeId: Int? = null,
val sickTypeName: String? = null,
)
@Serializable
data class SickTypeDto(
val id: String,
val name: String,
)
@Serializable
data class SickCreateRequest(
val sickTypeId: String,
val startDate: String,
val endDate: String? = null,
)
@Serializable
data class TimefixDto(
val id: String,
val worklogId: String? = null,
val newDate: String? = null,
val newTime: String? = null,
val newAction: String? = null,
val originalDate: String? = null,
val originalTime: String? = null,
val originalAction: String? = null,
)
@Serializable
data class WorklogEntryDto(
val id: String,
val time: String? = null,
val action: String? = null,
val tstamp: String? = null,
)
@Serializable
data class TimefixCreateRequest(
val worklogId: String,
val newDate: String,
val newTime: String,
val newAction: String,
)
@Serializable
data class WorkdaysDto(
val year: Int,
val workdays: Int = 0,
val holidays: Int = 0,
val sickDays: Double = 0.0,
val sickPercentage: Int = 0,
val vacationDays: Double = 0.0,
val workedDays: Int = 0,
)
@Serializable
data class CalendarDto(
val year: Int,
val month: Int,
val weeks: List<List<CalendarDayDto>> = emptyList(),
)
@Serializable
data class CalendarDayDto(
val date: String,
val day: Int,
val isCurrentMonth: Boolean = false,
val isToday: Boolean = false,
val holiday: String? = null,
val sick: Boolean = false,
val vacation: String? = null,
val workedHours: Double? = null,
)
@Serializable
data class MessageResponse(
val message: String? = null,
val error: String? = null,
)

View File

@@ -0,0 +1,86 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.Serializable
@Serializable
data class ProfileDto(
val id: String? = null,
val fullName: String = "",
val email: String? = null,
val stateId: String? = null,
val stateName: String? = null,
val weekWorkdays: Int? = null,
val dailyHours: Double? = null,
val preferredTitleType: Int? = null,
)
@Serializable
data class StateDto(
val id: String,
val name: String,
)
@Serializable
data class ProfileUpdateRequest(
val fullName: String,
val stateId: String? = null,
val weekWorkdays: Int,
val dailyHours: Double,
val preferredTitleType: Int,
)
@Serializable
data class PasswordChangeRequest(
val oldPassword: String,
val newPassword: String,
val confirmPassword: String,
)
@Serializable
data class TimewishDto(
val id: String,
val day: Int,
val dayName: String? = null,
val wishtype: Int,
val wishtypeName: String? = null,
val hours: Double? = null,
val startDate: String? = null,
val endDate: String? = null,
)
@Serializable
data class TimewishCreateRequest(
val day: Int,
val wishtype: Int,
val hours: Double? = null,
val startDate: String,
val endDate: String? = null,
)
@Serializable
data class InvitationDto(
val id: String,
val email: String,
val createdAt: String? = null,
val expiresAt: String? = null,
val status: String? = null,
val isExpired: Boolean = false,
val token: String? = null,
)
@Serializable
data class InviteRequest(
val email: String,
)
@Serializable
data class WatcherDto(
val id: String,
val email: String,
val createdAt: String? = null,
)
@Serializable
data class WatcherRequest(
val email: String,
)

View File

@@ -0,0 +1,289 @@
package de.tsschulz.timeclock.data.api
import de.tsschulz.timeclock.config.AppConfig
import de.tsschulz.timeclock.data.auth.TokenStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
private val JsonMedia = "application/json; charset=utf-8".toMediaType()
class TimeClockApiClient(
private val tokenStore: TokenStore,
private val json: Json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
},
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build(),
private val baseUrl: String = AppConfig.apiBaseUrl,
) {
private fun endpoint(path: String): String {
val base = baseUrl.trimEnd('/')
val p = path.trimStart('/')
return "$base/$p"
}
suspend fun postLogin(body: LoginRequest): LoginResponse {
val raw = execute(
Request.Builder()
.url(endpoint("auth/login"))
.post(json.encodeToString(LoginRequest.serializer(), body).toRequestBody(JsonMedia))
.build(),
)
return decode(LoginResponse.serializer(), raw)
}
suspend fun getMe(): MeResponse {
val t = tokenStore.getToken() ?: throw ApiException("Nicht angemeldet", 401)
val raw = execute(
Request.Builder()
.url(endpoint("auth/me"))
.header("Authorization", "Bearer $t")
.get()
.build(),
)
return decode(MeResponse.serializer(), raw)
}
suspend fun postLogout(): LogoutResponse {
val t = tokenStore.getToken() ?: throw ApiException("Nicht angemeldet", 401)
val raw = execute(
Request.Builder()
.url(endpoint("auth/logout"))
.header("Authorization", "Bearer $t")
.post("{}".toRequestBody(JsonMedia))
.build(),
)
return decode(LogoutResponse.serializer(), raw.ifBlank { "{}" })
}
suspend fun getCurrentState(): CurrentStateResponse {
val raw = execute(authorized("time-entries/current-state").get().build())
return decode(CurrentStateResponse.serializer(), raw)
}
suspend fun getRunningEntry(): RunningEntryDto {
val raw = execute(authorized("time-entries/running").get().build())
return decode(RunningEntryDto.serializer(), raw.ifBlank { "{}" })
}
suspend fun getTimeStats(): TimeStatsDto {
val raw = execute(authorized("time-entries/stats/summary").get().build())
return decode(TimeStatsDto.serializer(), raw.ifBlank { "{}" })
}
suspend fun postClock(action: String): ClockResponse {
val raw = execute(
authorized("time-entries/clock")
.post(json.encodeToString(ClockRequest.serializer(), ClockRequest(action)).toRequestBody(JsonMedia))
.build(),
)
return decode(ClockResponse.serializer(), raw)
}
suspend fun getWeekOverview(weekOffset: Int): WeekOverviewResponse {
val raw = execute(authorized("week-overview?weekOffset=$weekOffset").get().build())
return decode(WeekOverviewResponse.serializer(), raw)
}
suspend fun getTimeEntries(): List<TimeEntryDto> =
decode(ListSerializer(TimeEntryDto.serializer()), execute(authorized("time-entries").get().build()))
suspend fun deleteTimeEntry(id: String) {
execute(authorized("time-entries/$id").delete().build())
}
suspend fun getVacations(): List<VacationDto> =
decode(ListSerializer(VacationDto.serializer()), execute(authorized("vacation").get().build()))
suspend fun createVacation(request: VacationCreateRequest) {
execute(
authorized("vacation")
.post(json.encodeToString(VacationCreateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
}
suspend fun deleteVacation(id: String) {
execute(authorized("vacation/$id").delete().build())
}
suspend fun getSickEntries(): List<SickEntryDto> =
decode(ListSerializer(SickEntryDto.serializer()), execute(authorized("sick").get().build()))
suspend fun getSickTypes(): List<SickTypeDto> =
decode(ListSerializer(SickTypeDto.serializer()), execute(authorized("sick/types").get().build()))
suspend fun createSick(request: SickCreateRequest) {
execute(
authorized("sick")
.post(json.encodeToString(SickCreateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
}
suspend fun deleteSick(id: String) {
execute(authorized("sick/$id").delete().build())
}
suspend fun getTimefixes(): List<TimefixDto> =
decode(ListSerializer(TimefixDto.serializer()), execute(authorized("timefix").get().build()))
suspend fun getWorklogEntries(date: String): List<WorklogEntryDto> =
decode(ListSerializer(WorklogEntryDto.serializer()), execute(authorized("timefix/worklog-entries?date=$date").get().build()))
suspend fun createTimefix(request: TimefixCreateRequest) {
execute(
authorized("timefix")
.post(json.encodeToString(TimefixCreateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
}
suspend fun deleteTimefix(id: String) {
execute(authorized("timefix/$id").delete().build())
}
suspend fun getWorkdays(year: Int): WorkdaysDto =
decode(WorkdaysDto.serializer(), execute(authorized("workdays?year=$year").get().build()))
suspend fun getCalendar(year: Int, month: Int): CalendarDto =
decode(CalendarDto.serializer(), execute(authorized("calendar?year=$year&month=$month").get().build()))
suspend fun getProfile(): ProfileDto =
decode(ProfileDto.serializer(), execute(authorized("profile").get().build()))
suspend fun getStates(): List<StateDto> =
decode(ListSerializer(StateDto.serializer()), execute(authorized("profile/states").get().build()))
suspend fun updateProfile(request: ProfileUpdateRequest): MessageResponse {
val raw = execute(
authorized("profile")
.put(json.encodeToString(ProfileUpdateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(MessageResponse.serializer(), raw.ifBlank { "{}" })
}
suspend fun changePassword(request: PasswordChangeRequest): MessageResponse {
val raw = execute(
authorized("password")
.put(json.encodeToString(PasswordChangeRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(MessageResponse.serializer(), raw.ifBlank { "{}" })
}
suspend fun getTimewishes(): List<TimewishDto> =
decode(ListSerializer(TimewishDto.serializer()), execute(authorized("timewish").get().build()))
suspend fun createTimewish(request: TimewishCreateRequest): MessageResponse {
val raw = execute(
authorized("timewish")
.post(json.encodeToString(TimewishCreateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(MessageResponse.serializer(), raw.ifBlank { "{}" })
}
suspend fun deleteTimewish(id: String) {
execute(authorized("timewish/$id").delete().build())
}
suspend fun getInvites(): List<InvitationDto> =
decode(ListSerializer(InvitationDto.serializer()), execute(authorized("invite").get().build()))
suspend fun sendInvite(request: InviteRequest): InvitationDto {
val raw = execute(
authorized("invite")
.post(json.encodeToString(InviteRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(InvitationDto.serializer(), raw)
}
suspend fun getWatchers(): List<WatcherDto> =
decode(ListSerializer(WatcherDto.serializer()), execute(authorized("watcher").get().build()))
suspend fun addWatcher(request: WatcherRequest): WatcherDto {
val raw = execute(
authorized("watcher")
.post(json.encodeToString(WatcherRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(WatcherDto.serializer(), raw)
}
suspend fun deleteWatcher(id: String) {
execute(authorized("watcher/$id").delete().build())
}
suspend fun getHolidayStates(): List<HolidayStateDto> =
decode(ListSerializer(HolidayStateDto.serializer()), execute(authorized("holidays/states").get().build()))
suspend fun getHolidays(): HolidaysResponse =
decode(HolidaysResponse.serializer(), execute(authorized("holidays").get().build()))
suspend fun createHoliday(request: HolidayCreateRequest): HolidayDto {
val raw = execute(
authorized("holidays")
.post(json.encodeToString(HolidayCreateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(HolidayDto.serializer(), raw)
}
suspend fun deleteHoliday(id: String) {
execute(authorized("holidays/$id").delete().build())
}
suspend fun getRoleUsers(): List<RoleUserDto> =
decode(ListSerializer(RoleUserDto.serializer()), execute(authorized("roles/users").get().build()))
suspend fun updateUserRole(id: String, role: Int): RoleUserDto {
val raw = execute(
authorized("roles/users/$id")
.put(json.encodeToString(RoleUpdateRequest.serializer(), RoleUpdateRequest(role)).toRequestBody(JsonMedia))
.build(),
)
return decode(RoleUserDto.serializer(), raw)
}
private fun authorized(path: String): Request.Builder {
val t = tokenStore.getToken() ?: throw ApiException("Nicht angemeldet", 401)
return Request.Builder()
.url(endpoint(path))
.header("Authorization", "Bearer $t")
}
private suspend fun execute(request: Request): String = withContext(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
val raw = response.body?.string().orEmpty()
if (response.code == 401) {
tokenStore.clearToken()
}
if (!response.isSuccessful) {
val err = runCatching { json.decodeFromString(ErrorBody.serializer(), raw) }.getOrNull()
val msg = err?.error ?: err?.message ?: "HTTP ${response.code}"
throw ApiException(msg, response.code)
}
raw
}
}
private fun <T> decode(deserializer: KSerializer<T>, raw: String): T =
if (raw.isBlank()) json.decodeFromString(deserializer, "{}")
else json.decodeFromString(deserializer, raw)
}

View File

@@ -0,0 +1,101 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.Serializable
@Serializable
data class CurrentStateResponse(
val success: Boolean = false,
val state: String? = null,
val error: String? = null,
)
@Serializable
data class ClockRequest(
val action: String,
)
@Serializable
data class ClockResponse(
val success: Boolean = false,
val message: String? = null,
val entry: RunningEntryDto? = null,
val error: String? = null,
)
@Serializable
data class RunningEntryDto(
val id: String? = null,
val startTime: String? = null,
val currentPauseStart: String? = null,
)
@Serializable
data class TimeStatsDto(
val totalEntries: Int? = null,
val completedEntries: Int? = null,
val runningEntries: Int? = null,
val totalHours: String? = null,
val timestamp: String? = null,
val currentlyWorked: String? = null,
val open: String? = null,
val requiredBreakMinutes: Int? = null,
val alreadyTakenBreakMinutes: Int? = null,
val missingBreakMinutes: Int? = null,
val regularEnd: String? = null,
val overtime: String? = null,
val totalOvertime: String? = null,
val weekWorktime: String? = null,
val nonWorkingHours: String? = null,
val openForWeek: String? = null,
val adjustedEndToday: String? = null,
val adjustedEndTodayGeneral: String? = null,
val adjustedEndTodayWeek: String? = null,
)
@Serializable
data class TimeEntryDto(
val id: String,
val project: String? = null,
val description: String? = null,
val startTime: String? = null,
val endTime: String? = null,
val duration: Long? = null,
val isRunning: Boolean = false,
val userId: String? = null,
)
@Serializable
data class WeekOverviewResponse(
val success: Boolean = false,
val data: WeekOverviewDto? = null,
val error: String? = null,
)
@Serializable
data class WeekOverviewDto(
val weekStart: String? = null,
val weekEnd: String? = null,
val weekTotal: String? = null,
val totalAll: String? = null,
val days: List<WeekDayDto> = emptyList(),
)
@Serializable
data class WeekDayDto(
val name: String? = null,
val date: String? = null,
val isToday: Boolean = false,
val workTime: String? = null,
val totalWorkTime: String? = null,
val netWorkTime: String? = null,
val status: String? = null,
val statusText: String? = null,
val workBlocks: List<WorkBlockDto> = emptyList(),
)
@Serializable
data class WorkBlockDto(
val workTime: String? = null,
val totalWorkTime: String? = null,
val netWorkTime: String? = null,
)

View File

@@ -0,0 +1,83 @@
package de.tsschulz.timeclock.data.auth
import de.tsschulz.timeclock.data.api.ApiException
import de.tsschulz.timeclock.data.api.LoginRequest
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.UserDto
import java.io.IOException
data class UserProfile(
val id: Int,
val fullName: String,
val email: String?,
val role: Int,
) {
val isAdmin: Boolean get() = role == 1
}
class AuthRepository(
private val api: TimeClockApiClient,
private val tokenStore: TokenStore,
) {
fun hasStoredToken(): Boolean = tokenStore.getToken() != null
suspend fun restoreSession(): UserProfile? {
if (tokenStore.getToken() == null) return null
return try {
val me = api.getMe()
if (me.success && me.user != null) {
me.user!!.toProfile()
} else {
tokenStore.clearToken()
null
}
} catch (_: ApiException) {
null
} catch (_: IOException) {
null
} catch (_: Exception) {
null
}
}
suspend fun login(email: String, password: String, action: String = "0"): Result<UserProfile> {
return try {
val res = api.postLogin(
LoginRequest(
email = email.trim(),
password = password,
action = action,
),
)
if (res.success && !res.token.isNullOrBlank() && res.user != null) {
tokenStore.saveToken(res.token)
Result.success(res.user!!.toProfile())
} else {
Result.failure(Exception(res.error ?: "Login fehlgeschlagen"))
}
} catch (e: ApiException) {
Result.failure(Exception(e.message ?: "Login fehlgeschlagen"))
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun logout() {
try {
api.postLogout()
} catch (_: Exception) {
// Session serverseitig ungültig oder offline — lokal trotzdem leeren
} finally {
tokenStore.clearToken()
}
}
private fun UserDto.toProfile(): UserProfile =
UserProfile(
id = id,
fullName = fullName,
email = email,
role = role,
)
}

View File

@@ -0,0 +1,38 @@
package de.tsschulz.timeclock.data.auth
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class TokenStore(context: Context) {
private val appContext = context.applicationContext
private val prefs by lazy {
val masterKey = MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
appContext,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun getToken(): String? = prefs.getString(KEY_TOKEN, null)
fun saveToken(token: String) {
prefs.edit().putString(KEY_TOKEN, token).apply()
}
fun clearToken() {
prefs.edit().remove(KEY_TOKEN).apply()
}
private companion object {
const val PREFS_NAME = "timeclock_auth_prefs"
const val KEY_TOKEN = "jwt"
}
}

View File

@@ -0,0 +1,37 @@
package de.tsschulz.timeclock.data.booking
import de.tsschulz.timeclock.data.api.CalendarDto
import de.tsschulz.timeclock.data.api.SickCreateRequest
import de.tsschulz.timeclock.data.api.SickEntryDto
import de.tsschulz.timeclock.data.api.SickTypeDto
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimefixCreateRequest
import de.tsschulz.timeclock.data.api.TimefixDto
import de.tsschulz.timeclock.data.api.VacationCreateRequest
import de.tsschulz.timeclock.data.api.VacationDto
import de.tsschulz.timeclock.data.api.WorkdaysDto
import de.tsschulz.timeclock.data.api.WorklogEntryDto
class BookingRepository(
private val api: TimeClockApiClient,
) {
suspend fun getVacations(): List<VacationDto> = api.getVacations()
suspend fun createVacation(type: Int, start: String, end: String?) =
api.createVacation(VacationCreateRequest(type, start, end))
suspend fun deleteVacation(id: String) = api.deleteVacation(id)
suspend fun getSickEntries(): List<SickEntryDto> = api.getSickEntries()
suspend fun getSickTypes(): List<SickTypeDto> = api.getSickTypes()
suspend fun createSick(typeId: String, start: String, end: String?) =
api.createSick(SickCreateRequest(typeId, start, end ?: start))
suspend fun deleteSick(id: String) = api.deleteSick(id)
suspend fun getTimefixes(): List<TimefixDto> = api.getTimefixes()
suspend fun getWorklogEntries(date: String): List<WorklogEntryDto> = api.getWorklogEntries(date)
suspend fun createTimefix(worklogId: String, date: String, time: String, action: String) =
api.createTimefix(TimefixCreateRequest(worklogId, date, time, action))
suspend fun deleteTimefix(id: String) = api.deleteTimefix(id)
suspend fun getWorkdays(year: Int): WorkdaysDto = api.getWorkdays(year)
suspend fun getCalendar(year: Int, month: Int): CalendarDto = api.getCalendar(year, month)
}

View File

@@ -0,0 +1,51 @@
package de.tsschulz.timeclock.data.offline
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import java.util.UUID
@Serializable
data class PendingClockAction(
val id: String,
val action: String,
val createdAtEpochMillis: Long,
)
class OfflineClockQueue(
context: Context,
private val json: Json = Json { ignoreUnknownKeys = true },
) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun enqueue(action: String) {
val next = pending() + PendingClockAction(
id = UUID.randomUUID().toString(),
action = action,
createdAtEpochMillis = System.currentTimeMillis(),
)
save(next)
}
fun pending(): List<PendingClockAction> {
val raw = prefs.getString(KEY_ACTIONS, null) ?: return emptyList()
return runCatching {
json.decodeFromString(ListSerializer(PendingClockAction.serializer()), raw)
}.getOrDefault(emptyList())
}
fun remove(id: String) {
save(pending().filterNot { it.id == id })
}
private fun save(actions: List<PendingClockAction>) {
val raw = json.encodeToString(ListSerializer(PendingClockAction.serializer()), actions)
prefs.edit().putString(KEY_ACTIONS, raw).apply()
}
private companion object {
const val PREFS_NAME = "timeclock_offline_clock_queue"
const val KEY_ACTIONS = "pending_clock_actions"
}
}

View File

@@ -0,0 +1,37 @@
package de.tsschulz.timeclock.data.settings
import de.tsschulz.timeclock.data.api.InvitationDto
import de.tsschulz.timeclock.data.api.InviteRequest
import de.tsschulz.timeclock.data.api.PasswordChangeRequest
import de.tsschulz.timeclock.data.api.ProfileDto
import de.tsschulz.timeclock.data.api.ProfileUpdateRequest
import de.tsschulz.timeclock.data.api.StateDto
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimewishCreateRequest
import de.tsschulz.timeclock.data.api.TimewishDto
import de.tsschulz.timeclock.data.api.WatcherDto
import de.tsschulz.timeclock.data.api.WatcherRequest
class SettingsRepository(
private val api: TimeClockApiClient,
) {
suspend fun getProfile(): ProfileDto = api.getProfile()
suspend fun getStates(): List<StateDto> = api.getStates()
suspend fun updateProfile(fullName: String, stateId: String?, weekWorkdays: Int, dailyHours: Double, preferredTitleType: Int) =
api.updateProfile(ProfileUpdateRequest(fullName, stateId, weekWorkdays, dailyHours, preferredTitleType))
suspend fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) =
api.changePassword(PasswordChangeRequest(oldPassword, newPassword, confirmPassword))
suspend fun getTimewishes(): List<TimewishDto> = api.getTimewishes()
suspend fun createTimewish(day: Int, wishtype: Int, hours: Double?, startDate: String, endDate: String?) =
api.createTimewish(TimewishCreateRequest(day, wishtype, hours, startDate, endDate))
suspend fun deleteTimewish(id: String) = api.deleteTimewish(id)
suspend fun getInvites(): List<InvitationDto> = api.getInvites()
suspend fun sendInvite(email: String) = api.sendInvite(InviteRequest(email))
suspend fun getWatchers(): List<WatcherDto> = api.getWatchers()
suspend fun addWatcher(email: String) = api.addWatcher(WatcherRequest(email))
suspend fun deleteWatcher(id: String) = api.deleteWatcher(id)
}

View File

@@ -0,0 +1,70 @@
package de.tsschulz.timeclock.data.time
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimeEntryDto
import de.tsschulz.timeclock.data.api.TimeStatsDto
import de.tsschulz.timeclock.data.api.WeekOverviewDto
import de.tsschulz.timeclock.data.offline.OfflineClockQueue
import java.io.IOException
class TimeRepository(
private val api: TimeClockApiClient,
private val offlineClockQueue: OfflineClockQueue? = null,
) {
suspend fun loadDashboard(): TimeDashboard {
syncOfflineClockActions()
val state = api.getCurrentState().state
val running = api.getRunningEntry()
val stats = api.getTimeStats()
return TimeDashboard(
state = state,
runningStartTime = running.startTime,
currentPauseStart = running.currentPauseStart,
stats = stats,
)
}
suspend fun clock(action: String): TimeDashboard {
val response = try {
api.postClock(action)
} catch (e: IOException) {
offlineClockQueue?.enqueue(action)
throw IllegalStateException("Keine Verbindung. Die Stempelaktion wurde offline gespeichert und wird später synchronisiert.", e)
}
if (!response.success) {
throw IllegalStateException(response.error ?: "Stempeln fehlgeschlagen")
}
return loadDashboard()
}
suspend fun loadWeek(weekOffset: Int): WeekOverviewDto {
val response = api.getWeekOverview(weekOffset)
if (!response.success || response.data == null) {
throw IllegalStateException(response.error ?: "Wochenübersicht konnte nicht geladen werden")
}
return response.data
}
suspend fun loadEntries(): List<TimeEntryDto> = api.getTimeEntries()
suspend fun deleteEntry(id: String) = api.deleteTimeEntry(id)
suspend fun loadStats(): TimeStatsDto = api.getTimeStats()
private suspend fun syncOfflineClockActions() {
val queue = offlineClockQueue ?: return
queue.pending().forEach { pending ->
try {
val response = api.postClock(pending.action)
if (response.success) queue.remove(pending.id)
} catch (_: IOException) {
return
}
}
}
}
data class TimeDashboard(
val state: String?,
val runningStartTime: String?,
val currentPauseStart: String?,
val stats: TimeStatsDto,
)

View File

@@ -0,0 +1,414 @@
package de.tsschulz.timeclock.ui
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.tsschulz.timeclock.ui.admin.AdminViewModel
import de.tsschulz.timeclock.ui.admin.HolidaysAdminScreen
import de.tsschulz.timeclock.ui.admin.RolesAdminScreen
import de.tsschulz.timeclock.ui.auth.AuthViewModel
import de.tsschulz.timeclock.ui.auth.LoginScreen
import de.tsschulz.timeclock.ui.booking.BookingViewModel
import de.tsschulz.timeclock.ui.booking.CalendarScreen
import de.tsschulz.timeclock.ui.booking.SickScreen
import de.tsschulz.timeclock.ui.booking.TimefixScreen
import de.tsschulz.timeclock.ui.booking.VacationScreen
import de.tsschulz.timeclock.ui.booking.WorkdaysScreen
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcCard
import de.tsschulz.timeclock.ui.components.TcError
import de.tsschulz.timeclock.ui.components.TcLoading
import de.tsschulz.timeclock.ui.components.TcScaffold
import de.tsschulz.timeclock.ui.components.TcTextField
import de.tsschulz.timeclock.ui.model.AppRoute
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.model.adminSections
import de.tsschulz.timeclock.ui.model.userSections
import de.tsschulz.timeclock.ui.settings.InviteScreen
import de.tsschulz.timeclock.ui.settings.PasswordScreen
import de.tsschulz.timeclock.ui.settings.PermissionsScreen
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.TcSpacing
import de.tsschulz.timeclock.ui.time.EntriesScreen
import de.tsschulz.timeclock.ui.time.StatsScreen
import de.tsschulz.timeclock.ui.time.TimeViewModel
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun TimeClockApp(
authViewModel: AuthViewModel,
timeViewModel: TimeViewModel,
bookingViewModel: BookingViewModel,
settingsViewModel: SettingsViewModel,
adminViewModel: AdminViewModel,
) {
val authState by authViewModel.uiState.collectAsStateWithLifecycle()
val timeState by timeViewModel.uiState.collectAsStateWithLifecycle()
val bookingState by bookingViewModel.uiState.collectAsStateWithLifecycle()
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val adminState by adminViewModel.uiState.collectAsStateWithLifecycle()
if (!authState.isAuthenticated) {
LaunchedEffect(Unit) { timeViewModel.stop() }
LoginScreen(
state = authState,
onLogin = { e, p, action -> authViewModel.login(e, p, action) },
onRetryBootstrap = { authViewModel.retryBootstrap() },
)
return
}
val user = authState.user
if (user == null) {
LoginScreen(
state = authState,
onLogin = { e, p, action -> authViewModel.login(e, p, action) },
onRetryBootstrap = { authViewModel.retryBootstrap() },
)
return
}
val sections = if (user.isAdmin) adminSections else userSections
LaunchedEffect(user.id) { timeViewModel.start() }
LaunchedEffect(user.id) { bookingViewModel.loadPhase4() }
LaunchedEffect(user.id) { settingsViewModel.loadPhase5() }
LaunchedEffect(user.id, user.isAdmin) { adminViewModel.loadPhase6(user.isAdmin) }
var selectedRoute by remember { mutableStateOf(AppRoute.Week) }
BoxWithConstraints(modifier = Modifier.background(TcColors.Background)) {
val isTablet = maxWidth >= 840.dp
TcScaffold(
title = selectedRoute.title,
userName = user.fullName,
sections = sections,
selectedRoute = selectedRoute,
isTablet = isTablet,
statusRows = timeState.statusRows,
primaryStatusAction = timeState.primaryAction,
secondaryStatusAction = timeState.secondaryAction,
onRouteSelected = { selectedRoute = it },
onLogout = {
timeViewModel.stop()
authViewModel.logout()
},
onStatusAction = { action ->
action.clockAction?.let { timeViewModel.clock(it) }
},
) {
timeState.error?.let { TcError(it) }
DemoScreen(
route = selectedRoute,
isTablet = isTablet,
week = timeState.week,
weekLoading = timeState.weekLoading,
weekError = timeState.weekError,
weekOffset = timeState.weekOffset,
onWeekOffset = { timeViewModel.loadWeek(it) },
timeState = timeState,
timeViewModel = timeViewModel,
bookingState = bookingState,
bookingViewModel = bookingViewModel,
settingsState = settingsState,
settingsViewModel = settingsViewModel,
adminState = adminState,
adminViewModel = adminViewModel,
)
}
}
}
@Composable
private fun DemoScreen(
route: AppRoute,
isTablet: Boolean,
week: WeekOverviewDto?,
weekLoading: Boolean,
weekError: String?,
weekOffset: Int,
onWeekOffset: (Int) -> Unit,
timeState: de.tsschulz.timeclock.ui.time.TimeUiState,
timeViewModel: TimeViewModel,
bookingState: de.tsschulz.timeclock.ui.booking.BookingUiState,
bookingViewModel: BookingViewModel,
settingsState: de.tsschulz.timeclock.ui.settings.SettingsUiState,
settingsViewModel: SettingsViewModel,
adminState: de.tsschulz.timeclock.ui.admin.AdminUiState,
adminViewModel: AdminViewModel,
) {
when (route) {
AppRoute.Week -> WeekOverviewScreen(
week = week,
loading = weekLoading,
error = weekError,
weekOffset = weekOffset,
onWeekOffset = onWeekOffset,
isTablet = isTablet,
)
AppRoute.Timefix -> TimefixScreen(
state = bookingState,
isTablet = isTablet,
onDate = { bookingViewModel.setTimefixDate(it) },
onCreate = { id, date, time, action -> bookingViewModel.createTimefix(id, date, time, action) },
onDelete = { bookingViewModel.deleteTimefix(it) },
)
AppRoute.Vacation -> VacationScreen(
state = bookingState,
isTablet = isTablet,
onCreate = { type, start, end -> bookingViewModel.createVacation(type, start, end) },
onDelete = { bookingViewModel.deleteVacation(it) },
)
AppRoute.Sick -> SickScreen(
state = bookingState,
isTablet = isTablet,
onCreate = { type, start, end -> bookingViewModel.createSick(type, start, end) },
onDelete = { bookingViewModel.deleteSick(it) },
)
AppRoute.Workdays -> WorkdaysScreen(state = bookingState, onYear = { bookingViewModel.loadWorkdays(it) })
AppRoute.Calendar -> CalendarScreen(state = bookingState, onMonth = { bookingViewModel.changeCalendarMonth(it) })
AppRoute.Entries -> {
LaunchedEffect(Unit) { timeViewModel.loadEntries() }
EntriesScreen(
entries = timeState.entries,
loading = timeState.entriesLoading,
error = timeState.entriesError,
onRefresh = { timeViewModel.loadEntries() },
onDelete = { timeViewModel.deleteEntry(it) },
)
}
AppRoute.Stats -> {
LaunchedEffect(Unit) { timeViewModel.loadStats() }
StatsScreen(
stats = timeState.stats,
loading = timeState.statsLoading,
error = timeState.statsError,
onRefresh = { timeViewModel.loadStats() },
)
}
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.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.Holidays -> HolidaysAdminScreen(
state = adminState,
isTablet = isTablet,
onCreate = { date, hours, description, stateIds -> adminViewModel.createHoliday(date, hours, description, stateIds) },
onDelete = { adminViewModel.deleteHoliday(it) },
)
AppRoute.Roles -> RolesAdminScreen(
state = adminState,
onUpdateRole = { id, role -> adminViewModel.updateUserRole(id, role) },
)
}
}
@Composable
private fun WeekOverviewScreen(
week: WeekOverviewDto?,
loading: Boolean,
error: String?,
weekOffset: Int,
onWeekOffset: (Int) -> Unit,
isTablet: Boolean,
) {
TcCard {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
TcButton("← Vorherige Woche", variant = ButtonVariant.Secondary, onClick = { onWeekOffset(weekOffset - 1) })
Text(
text = week?.rangeText() ?: "Wochenübersicht",
color = TcColors.Text,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
TcButton("Nächste Woche →", variant = ButtonVariant.Secondary, onClick = { onWeekOffset(weekOffset + 1) })
}
}
when {
loading -> TcLoading("Lade Wochenübersicht...")
error != null -> TcError(error)
week == null -> TcCard { Text("Keine Wochenübersicht vorhanden", color = TcColors.TextMuted) }
isTablet -> WeekTablet(week)
else -> WeekPhone(week)
}
}
@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 ?: "")
}
}
}
}
@Composable
private fun WeekPhone(week: WeekOverviewDto) {
week.days.forEach { day -> WeekDayCard(day) }
TcCard {
SectionTitle("Wochensumme")
DetailRow("Arbeitszeit", week.weekTotal ?: "0:00")
DetailRow("Gesamt", week.totalAll ?: week.weekTotal ?: "0:00")
}
}
@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 ?: "")
}
}
@Composable
private fun CalendarDemo(isTablet: Boolean) {
if (isTablet) {
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Xl), modifier = Modifier.fillMaxWidth()) {
ListDemo(title = "Kalender", modifier = Modifier.weight(1f))
TcCard(modifier = Modifier.weight(1f)) {
SectionTitle("Tagesdetails")
DetailRow("Datum", "14.05.2026")
DetailRow("Typ", "Arbeitstag")
DetailRow("Status", "Arbeit läuft")
}
}
} else {
ListDemo(title = "Kalender")
}
}
@Composable
private fun FormDemo(title: String, primaryAction: String) {
var text by remember(title) { mutableStateOf("") }
TcCard {
SectionTitle(title)
TcTextField(label = "Bezeichnung", value = text, onValueChange = { text = it }, placeholder = "Eingabe")
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), modifier = Modifier.padding(top = TcSpacing.Lg)) {
TcButton(primaryAction, variant = ButtonVariant.Primary)
TcButton("Abbrechen")
}
}
}
@Composable
private fun ListDemo(title: String, modifier: Modifier = Modifier) {
TcCard(modifier = modifier) {
SectionTitle(title)
DetailRow("Eintrag 1", "bereit")
DetailRow("Eintrag 2", "offen")
DetailRow("Eintrag 3", "geprüft")
}
}
@Composable
private fun WeekCard(day: String, time: String, status: String) {
TcCard {
SectionTitle(day)
DetailRow("Zeit", time)
DetailRow("Status", status)
}
}
private fun WeekOverviewDto.rangeText(): String =
"${weekStart.toDisplayDate()} - ${weekEnd.toDisplayDate()}"
private fun String?.toDisplayDate(): String {
if (this.isNullOrBlank()) return ""
return runCatching {
LocalDate.parse(this.substring(0, 10)).format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY))
}.recoverCatching {
OffsetDateTime.parse(this).toLocalDate().format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY))
}.getOrDefault(this)
}
@Composable
private fun SectionTitle(text: String) {
Text(
text = text,
color = TcColors.Text,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = TcSpacing.Md),
)
}
@Composable
private fun DetailRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = "$label:", color = TcColors.TextMuted, fontSize = 14.sp)
Text(text = value, color = TcColors.Text, fontSize = 14.sp)
}
}

View File

@@ -0,0 +1,278 @@
package de.tsschulz.timeclock.ui.admin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.data.api.HolidayDto
import de.tsschulz.timeclock.data.api.RoleUserDto
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcCard
import de.tsschulz.timeclock.ui.components.TcError
import de.tsschulz.timeclock.ui.components.TcLoading
import de.tsschulz.timeclock.ui.components.TcTextField
import de.tsschulz.timeclock.ui.model.ButtonVariant
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
@Composable
fun HolidaysAdminScreen(
state: AdminUiState,
isTablet: Boolean,
onCreate: (String, Double, String, List<String>) -> Unit,
onDelete: (String) -> Unit,
) {
var date by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var hours by rememberSaveable { mutableStateOf("8") }
var description by rememberSaveable { mutableStateOf("") }
var stateIds by rememberSaveable { mutableStateOf("") }
AdminFrame(state) {
if (isTablet) {
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Xl), modifier = Modifier.fillMaxWidth()) {
HolidayForm(
state = state,
date = date,
onDate = { date = it },
hours = hours,
onHours = { hours = it },
description = description,
onDescription = { description = it },
stateIds = stateIds,
onStateIds = { stateIds = it },
onCreate = {
onCreate(date, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds))
description = ""
},
modifier = Modifier.weight(0.9f),
)
Column(modifier = Modifier.weight(1.1f), verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg)) {
HolidayList("Zukünftige Feiertage", state.futureHolidays, onDelete)
HolidayList("Vergangene Feiertage", state.pastHolidays, onDelete)
}
}
} else {
HolidayForm(
state = state,
date = date,
onDate = { date = it },
hours = hours,
onHours = { hours = it },
description = description,
onDescription = { description = it },
stateIds = stateIds,
onStateIds = { stateIds = it },
onCreate = {
onCreate(date, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds))
description = ""
},
)
HolidayList("Zukünftige Feiertage", state.futureHolidays, onDelete)
HolidayList("Vergangene Feiertage", state.pastHolidays, onDelete)
}
}
}
@Composable
fun RolesAdminScreen(
state: AdminUiState,
onUpdateRole: (String, Int) -> Unit,
) {
AdminFrame(state) {
TcCard {
SectionTitle("Rechte")
Text(
text = "Als Administrator können Sie hier die Berechtigungen anderer Benutzer verwalten.",
color = TcColors.TextMuted,
fontSize = 14.sp,
)
}
ListCard("Benutzer", state.users) { user ->
UserRoleRow(user, onUpdateRole)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun HolidayForm(
state: AdminUiState,
date: String,
onDate: (String) -> Unit,
hours: String,
onHours: (String) -> Unit,
description: String,
onDescription: (String) -> Unit,
stateIds: String,
onStateIds: (String) -> Unit,
onCreate: () -> Unit,
modifier: Modifier = Modifier,
) {
FormCard("Feiertag hinzufügen", modifier) {
TcTextField("Datum", date, onDate, placeholder = "YYYY-MM-DD")
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(
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
val selected = parseStateIds(stateIds).toSet()
state.holidayStates.forEach { item ->
TcButton(
text = item.name,
variant = if (item.id in selected) ButtonVariant.Primary else ButtonVariant.Default,
onClick = { onStateIds(toggleStateId(stateIds, item.id)) },
)
}
}
}
TcButton("Feiertag hinzufügen", variant = ButtonVariant.Primary, onClick = {
if (date.isNotBlank() && description.isNotBlank()) onCreate()
})
}
}
@Composable
private fun HolidayList(title: String, holidays: List<HolidayDto>, onDelete: (String) -> Unit) {
ListCard(title, holidays) { holiday -> HolidayRow(holiday, onDelete) }
}
@Composable
private fun HolidayRow(holiday: HolidayDto, onDelete: (String) -> Unit) = DataRow(
title = holiday.description,
details = "${holiday.date.toDisplayDate()} - ${holiday.hours} h - ${
if (holiday.isFederal || holiday.states.isEmpty()) "Bundesfeiertag" else holiday.states.joinToString()
}",
action = "Löschen",
variant = ButtonVariant.Danger,
onAction = { onDelete(holiday.id) },
)
@Composable
private fun UserRoleRow(user: RoleUserDto, onUpdateRole: (String, Int) -> Unit) {
val isAdmin = user.role == 1 || user.roleString == "admin"
DataRow(
title = user.fullName,
details = "${user.stateName ?: "-"} - ${if (isAdmin) "Administrator" else "Benutzer"}",
action = if (isAdmin) "Zu Benutzer" else "Zu Admin",
variant = if (isAdmin) ButtonVariant.Secondary else ButtonVariant.Primary,
onAction = { onUpdateRole(user.id, if (isAdmin) 0 else 1) },
)
}
@Composable
private fun AdminFrame(state: AdminUiState, content: @Composable ColumnScope.() -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg)) {
if (state.loading) TcLoading("Lade Daten...")
state.error?.let { TcError(it) }
state.success?.let {
TcCard { Text(it, color = TcColors.Success, fontSize = 14.sp, fontWeight = FontWeight.Medium) }
}
content()
}
}
@Composable
private fun FormCard(title: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
TcCard(modifier = modifier) {
SectionTitle(title)
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Md), content = content)
}
}
@Composable
private fun <T> ListCard(title: String, items: List<T>, row: @Composable (T) -> Unit) {
TcCard {
SectionTitle(title)
if (items.isEmpty()) {
Text("Keine Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 14.sp)
} else {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
items.forEach { item -> row(item) }
}
}
}
}
@Composable
private fun DataRow(
title: String,
details: String,
action: String,
variant: ButtonVariant,
onAction: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Text(details, color = TcColors.TextMuted, fontSize = 13.sp)
}
TcButton(action, variant = variant, onClick = onAction)
}
}
@Composable
private fun SectionTitle(title: String) {
Text(
text = title,
color = TcColors.Text,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = TcSpacing.Sm),
)
}
private fun parseStateIds(raw: String): List<String> =
raw.split(',', ';', ' ')
.map { it.trim() }
.filter { it.isNotBlank() }
private fun toggleStateId(raw: String, id: String): String {
val ids = parseStateIds(raw).toMutableList()
if (id in ids) ids.remove(id) else ids.add(id)
return ids.joinToString(",")
}
private fun String?.toDisplayDate(): String {
if (this.isNullOrBlank()) return "-"
return runCatching {
val p = LocalDate.parse(this)
"%02d.%02d.%04d".format(p.dayOfMonth, p.monthValue, p.year)
}.getOrDefault(this)
}

View File

@@ -0,0 +1,95 @@
package de.tsschulz.timeclock.ui.admin
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import de.tsschulz.timeclock.data.admin.AdminRepository
import de.tsschulz.timeclock.data.api.HolidayDto
import de.tsschulz.timeclock.data.api.HolidayStateDto
import de.tsschulz.timeclock.data.api.RoleUserDto
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.auth.TokenStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AdminUiState(
val loading: Boolean = false,
val error: String? = null,
val success: String? = null,
val holidayStates: List<HolidayStateDto> = emptyList(),
val futureHolidays: List<HolidayDto> = emptyList(),
val pastHolidays: List<HolidayDto> = emptyList(),
val users: List<RoleUserDto> = emptyList(),
)
class AdminViewModel(
private val repository: AdminRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(AdminUiState())
val uiState: StateFlow<AdminUiState> = _uiState.asStateFlow()
fun loadPhase6(isAdmin: Boolean) {
if (!isAdmin) return
loadHolidays()
loadRoles()
}
fun loadHolidays() = launchLoad {
val holidays = repository.getHolidays()
copy(
holidayStates = repository.getHolidayStates(),
futureHolidays = holidays.future,
pastHolidays = holidays.past,
)
}
fun createHoliday(date: String, hours: Double, description: String, stateIds: List<String>) = launchMutation("Feiertag gespeichert") {
repository.createHoliday(date, hours, description, stateIds)
loadHolidays()
}
fun deleteHoliday(id: String) = launchMutation("Feiertag gelöscht") {
repository.deleteHoliday(id)
loadHolidays()
}
fun loadRoles() = launchLoad {
copy(users = repository.getRoleUsers())
}
fun updateUserRole(id: String, role: Int) = launchMutation("Rolle geändert") {
repository.updateUserRole(id, role)
loadRoles()
}
private fun launchLoad(reducer: suspend AdminUiState.() -> AdminUiState) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null, success = null) }
runCatching { _uiState.value.reducer() }
.onSuccess { next -> _uiState.value = next.copy(loading = false, error = null) }
.onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Daten konnten nicht geladen werden") } }
}
}
private fun launchMutation(successMessage: String, block: suspend () -> Unit) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null, success = null) }
runCatching { block() }
.onSuccess { _uiState.update { it.copy(success = successMessage) } }
.onFailure { e -> _uiState.update { it.copy(error = e.message ?: "Aktion fehlgeschlagen") } }
_uiState.update { it.copy(loading = false) }
}
}
class Factory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val tokenStore = TokenStore(application)
return AdminViewModel(AdminRepository(TimeClockApiClient(tokenStore))) as T
}
}
}

View File

@@ -0,0 +1,109 @@
package de.tsschulz.timeclock.ui.auth
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import de.tsschulz.timeclock.data.auth.AuthRepository
import de.tsschulz.timeclock.data.auth.TokenStore
import de.tsschulz.timeclock.data.auth.UserProfile
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AuthUiState(
val bootstrapping: Boolean = true,
val isAuthenticated: Boolean = false,
val user: UserProfile? = null,
val loginInProgress: Boolean = false,
val error: String? = null,
/** Gespeicherter Token, aber `/auth/me` ist fehlgeschlagen (z. B. offline). */
val bootstrapWarn: String? = null,
)
class AuthViewModel(
private val repository: AuthRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch { runBootstrap() }
}
private suspend fun runBootstrap() {
val user = repository.restoreSession()
val warn = if (user == null && repository.hasStoredToken()) {
"Profil konnte nicht geladen werden. Bitte Netzwerk prüfen oder erneut versuchen."
} else {
null
}
_uiState.value = AuthUiState(
bootstrapping = false,
isAuthenticated = user != null,
user = user,
bootstrapWarn = warn,
)
}
fun login(email: String, password: String, action: String) {
if (email.isBlank() || password.isBlank()) {
_uiState.update { it.copy(error = "E-Mail und Passwort sind erforderlich") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(loginInProgress = true, error = null) }
repository.login(email, password, action).fold(
onSuccess = { user ->
_uiState.update {
it.copy(
loginInProgress = false,
isAuthenticated = true,
user = user,
error = null,
bootstrapWarn = null,
)
}
},
onFailure = { e ->
_uiState.update {
it.copy(
loginInProgress = false,
error = e.message ?: "Login fehlgeschlagen",
)
}
},
)
}
}
fun logout() {
viewModelScope.launch {
repository.logout()
_uiState.value = AuthUiState(bootstrapping = false)
}
}
fun retryBootstrap() {
viewModelScope.launch {
_uiState.update { it.copy(bootstrapping = true, bootstrapWarn = null) }
runBootstrap()
}
}
class Factory(
private val application: Application,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val tokenStore = TokenStore(application)
val api = TimeClockApiClient(tokenStore)
val repo = AuthRepository(api, tokenStore)
return AuthViewModel(repo) as T
}
}
}

View File

@@ -0,0 +1,257 @@
package de.tsschulz.timeclock.ui.auth
import androidx.compose.foundation.BorderStroke
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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.components.TcBrandTitle
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcTextField
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
/**
* Layout und Farben an die Web-Loginseite angelehnt (`Login.vue`: `.auth-page`, Navbar, `.auth-form-container`).
*/
@Composable
fun LoginScreen(
state: AuthUiState,
onLogin: (email: String, password: String, action: String) -> Unit,
onRetryBootstrap: () -> Unit,
modifier: Modifier = Modifier,
) {
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.background(TcColors.Background),
) {
val useWideFormRows = maxWidth >= 560.dp
val formHorizontalPadding = if (maxWidth < 420.dp) 24.dp else 48.dp
val formVerticalPadding = if (maxWidth < 420.dp) 24.dp else 40.dp
Column(Modifier.fillMaxSize()) {
LoginTopBar()
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = TcSpacing.Xxl, vertical = TcSpacing.WebContainer),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.widthIn(max = 900.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(4.dp, RoundedCornerShape(TcRadius.AuthPanel), clip = false)
.background(TcColors.FormSurface, RoundedCornerShape(TcRadius.AuthPanel))
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.AuthPanel)),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
TcColors.FormHeaderBg,
RoundedCornerShape(topStart = TcRadius.AuthPanel, topEnd = TcRadius.AuthPanel),
)
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Einloggen",
color = TcColors.Text,
fontSize = 25.6.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
)
}
HorizontalDivider(color = TcColors.Border, thickness = 1.dp)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = formHorizontalPadding, vertical = formVerticalPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (state.bootstrapping) {
Text(
text = "Sitzung wird geprüft…",
color = TcColors.TextMuted,
fontSize = 14.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
} else {
state.bootstrapWarn?.let { msg ->
AuthErrorBanner(message = msg)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
TcButton(
text = "Erneut versuchen",
variant = ButtonVariant.Primary,
onClick = onRetryBootstrap,
)
}
}
state.error?.let { AuthErrorBanner(message = it) }
AuthFormRow(
label = "E-Mail-Adresse",
horizontal = useWideFormRows,
) {
TcTextField(
label = "",
value = email,
onValueChange = { email = it },
placeholder = "Ihre E-Mail-Adresse eingeben",
showLabel = false,
)
}
AuthFormRow(
label = "Passwort",
horizontal = useWideFormRows,
) {
TcTextField(
label = "",
value = password,
onValueChange = { password = it },
placeholder = "Ihr Passwort eingeben",
isPassword = true,
showLabel = false,
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
TcButton(
text = if (state.loginInProgress) "Wird eingeloggt…" else "Einloggen",
variant = ButtonVariant.Primary,
onClick = { onLogin(email, password, "0") },
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
)
}
}
}
}
}
}
}
}
}
@Composable
private fun LoginTopBar() {
Row(
modifier = Modifier
.fillMaxWidth()
.shadow(2.dp)
.background(TcColors.Navbar)
.border(BorderStroke(1.dp, TcColors.BorderSoft))
.padding(horizontal = TcSpacing.WebContainer, vertical = TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
) {
TcBrandTitle()
}
}
@Composable
private fun AuthErrorBanner(message: String) {
Text(
text = message,
color = TcColors.AuthErrorText,
fontSize = 14.sp,
modifier = Modifier
.fillMaxWidth()
.background(TcColors.AuthErrorBg, RoundedCornerShape(TcRadius.Medium))
.border(BorderStroke(1.dp, TcColors.AuthErrorBorder), RoundedCornerShape(TcRadius.Medium))
.padding(12.dp),
)
}
@Composable
private fun AuthFormRow(
label: String,
horizontal: Boolean,
content: @Composable () -> Unit,
) {
if (horizontal) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = label,
color = Color333,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
modifier = Modifier.width(192.dp),
)
Box(modifier = Modifier.weight(1f)) {
content()
}
}
} else {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
color = Color333,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = TcSpacing.Sm),
)
content()
}
}
}
private val Color333 = androidx.compose.ui.graphics.Color(0xFF333333)

View File

@@ -0,0 +1,300 @@
package de.tsschulz.timeclock.ui.booking
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.data.api.CalendarDayDto
import de.tsschulz.timeclock.data.api.CalendarDto
import de.tsschulz.timeclock.data.api.SickEntryDto
import de.tsschulz.timeclock.data.api.TimefixDto
import de.tsschulz.timeclock.data.api.VacationDto
import de.tsschulz.timeclock.data.api.WorkdaysDto
import de.tsschulz.timeclock.data.api.WorklogEntryDto
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcCard
import de.tsschulz.timeclock.ui.components.TcError
import de.tsschulz.timeclock.ui.components.TcLoading
import de.tsschulz.timeclock.ui.components.TcTextField
import de.tsschulz.timeclock.ui.model.ButtonVariant
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.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun VacationScreen(state: BookingUiState, isTablet: Boolean, onCreate: (Int, String, String?) -> Unit, onDelete: (String) -> Unit) {
var type by rememberSaveable { mutableStateOf("0") }
var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var end by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
Phase4Frame(state) {
FormCard("Urlaub eintragen", isTablet) {
TcTextField("Umfang (0 Zeitraum, 1 Halber Tag)", type, { type = it }, placeholder = "0")
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)
})
}
ListCard("Urlaubseinträge", state.vacations) { item ->
VacationRow(item, onDelete)
}
}
}
@Composable
fun SickScreen(state: BookingUiState, isTablet: Boolean, onCreate: (String, String, String?) -> Unit, onDelete: (String) -> Unit) {
var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var end by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var typeId by rememberSaveable { mutableStateOf(state.sickTypes.firstOrNull()?.id?.toString().orEmpty()) }
Phase4Frame(state) {
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}" })
TcButton("Erkrankung eintragen", variant = ButtonVariant.Primary, onClick = {
if (typeId.isNotBlank()) onCreate(typeId, start, end.ifBlank { start })
})
}
ListCard("Krankheitseinträge", state.sickEntries) { item ->
SickRow(item, onDelete)
}
}
}
@Composable
fun TimefixScreen(
state: BookingUiState,
isTablet: Boolean,
onDate: (String) -> Unit,
onCreate: (String, String, String, String) -> Unit,
onDelete: (String) -> Unit,
) {
var worklogId by rememberSaveable { mutableStateOf("") }
var time by rememberSaveable { mutableStateOf("08:00") }
var action by rememberSaveable { mutableStateOf("start work") }
Phase4Frame(state) {
FormCard("Zeitkorrektur", isTablet) {
TcTextField("Datum", state.timefixDate, onDate, placeholder = "YYYY-MM-DD")
TcTextField("Worklog-ID", worklogId, { worklogId = it }, placeholder = state.worklogEntries.firstOrNull()?.id?.toString() ?: "")
TcTextField("Neue Uhrzeit", time, { time = it }, placeholder = "HH:MM")
TcTextField("Aktion", action, { action = it }, placeholder = "start work")
TcButton("Korrektur erstellen", variant = ButtonVariant.Primary, onClick = {
if (worklogId.isNotBlank()) onCreate(worklogId, state.timefixDate, time, action)
})
if (state.worklogEntries.isEmpty()) {
Text("Für dieses Datum sind keine Worklog-Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 13.sp)
}
}
ListCard("Worklog-Einträge am Datum", state.worklogEntries) { item -> WorklogRow(item) }
ListCard("Zeitkorrekturen heute", state.timefixes) { item -> TimefixRow(item, onDelete) }
}
}
@Composable
fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) {
var year by rememberSaveable { mutableStateOf(state.workdaysYear.toString()) }
Phase4Frame(state) {
FormCard("Arbeitstage", isTablet = false) {
TcTextField("Jahr", year, { year = it }, placeholder = "2026")
TcButton("Jahr laden", variant = ButtonVariant.Primary, onClick = { year.toIntOrNull()?.let(onYear) })
}
WorkdaysCard(state.workdays)
}
}
@Composable
fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) {
Phase4Frame(state) {
TcCard {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
TcButton("", variant = ButtonVariant.Secondary, onClick = { onMonth(-1) })
Text(
text = YearMonth.of(state.calendarYear, state.calendarMonth)
.format(DateTimeFormatter.ofPattern("MMMM yyyy", Locale.GERMANY)),
color = TcColors.Text,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
TcButton("", variant = ButtonVariant.Secondary, onClick = { onMonth(1) })
}
}
CalendarGrid(state.calendar)
}
}
@Composable
private fun Phase4Frame(state: BookingUiState, content: @Composable () -> Unit) {
if (state.loading) TcLoading("Lade Daten...")
state.error?.let { TcError(it) }
content()
}
@Composable
private fun FormCard(title: String, isTablet: Boolean, content: @Composable ColumnScope.() -> Unit) {
TcCard {
SectionTitle(title)
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Md), content = content)
}
}
@Composable
private fun <T> ListCard(title: String, items: List<T>, row: @Composable (T) -> Unit) {
TcCard {
SectionTitle(title)
if (items.isEmpty()) {
Text("Keine Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 14.sp)
} else {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
items.forEach { item -> row(item) }
}
}
}
}
@Composable
private fun VacationRow(item: VacationDto, onDelete: (String) -> Unit) = DataRow(
title = item.type ?: "Urlaub",
details = "${item.startDate.toDisplayDate()} - ${item.endDate.toDisplayDate()}",
onDelete = { onDelete(item.id) },
)
@Composable
private fun SickRow(item: SickEntryDto, onDelete: (String) -> Unit) = DataRow(
title = item.sickTypeName ?: "Krankheit",
details = "${item.startDate.toDisplayDate()} - ${item.endDate.toDisplayDate()}",
onDelete = { onDelete(item.id) },
)
@Composable
private fun TimefixRow(item: TimefixDto, onDelete: (String) -> Unit) = DataRow(
title = item.newAction ?: "Zeitkorrektur",
details = "${item.originalDate.toDisplayDate()} ${item.originalTime ?: "—"}${item.newDate.toDisplayDate()} ${item.newTime ?: "—"}",
onDelete = { onDelete(item.id) },
)
@Composable
private fun WorklogRow(item: WorklogEntryDto) = DataRow(
title = "#${item.id} ${item.action ?: "—"}",
details = "${item.time ?: "—"} (${item.tstamp ?: "—"})",
)
@Composable
private fun DataRow(title: String, details: String, onDelete: (() -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Text(details, color = TcColors.TextMuted, fontSize = 13.sp)
}
onDelete?.let { TcButton("Löschen", variant = ButtonVariant.Danger, onClick = it) }
}
}
@Composable
private fun WorkdaysCard(data: WorkdaysDto?) {
TcCard {
SectionTitle("Jahresstatistik")
if (data == null) {
Text("Keine Statistik geladen.", color = TcColors.TextMuted)
} else {
Detail("Jahr", data.year.toString())
Detail("Werktage", data.workdays.toString())
Detail("Feiertage", data.holidays.toString())
Detail("Urlaubstage", data.vacationDays.toString())
Detail("Krankheitstage", "${data.sickDays} (${data.sickPercentage}%)")
Detail("Gearbeitete Tage", data.workedDays.toString())
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CalendarGrid(data: CalendarDto?) {
TcCard {
SectionTitle("Kalender")
if (data == null) {
Text("Kein Kalender geladen.", color = TcColors.TextMuted)
} else {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
data.weeks.forEach { week ->
FlowRow(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
week.forEach { CalendarCell(it) }
}
}
}
}
}
}
@Composable
private fun CalendarCell(day: CalendarDayDto) {
val bg = when {
day.isToday -> TcColors.ActiveMenu
!day.isCurrentMonth -> TcColors.Card
else -> TcColors.Background
}
Column(
modifier = Modifier
.width(92.dp)
.background(bg, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Sm),
) {
Text(day.day.toString(), color = TcColors.Text, 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) }
}
}
@Composable
private fun SectionTitle(text: String) {
Text(text, color = TcColors.Text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = TcSpacing.Md))
}
@Composable
private fun Detail(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("$label:", color = TcColors.TextMuted, fontSize = 14.sp)
Text(value, color = TcColors.Text, fontSize = 14.sp)
}
}
private fun String?.toDisplayDate(): String {
if (this.isNullOrBlank()) return ""
return runCatching {
LocalDate.parse(this.substring(0, 10)).format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY))
}.getOrDefault(this)
}

View File

@@ -0,0 +1,132 @@
package de.tsschulz.timeclock.ui.booking
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import de.tsschulz.timeclock.data.api.CalendarDto
import de.tsschulz.timeclock.data.api.SickEntryDto
import de.tsschulz.timeclock.data.api.SickTypeDto
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimefixDto
import de.tsschulz.timeclock.data.api.VacationDto
import de.tsschulz.timeclock.data.api.WorkdaysDto
import de.tsschulz.timeclock.data.api.WorklogEntryDto
import de.tsschulz.timeclock.data.auth.TokenStore
import de.tsschulz.timeclock.data.booking.BookingRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.YearMonth
data class BookingUiState(
val loading: Boolean = false,
val error: String? = null,
val vacations: List<VacationDto> = emptyList(),
val sickEntries: List<SickEntryDto> = emptyList(),
val sickTypes: List<SickTypeDto> = emptyList(),
val timefixes: List<TimefixDto> = emptyList(),
val worklogEntries: List<WorklogEntryDto> = emptyList(),
val timefixDate: String = LocalDate.now().toString(),
val workdaysYear: Int = LocalDate.now().year,
val workdays: WorkdaysDto? = null,
val calendarYear: Int = LocalDate.now().year,
val calendarMonth: Int = LocalDate.now().monthValue,
val calendar: CalendarDto? = null,
)
class BookingViewModel(
private val repository: BookingRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(BookingUiState())
val uiState: StateFlow<BookingUiState> = _uiState.asStateFlow()
fun loadPhase4() {
loadVacations()
loadSick()
loadTimefix()
loadWorkdays(_uiState.value.workdaysYear)
loadCalendar(_uiState.value.calendarYear, _uiState.value.calendarMonth)
}
fun loadVacations() = launchLoad { copy(vacations = repository.getVacations()) }
fun createVacation(type: Int, start: String, end: String?) = launchMutation {
repository.createVacation(type, start, end)
loadVacations()
}
fun deleteVacation(id: String) = launchMutation {
repository.deleteVacation(id)
loadVacations()
}
fun loadSick() = launchLoad {
copy(sickEntries = repository.getSickEntries(), sickTypes = repository.getSickTypes())
}
fun createSick(typeId: String, start: String, end: String?) = launchMutation {
repository.createSick(typeId, start, end)
loadSick()
}
fun deleteSick(id: String) = launchMutation {
repository.deleteSick(id)
loadSick()
}
fun setTimefixDate(date: String) {
_uiState.update { it.copy(timefixDate = date) }
loadTimefix(date)
}
fun loadTimefix(date: String = _uiState.value.timefixDate) = launchLoad {
copy(timefixes = repository.getTimefixes(), worklogEntries = repository.getWorklogEntries(date))
}
fun createTimefix(worklogId: String, date: String, time: String, action: String) = launchMutation {
repository.createTimefix(worklogId, date, time, action)
loadTimefix(date)
}
fun deleteTimefix(id: String) = launchMutation {
repository.deleteTimefix(id)
loadTimefix()
}
fun loadWorkdays(year: Int) = launchLoad {
copy(workdaysYear = year, workdays = repository.getWorkdays(year))
}
fun changeCalendarMonth(delta: Int) {
val current = YearMonth.of(_uiState.value.calendarYear, _uiState.value.calendarMonth).plusMonths(delta.toLong())
loadCalendar(current.year, current.monthValue)
}
fun loadCalendar(year: Int, month: Int) = launchLoad {
copy(calendarYear = year, calendarMonth = month, calendar = repository.getCalendar(year, month))
}
private fun launchLoad(reducer: suspend BookingUiState.() -> BookingUiState) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null) }
runCatching { _uiState.value.reducer() }
.onSuccess { next -> _uiState.value = next.copy(loading = false, error = null) }
.onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Daten konnten nicht geladen werden") } }
}
}
private fun launchMutation(block: suspend () -> Unit) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null) }
runCatching { block() }
.onFailure { e -> _uiState.update { it.copy(error = e.message ?: "Aktion fehlgeschlagen") } }
_uiState.update { it.copy(loading = false) }
}
}
class Factory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val tokenStore = TokenStore(application)
return BookingViewModel(BookingRepository(TimeClockApiClient(tokenStore))) as T
}
}
}

View File

@@ -0,0 +1,40 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.R
import de.tsschulz.timeclock.ui.theme.TcColors
/** App-Logo + „Stechuhr“ wie in der Web-Navbar. */
@Composable
fun TcBrandTitle(modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Image(
painter = painterResource(R.drawable.ic_stechuhr_logo),
contentDescription = null,
modifier = Modifier.size(32.dp),
contentScale = ContentScale.Fit,
)
Text(
text = "Stechuhr",
color = TcColors.Text,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
)
}
}

View File

@@ -0,0 +1,64 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
@Composable
fun TcButton(
text: String,
modifier: Modifier = Modifier,
variant: ButtonVariant = ButtonVariant.Default,
contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
onClick: () -> Unit = {},
) {
val style = buttonStyle(variant)
Box(
modifier = modifier
.defaultMinSize(minHeight = 36.dp)
.background(style.background, RoundedCornerShape(TcRadius.Medium))
.border(BorderStroke(1.dp, style.border), RoundedCornerShape(TcRadius.Medium))
.clickable(onClick = onClick)
.padding(contentPadding),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = style.text,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
)
}
}
private data class TcButtonStyle(
val background: Color,
val border: Color,
val text: Color,
)
private fun buttonStyle(variant: ButtonVariant): TcButtonStyle =
when (variant) {
ButtonVariant.Default -> TcButtonStyle(TcColors.Button, TcColors.ButtonBorder, Color(0xFF333333))
ButtonVariant.Primary -> TcButtonStyle(TcColors.Primary, TcColors.PrimaryBorder, Color.White)
ButtonVariant.Success -> TcButtonStyle(TcColors.Success, TcColors.SuccessBorder, Color.White)
ButtonVariant.Danger -> TcButtonStyle(TcColors.Danger, TcColors.DangerBorder, Color.White)
ButtonVariant.Secondary -> TcButtonStyle(TcColors.Secondary, TcColors.Secondary, Color.White)
}

View File

@@ -0,0 +1,35 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.unit.dp
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
@Composable
fun TcCard(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(TcSpacing.Xl),
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
.shadow(2.dp, RoundedCornerShape(TcRadius.Card), clip = false)
.background(TcColors.Card, RoundedCornerShape(TcRadius.Card))
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Card))
.padding(contentPadding),
content = content,
)
}

View File

@@ -0,0 +1,285 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.EventNote
import androidx.compose.material.icons.filled.AdminPanelSettings
import androidx.compose.material.icons.filled.Assessment
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.model.AppRoute
import de.tsschulz.timeclock.ui.model.MenuSection
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
@Composable
fun TcSectionMenu(
sections: List<MenuSection>,
selectedRoute: AppRoute,
onRouteSelected: (AppRoute) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxHeight()
.width(230.dp)
.background(TcColors.Background)
.border(BorderStroke(1.dp, TcColors.Border))
.verticalScroll(rememberScrollState())
.padding(vertical = TcSpacing.Md),
) {
sections.forEach { section ->
Text(
text = section.title,
color = TcColors.TextMuted,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
modifier = Modifier.padding(horizontal = TcSpacing.Lg, vertical = TcSpacing.Sm),
)
section.items.forEach { item ->
val selected = item.route == selectedRoute
Text(
text = item.label,
color = if (selected) TcColors.Text else Color333,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier
.fillMaxWidth()
.background(if (selected) TcColors.ActiveMenu else TcColors.Background)
.clickable { onRouteSelected(item.route) }
.padding(horizontal = TcSpacing.Xl, vertical = TcSpacing.Sm),
)
}
}
}
}
@Composable
fun TcBottomNavigation(
sections: List<MenuSection>,
selectedRoute: AppRoute,
onRouteSelected: (AppRoute) -> Unit,
modifier: Modifier = Modifier,
) {
val items = sections.mapNotNull { section ->
val firstRoute = section.items.firstOrNull()?.route ?: return@mapNotNull null
BottomNavItem(section.title, firstRoute, section.icon())
}
var openSectionTitle by rememberSaveable { mutableStateOf<String?>(null) }
Column(
modifier = modifier
.fillMaxWidth()
.background(TcColors.Navbar)
.border(BorderStroke(1.dp, TcColors.BorderSoft))
.padding(horizontal = TcSpacing.Sm, vertical = TcSpacing.Xs),
) {
val openSection = sections.firstOrNull { it.title == openSectionTitle }
openSection?.let { section ->
Column(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Card, RoundedCornerShape(TcRadius.Medium))
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Medium))
.padding(vertical = TcSpacing.Sm),
) {
section.items.forEach { menuItem ->
val selected = menuItem.route == selectedRoute
Text(
text = menuItem.label,
color = if (selected) TcColors.Text else Color333,
fontSize = 15.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier
.fillMaxWidth()
.background(if (selected) TcColors.ActiveMenu else TcColors.Card)
.clickable {
onRouteSelected(menuItem.route)
openSectionTitle = null
}
.padding(horizontal = TcSpacing.Xl, vertical = TcSpacing.Md),
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
items.forEach { item ->
val selected = item.route == selectedRoute || isRouteInSection(item.route, selectedRoute)
val expanded = openSectionTitle == item.label
Column(
modifier = Modifier
.heightIn(min = 56.dp)
.weight(1f)
.background(if (selected || expanded) TcColors.ActiveMenu else TcColors.Navbar, RoundedCornerShape(TcRadius.Small))
.clickable {
openSectionTitle = if (expanded) null else item.label
}
.padding(vertical = TcSpacing.Sm),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(imageVector = item.icon, contentDescription = item.label, tint = TcColors.TextMuted)
Text(text = item.label, color = TcColors.TextMuted, fontSize = 11.sp)
}
}
}
}
}
@Composable
fun TcMobileSubMenu(
sections: List<MenuSection>,
selectedRoute: AppRoute,
onRouteSelected: (AppRoute) -> Unit,
modifier: Modifier = Modifier,
) {
val section = sections.firstOrNull { s -> s.items.any { it.route == selectedRoute } } ?: return
Column(
modifier = modifier
.fillMaxWidth()
.background(TcColors.Card)
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
Text(section.title, color = TcColors.TextMuted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
section.items.forEach { item ->
val selected = item.route == selectedRoute
Text(
text = item.label,
color = if (selected) TcColors.Text else Color333,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier
.fillMaxWidth()
.background(if (selected) TcColors.ActiveMenu else TcColors.Card, RoundedCornerShape(TcRadius.Small))
.clickable { onRouteSelected(item.route) }
.padding(horizontal = TcSpacing.Md, vertical = TcSpacing.Sm),
)
}
}
}
@Composable
fun TcMobileMainMenu(
sections: List<MenuSection>,
selectedRoute: AppRoute,
onRouteSelected: (AppRoute) -> Unit,
modifier: Modifier = Modifier,
) {
val selectedSectionTitle = sections.firstOrNull { section ->
section.items.any { it.route == selectedRoute }
}?.title
var openSectionTitle by rememberSaveable { mutableStateOf(selectedSectionTitle) }
LaunchedEffect(selectedSectionTitle) {
openSectionTitle = selectedSectionTitle
}
Column(
modifier = modifier
.fillMaxWidth()
.background(TcColors.Card)
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Medium))
.padding(vertical = TcSpacing.Sm),
) {
sections.forEach { section ->
val open = openSectionTitle == section.title
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
openSectionTitle = if (open) null else section.title
}
.padding(horizontal = TcSpacing.Lg, vertical = TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
Text(
text = section.title,
color = TcColors.Text,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Text(if (open) "" else "", color = TcColors.TextMuted, fontSize = 14.sp)
}
if (open) {
section.items.forEach { item ->
val selected = item.route == selectedRoute
Text(
text = item.label,
color = if (selected) TcColors.Text else Color333,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier
.fillMaxWidth()
.background(if (selected) TcColors.ActiveMenu else TcColors.Card)
.clickable { onRouteSelected(item.route) }
.padding(horizontal = TcSpacing.Xl, vertical = TcSpacing.Sm),
)
}
}
}
}
}
private data class BottomNavItem(
val label: String,
val route: AppRoute,
val icon: ImageVector,
)
private fun isRouteInSection(sectionRoute: AppRoute, selectedRoute: AppRoute): Boolean =
when (sectionRoute) {
AppRoute.Week -> selectedRoute in setOf(AppRoute.Timefix, AppRoute.Vacation, AppRoute.Sick, AppRoute.Workdays, AppRoute.Calendar)
AppRoute.Profile -> selectedRoute in setOf(AppRoute.Password, AppRoute.Timewish, AppRoute.Permissions, AppRoute.Invite)
AppRoute.Entries -> selectedRoute == AppRoute.Stats
AppRoute.Holidays -> selectedRoute == AppRoute.Roles
else -> false
}
private fun MenuSection.icon(): ImageVector =
when (title) {
"Buchungen" -> Icons.AutoMirrored.Filled.EventNote
"Einstellungen" -> Icons.Filled.Settings
"Auswertung" -> Icons.Filled.Assessment
"Verwaltung" -> Icons.Filled.AdminPanelSettings
else -> Icons.Filled.CalendarMonth
}
private val Color333 = androidx.compose.ui.graphics.Color(0xFF333333)

View File

@@ -0,0 +1,185 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.model.AppRoute
import de.tsschulz.timeclock.ui.model.MenuSection
import de.tsschulz.timeclock.ui.model.StatusAction
import de.tsschulz.timeclock.ui.model.StatusRow
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
@Composable
fun TcScaffold(
title: String,
userName: String,
sections: List<MenuSection>,
selectedRoute: AppRoute,
isTablet: Boolean,
statusRows: List<StatusRow>,
primaryStatusAction: StatusAction?,
secondaryStatusAction: StatusAction?,
onRouteSelected: (AppRoute) -> Unit,
onLogout: () -> Unit,
onStatusAction: (StatusAction) -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
if (isTablet) {
Row(modifier = Modifier.fillMaxSize().background(TcColors.Background)) {
TcSectionMenu(
sections = sections,
selectedRoute = selectedRoute,
onRouteSelected = onRouteSelected,
)
Column(modifier = Modifier.fillMaxSize()) {
TcTopBar(title = title, userName = userName, compact = false, onLogout = onLogout)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TcSpacing.WebContainer, vertical = TcSpacing.Lg),
horizontalArrangement = Arrangement.End,
) {
TcStatusBox(
rows = statusRows,
primaryAction = primaryStatusAction,
secondaryAction = secondaryStatusAction,
modifier = Modifier.fillMaxWidth(0.48f),
onAction = onStatusAction,
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = TcSpacing.WebContainer)
.padding(bottom = TcSpacing.Xxl),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Xl),
content = content,
)
}
}
} else {
Column(modifier = Modifier.fillMaxSize().background(TcColors.Background)) {
TcTopBar(title = title, userName = userName, compact = true, onLogout = onLogout)
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = TcSpacing.Lg, vertical = TcSpacing.Lg),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg),
) {
TcStatusBox(
rows = statusRows,
primaryAction = primaryStatusAction,
secondaryAction = secondaryStatusAction,
modifier = Modifier.fillMaxWidth(),
onAction = onStatusAction,
)
content()
}
TcBottomNavigation(
sections = sections,
selectedRoute = selectedRoute,
onRouteSelected = onRouteSelected,
)
}
}
}
@Composable
fun TcTopBar(
title: String,
userName: String,
modifier: Modifier = Modifier,
compact: Boolean = false,
onLogout: () -> Unit = {},
) {
val barModifier = modifier
.fillMaxWidth()
.shadow(2.dp)
.background(TcColors.Navbar)
.border(BorderStroke(1.dp, TcColors.BorderSoft))
.statusBarsPadding()
.padding(horizontal = TcSpacing.Xl, vertical = TcSpacing.Md)
if (compact) {
Column(
modifier = barModifier,
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TcSpacing.Lg)) {
TcBrandTitle()
Box(modifier = Modifier.weight(1f))
TcButton(text = "Abmelden", onClick = onLogout)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md)) {
PageTitle(title = title)
Text(
text = userName,
color = TcColors.TextMuted,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
return
}
Row(
modifier = barModifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Lg),
) {
TcBrandTitle()
PageTitle(title = title)
Box(modifier = Modifier.weight(1f))
Text(text = userName, color = TcColors.TextMuted, fontSize = 14.sp)
TcButton(text = "Abmelden", onClick = onLogout)
}
}
@Composable
private fun PageTitle(title: String) {
Box(
modifier = Modifier
.background(TcColors.Background, RoundedCornerShape(TcRadius.Small))
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Small))
.padding(horizontal = 15.dp, vertical = 4.dp),
) {
Text(
text = title,
color = Color2c3e50,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private val Color2c3e50 = androidx.compose.ui.graphics.Color(0xFF2C3E50)

View File

@@ -0,0 +1,41 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcSpacing
@Composable
fun TcLoading(text: String = "Lädt...") {
TcCard {
Text(text = text, color = TcColors.TextMuted, fontSize = 14.sp)
}
}
@Composable
fun TcError(message: String) {
TcCard {
Text(text = "Fehler", color = TcColors.Danger, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(text = message, color = TcColors.TextMuted, fontSize = 14.sp, modifier = Modifier.padding(top = TcSpacing.Sm))
}
}
@Composable
fun TcEmptyState(title: String, message: String) {
Column(
modifier = Modifier.fillMaxWidth().padding(TcSpacing.Xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
Text(text = title, color = TcColors.Text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
Text(text = message, color = TcColors.TextMuted, fontSize = 14.sp)
}
}

View File

@@ -0,0 +1,73 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.Text
import androidx.compose.runtime.Composable
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.ui.model.StatusAction
import de.tsschulz.timeclock.ui.model.StatusRow
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TcStatusBox(
rows: List<StatusRow>,
primaryAction: StatusAction?,
secondaryAction: StatusAction?,
modifier: Modifier = Modifier,
onAction: (StatusAction) -> Unit = {},
) {
Column(
modifier = modifier
.background(TcColors.Card, RoundedCornerShape(TcRadius.Card))
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Card))
.padding(TcSpacing.Md),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Md),
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
primaryAction?.let { action ->
TcButton(text = action.label, variant = action.variant, onClick = { onAction(action) })
}
secondaryAction?.let { action ->
TcButton(text = action.label, variant = action.variant, onClick = { onAction(action) })
}
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
rows.forEach { row ->
if (row.isHeading) {
Text(
text = row.label,
color = TcColors.Text,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = TcSpacing.Xs),
)
} else {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = "${row.label}:", color = TcColors.TextMuted, fontSize = 13.sp)
Text(text = row.value ?: "-", color = TcColors.Text, fontSize = 13.sp)
}
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
@Composable
fun TcTextField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "",
isPassword: Boolean = false,
showLabel: Boolean = true,
) {
Column(modifier = modifier.fillMaxWidth()) {
if (showLabel) {
Text(
text = label,
color = Color333,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = TcSpacing.Sm),
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(color = TcColors.Text, fontSize = 14.sp),
cursorBrush = SolidColor(TcColors.Primary),
singleLine = true,
visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None,
keyboardOptions = KeyboardOptions(
keyboardType = if (isPassword) KeyboardType.Password else KeyboardType.Email,
),
modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
.border(BorderStroke(1.dp, TcColors.InputBorder), RoundedCornerShape(TcRadius.Medium))
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
if (value.isEmpty() && placeholder.isNotEmpty()) {
Text(text = placeholder, color = TcColors.TextMuted, fontSize = 14.sp)
}
innerTextField()
}
},
)
}
}
private val Color333 = androidx.compose.ui.graphics.Color(0xFF333333)

View File

@@ -0,0 +1,52 @@
package de.tsschulz.timeclock.ui.model
val userSections = listOf(
MenuSection(
title = "Buchungen",
items = listOf(
MenuItem("Wochenübersicht", AppRoute.Week),
MenuItem("Zeitkorrekturen", AppRoute.Timefix),
MenuItem("Urlaub", AppRoute.Vacation),
MenuItem("Krankheit", AppRoute.Sick),
MenuItem("Arbeitstage", AppRoute.Workdays),
MenuItem("Kalender", AppRoute.Calendar),
),
),
MenuSection(
title = "Einstellungen",
items = listOf(
MenuItem("Persönliches", AppRoute.Profile),
MenuItem("Passwort ändern", AppRoute.Password),
MenuItem("Zeitwünsche", AppRoute.Timewish),
MenuItem("Zugriffe verwalten", AppRoute.Permissions),
MenuItem("Einladen", AppRoute.Invite),
),
),
MenuSection(
title = "Auswertung",
items = listOf(
MenuItem("Einträge", AppRoute.Entries),
MenuItem("Statistiken", AppRoute.Stats),
),
),
)
val adminSections = userSections + MenuSection(
title = "Verwaltung",
items = listOf(
MenuItem("Feiertage", AppRoute.Holidays),
MenuItem("Rechte", AppRoute.Roles),
),
)
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"),
)
val mockPrimaryAction = StatusAction("Pause starten", ButtonVariant.Success)
val mockSecondaryAction = StatusAction("Gehen", ButtonVariant.Secondary)

View File

@@ -0,0 +1,50 @@
package de.tsschulz.timeclock.ui.model
data class MenuItem(
val label: String,
val route: AppRoute,
)
data class MenuSection(
val title: String,
val items: List<MenuItem>,
)
enum class AppRoute(val title: String) {
Week("Wochenübersicht"),
Timefix("Zeitkorrekturen"),
Vacation("Urlaub"),
Sick("Krankheit"),
Workdays("Arbeitstage"),
Calendar("Kalender"),
Entries("Einträge"),
Stats("Statistiken"),
Export("Export"),
Profile("Persönliches"),
Password("Passwort ändern"),
Timewish("Zeitwünsche"),
Permissions("Zugriffe verwalten"),
Invite("Einladen"),
Holidays("Feiertage"),
Roles("Rechte"),
}
data class StatusRow(
val label: String,
val value: String? = null,
val isHeading: Boolean = false,
)
data class StatusAction(
val label: String,
val variant: ButtonVariant,
val clockAction: String? = null,
)
enum class ButtonVariant {
Default,
Primary,
Success,
Danger,
Secondary,
}

View File

@@ -0,0 +1,304 @@
package de.tsschulz.timeclock.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.TimewishDto
import de.tsschulz.timeclock.data.api.WatcherDto
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcCard
import de.tsschulz.timeclock.ui.components.TcError
import de.tsschulz.timeclock.ui.components.TcLoading
import de.tsschulz.timeclock.ui.components.TcTextField
import de.tsschulz.timeclock.ui.model.ButtonVariant
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
@Composable
fun ProfileScreen(
state: SettingsUiState,
isTablet: Boolean,
onSave: (String, String?, Int, Double, Int) -> Unit,
) {
val profile = state.profile
var fullName by rememberSaveable { mutableStateOf("") }
var stateId by rememberSaveable { mutableStateOf("") }
var weekWorkdays by rememberSaveable { mutableStateOf("5") }
var dailyHours by rememberSaveable { mutableStateOf("8.0") }
var preferredTitleType by rememberSaveable { mutableStateOf("0") }
LaunchedEffect(profile) {
profile?.let {
fullName = it.fullName
stateId = it.stateId.orEmpty()
weekWorkdays = (it.weekWorkdays ?: 5).toString()
dailyHours = (it.dailyHours ?: 8.0).toString()
preferredTitleType = (it.preferredTitleType ?: 0).toString()
}
}
SettingsFrame(state) {
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}" })
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")
TcButton("Speichern", variant = ButtonVariant.Primary, onClick = {
onSave(
fullName,
stateId.ifBlank { null },
weekWorkdays.toIntOrNull() ?: 5,
dailyHours.toDoubleOrNull() ?: 8.0,
preferredTitleType.toIntOrNull() ?: 0,
)
})
}
ProfileDetails(profile)
}
}
}
@Composable
fun PasswordScreen(
state: SettingsUiState,
onChange: (String, String, String) -> Unit,
) {
var oldPassword by rememberSaveable { mutableStateOf("") }
var newPassword by rememberSaveable { mutableStateOf("") }
var confirmPassword by rememberSaveable { mutableStateOf("") }
SettingsFrame(state) {
FormCard("Passwort ändern") {
TcTextField("Aktuelles Passwort", oldPassword, { oldPassword = it }, isPassword = true)
TcTextField("Neues Passwort", newPassword, { newPassword = it }, isPassword = true)
TcTextField("Neues Passwort wiederholen", confirmPassword, { confirmPassword = it }, isPassword = true)
TcButton("Passwort ändern", variant = ButtonVariant.Primary, onClick = {
onChange(oldPassword, newPassword, confirmPassword)
oldPassword = ""
newPassword = ""
confirmPassword = ""
})
}
}
}
@Composable
fun TimewishScreen(
state: SettingsUiState,
isTablet: Boolean,
onCreate: (Int, Int, Double?, String, String?) -> Unit,
onDelete: (String) -> Unit,
) {
var day by rememberSaveable { mutableStateOf("1") }
var wishtype by rememberSaveable { mutableStateOf("1") }
var hours by rememberSaveable { mutableStateOf("8.0") }
var startDate by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var endDate by rememberSaveable { mutableStateOf("") }
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")
TcButton("Zeitwunsch speichern", variant = ButtonVariant.Primary, onClick = {
onCreate(
day.toIntOrNull() ?: 1,
wishtype.toIntOrNull() ?: 1,
hours.toDoubleOrNull(),
startDate,
endDate.ifBlank { null },
)
})
}
ListCard("Zeitwünsche", state.timewishes) { item -> TimewishRow(item, onDelete) }
}
}
}
@Composable
fun InviteScreen(
state: SettingsUiState,
isTablet: Boolean,
onSend: (String) -> Unit,
) {
var email by rememberSaveable { mutableStateOf("") }
SettingsFrame(state) {
ResponsiveSettings(isTablet) {
FormCard("Einladen") {
TcTextField("E-Mail", email, { email = it }, placeholder = "name@example.com")
TcButton("Einladung senden", variant = ButtonVariant.Primary, onClick = {
if (email.isNotBlank()) {
onSend(email)
email = ""
}
})
}
ListCard("Einladungen", state.invites) { item -> InviteRow(item) }
}
}
}
@Composable
fun PermissionsScreen(
state: SettingsUiState,
isTablet: Boolean,
onAdd: (String) -> Unit,
onDelete: (String) -> Unit,
) {
var email by rememberSaveable { mutableStateOf("") }
SettingsFrame(state) {
ResponsiveSettings(isTablet) {
FormCard("Zugriff hinzufügen") {
TcTextField("E-Mail", email, { email = it }, placeholder = "name@example.com")
TcButton("Zugriff speichern", variant = ButtonVariant.Primary, onClick = {
if (email.isNotBlank()) {
onAdd(email)
email = ""
}
})
}
ListCard("Aktuelle Zugriffe", state.watchers) { item -> WatcherRow(item, onDelete) }
}
}
}
@Composable
private fun SettingsFrame(state: SettingsUiState, content: @Composable () -> Unit) {
if (state.loading) TcLoading("Lade Daten...")
state.error?.let { TcError(it) }
state.success?.let {
TcCard { Text(it, color = TcColors.Success, fontSize = 14.sp, fontWeight = FontWeight.Medium) }
}
content()
}
@Composable
private fun ResponsiveSettings(isTablet: Boolean, content: @Composable ColumnScope.() -> Unit) {
if (isTablet) {
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Xl), modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg), content = content)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg), content = content)
}
}
@Composable
private fun FormCard(title: String, content: @Composable ColumnScope.() -> Unit) {
TcCard {
SectionTitle(title)
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Md), content = content)
}
}
@Composable
private fun <T> ListCard(title: String, items: List<T>, row: @Composable (T) -> Unit) {
TcCard {
SectionTitle(title)
if (items.isEmpty()) {
Text("Keine Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 14.sp)
} else {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
items.forEach { item -> row(item) }
}
}
}
}
@Composable
private fun ProfileDetails(profile: ProfileDto?) {
TcCard {
SectionTitle("Konto")
Detail("E-Mail", profile?.email ?: "-")
Detail("Bundesland", profile?.stateName ?: "-")
Detail("Wochenarbeitstage", profile?.weekWorkdays?.toString() ?: "-")
Detail("Tagesstunden", profile?.dailyHours?.toString() ?: "-")
}
}
@Composable
private fun TimewishRow(item: TimewishDto, onDelete: (String) -> Unit) = DataRow(
title = item.dayName ?: "Tag ${item.day}",
details = "${item.wishtypeName ?: "Typ ${item.wishtype}"} - ${item.hours ?: 0.0} h - ${item.startDate ?: "-"} bis ${item.endDate ?: "-"}",
onDelete = { onDelete(item.id) },
)
@Composable
private fun InviteRow(item: InvitationDto) = DataRow(
title = item.email,
details = "${item.status ?: "offen"} - gültig bis ${item.expiresAt ?: "-"}",
)
@Composable
private fun WatcherRow(item: WatcherDto, onDelete: (String) -> Unit) = DataRow(
title = item.email,
details = "Seit ${item.createdAt ?: "-"}",
onDelete = { onDelete(item.id) },
)
@Composable
private fun DataRow(title: String, details: String, onDelete: (() -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Text(details, color = TcColors.TextMuted, fontSize = 13.sp)
}
onDelete?.let { TcButton("Löschen", variant = ButtonVariant.Danger, onClick = it) }
}
}
@Composable
private fun SectionTitle(title: String) {
Text(
text = title,
color = TcColors.Text,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = TcSpacing.Sm),
)
}
@Composable
private fun Detail(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, color = TcColors.TextMuted, fontSize = 14.sp)
Text(value, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}

View File

@@ -0,0 +1,121 @@
package de.tsschulz.timeclock.ui.settings
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
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.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimewishDto
import de.tsschulz.timeclock.data.api.WatcherDto
import de.tsschulz.timeclock.data.auth.TokenStore
import de.tsschulz.timeclock.data.settings.SettingsRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SettingsUiState(
val loading: Boolean = false,
val error: String? = null,
val success: String? = null,
val profile: ProfileDto? = null,
val states: List<StateDto> = emptyList(),
val timewishes: List<TimewishDto> = emptyList(),
val invites: List<InvitationDto> = emptyList(),
val watchers: List<WatcherDto> = emptyList(),
)
class SettingsViewModel(
private val repository: SettingsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
fun loadPhase5() {
loadProfile()
loadTimewishes()
loadInvites()
loadWatchers()
}
fun loadProfile() = launchLoad {
copy(profile = repository.getProfile(), states = repository.getStates())
}
fun updateProfile(fullName: String, stateId: String?, weekWorkdays: Int, dailyHours: Double, preferredTitleType: Int) = launchMutation("Profil gespeichert") {
repository.updateProfile(fullName, stateId, weekWorkdays, dailyHours, preferredTitleType)
loadProfile()
}
fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) = launchMutation("Passwort geändert") {
repository.changePassword(oldPassword, newPassword, confirmPassword)
}
fun loadTimewishes() = launchLoad {
copy(timewishes = repository.getTimewishes())
}
fun createTimewish(day: Int, wishtype: Int, hours: Double?, startDate: String, endDate: String?) = launchMutation("Zeitwunsch gespeichert") {
repository.createTimewish(day, wishtype, hours, startDate, endDate)
loadTimewishes()
}
fun deleteTimewish(id: String) = launchMutation("Zeitwunsch gelöscht") {
repository.deleteTimewish(id)
loadTimewishes()
}
fun loadInvites() = launchLoad {
copy(invites = repository.getInvites())
}
fun sendInvite(email: String) = launchMutation("Einladung gesendet") {
repository.sendInvite(email)
loadInvites()
}
fun loadWatchers() = launchLoad {
copy(watchers = repository.getWatchers())
}
fun addWatcher(email: String) = launchMutation("Zugriff gespeichert") {
repository.addWatcher(email)
loadWatchers()
}
fun deleteWatcher(id: String) = launchMutation("Zugriff entfernt") {
repository.deleteWatcher(id)
loadWatchers()
}
private fun launchLoad(reducer: suspend SettingsUiState.() -> SettingsUiState) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null, success = null) }
runCatching { _uiState.value.reducer() }
.onSuccess { next -> _uiState.value = next.copy(loading = false, error = null) }
.onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Daten konnten nicht geladen werden") } }
}
}
private fun launchMutation(successMessage: String, block: suspend () -> Unit) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null, success = null) }
runCatching { block() }
.onSuccess { _uiState.update { it.copy(success = successMessage) } }
.onFailure { e -> _uiState.update { it.copy(error = e.message ?: "Aktion fehlgeschlagen") } }
_uiState.update { it.copy(loading = false) }
}
}
class Factory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val tokenStore = TokenStore(application)
return SettingsViewModel(SettingsRepository(TimeClockApiClient(tokenStore))) as T
}
}
}

View File

@@ -0,0 +1,87 @@
package de.tsschulz.timeclock.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
object TcColors {
val Background = Color.White
val Text = Color.Black
val TextMuted = Color(0xFF555555)
val Border = Color(0xFFE0E0E0)
val BorderSoft = Color(0xFFE0FFE0)
val Navbar = Color(0xFFF0FFEC)
val Card = Color(0xFFFAFAFA)
val Button = Color(0xFFF5F5F5)
val ButtonHover = Color(0xFFE5E5E5)
val ButtonBorder = Color(0xFFCCCCCC)
val Primary = Color(0xFF5BC0DE)
val PrimaryBorder = Color(0xFF46B8DA)
val PrimaryHover = Color(0xFF31B0D5)
val Success = Color(0xFF5CB85C)
val SuccessBorder = Color(0xFF4CAE4C)
val Danger = Color(0xFFD9534F)
val DangerBorder = Color(0xFFD43F3A)
val Secondary = Color(0xFF6C757D)
val InputBorder = Color(0xFFDDDDDD)
val ActiveMenu = Color(0xFFE8F5E9)
/** Login-Formular (Web: `.auth-form-container` / `h2`) */
val FormSurface = Color.White
val FormHeaderBg = Color(0xFFF5F5F5)
val AuthErrorBg = Color(0xFFF2DEDE)
val AuthErrorBorder = Color(0xFFEBCCD1)
val AuthErrorText = Color(0xFFA94442)
val GoogleText = Color(0xFF444444)
val DividerText = Color(0xFF999999)
}
object TcSpacing {
val Xs: Dp = 4.dp
val Sm: Dp = 8.dp
val Md: Dp = 12.dp
val Lg: Dp = 16.dp
val Xl: Dp = 24.dp
val Xxl: Dp = 32.dp
val WebContainer: Dp = 48.dp
}
object TcRadius {
val Small: Dp = 3.dp
val Medium: Dp = 4.dp
val Card: Dp = 6.dp
/** Web-Login-Container */
val AuthPanel: Dp = 8.dp
}
private val TimeClockColorScheme = lightColorScheme(
primary = TcColors.Primary,
secondary = TcColors.Secondary,
background = TcColors.Background,
surface = TcColors.Background,
surfaceVariant = TcColors.FormHeaderBg,
surfaceContainer = TcColors.Background,
surfaceContainerHigh = TcColors.FormSurface,
outline = TcColors.Border,
outlineVariant = TcColors.InputBorder,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = TcColors.Text,
onSurface = TcColors.Text,
onSurfaceVariant = TcColors.TextMuted,
)
@Composable
fun TimeClockTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = TimeClockColorScheme,
typography = MaterialTheme.typography,
content = content,
)
}

View File

@@ -0,0 +1,152 @@
package de.tsschulz.timeclock.ui.time
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.Text
import androidx.compose.runtime.Composable
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.TimeEntryDto
import de.tsschulz.timeclock.data.api.TimeStatsDto
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcCard
import de.tsschulz.timeclock.ui.components.TcError
import de.tsschulz.timeclock.ui.components.TcLoading
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun EntriesScreen(
entries: List<TimeEntryDto>,
loading: Boolean,
error: String?,
onRefresh: () -> Unit,
onDelete: (String) -> Unit,
) {
TcCard {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
SectionTitle("${entries.size} Einträge")
TcButton("Aktualisieren", variant = ButtonVariant.Secondary, onClick = onRefresh)
}
}
if (loading) TcLoading("Lade Einträge...")
error?.let { TcError(it) }
if (!loading && entries.isEmpty()) {
TcCard { Text("Keine Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 14.sp) }
} else {
entries.forEach { entry -> EntryRow(entry, onDelete) }
}
}
@Composable
fun StatsScreen(
stats: TimeStatsDto?,
loading: Boolean,
error: String?,
onRefresh: () -> Unit,
) {
TcCard {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
SectionTitle("Statistiken")
TcButton("Aktualisieren", variant = ButtonVariant.Secondary, onClick = onRefresh)
}
}
if (loading) TcLoading("Lade Statistiken...")
error?.let { TcError(it) }
val data = stats
if (data == null && !loading) {
TcCard { Text("Keine Statistiken vorhanden.", color = TcColors.TextMuted, fontSize = 14.sp) }
return
}
data?.let {
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Lg), modifier = Modifier.fillMaxWidth()) {
StatCard("Arbeitszeit heute", it.currentlyWorked ?: "-", Modifier.weight(1f))
StatCard("Offen heute", it.open ?: "-", Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Lg), modifier = Modifier.fillMaxWidth()) {
StatCard("Woche", it.weekWorktime ?: "-", Modifier.weight(1f))
StatCard("Überstunden", it.totalOvertime ?: it.overtime ?: "-", Modifier.weight(1f))
}
TcCard {
Detail("Reguläres Ende", it.regularEnd ?: "-")
Detail("Angepasstes Ende", it.adjustedEndTodayGeneral ?: it.adjustedEndToday ?: "-")
Detail("Fehlende Pause", it.missingBreakMinutes?.let { minutes -> "$minutes min" } ?: "-")
Detail("Nicht-Arbeitszeit", it.nonWorkingHours ?: "-")
}
}
}
@Composable
private fun EntryRow(entry: TimeEntryDto, onDelete: (String) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
) {
Column(modifier = Modifier.weight(1f)) {
Text(entry.project ?: "Allgemein", color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Text(
"${entry.startTime.toDisplayDateTime()} - ${entry.endTime.toDisplayDateTime()} · ${entry.duration.toDurationText()}",
color = TcColors.TextMuted,
fontSize = 13.sp,
)
if (!entry.description.isNullOrBlank()) Text(entry.description, color = TcColors.TextMuted, fontSize = 13.sp)
}
Text(if (entry.isRunning) "Läuft" else "Beendet", color = if (entry.isRunning) TcColors.Danger else TcColors.Success, fontSize = 13.sp)
TcButton("Löschen", variant = ButtonVariant.Danger, onClick = { onDelete(entry.id) })
}
}
@Composable
private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) {
TcCard(modifier = modifier) {
Text(value, color = TcColors.Text, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Text(label, color = TcColors.TextMuted, fontSize = 13.sp)
}
}
@Composable
private fun SectionTitle(title: String) {
Text(title, color = TcColors.Text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
}
@Composable
private fun Detail(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, color = TcColors.TextMuted, fontSize = 14.sp)
Text(value, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
private fun String?.toDisplayDateTime(): String {
if (this.isNullOrBlank()) return "-"
return runCatching {
OffsetDateTime.parse(this).format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale.GERMANY))
}.getOrDefault(this)
}
private fun Long?.toDurationText(): String {
if (this == null) return "-"
val hours = this / 3600
val minutes = (this % 3600) / 60
val seconds = this % 60
return "%02d:%02d:%02d".format(hours, minutes, seconds)
}

View File

@@ -0,0 +1,224 @@
package de.tsschulz.timeclock.ui.time
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimeEntryDto
import de.tsschulz.timeclock.data.api.TimeStatsDto
import de.tsschulz.timeclock.data.api.WeekOverviewDto
import de.tsschulz.timeclock.data.auth.TokenStore
import de.tsschulz.timeclock.data.offline.OfflineClockQueue
import de.tsschulz.timeclock.data.time.TimeDashboard
import de.tsschulz.timeclock.data.time.TimeRepository
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.model.StatusAction
import de.tsschulz.timeclock.ui.model.StatusRow
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
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 primaryAction: StatusAction? = null,
val secondaryAction: StatusAction? = null,
val weekLoading: Boolean = false,
val weekOffset: Int = 0,
val week: WeekOverviewDto? = null,
val weekError: String? = null,
val entries: List<TimeEntryDto> = emptyList(),
val entriesLoading: Boolean = false,
val entriesError: String? = null,
val stats: TimeStatsDto? = null,
val statsLoading: Boolean = false,
val statsError: String? = null,
)
class TimeViewModel(
private val repository: TimeRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(TimeUiState())
val uiState: StateFlow<TimeUiState> = _uiState.asStateFlow()
private var refreshJob: Job? = null
fun start() {
if (refreshJob != null) return
refreshJob = viewModelScope.launch {
refreshAll()
while (true) {
delay(30_000)
refreshDashboard()
}
}
}
fun stop() {
refreshJob?.cancel()
refreshJob = null
_uiState.value = TimeUiState()
}
fun refreshAll() {
refreshDashboard()
loadWeek(_uiState.value.weekOffset)
}
fun refreshDashboard() {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null) }
runCatching { repository.loadDashboard() }
.onSuccess { dashboard ->
_uiState.update {
it.copy(
loading = false,
dashboard = dashboard,
statusRows = dashboard.toStatusRows(),
primaryAction = dashboard.primaryAction(),
secondaryAction = dashboard.secondaryAction(),
error = null,
)
}
}
.onFailure { e ->
_uiState.update { it.copy(loading = false, error = e.message ?: "Status konnte nicht geladen werden") }
}
}
}
fun clock(action: String) {
viewModelScope.launch {
_uiState.update { it.copy(clockInProgress = true, error = null) }
runCatching { repository.clock(action) }
.onSuccess { dashboard ->
_uiState.update {
it.copy(
clockInProgress = false,
dashboard = dashboard,
statusRows = dashboard.toStatusRows(),
primaryAction = dashboard.primaryAction(),
secondaryAction = dashboard.secondaryAction(),
error = null,
)
}
loadWeek(_uiState.value.weekOffset)
}
.onFailure { e ->
_uiState.update { it.copy(clockInProgress = false, error = e.message ?: "Stempeln fehlgeschlagen") }
}
}
}
fun loadWeek(weekOffset: Int) {
viewModelScope.launch {
_uiState.update { it.copy(weekLoading = true, weekError = null, weekOffset = weekOffset) }
runCatching { repository.loadWeek(weekOffset) }
.onSuccess { week ->
_uiState.update { it.copy(weekLoading = false, week = week, weekError = null) }
}
.onFailure { e ->
_uiState.update {
it.copy(weekLoading = false, weekError = e.message ?: "Wochenübersicht konnte nicht geladen werden")
}
}
}
}
fun loadEntries() {
viewModelScope.launch {
_uiState.update { it.copy(entriesLoading = true, entriesError = null) }
runCatching { repository.loadEntries() }
.onSuccess { entries -> _uiState.update { it.copy(entriesLoading = false, entries = entries) } }
.onFailure { e -> _uiState.update { it.copy(entriesLoading = false, entriesError = e.message ?: "Einträge konnten nicht geladen werden") } }
}
}
fun deleteEntry(id: String) {
viewModelScope.launch {
_uiState.update { it.copy(entriesLoading = true, entriesError = null) }
runCatching {
repository.deleteEntry(id)
repository.loadEntries()
}
.onSuccess { entries -> _uiState.update { it.copy(entriesLoading = false, entries = entries) } }
.onFailure { e -> _uiState.update { it.copy(entriesLoading = false, entriesError = e.message ?: "Eintrag konnte nicht gelöscht werden") } }
}
}
fun loadStats() {
viewModelScope.launch {
_uiState.update { it.copy(statsLoading = true, statsError = null) }
runCatching { repository.loadStats() }
.onSuccess { stats -> _uiState.update { it.copy(statsLoading = false, stats = stats) } }
.onFailure { e -> _uiState.update { it.copy(statsLoading = false, statsError = e.message ?: "Statistiken konnten nicht geladen werden") } }
}
}
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 ?: ""))
}
}
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")
}
private fun TimeDashboard.secondaryAction(): StatusAction? =
when (state) {
"start work", "stop pause" -> StatusAction("Gehen", ButtonVariant.Secondary, "stop work")
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 String.toDisplayTime(): String =
runCatching {
OffsetDateTime.parse(this).toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm", Locale.GERMANY))
}.getOrDefault(this)
class Factory(
private val application: Application,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val tokenStore = TokenStore(application)
val api = TimeClockApiClient(tokenStore)
val repo = TimeRepository(api, OfflineClockQueue(application))
return TimeViewModel(repo) as T
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_stechuhr_logo" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_stechuhr_logo" />
</adaptive-icon>

View File

@@ -0,0 +1,3 @@
<resources>
<color name="ic_launcher_background">#F0FFEC</color>
</resources>

View File

@@ -0,0 +1,8 @@
<resources>
<style name="Theme.TimeClock" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">#F0FFEC</item>
<item name="android:navigationBarColor">#FFFFFF</item>
</style>
</resources>

View File

@@ -0,0 +1,111 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ApiSerializationTest {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
@Test
fun loginResponseMapsWebUserFields() {
val raw = """
{
"success": true,
"token": "jwt",
"user": {
"id": 1,
"full_name": "Max Mustermann",
"email": "max@example.com",
"role": 1,
"daily_hours": 8,
"week_workdays": 5
}
}
""".trimIndent()
val decoded = json.decodeFromString(LoginResponse.serializer(), raw)
assertTrue(decoded.success)
assertEquals("jwt", decoded.token)
assertEquals("Max Mustermann", decoded.user?.fullName)
assertEquals(1, decoded.user?.role)
assertEquals(5, decoded.user?.weekWorkdays)
}
@Test
fun protectedIdsDecodeAsStrings() {
val raw = """
[
{
"id": "abc.def",
"project": "Allgemein",
"description": "Test",
"startTime": "2026-05-14T08:00:00+02:00",
"endTime": null,
"duration": 3600,
"isRunning": true
}
]
""".trimIndent()
val decoded = json.decodeFromString(ListSerializer(TimeEntryDto.serializer()), raw)
assertEquals("abc.def", decoded.first().id)
assertTrue(decoded.first().isRunning)
assertEquals(3600L, decoded.first().duration)
}
@Test
fun holidayResponseKeepsFutureAndPastBuckets() {
val raw = """
{
"future": [
{
"id": "holiday.hash",
"date": "2026-10-03",
"hours": 8,
"description": "Tag der Deutschen Einheit",
"states": [],
"isFederal": true
}
],
"past": []
}
""".trimIndent()
val decoded = json.decodeFromString(HolidaysResponse.serializer(), raw)
assertEquals(1, decoded.future.size)
assertEquals("Tag der Deutschen Einheit", decoded.future.first().description)
assertTrue(decoded.future.first().isFederal)
assertTrue(decoded.past.isEmpty())
}
@Test
fun roleUserDecodesUserRole() {
val raw = """
{
"id": "user.hash",
"fullName": "Erika Musterfrau",
"role": 0,
"roleString": "user",
"stateName": "Nordrhein-Westfalen"
}
""".trimIndent()
val decoded = json.decodeFromString(RoleUserDto.serializer(), raw)
assertEquals("user.hash", decoded.id)
assertEquals("Erika Musterfrau", decoded.fullName)
assertFalse(decoded.role == 1)
assertEquals("Nordrhein-Westfalen", decoded.stateName)
}
}

View File

@@ -0,0 +1,25 @@
package de.tsschulz.timeclock.data.offline
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test
class PendingClockActionTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun pendingClockActionsRoundTrip() {
val actions = listOf(
PendingClockAction(id = "1", action = "start work", createdAtEpochMillis = 1_777_000_000_000),
PendingClockAction(id = "2", action = "stop work", createdAtEpochMillis = 1_777_000_300_000),
)
val raw = json.encodeToString(ListSerializer(PendingClockAction.serializer()), actions)
val decoded = json.decodeFromString(ListSerializer(PendingClockAction.serializer()), raw)
assertEquals(actions, decoded)
assertEquals("start work", decoded.first().action)
assertEquals("stop work", decoded.last().action)
}
}