Update StatusBox to display app version and adjust refresh intervals
Add a new element in StatusBox.vue to show the application version. Update the refresh interval for status display from every 0.5 seconds to every 1 second for improved performance. Increment mobile app version to 0.8.0-alpha8 and version code to 9. Introduce new VersionScreen in the mobile app for displaying version information, and update navigation and mock data accordingly.
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
<span class="value" v-if="value !== null">{{ value || '—' }}</span>
|
<span class="value" v-if="value !== null">{{ value || '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="status-version">TimeClock v{{ appVersion }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-box loading" v-else>
|
<div class="status-box loading" v-else>
|
||||||
Lädt…
|
Lädt…
|
||||||
@@ -40,8 +41,10 @@ import { onMounted, onBeforeUnmount, ref, computed } from 'vue'
|
|||||||
import { useTimeStore } from '../stores/timeStore'
|
import { useTimeStore } from '../stores/timeStore'
|
||||||
import { useAuthStore } from '../stores/authStore'
|
import { useAuthStore } from '../stores/authStore'
|
||||||
import { API_BASE_URL } from '@/config/api'
|
import { API_BASE_URL } from '@/config/api'
|
||||||
|
import { APP_VERSION } from '@/config/version'
|
||||||
|
|
||||||
const API_URL = API_BASE_URL
|
const API_URL = API_BASE_URL
|
||||||
|
const appVersion = APP_VERSION
|
||||||
const timeStore = useTimeStore()
|
const timeStore = useTimeStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const stats = ref({})
|
const stats = ref({})
|
||||||
@@ -389,11 +392,11 @@ onMounted(async () => {
|
|||||||
await refreshStatusData()
|
await refreshStatusData()
|
||||||
}, 60000)
|
}, 60000)
|
||||||
|
|
||||||
// Anzeige 2x pro Sekunde aktualisieren (nur Berechnung, keine Server-Requests)
|
// Anzeige jede Sekunde aktualisieren (nur Berechnung, keine Server-Requests)
|
||||||
displayUpdateInterval = setInterval(() => {
|
displayUpdateInterval = setInterval(() => {
|
||||||
updateCurrentlyWorkedTime()
|
updateCurrentlyWorkedTime()
|
||||||
updateOpenTime()
|
updateOpenTime()
|
||||||
}, 500)
|
}, 1000)
|
||||||
|
|
||||||
// Event-Listener für Login
|
// Event-Listener für Login
|
||||||
window.addEventListener('login-completed', handleLoginCompleted)
|
window.addEventListener('login-completed', handleLoginCompleted)
|
||||||
@@ -584,4 +587,13 @@ const displayRows = computed(() => {
|
|||||||
color: #000;
|
color: #000;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-version {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid #e8e8e8;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
2
frontend/src/config/version.js
Normal file
2
frontend/src/config/version.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/** Sichtbare Web-App-Version (synchron mit package.json). */
|
||||||
|
export const APP_VERSION = '3.0.0'
|
||||||
@@ -23,8 +23,8 @@ android {
|
|||||||
applicationId = "de.tsschulz.timeclock"
|
applicationId = "de.tsschulz.timeclock"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 8
|
versionCode = 9
|
||||||
versionName = "0.8.0-alpha7"
|
versionName = "0.8.0-alpha8"
|
||||||
buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -56,6 +56,7 @@ import de.tsschulz.timeclock.ui.settings.PermissionsScreen
|
|||||||
import de.tsschulz.timeclock.ui.settings.ProfileScreen
|
import de.tsschulz.timeclock.ui.settings.ProfileScreen
|
||||||
import de.tsschulz.timeclock.ui.settings.SettingsViewModel
|
import de.tsschulz.timeclock.ui.settings.SettingsViewModel
|
||||||
import de.tsschulz.timeclock.ui.settings.TimewishScreen
|
import de.tsschulz.timeclock.ui.settings.TimewishScreen
|
||||||
|
import de.tsschulz.timeclock.ui.settings.VersionScreen
|
||||||
import de.tsschulz.timeclock.ui.theme.TcColors
|
import de.tsschulz.timeclock.ui.theme.TcColors
|
||||||
import de.tsschulz.timeclock.ui.theme.TcRadius
|
import de.tsschulz.timeclock.ui.theme.TcRadius
|
||||||
import de.tsschulz.timeclock.ui.theme.TcSpacing
|
import de.tsschulz.timeclock.ui.theme.TcSpacing
|
||||||
@@ -295,6 +296,7 @@ private fun DemoScreen(
|
|||||||
onOpenDeletionPage = { openUrl("https://stechuhr3.tsschulz.de/account-loeschen") }
|
onOpenDeletionPage = { openUrl("https://stechuhr3.tsschulz.de/account-loeschen") }
|
||||||
)
|
)
|
||||||
AppRoute.Privacy -> PrivacyScreen()
|
AppRoute.Privacy -> PrivacyScreen()
|
||||||
|
AppRoute.Version -> VersionScreen()
|
||||||
AppRoute.Holidays -> HolidaysAdminScreen(
|
AppRoute.Holidays -> HolidaysAdminScreen(
|
||||||
state = adminState,
|
state = adminState,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
|
|||||||
@@ -267,7 +267,15 @@ private data class BottomNavItem(
|
|||||||
private fun isRouteInSection(sectionRoute: AppRoute, selectedRoute: AppRoute): Boolean =
|
private fun isRouteInSection(sectionRoute: AppRoute, selectedRoute: AppRoute): Boolean =
|
||||||
when (sectionRoute) {
|
when (sectionRoute) {
|
||||||
AppRoute.Week -> selectedRoute in setOf(AppRoute.Timefix, AppRoute.Vacation, AppRoute.Sick, AppRoute.Workdays, AppRoute.Calendar)
|
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.Profile -> selectedRoute in setOf(
|
||||||
|
AppRoute.Password,
|
||||||
|
AppRoute.Timewish,
|
||||||
|
AppRoute.Permissions,
|
||||||
|
AppRoute.Invite,
|
||||||
|
AppRoute.AccountDeletion,
|
||||||
|
AppRoute.Privacy,
|
||||||
|
AppRoute.Version,
|
||||||
|
)
|
||||||
AppRoute.Entries -> selectedRoute == AppRoute.Stats
|
AppRoute.Entries -> selectedRoute == AppRoute.Stats
|
||||||
AppRoute.Holidays -> selectedRoute == AppRoute.Roles
|
AppRoute.Holidays -> selectedRoute == AppRoute.Roles
|
||||||
else -> false
|
else -> false
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ val userSections = listOf(
|
|||||||
MenuItem("Einladen", AppRoute.Invite),
|
MenuItem("Einladen", AppRoute.Invite),
|
||||||
MenuItem("Account löschen", AppRoute.AccountDeletion),
|
MenuItem("Account löschen", AppRoute.AccountDeletion),
|
||||||
MenuItem("Datenschutz", AppRoute.Privacy),
|
MenuItem("Datenschutz", AppRoute.Privacy),
|
||||||
|
MenuItem("Version", AppRoute.Version),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MenuSection(
|
MenuSection(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ enum class AppRoute(val title: String) {
|
|||||||
Invite("Einladen"),
|
Invite("Einladen"),
|
||||||
AccountDeletion("Account löschen"),
|
AccountDeletion("Account löschen"),
|
||||||
Privacy("Datenschutz"),
|
Privacy("Datenschutz"),
|
||||||
|
Version("Version"),
|
||||||
Holidays("Feiertage"),
|
Holidays("Feiertage"),
|
||||||
Roles("Rechte"),
|
Roles("Rechte"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package de.tsschulz.timeclock.ui.settings
|
||||||
|
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import de.tsschulz.timeclock.BuildConfig
|
||||||
|
import de.tsschulz.timeclock.ui.components.TcCard
|
||||||
|
import de.tsschulz.timeclock.ui.theme.TcColors
|
||||||
|
import de.tsschulz.timeclock.ui.theme.TcSpacing
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VersionScreen(modifier: Modifier = Modifier) {
|
||||||
|
TcCard(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Md)) {
|
||||||
|
Text(
|
||||||
|
text = "Versionsinfo",
|
||||||
|
color = TcColors.Text,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
VersionRow("App", "Stechuhr")
|
||||||
|
VersionRow("Version", BuildConfig.VERSION_NAME)
|
||||||
|
VersionRow("Build", BuildConfig.VERSION_CODE.toString())
|
||||||
|
VersionRow("Paket", BuildConfig.APPLICATION_ID)
|
||||||
|
VersionRow("API", BuildConfig.API_BASE_URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VersionRow(label: String, value: String) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||||
|
Text(text = label, color = TcColors.TextMuted, fontSize = 13.sp)
|
||||||
|
Text(text = value, color = TcColors.Text, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package de.tsschulz.timeclock.ui.time
|
||||||
|
|
||||||
|
import de.tsschulz.timeclock.data.api.TimeStatsDto
|
||||||
|
import de.tsschulz.timeclock.data.time.TimeDashboard
|
||||||
|
import de.tsschulz.timeclock.ui.model.StatusRow
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/** Live-Anzeige für Statuszeilen (sekündlicher Tick, analog Web-StatusBox). */
|
||||||
|
internal fun TimeDashboard.toLiveStatusRows(nowMs: Long = System.currentTimeMillis()): List<StatusRow> {
|
||||||
|
val stats = stats
|
||||||
|
val worked = computeCurrentlyWorked(stats, state, runningStartTime, currentPauseStart, nowMs)
|
||||||
|
val open = computeOpen(stats, state, nowMs)
|
||||||
|
val regularEnd = computeRegularEnd(stats, state, open, nowMs)
|
||||||
|
|
||||||
|
return buildList {
|
||||||
|
add(StatusRow("Derzeit gearbeitet", worked.toHoursLabel()))
|
||||||
|
add(StatusRow("Offen", open.toHoursLabel()))
|
||||||
|
add(StatusRow("Normales Arbeitsende", regularEnd.toClockLabel()))
|
||||||
|
addOvertimeRow("Woche", stats.overtime)
|
||||||
|
addOvertimeRow("Gesamt", stats.totalOvertime)
|
||||||
|
add(StatusRow("Wochenarbeitszeit", stats.weekWorktime.toHoursLabel()))
|
||||||
|
add(StatusRow("Arbeitsfreie Stunden", stats.nonWorkingHours.toHoursLabel()))
|
||||||
|
add(StatusRow("Offen für Woche", stats.openForWeek.toHoursLabel()))
|
||||||
|
add(StatusRow("Bereinigtes Arbeitsende (heute)", isHeading = true))
|
||||||
|
add(StatusRow("- Generell", stats.adjustedEndTodayGeneral.toClockLabel()))
|
||||||
|
add(StatusRow("- Woche", stats.adjustedEndTodayWeek.toClockLabel()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeCurrentlyWorked(
|
||||||
|
stats: TimeStatsDto,
|
||||||
|
state: String?,
|
||||||
|
runningStartTime: String?,
|
||||||
|
currentPauseStart: String?,
|
||||||
|
nowMs: Long,
|
||||||
|
): String? {
|
||||||
|
if (state == null || state == "stop work") return stats.currentlyWorked?.takeIf { it.isNotBlank() } ?: "—"
|
||||||
|
|
||||||
|
val startMs = parseInstantMs(runningStartTime)
|
||||||
|
if (startMs != null && (state == "start work" || state == "stop pause")) {
|
||||||
|
val workedMs = (nowMs - startMs).coerceAtLeast(0)
|
||||||
|
return formatDurationSeconds(workedMs / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startMs != null && state == "start pause") {
|
||||||
|
val pauseStartMs = parseInstantMs(currentPauseStart) ?: nowMs
|
||||||
|
val workedMs = (pauseStartMs - startMs).coerceAtLeast(0)
|
||||||
|
return formatDurationSeconds(workedMs / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tickFromServerTime(stats.currentlyWorked, stats.timestamp, state == "start pause", nowMs)
|
||||||
|
?: stats.currentlyWorked
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeOpen(
|
||||||
|
stats: TimeStatsDto,
|
||||||
|
state: String?,
|
||||||
|
nowMs: Long,
|
||||||
|
): String? {
|
||||||
|
val base = stats.open?.takeIf { it.isNotBlank() && it != "—" } ?: return stats.open
|
||||||
|
if (base.contains("erreicht", ignoreCase = true)) return base
|
||||||
|
|
||||||
|
val openSeconds = parseTimeToSeconds(base) ?: return base
|
||||||
|
val elapsed = elapsedSecondsSince(stats.timestamp, nowMs)
|
||||||
|
val pause = state == "start pause"
|
||||||
|
val remaining = if (pause) openSeconds else (openSeconds - elapsed).coerceAtLeast(0)
|
||||||
|
return if (remaining <= 0) "Arbeitsende erreicht" else formatDurationSeconds(remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeRegularEnd(
|
||||||
|
stats: TimeStatsDto,
|
||||||
|
state: String?,
|
||||||
|
openDisplay: String?,
|
||||||
|
nowMs: Long,
|
||||||
|
): String? {
|
||||||
|
val regular = stats.regularEnd?.takeIf { it.isNotBlank() } ?: return stats.regularEnd
|
||||||
|
if (regular.contains("erreicht", ignoreCase = true)) return regular
|
||||||
|
if (openDisplay?.contains("erreicht", ignoreCase = true) == true) {
|
||||||
|
return if ((stats.missingBreakMinutes ?: 0) > 0) {
|
||||||
|
"Erreicht (+${stats.missingBreakMinutes}min Pause)"
|
||||||
|
} else {
|
||||||
|
"Erreicht"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val openRaw = stats.open?.takeIf { it.isNotBlank() } ?: return regular
|
||||||
|
val openSeconds = parseTimeToSeconds(openRaw) ?: return regular
|
||||||
|
val elapsed = elapsedSecondsSince(stats.timestamp, nowMs)
|
||||||
|
val pause = state == "start pause"
|
||||||
|
val remaining = if (pause) openSeconds else (openSeconds - elapsed).coerceAtLeast(0)
|
||||||
|
if (remaining <= 0) {
|
||||||
|
return if ((stats.missingBreakMinutes ?: 0) > 0) {
|
||||||
|
"Erreicht (+${stats.missingBreakMinutes}min Pause)"
|
||||||
|
} else {
|
||||||
|
"Erreicht"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val end = Instant.ofEpochMilli(nowMs + remaining * 1000)
|
||||||
|
.atZone(java.time.ZoneId.systemDefault())
|
||||||
|
return end.format(DateTimeFormatter.ofPattern("HH:mm:ss"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tickFromServerTime(
|
||||||
|
time: String?,
|
||||||
|
timestamp: String?,
|
||||||
|
frozen: Boolean,
|
||||||
|
nowMs: Long,
|
||||||
|
): String? {
|
||||||
|
val base = time?.takeIf { it.isNotBlank() && it != "—" } ?: return null
|
||||||
|
val baseSeconds = parseTimeToSeconds(base) ?: return null
|
||||||
|
val elapsed = if (frozen) 0 else elapsedSecondsSince(timestamp, nowMs)
|
||||||
|
return formatDurationSeconds(baseSeconds + elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun elapsedSecondsSince(timestamp: String?, nowMs: Long): Long {
|
||||||
|
val ts = parseInstantMs(timestamp) ?: return 0
|
||||||
|
return ((nowMs - ts) / 1000).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseInstantMs(value: String?): Long? {
|
||||||
|
if (value.isNullOrBlank()) return null
|
||||||
|
return runCatching { Instant.parse(value).toEpochMilli() }
|
||||||
|
.recoverCatching { OffsetDateTime.parse(value).toInstant().toEpochMilli() }
|
||||||
|
.recoverCatching {
|
||||||
|
java.time.LocalDateTime.parse(value.replace(' ', 'T'))
|
||||||
|
.atZone(java.time.ZoneId.systemDefault())
|
||||||
|
.toInstant()
|
||||||
|
.toEpochMilli()
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTimeToSeconds(time: String): Long? {
|
||||||
|
val parts = time.trim().split(":")
|
||||||
|
if (parts.size < 2) return null
|
||||||
|
val h = parts[0].toLongOrNull() ?: return null
|
||||||
|
val m = parts[1].toLongOrNull() ?: return null
|
||||||
|
val s = parts.getOrNull(2)?.toLongOrNull() ?: 0L
|
||||||
|
return h * 3600 + m * 60 + s
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDurationSeconds(totalSeconds: Long): String {
|
||||||
|
val hours = totalSeconds / 3600
|
||||||
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
return "%02d:%02d:%02d".format(hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<StatusRow>.addOvertimeRow(scope: String, value: String?) {
|
||||||
|
val raw = value?.takeIf { it.isNotBlank() && it != "—" } ?: "—"
|
||||||
|
val isNegative = raw.startsWith("-")
|
||||||
|
val label = if (isNegative) "Fehlzeit ($scope)" else "Überstunden ($scope)"
|
||||||
|
val displayValue = if (isNegative) raw.removePrefix("-").toHoursLabel() else raw.toHoursLabel()
|
||||||
|
add(StatusRow(label, displayValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.toHoursLabel(): String {
|
||||||
|
val value = this?.takeIf { it.isNotBlank() } ?: return "—"
|
||||||
|
if (value == "—" || value.contains("erreicht", ignoreCase = true)) return value
|
||||||
|
return if (value.endsWith(" h")) value else "$value h"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.toClockLabel(): String {
|
||||||
|
val value = this?.takeIf { it.isNotBlank() } ?: return "—"
|
||||||
|
if (value == "—" || value.contains("erreicht", ignoreCase = true) || value.endsWith(" Uhr")) return value
|
||||||
|
return "$value Uhr"
|
||||||
|
}
|
||||||
@@ -50,14 +50,23 @@ class TimeViewModel(
|
|||||||
private val _uiState = MutableStateFlow(TimeUiState())
|
private val _uiState = MutableStateFlow(TimeUiState())
|
||||||
val uiState: StateFlow<TimeUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<TimeUiState> = _uiState.asStateFlow()
|
||||||
private var refreshJob: Job? = null
|
private var refreshJob: Job? = null
|
||||||
|
private var tickJob: Job? = null
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (refreshJob != null) return
|
if (refreshJob != null) return
|
||||||
refreshJob = viewModelScope.launch {
|
refreshJob = viewModelScope.launch {
|
||||||
refreshAll()
|
refreshDashboard()
|
||||||
|
loadWeek(_uiState.value.weekOffset)
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(30_000)
|
delay(60_000)
|
||||||
refreshDashboard()
|
refreshDashboard(silent = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tickJob != null) return
|
||||||
|
tickJob = viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(1_000)
|
||||||
|
tickLiveDisplay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +74,8 @@ class TimeViewModel(
|
|||||||
fun stop() {
|
fun stop() {
|
||||||
refreshJob?.cancel()
|
refreshJob?.cancel()
|
||||||
refreshJob = null
|
refreshJob = null
|
||||||
|
tickJob?.cancel()
|
||||||
|
tickJob = null
|
||||||
_uiState.value = TimeUiState()
|
_uiState.value = TimeUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,25 +84,41 @@ class TimeViewModel(
|
|||||||
loadWeek(_uiState.value.weekOffset)
|
loadWeek(_uiState.value.weekOffset)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshDashboard() {
|
fun refreshDashboard(silent: Boolean = false) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(loading = true, error = null) }
|
if (!silent) {
|
||||||
|
_uiState.update { it.copy(loading = true, error = null) }
|
||||||
|
}
|
||||||
runCatching { repository.loadDashboard() }
|
runCatching { repository.loadDashboard() }
|
||||||
.onSuccess { dashboard ->
|
.onSuccess { dashboard -> applyDashboard(dashboard, clearError = !silent) }
|
||||||
|
.onFailure { e ->
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
loading = false,
|
loading = false,
|
||||||
dashboard = dashboard,
|
error = e.message ?: "Status konnte nicht geladen werden",
|
||||||
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") }
|
}
|
||||||
}
|
|
||||||
|
private fun tickLiveDisplay() {
|
||||||
|
val dashboard = _uiState.value.dashboard ?: return
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(statusRows = dashboard.toLiveStatusRows())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDashboard(dashboard: TimeDashboard, clearError: Boolean) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
loading = false,
|
||||||
|
dashboard = dashboard,
|
||||||
|
statusRows = dashboard.toLiveStatusRows(),
|
||||||
|
primaryAction = dashboard.primaryAction(),
|
||||||
|
secondaryAction = dashboard.secondaryAction(),
|
||||||
|
error = if (clearError) null else it.error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +131,7 @@ class TimeViewModel(
|
|||||||
it.copy(
|
it.copy(
|
||||||
clockInProgress = false,
|
clockInProgress = false,
|
||||||
dashboard = dashboard,
|
dashboard = dashboard,
|
||||||
statusRows = dashboard.toStatusRows(),
|
statusRows = dashboard.toLiveStatusRows(),
|
||||||
primaryAction = dashboard.primaryAction(),
|
primaryAction = dashboard.primaryAction(),
|
||||||
secondaryAction = dashboard.secondaryAction(),
|
secondaryAction = dashboard.secondaryAction(),
|
||||||
error = null,
|
error = null,
|
||||||
@@ -127,7 +154,10 @@ class TimeViewModel(
|
|||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(weekLoading = false, weekError = e.message ?: "Wochenübersicht konnte nicht geladen werden")
|
it.copy(
|
||||||
|
weekLoading = false,
|
||||||
|
weekError = e.message ?: "Wochenübersicht konnte nicht geladen werden",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,23 +193,6 @@ class TimeViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TimeDashboard.toStatusRows(): List<StatusRow> {
|
|
||||||
val stats = stats
|
|
||||||
return buildList {
|
|
||||||
add(StatusRow("Derzeit gearbeitet", stats.currentlyWorked.toHoursLabel()))
|
|
||||||
add(StatusRow("Offen", stats.open.toHoursLabel()))
|
|
||||||
add(StatusRow("Normales Arbeitsende", stats.regularEnd.toClockLabel()))
|
|
||||||
addOvertimeRow("Woche", stats.overtime)
|
|
||||||
addOvertimeRow("Gesamt", stats.totalOvertime)
|
|
||||||
add(StatusRow("Wochenarbeitszeit", stats.weekWorktime.toHoursLabel()))
|
|
||||||
add(StatusRow("Arbeitsfreie Stunden", stats.nonWorkingHours.toHoursLabel()))
|
|
||||||
add(StatusRow("Offen für Woche", stats.openForWeek.toHoursLabel()))
|
|
||||||
add(StatusRow("Bereinigtes Arbeitsende (heute)", isHeading = true))
|
|
||||||
add(StatusRow("- Generell", stats.adjustedEndTodayGeneral.toClockLabel()))
|
|
||||||
add(StatusRow("- Woche", stats.adjustedEndTodayWeek.toClockLabel()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun TimeDashboard.primaryAction(): StatusAction? =
|
private fun TimeDashboard.primaryAction(): StatusAction? =
|
||||||
when (state) {
|
when (state) {
|
||||||
null, "stop work" -> StatusAction("Arbeit beginnen", ButtonVariant.Success, "start work")
|
null, "stop work" -> StatusAction("Arbeit beginnen", ButtonVariant.Success, "start work")
|
||||||
@@ -195,26 +208,6 @@ class TimeViewModel(
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MutableList<StatusRow>.addOvertimeRow(scope: String, value: String?) {
|
|
||||||
val raw = value?.takeIf { it.isNotBlank() && it != "—" } ?: "—"
|
|
||||||
val isNegative = raw.startsWith("-")
|
|
||||||
val label = if (isNegative) "Fehlzeit ($scope)" else "Überstunden ($scope)"
|
|
||||||
val displayValue = if (isNegative) raw.removePrefix("-").toHoursLabel() else raw.toHoursLabel()
|
|
||||||
add(StatusRow(label, displayValue))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String?.toHoursLabel(): String {
|
|
||||||
val value = this?.takeIf { it.isNotBlank() } ?: return "—"
|
|
||||||
if (value == "—" || value.contains("erreicht")) return value
|
|
||||||
return if (value.endsWith(" h")) value else "$value h"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String?.toClockLabel(): String {
|
|
||||||
val value = this?.takeIf { it.isNotBlank() } ?: return "—"
|
|
||||||
if (value == "—" || value.contains("erreicht") || value.endsWith(" Uhr")) return value
|
|
||||||
return "$value Uhr"
|
|
||||||
}
|
|
||||||
|
|
||||||
class Factory(
|
class Factory(
|
||||||
private val application: Application,
|
private val application: Application,
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
|
|||||||
Reference in New Issue
Block a user