feat(desktop-onboarding): neue Onboarding-UI implementiert, Backup- und Rollenmanagement hinzugefügt
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Failing after 3m10s
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 6m37s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 5m59s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled

- Einbindung eines komplett überarbeiteten Onboarding-Screens mit validierten Eingaben für Gerätename, Sicherheitsschlüssel und Backup-Pfad.
- `SettingsManager` eingeführt zur Speicherung der Onboarding-Daten in `settings.json`.
- Navigation verbessert: Onboarding-Workflow startet, wenn Konfiguration fehlt; neues "Setup"-Icon in der Navigationsleiste hinzugefügt.
- Backend: Geräte-API und `DeviceSecurityFilter` für Authentifizierung per Sicherheitsschlüssel implementiert.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-15 15:48:55 +02:00
parent a5f5e7a24b
commit a6fcb81594
23 changed files with 900 additions and 275 deletions
@@ -0,0 +1,7 @@
{
"geraetName": "Meldestelle",
"sharedKey": "Meldestelle",
"backupPath": "/home/stefan/WsMeldestelle/Meldestelle/meldestelle/docs/temp",
"networkRole": "MASTER",
"syncInterval": 20
}
@@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import at.mocode.desktop.navigation.DesktopNavigationPort
import at.mocode.desktop.screens.layout.DesktopMainLayout
import at.mocode.desktop.screens.onboarding.SettingsManager
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.auth.presentation.LoginScreen
import at.mocode.frontend.core.auth.presentation.LoginViewModel
@@ -34,6 +35,13 @@ fun DesktopApp() {
val currentScreen by nav.currentScreen.collectAsState()
val loginViewModel: LoginViewModel = koinViewModel()
// Onboarding-Check beim Start
LaunchedEffect(Unit) {
if (!SettingsManager.isConfigured()) {
nav.navigateToScreen(AppScreen.Onboarding)
}
}
val authState by authTokenManager.authState.collectAsState()
// Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt
@@ -4,11 +4,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.*
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -16,6 +16,8 @@ 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 at.mocode.desktop.screens.onboarding.OnboardingSettings
import at.mocode.desktop.screens.onboarding.SettingsManager
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.core.navigation.AppScreen
@@ -60,9 +62,8 @@ fun DesktopMainLayout(
onBack: () -> Unit,
onLogout: () -> Unit,
) {
// Onboarding-Eingaben zwischen Navigationswechseln behalten
var obGeraet by rememberSaveable { mutableStateOf("") }
var obKey by rememberSaveable { mutableStateOf("") }
// Onboarding-Daten (On-the-fly geladen oder Default)
var onboardingSettings by remember { mutableStateOf(SettingsManager.loadSettings() ?: OnboardingSettings()) }
Row(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
// Navigation Rail (Modernere Seitenleiste)
@@ -84,10 +85,8 @@ fun DesktopMainLayout(
currentScreen = currentScreen,
onNavigate = onNavigate,
onBack = onBack,
obGeraet = obGeraet,
obKey = obKey,
onObGeraetChange = { obGeraet = it },
onObKeyChange = { obKey = it },
onSettingsChange = { onboardingSettings = it },
settings = onboardingSettings,
)
}
@@ -151,15 +150,25 @@ private fun DesktopNavRail(
)
NavRailItem(
icon = Icons.Default.Settings,
label = "Tools",
icon = Icons.Default.WifiTethering,
label = "Sync",
selected = currentScreen is AppScreen.Ping,
onClick = { onNavigate(AppScreen.Ping) }
)
Spacer(Modifier.weight(1f))
NavRailItem(
icon = Icons.Default.AppRegistration,
label = "Setup",
selected = currentScreen is AppScreen.Onboarding,
onClick = { onNavigate(AppScreen.Onboarding) }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NavRailItem(
icon: ImageVector,
@@ -170,23 +179,35 @@ private fun NavRailItem(
val tint = if (selected) MaterialTheme.colorScheme.primary else AppColors.NavigationContent
val background = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else Color.Transparent
Surface(
modifier = Modifier
.size(48.dp)
.clickable(onClick = onClick),
shape = MaterialTheme.shapes.medium,
color = background
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
positioning = TooltipAnchorPosition.Right
),
tooltip = {
PlainTooltip {
Text(label)
}
},
state = rememberTooltipState()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
Surface(
modifier = Modifier
.size(48.dp)
.clickable(onClick = onClick),
shape = MaterialTheme.shapes.medium,
color = background
) {
Icon(
imageVector = icon,
contentDescription = label,
tint = tint,
modifier = Modifier.size(Dimens.IconSizeM)
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = label,
tint = tint,
modifier = Modifier.size(Dimens.IconSizeM)
)
}
}
}
}
@@ -475,28 +496,20 @@ private fun DesktopContentArea(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onBack: () -> Unit,
obGeraet: String,
obKey: String,
onObGeraetChange: (String) -> Unit,
onObKeyChange: (String) -> Unit,
settings: OnboardingSettings,
onSettingsChange: (OnboardingSettings) -> Unit,
) {
when (currentScreen) {
// Onboarding ohne Login
// Onboarding (Geräte-Setup)
is AppScreen.Onboarding -> {
val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject()
at.mocode.frontend.core.designsystem.theme.AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
at.mocode.desktop.v2.OnboardingScreen(
geraetName = obGeraet,
secureKey = obKey,
onGeraetNameChange = onObGeraetChange,
onSecureKeyChange = onObKeyChange,
) { _, _ ->
authTokenManager.setToken("dummy.jwt.token")
onNavigate(AppScreen.VeranstaltungVerwaltung)
}
at.mocode.desktop.v2.OnboardingScreen(
settings = settings,
onSettingsChange = onSettingsChange,
onContinue = { finalSettings ->
SettingsManager.saveSettings(finalSettings)
onNavigate(AppScreen.VeranstaltungVerwaltung)
}
}
)
}
// Haupt-Zentrale: Veranstaltung-Verwaltung
@@ -1,93 +0,0 @@
package at.mocode.desktop.screens.onboarding
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
enum class ZnsStatus { NONE, LOCAL, SYNCED }
@Composable
fun OnboardingScreen(
initialName: String = "",
initialKey: String = "",
initialZns: ZnsStatus = ZnsStatus.NONE,
onZnsSync: () -> Unit = {},
onZnsUsb: () -> Unit = {},
onContinue: (geraetName: String, sharedKey: String, znsStatus: ZnsStatus) -> Unit,
) {
var geraetName by rememberSaveable { mutableStateOf(initialName) }
var sharedKey by rememberSaveable { mutableStateOf(initialKey) }
var znsStatus by rememberSaveable { mutableStateOf(initialZns) }
var showPassword by remember { mutableStateOf(false) }
val nameValid = OnboardingValidator.isNameValid(geraetName)
val keyValid = OnboardingValidator.isKeyValid(sharedKey)
val canContinue = OnboardingValidator.canContinue(geraetName, sharedKey)
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Onboarding", style = MaterialTheme.typography.headlineSmall)
Card {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Gerätename (Pflicht)", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = geraetName,
onValueChange = { geraetName = it },
placeholder = { Text("z. B. Meldestelle") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = !nameValid && geraetName.isNotBlank()
)
Text("Sicherheitsschlüssel (Pflicht)", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = sharedKey,
onValueChange = { sharedKey = it },
placeholder = { Text("z. B. Neumarkt2026") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = !keyValid && sharedKey.isNotBlank(),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val label = if (showPassword) "Verbergen" else "Anzeigen"
TextButton(onClick = { showPassword = !showPassword }) {
Text(label)
}
}
)
Text("ZNS-Daten (optional)", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = {
znsStatus = ZnsStatus.SYNCED
onZnsSync()
}, label = { Text("Aktualisieren") })
AssistChip(onClick = {
znsStatus = ZnsStatus.LOCAL
onZnsUsb()
}, label = { Text("USB-Import") })
Spacer(Modifier.width(8.dp))
Text("Status: $znsStatus")
}
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { onContinue(geraetName.trim(), sharedKey.trim(), znsStatus) }, enabled = canContinue) {
Text("Weiter zu den Veranstaltungen")
}
if (!canContinue) {
Text("Bitte Gerätename (min. 3) und Schlüssel (min. 8) angeben.", color = MaterialTheme.colorScheme.error)
}
}
}
}
@@ -0,0 +1,19 @@
package at.mocode.desktop.screens.onboarding
import kotlinx.serialization.Serializable
@Serializable
enum class NetworkRole {
MASTER,
CLIENT
}
@Serializable
data class OnboardingSettings(
val geraetName: String = "",
val sharedKey: String = "",
val backupPath: String = "",
val networkRole: NetworkRole = NetworkRole.CLIENT,
val syncInterval: Int = 30, // in Minuten
val defaultPrinter: String = ""
)
@@ -7,6 +7,8 @@ package at.mocode.desktop.screens.onboarding
* Regeln gemäß Onboarding-Spezifikation:
* - Gerätename: mindestens 3 Zeichen (nach trim)
* - Sicherheitsschlüssel: mindestens 8 Zeichen (nach trim)
* - Backup-Pfad: darf nicht leer sein und muss existieren (Prüfung optional hier)
* - Sync-Intervall: zwischen 1 und 60 Minuten
*/
object OnboardingValidator {
@@ -16,15 +18,28 @@ object OnboardingValidator {
/** Mindestlänge für den Sicherheitsschlüssel. */
const val MIN_KEY_LENGTH = 8
/** Standard-Sync-Intervall in Minuten. */
const val DEFAULT_SYNC_INTERVAL = 30
/** Gibt `true` zurück, wenn der Gerätename gültig ist. */
fun isNameValid(name: String): Boolean = name.trim().length >= MIN_NAME_LENGTH
/** Gibt `true` zurück, wenn der Sicherheitsschlüssel gültig ist. */
fun isKeyValid(key: String): Boolean = key.trim().length >= MIN_KEY_LENGTH
/** Gibt `true` zurück, wenn der Backup-Pfad gültig ist. */
fun isBackupPathValid(path: String): Boolean = path.isNotBlank()
/** Gibt `true` zurück, wenn das Sync-Intervall gültig ist. */
fun isSyncIntervalValid(interval: Int): Boolean = interval in 1..60
/**
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und
* der „Weiter"-Button aktiviert werden darf.
*/
fun canContinue(name: String, key: String): Boolean = isNameValid(name) && isKeyValid(key)
fun canContinue(settings: OnboardingSettings): Boolean =
isNameValid(settings.geraetName) &&
isKeyValid(settings.sharedKey) &&
isBackupPathValid(settings.backupPath) &&
isSyncIntervalValid(settings.syncInterval)
}
@@ -0,0 +1,34 @@
package at.mocode.desktop.screens.onboarding
import kotlinx.serialization.json.Json
import java.io.File
object SettingsManager {
private val settingsFile = File("settings.json")
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
fun saveSettings(settings: OnboardingSettings) {
try {
val content = json.encodeToString(settings)
settingsFile.writeText(content)
} catch (e: Exception) {
println("Fehler beim Speichern der Einstellungen: ${e.message}")
}
}
fun loadSettings(): OnboardingSettings? {
if (!settingsFile.exists()) return null
return try {
val content = settingsFile.readText()
json.decodeFromString<OnboardingSettings>(content)
} catch (e: Exception) {
println("Fehler beim Laden der Einstellungen: ${e.message}")
null
}
}
fun isConfigured(): Boolean {
val settings = loadSettings() ?: return false
return OnboardingValidator.canContinue(settings)
}
}
@@ -5,136 +5,242 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import at.mocode.desktop.screens.onboarding.NetworkRole
import at.mocode.desktop.screens.onboarding.OnboardingSettings
import at.mocode.desktop.screens.onboarding.OnboardingValidator
import at.mocode.frontend.core.designsystem.components.MsTextField
import javax.print.PrintServiceLookup
import javax.swing.JFileChooser
@Composable
fun OnboardingScreen(
geraetName: String,
secureKey: String,
onGeraetNameChange: (String) -> Unit,
onSecureKeyChange: (String) -> Unit,
onContinue: (String, String) -> Unit,
settings: OnboardingSettings,
onSettingsChange: (OnboardingSettings) -> Unit,
onContinue: (OnboardingSettings) -> Unit,
) {
DesktopThemeV2 {
Surface(color = MaterialTheme.colorScheme.background) {
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Onboarding", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
Column(
modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"Willkommen beim Meldestelle-Biest",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
Text(
"Bitte konfiguriere deine lokale Instanz (Geburtsurkunde).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
var showPw by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val frName = remember { FocusRequester() }
val frKey = remember { FocusRequester() }
val frBtn = remember { FocusRequester() }
MsTextField(
value = geraetName,
onValueChange = { onGeraetNameChange(it) },
label = "Gerätename (Pflicht)",
modifier = Modifier
.fillMaxWidth()
.focusRequester(frName)
.onKeyEvent { e ->
if (e.type == KeyEventType.KeyUp) {
when (e.key) {
Key.Tab, Key.Enter -> {
focusManager.moveFocus(FocusDirection.Next)
true
}
else -> false
}
} else false
}
,
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
MsTextField(
value = secureKey,
onValueChange = { onSecureKeyChange(it) },
label = "Sicherheitsschlüssel (Pflicht)",
trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
onTrailingIconClick = { showPw = !showPw },
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier
.fillMaxWidth()
.focusRequester(frKey)
.onKeyEvent { e ->
if (e.type == KeyEventType.KeyUp) {
when (e.key) {
Key.Tab -> {
focusManager.moveFocus(FocusDirection.Next)
true
}
Key.Enter -> {
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
onContinue(geraetName, secureKey)
} else {
focusManager.moveFocus(FocusDirection.Next)
}
true
}
else -> false
}
} else false
}
,
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(onDone = {
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
onContinue(geraetName, secureKey)
} else {
focusManager.moveFocus(FocusDirection.Next)
}
})
)
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
val enabled = geraetName.trim().length >= 3 && secureKey.trim().length >= 8
Button(
onClick = { onContinue(geraetName, secureKey) },
enabled = enabled,
modifier = Modifier
.focusRequester(frBtn)
.onKeyEvent { e ->
if (e.type == KeyEventType.KeyUp && (e.key == Key.Enter)) {
if (enabled) onContinue(geraetName, secureKey)
true
} else false
}
) {
Text("Zu den Veranstaltungen")
MsTextField(
value = settings.geraetName,
onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
label = "Gerätename (Pflicht)",
placeholder = "z. B. Meldestelle-PC-1",
modifier = Modifier.fillMaxWidth(),
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
MsTextField(
value = settings.sharedKey,
onValueChange = { onSettingsChange(settings.copy(sharedKey = it)) },
label = "Sicherheitsschlüssel (Pflicht)",
placeholder = "Shared Secret für Netzwerk-Sync",
trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
onTrailingIconClick = { showPw = !showPw },
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("⚙️ Lokale Einstellungen", style = MaterialTheme.typography.titleMedium)
MsTextField(
value = settings.backupPath,
onValueChange = { onSettingsChange(settings.copy(backupPath = it)) },
label = "💾 Datenbank-Sicherungspfad (Backup)",
placeholder = "Pfad zum Backup-Verzeichnis",
modifier = Modifier.fillMaxWidth(),
trailingIcon = Icons.Default.FolderOpen,
onTrailingIconClick = {
val chooser = JFileChooser()
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.dialogTitle = "Backup-Verzeichnis auswählen"
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
onSettingsChange(settings.copy(backupPath = chooser.selectedFile.absolutePath))
}
},
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
Text("🌐 Netzwerk-Rolle", style = MaterialTheme.typography.labelLarge)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = settings.networkRole == NetworkRole.MASTER,
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) }
)
Text(
"Master (Hostet lokale DB)",
modifier = Modifier.clickable { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) })
}
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = settings.networkRole == NetworkRole.CLIENT,
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) }
)
Text(
"Client",
modifier = Modifier.clickable { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) })
}
}
Column {
Text("📡 Sync-Intervall: ${settings.syncInterval} Minuten", style = MaterialTheme.typography.labelLarge)
Slider(
value = settings.syncInterval.toFloat(),
onValueChange = { onSettingsChange(settings.copy(syncInterval = it.toInt())) },
valueRange = 1f..60f,
steps = 59,
modifier = Modifier.fillMaxWidth()
)
}
var showPrinterDialog by remember { mutableStateOf(false) }
val availablePrinters = remember {
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }
}
MsTextField(
value = settings.defaultPrinter,
onValueChange = { onSettingsChange(settings.copy(defaultPrinter = it)) },
label = "🖨️ Standard-Drucker",
placeholder = "Name des Standard-Druckers",
modifier = Modifier.fillMaxWidth(),
trailingIcon = Icons.Default.Print,
onTrailingIconClick = { showPrinterDialog = true },
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(onDone = {
if (OnboardingValidator.canContinue(settings)) onContinue(settings)
})
)
if (showPrinterDialog) {
AlertDialog(
onDismissRequest = { showPrinterDialog = false },
title = { Text("Drucker auswählen") },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
if (availablePrinters.isEmpty()) {
Text("Keine Drucker gefunden", style = MaterialTheme.typography.bodyMedium)
} else {
availablePrinters.forEach { printer ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onSettingsChange(settings.copy(defaultPrinter = printer))
showPrinterDialog = false
}
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = settings.defaultPrinter == printer,
onClick = null
)
Spacer(Modifier.width(8.dp))
Text(printer)
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showPrinterDialog = false }) {
Text("Schließen")
}
}
)
}
}
}
val canContinue = OnboardingValidator.canContinue(settings)
Button(
onClick = { onContinue(settings) },
enabled = canContinue,
modifier = Modifier.align(Alignment.End)
) {
Text("Konfiguration speichern & starten")
}
if (!canContinue) {
Text(
"Bitte alle Pflichtfelder korrekt ausfüllen (Name min. 3, Key min. 8, Backup-Pfad gesetzt).",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall
)
}
if (!enabled) Text("Mind. 3 Zeichen für Namen und 8 Zeichen für Schlüssel", color = Color(0xFFB00020))
}
}
}
}
@Preview
@Composable
fun OnboardingScreenPreview() {
var settings by remember { mutableStateOf(OnboardingSettings()) }
OnboardingScreen(
settings = settings,
onSettingsChange = { settings = it },
onContinue = {}
)
}
@Composable
fun PferdProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 {
val pferd = remember(id) { StoreV2.pferde.firstOrNull { it.id == id } }
if (pferd == null) { Text("Pferd nicht gefunden"); return@DesktopThemeV2 }
if (pferd == null) {
Text("Pferd nicht gefunden"); return@DesktopThemeV2
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
@@ -144,13 +250,17 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) {
var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF374151), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
Box(
modifier = Modifier.size(56.dp).background(Color(0xFF374151), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text(pferd.name.take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(pferd.name, style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull(pferd.oepsNummer?.let { "OEPS: $it" }, pferd.feiId?.let { "FEI: $it" }).joinToString(" · ")
val l2 =
listOfNotNull(pferd.oepsNummer?.let { "OEPS: $it" }, pferd.feiId?.let { "FEI: $it" }).joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
val l3 = listOfNotNull(pferd.geburtsdatum?.let { "geb. $it" }, pferd.farbe).joinToString(" · ")
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
@@ -203,7 +313,9 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) {
fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 {
val r = remember(id) { StoreV2.reiter.firstOrNull { it.id == id } }
if (r == null) { Text("Reiter nicht gefunden"); return@DesktopThemeV2 }
if (r == null) {
Text("Reiter nicht gefunden"); return@DesktopThemeV2
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
@@ -213,14 +325,22 @@ fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF4B5563), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
val initials = (r.vorname + " " + r.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("")
Box(
modifier = Modifier.size(56.dp).background(Color(0xFF4B5563), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
val initials =
(r.vorname + " " + r.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2)
.joinToString("")
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("${r.vorname} ${r.nachname}", style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull(r.oepsNummer?.let { "OEPS: $it" }, r.feiId?.let { "FEI: $it" }, r.lizenzKlasse.takeIf { it.isNotBlank() } ).joinToString(" · ")
val l2 = listOfNotNull(
r.oepsNummer?.let { "OEPS: $it" },
r.feiId?.let { "FEI: $it" },
r.lizenzKlasse.takeIf { it.isNotBlank() }).joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
r.verein?.let { Text(it, color = Color(0xFF6B7280)) }
}
@@ -277,7 +397,9 @@ fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
fun VereinProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 {
val v = remember(id) { StoreV2.vereine.firstOrNull { it.id == id } }
if (v == null) { Text("Verein nicht gefunden"); return@DesktopThemeV2 }
if (v == null) {
Text("Verein nicht gefunden"); return@DesktopThemeV2
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
@@ -287,13 +409,17 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
Box(
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text((v.kurzname ?: v.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(v.name, style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull("OEPS: ${v.oepsNummer}", v.ort, v.plz, v.strasse).filter { it.isNotBlank() }.joinToString(" · ")
val l2 = listOfNotNull("OEPS: ${v.oepsNummer}", v.ort, v.plz, v.strasse).filter { it.isNotBlank() }
.joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
val l3 = listOfNotNull(v.email, v.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
@@ -340,7 +466,12 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
OutlinedTextField(ort, { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
OutlinedTextField(plz, { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
}
OutlinedTextField(strasse, { strasse = it }, label = { Text("Straße / Adresse") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(
strasse,
{ strasse = it },
label = { Text("Straße / Adresse") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
OutlinedTextField(tel, { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
@@ -357,7 +488,9 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 {
val f = remember(id) { StoreV2.funktionaere.firstOrNull { it.id == id } }
if (f == null) { Text("Funktionär nicht gefunden"); return@DesktopThemeV2 }
if (f == null) {
Text("Funktionär nicht gefunden"); return@DesktopThemeV2
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
@@ -367,14 +500,21 @@ fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF111827), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
val initials = (f.vorname + " " + f.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("")
Box(
modifier = Modifier.size(56.dp).background(Color(0xFF111827), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
val initials =
(f.vorname + " " + f.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2)
.joinToString("")
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("${f.vorname} ${f.nachname}", style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull(f.richterNummer?.let { "Nr. $it" }, f.richterQualifikation?.let { "Qual.: $it" }).joinToString(" · ")
val l2 = listOfNotNull(
f.richterNummer?.let { "Nr. $it" },
f.richterQualifikation?.let { "Qual.: $it" }).joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
f.email?.let { Text(it, color = Color(0xFF6B7280)) }
}
@@ -411,7 +551,12 @@ fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(num, { num = it }, label = { Text("Nummer") }, modifier = Modifier.weight(1f))
OutlinedTextField(qual, { qual = it }, label = { Text("Qualifikation") }, modifier = Modifier.weight(1f))
OutlinedTextField(
qual,
{ qual = it },
label = { Text("Qualifikation") },
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
}
@@ -500,12 +645,21 @@ fun VeranstalterDetailV2(
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text((verein.kurzname ?: verein.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
Text(
(verein.kurzname ?: verein.name).take(2).uppercase(),
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(verein.name, style = MaterialTheme.typography.titleMedium)
val line2 = listOfNotNull("OEPS: ${verein.oepsNummer}", verein.ort, verein.plz, verein.strasse).filter { it.isNotBlank() }.joinToString(" · ")
val line2 = listOfNotNull(
"OEPS: ${verein.oepsNummer}",
verein.ort,
verein.plz,
verein.strasse
).filter { it.isNotBlank() }.joinToString(" · ")
if (line2.isNotBlank()) Text(line2, color = Color(0xFF6B7280))
val line3 = listOfNotNull(verein.email, verein.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
if (line3.isNotBlank()) Text(line3, color = Color(0xFF6B7280))
@@ -545,19 +699,59 @@ fun VeranstalterDetailV2(
title = { Text("Veranstalter bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = oeps, onValueChange = { oeps = it }, label = { Text("OEPS-Nummer") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = logo, onValueChange = { logo = it }, label = { Text("Logo-URL") }, modifier = Modifier.weight(1f))
OutlinedTextField(
value = oeps,
onValueChange = { oeps = it },
label = { Text("OEPS-Nummer") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = logo,
onValueChange = { logo = it },
label = { Text("Logo-URL") },
modifier = Modifier.weight(1f)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = ort, onValueChange = { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = plz, onValueChange = { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Ort") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = plz,
onValueChange = { plz = it },
label = { Text("PLZ") },
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(value = strasse, onValueChange = { strasse = it }, label = { Text("Straße / Adresse") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(
value = strasse,
onValueChange = { strasse = it },
label = { Text("Straße / Adresse") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = tel, onValueChange = { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("E-Mail") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = tel,
onValueChange = { tel = it },
label = { Text("Telefon") },
modifier = Modifier.weight(1f)
)
}
}
}
@@ -580,9 +774,9 @@ fun VeranstalterDetailV2(
val q = search.trim()
if (q.isEmpty()) events else events.filter {
it.titel.contains(q, ignoreCase = true) ||
it.status.contains(q, ignoreCase = true) ||
it.datumVon.contains(q, ignoreCase = true) ||
(it.datumBis?.contains(q, ignoreCase = true) == true)
it.status.contains(q, ignoreCase = true) ||
it.datumVon.contains(q, ignoreCase = true) ||
(it.datumBis?.contains(q, ignoreCase = true) == true)
}
}
if (filtered.isEmpty()) Text("Keine passenden Veranstaltungen gefunden.", color = Color(0xFF6B7280))
@@ -613,7 +807,13 @@ fun VeranstalterDetailV2(
text = { Text("Diese Aktion entfernt die Veranstaltung und alle zugehörigen Turniere im Prototypen.") }
)
}
IconButton(onClick = { confirm = true }) { Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626)) }
IconButton(onClick = { confirm = true }) {
Icon(
Icons.Default.Delete,
contentDescription = "Löschen",
tint = Color(0xFFDC2626)
)
}
}
}
}
@@ -82,37 +82,54 @@ class OnboardingValidatorTest {
@Test
fun `B2 canContinue false wenn beide Felder leer`() {
assertFalse(OnboardingValidator.canContinue("", ""))
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "", sharedKey = "")))
}
@Test
fun `B2 canContinue false wenn nur Name gültig`() {
assertFalse(OnboardingValidator.canContinue("Meldestelle", "kurz"))
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "Meldestelle", sharedKey = "kurz")))
}
@Test
fun `B2 canContinue false wenn nur Schlüssel gültig`() {
assertFalse(OnboardingValidator.canContinue("AB", "Neumarkt2026"))
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "AB", sharedKey = "Neumarkt2026")))
}
@Test
fun `B2 canContinue true wenn beide Felder gültig`() {
assertTrue(OnboardingValidator.canContinue("Meldestelle", "Neumarkt2026"))
// Beachte: backupPath muss für true auch gesetzt sein
assertTrue(
OnboardingValidator.canContinue(
OnboardingSettings(
geraetName = "Meldestelle",
sharedKey = "Neumarkt2026",
backupPath = "/tmp"
)
)
)
}
@Test
fun `B2 canContinue false bei Grenzfall Name 2 Zeichen und gültigem Schlüssel`() {
assertFalse(OnboardingValidator.canContinue("AB", "12345678"))
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "AB", sharedKey = "12345678")))
}
@Test
fun `B2 canContinue false bei gültigem Namen und Grenzfall Schlüssel 7 Zeichen`() {
assertFalse(OnboardingValidator.canContinue("Meldestelle", "1234567"))
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "Meldestelle", sharedKey = "1234567")))
}
@Test
fun `B2 canContinue true bei exakten Mindestlängen`() {
assertTrue(OnboardingValidator.canContinue("ABC", "12345678"))
assertTrue(
OnboardingValidator.canContinue(
OnboardingSettings(
geraetName = "ABC",
sharedKey = "12345678",
backupPath = "/tmp"
)
)
)
}
// ─── Doppelklick-Schutz (Submit-Guard) ──────────────────────────────────────
@@ -120,10 +137,9 @@ class OnboardingValidatorTest {
@Test
fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() {
// Simuliert schnelles Doppelklick: canContinue darf sich nicht ändern
val name = "Meldestelle"
val key = "Neumarkt2026"
val first = OnboardingValidator.canContinue(name, key)
val second = OnboardingValidator.canContinue(name, key)
val settings = OnboardingSettings(geraetName = "Meldestelle", sharedKey = "Neumarkt2026", backupPath = "/tmp")
val first = OnboardingValidator.canContinue(settings)
val second = OnboardingValidator.canContinue(settings)
assertTrue(first)
assertTrue(second)
}
@@ -147,7 +163,13 @@ class OnboardingValidatorTest {
"Sicherheitsschlüssel muss nach Zurück-Navigation noch gültig sein (rememberSaveable-Fix)"
)
assertTrue(
OnboardingValidator.canContinue(wiederhergestellterName, wiederhergestellterKey),
OnboardingValidator.canContinue(
OnboardingSettings(
geraetName = wiederhergestellterName,
sharedKey = wiederhergestellterKey,
backupPath = "/tmp"
)
),
"Weiter-Button muss nach Zurück-Navigation aktiviert bleiben"
)
}
@@ -160,7 +182,7 @@ class OnboardingValidatorTest {
val nameNachReset = ""
val keyNachReset = ""
assertFalse(
OnboardingValidator.canContinue(nameNachReset, keyNachReset),
OnboardingValidator.canContinue(OnboardingSettings(geraetName = nameNachReset, sharedKey = keyNachReset)),
"Nach Abbrechen darf der Weiter-Button nicht aktiviert sein"
)
}