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:
@@ -23,8 +23,8 @@ android {
|
||||
applicationId = "de.tsschulz.timeclock"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 8
|
||||
versionName = "0.8.0-alpha7"
|
||||
versionCode = 9
|
||||
versionName = "0.8.0-alpha8"
|
||||
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.SettingsViewModel
|
||||
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.TcRadius
|
||||
import de.tsschulz.timeclock.ui.theme.TcSpacing
|
||||
@@ -295,6 +296,7 @@ private fun DemoScreen(
|
||||
onOpenDeletionPage = { openUrl("https://stechuhr3.tsschulz.de/account-loeschen") }
|
||||
)
|
||||
AppRoute.Privacy -> PrivacyScreen()
|
||||
AppRoute.Version -> VersionScreen()
|
||||
AppRoute.Holidays -> HolidaysAdminScreen(
|
||||
state = adminState,
|
||||
isTablet = isTablet,
|
||||
|
||||
@@ -267,7 +267,15 @@ private data class BottomNavItem(
|
||||
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.Profile -> selectedRoute in setOf(
|
||||
AppRoute.Password,
|
||||
AppRoute.Timewish,
|
||||
AppRoute.Permissions,
|
||||
AppRoute.Invite,
|
||||
AppRoute.AccountDeletion,
|
||||
AppRoute.Privacy,
|
||||
AppRoute.Version,
|
||||
)
|
||||
AppRoute.Entries -> selectedRoute == AppRoute.Stats
|
||||
AppRoute.Holidays -> selectedRoute == AppRoute.Roles
|
||||
else -> false
|
||||
|
||||
@@ -22,6 +22,7 @@ val userSections = listOf(
|
||||
MenuItem("Einladen", AppRoute.Invite),
|
||||
MenuItem("Account löschen", AppRoute.AccountDeletion),
|
||||
MenuItem("Datenschutz", AppRoute.Privacy),
|
||||
MenuItem("Version", AppRoute.Version),
|
||||
),
|
||||
),
|
||||
MenuSection(
|
||||
|
||||
@@ -27,6 +27,7 @@ enum class AppRoute(val title: String) {
|
||||
Invite("Einladen"),
|
||||
AccountDeletion("Account löschen"),
|
||||
Privacy("Datenschutz"),
|
||||
Version("Version"),
|
||||
Holidays("Feiertage"),
|
||||
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())
|
||||
val uiState: StateFlow<TimeUiState> = _uiState.asStateFlow()
|
||||
private var refreshJob: Job? = null
|
||||
private var tickJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
if (refreshJob != null) return
|
||||
refreshJob = viewModelScope.launch {
|
||||
refreshAll()
|
||||
refreshDashboard()
|
||||
loadWeek(_uiState.value.weekOffset)
|
||||
while (true) {
|
||||
delay(30_000)
|
||||
refreshDashboard()
|
||||
delay(60_000)
|
||||
refreshDashboard(silent = true)
|
||||
}
|
||||
}
|
||||
if (tickJob != null) return
|
||||
tickJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(1_000)
|
||||
tickLiveDisplay()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +74,8 @@ class TimeViewModel(
|
||||
fun stop() {
|
||||
refreshJob?.cancel()
|
||||
refreshJob = null
|
||||
tickJob?.cancel()
|
||||
tickJob = null
|
||||
_uiState.value = TimeUiState()
|
||||
}
|
||||
|
||||
@@ -73,25 +84,41 @@ class TimeViewModel(
|
||||
loadWeek(_uiState.value.weekOffset)
|
||||
}
|
||||
|
||||
fun refreshDashboard() {
|
||||
fun refreshDashboard(silent: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(loading = true, error = null) }
|
||||
if (!silent) {
|
||||
_uiState.update { it.copy(loading = true, error = null) }
|
||||
}
|
||||
runCatching { repository.loadDashboard() }
|
||||
.onSuccess { dashboard ->
|
||||
.onSuccess { dashboard -> applyDashboard(dashboard, clearError = !silent) }
|
||||
.onFailure { e ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
dashboard = dashboard,
|
||||
statusRows = dashboard.toStatusRows(),
|
||||
primaryAction = dashboard.primaryAction(),
|
||||
secondaryAction = dashboard.secondaryAction(),
|
||||
error = null,
|
||||
error = e.message ?: "Status konnte nicht geladen werden",
|
||||
)
|
||||
}
|
||||
}
|
||||
.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(
|
||||
clockInProgress = false,
|
||||
dashboard = dashboard,
|
||||
statusRows = dashboard.toStatusRows(),
|
||||
statusRows = dashboard.toLiveStatusRows(),
|
||||
primaryAction = dashboard.primaryAction(),
|
||||
secondaryAction = dashboard.secondaryAction(),
|
||||
error = null,
|
||||
@@ -127,7 +154,10 @@ class TimeViewModel(
|
||||
}
|
||||
.onFailure { e ->
|
||||
_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? =
|
||||
when (state) {
|
||||
null, "stop work" -> StatusAction("Arbeit beginnen", ButtonVariant.Success, "start work")
|
||||
@@ -195,26 +208,6 @@ class TimeViewModel(
|
||||
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(
|
||||
private val application: Application,
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
Reference in New Issue
Block a user