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:
65
mobile-app/composeApp/build.gradle.kts
Normal file
65
mobile-app/composeApp/build.gradle.kts
Normal 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")
|
||||
}
|
||||
BIN
mobile-app/composeApp/release/composeApp-release.aab
Normal file
BIN
mobile-app/composeApp/release/composeApp-release.aab
Normal file
Binary file not shown.
5
mobile-app/composeApp/src/debug/AndroidManifest.xml
Normal file
5
mobile-app/composeApp/src/debug/AndroidManifest.xml
Normal 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>
|
||||
20
mobile-app/composeApp/src/main/AndroidManifest.xml
Normal file
20
mobile-app/composeApp/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.tsschulz.timeclock.data.api
|
||||
|
||||
class ApiException(
|
||||
message: String,
|
||||
val code: Int,
|
||||
) : Exception(message)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
3
mobile-app/composeApp/src/main/res/values/colors.xml
Normal file
3
mobile-app/composeApp/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#F0FFEC</color>
|
||||
</resources>
|
||||
8
mobile-app/composeApp/src/main/res/values/styles.xml
Normal file
8
mobile-app/composeApp/src/main/res/values/styles.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user