feat: verbessere Onboarding-Workflow, verbessere mDNS-Discovery & ZNS-Import
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Failing after 1m1s
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 6m29s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 6m14s
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Failing after 1m17s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Successful in 1m48s

Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-17 22:51:59 +02:00
parent 8f6044abe3
commit 88983f2b4e
22 changed files with 610 additions and 92 deletions
@@ -1,12 +1,12 @@
{
"geraetName": "Meldestelle",
"sharedKey": "Meldestelle",
"backupPath": "/home/stefan/WsMeldestelle/Meldestelle/meldestelle/docs/temp",
"backupPath": "/mocode/Meldestelle/docs/temp",
"networkRole": "MASTER",
"expectedClients": [
{
"name": "Richter-Turm",
"role": "RICHTER"
"name": "Zeithnehmer",
"role": "ZEITNEHMER"
}
]
}
@@ -353,3 +353,11 @@ object Store {
fun allEvents(): List<Veranstaltung> = veranstaltungen.values.flatten()
}
fun Verein.toRemote() = at.mocode.frontend.core.domain.zns.ZnsRemoteVerein(
id = this.id.toString(),
name = this.name,
oepsNummer = this.oepsNummer,
ort = this.ort,
bundesland = this.bundesland
)
@@ -82,6 +82,14 @@ fun DesktopMainLayout(
// Onboarding-Daten (On-the-fly geladen oder Default)
var onboardingSettings by remember { mutableStateOf(SettingsManager.loadSettings() ?: OnboardingSettings()) }
// Automatische Umleitung zum Onboarding, wenn Setup fehlt (außer wir sind bereits dort)
LaunchedEffect(onboardingSettings) {
if (!onboardingSettings.isConfigured && currentScreen !is AppScreen.Onboarding) {
println("[DesktopNav] Setup fehlt -> Umleitung zum Onboarding")
onNavigate(AppScreen.Onboarding)
}
}
Row(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
// Navigation Rail (Modernere Seitenleiste)
DesktopNavRail(
@@ -102,13 +110,19 @@ fun DesktopMainLayout(
currentScreen = currentScreen,
onNavigate = onNavigate,
onBack = onBack,
onSettingsChange = { onboardingSettings = it },
onSettingsChange = {
onboardingSettings = it
SettingsManager.saveSettings(it)
},
settings = onboardingSettings,
)
}
HorizontalDivider(thickness = Dimens.BorderThin, color = MaterialTheme.colorScheme.outlineVariant)
DesktopFooterBar(settings = onboardingSettings)
DesktopFooterBar(
settings = onboardingSettings,
onSetupClick = { onNavigate(AppScreen.Onboarding) }
)
}
}
}
@@ -155,8 +169,8 @@ private fun DesktopNavRail(
NavRailItem(
icon = Icons.Default.People,
label = "Vereine",
selected = currentScreen is AppScreen.VereinVerwaltung,
onClick = { onNavigate(AppScreen.VereinVerwaltung) }
selected = currentScreen is AppScreen.Vereine || currentScreen is AppScreen.VereinVerwaltung,
onClick = { onNavigate(AppScreen.Vereine) }
)
NavRailItem(
@@ -532,11 +546,16 @@ private fun DesktopContentArea(
when (currentScreen) {
// Onboarding (Geräte-Setup)
is AppScreen.Onboarding -> {
println("[Screen] Rendering Onboarding")
OnboardingScreen(
settings = settings,
onSettingsChange = onSettingsChange,
onContinue = { finalSettings: OnboardingSettings ->
SettingsManager.saveSettings(finalSettings)
// Vision_04: Sicherheitsschlüssel als Token setzen, damit Cloud-Suche funktioniert
val authTokenManager =
org.koin.core.context.GlobalContext.get().get<at.mocode.frontend.core.auth.data.AuthTokenManager>()
authTokenManager.setToken(finalSettings.sharedKey)
onNavigate(AppScreen.VeranstaltungVerwaltung)
}
)
@@ -599,11 +618,13 @@ private fun DesktopContentArea(
// --- Verein-Verwaltung & Profil ---
is AppScreen.VereinVerwaltung -> {
println("[Screen] Rendering VereinVerwaltung (VereinScreen)")
val vereinViewModel: VereinViewModel = koinViewModel()
VereinScreen(viewModel = vereinViewModel)
}
is AppScreen.VereinProfil -> {
println("[Screen] Rendering VereinProfil #${currentScreen.id}")
val vereinViewModel: VereinViewModel = koinViewModel()
// Mock: Selektion im ViewModel (falls unterstützt)
VereinScreen(viewModel = vereinViewModel)
@@ -793,6 +814,7 @@ private fun DesktopContentArea(
// Ping-Screen
is AppScreen.Ping -> {
println("[Screen] Rendering Ping")
val pingViewModel: PingViewModel = koinInject()
PingScreen(
viewModel = pingViewModel,
@@ -808,8 +830,8 @@ private fun DesktopContentArea(
)
}
// Vereins-Verwaltung
is AppScreen.Vereine -> {
println("[Screen] Rendering Vereine (VereinScreen)")
val vereinViewModel: VereinViewModel = koinViewModel()
VereinScreen(
viewModel = vereinViewModel
@@ -859,7 +881,10 @@ private fun DesktopContentArea(
}
@Composable
private fun DesktopFooterBar(settings: OnboardingSettings) {
private fun DesktopFooterBar(
settings: OnboardingSettings,
onSetupClick: () -> Unit = {}
) {
val connectivityTracker = koinInject<ConnectivityTracker>()
val discoveryService = koinInject<NetworkDiscoveryService>()
val znsImporter = koinInject<ZnsImportProvider>()
@@ -881,7 +906,8 @@ private fun DesktopFooterBar(settings: OnboardingSettings) {
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 1.dp
tonalElevation = 1.dp,
modifier = Modifier.clickable(onClick = onSetupClick)
) {
Row(
modifier = Modifier
@@ -9,16 +9,24 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import at.mocode.desktop.theme.DesktopTheme
import at.mocode.frontend.core.designsystem.components.MsEnumDropdown
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import org.koin.compose.koinInject
import java.io.File
import javax.swing.JFileChooser
import javax.swing.UIManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -30,7 +38,7 @@ fun OnboardingScreen(
LaunchedEffect(Unit) { println("[Screen] OnboardingScreen geladen") }
var currentStep by remember { mutableStateOf(0) }
val discoveryService: NetworkDiscoveryService = koinInject()
val discoveredServices by remember { mutableStateOf(discoveryService.getDiscoveredServices()) }
val discoveredServices by discoveryService.discoveredServices.collectAsState()
// Automatische Discovery starten, wenn wir auf Schritt 0 sind
LaunchedEffect(currentStep) {
@@ -138,6 +146,7 @@ fun OnboardingScreen(
}
)
var passwordVisible by remember { mutableStateOf(false) }
OutlinedTextField(
value = settings.sharedKey,
onValueChange = { onSettingsChange(settings.copy(sharedKey = it)) },
@@ -145,6 +154,15 @@ fun OnboardingScreen(
placeholder = { Text("Mindestens 8 Zeichen") },
modifier = Modifier.fillMaxWidth(),
isError = settings.sharedKey.isNotEmpty() && !OnboardingValidator.isKeyValid(settings.sharedKey),
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (passwordVisible) "Verbergen" else "Anzeigen"
)
}
},
supportingText = {
if (settings.sharedKey.isNotEmpty() && !OnboardingValidator.isKeyValid(settings.sharedKey)) {
Text("Mindestens ${OnboardingValidator.MIN_KEY_LENGTH} Zeichen erforderlich.")
@@ -160,15 +178,43 @@ fun OnboardingScreen(
style = MaterialTheme.typography.bodySmall
)
Spacer(Modifier.height(8.dp))
settings.expectedClients.forEachIndexed { index, client ->
ListItem(
headlineContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(client.name)
Badge { Text(client.role.name) }
Text(
client.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
SuggestionChip(
onClick = {},
label = { Text(client.role.name) },
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
labelColor = MaterialTheme.colorScheme.onSecondaryContainer
)
)
}
},
supportingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
Modifier.size(8.dp).padding(top = 2.dp)
)
Text(
if (client.isOnline) "Verbunden" else "Offline",
style = MaterialTheme.typography.labelSmall,
color = if (client.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
trailingContent = {
@@ -179,11 +225,17 @@ fun OnboardingScreen(
Icon(
Icons.Default.Delete,
contentDescription = "Löschen",
tint = MaterialTheme.colorScheme.error
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
},
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.5f
)
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
@@ -204,15 +256,14 @@ fun OnboardingScreen(
modifier = Modifier.weight(1f)
)
// Simple Role Selector (nur ein kleiner Button für den Prototyp hier)
IconButton(onClick = {
val roles = NetworkRole.entries.filter { it != NetworkRole.MASTER }
val nextIndex = (roles.indexOf(newClientRole) + 1) % roles.size
newClientRole = roles[nextIndex]
}) {
Icon(Icons.Default.Settings, null)
}
Text(newClientRole.name, style = MaterialTheme.typography.labelSmall)
// Role Selector Dropdown
MsEnumDropdown(
label = "Rolle",
options = NetworkRole.entries.filter { it != NetworkRole.MASTER }.toTypedArray(),
selectedOption = newClientRole,
onOptionSelected = { newClientRole = it },
modifier = Modifier.weight(0.5f)
)
Button(
onClick = {
@@ -243,6 +294,29 @@ fun OnboardingScreen(
label = { Text("Backup-Verzeichnis (Pfad)") },
placeholder = { Text("/pfad/zu/den/backups") },
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
IconButton(onClick = {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
val chooser = JFileChooser().apply {
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
dialogTitle = "Backup-Verzeichnis wählen"
if (settings.backupPath.isNotEmpty()) {
val currentDir = File(settings.backupPath)
if (currentDir.exists()) currentDirectory = currentDir
}
}
val result = chooser.showOpenDialog(null)
if (result == JFileChooser.APPROVE_OPTION) {
onSettingsChange(settings.copy(backupPath = chooser.selectedFile.absolutePath))
}
} catch (e: Exception) {
println("[Error] Fehler beim Öffnen des Verzeichnis-Wählers: ${e.message}")
}
}) {
Icon(Icons.Outlined.FolderOpen, contentDescription = "Verzeichnis wählen")
}
},
isError = settings.backupPath.isNotEmpty() && !OnboardingValidator.isBackupPathValid(settings.backupPath)
)
@@ -21,12 +21,10 @@ 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 at.mocode.desktop.data.Store
import at.mocode.desktop.data.Turnier
import at.mocode.desktop.data.TurnierStore
import at.mocode.desktop.data.Veranstaltung
import at.mocode.desktop.data.*
import at.mocode.desktop.theme.DesktopTheme
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import kotlinx.coroutines.delay
import org.koin.compose.koinInject
import java.time.Instant
import java.time.LocalDate
@@ -430,32 +428,68 @@ fun Step1Veranstalter(
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) {
var search by remember { mutableStateOf("") }
val filteredVereine = remember(search) {
Store.vereine.filter {
val filteredVereine = remember(search, znsState.remoteResults) {
val local = Store.vereine.filter {
it.name.contains(search, ignoreCase = true) || (it.ort?.contains(search, ignoreCase = true)
?: false)
?: false) || it.oepsNummer.contains(search, ignoreCase = true)
}
// Cloud-Ergebnisse beimischen, falls lokal nichts gefunden oder Suche aktiv
val remote = znsState.remoteResults.filter { r ->
local.none { l -> l.oepsNummer == r.oepsNummer }
}
(local.map { it.toRemote() } + remote).sortedBy { it.name }
}
// Cloud-Suche triggern
LaunchedEffect(search) {
if (search.length >= 3) {
delay(500.milliseconds)
znsImporter.searchRemote(search)
}
}
Text("Oder bestehenden Veranstalter wählen:", style = MaterialTheme.typography.titleSmall)
Text("Veranstalter suchen (lokal & Cloud):", style = MaterialTheme.typography.titleSmall)
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Veranstalter suchen...") },
label = { Text("Name, Ort oder OEPS-Nr...") },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
leadingIcon = {
if (znsState.isSearching) CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
else Icon(Icons.Default.Search, contentDescription = null)
},
trailingIcon = {
if (search.isNotEmpty()) {
IconButton(onClick = { search = "" }) { Icon(Icons.Default.Close, null) }
}
},
singleLine = true
)
if (znsState.errorMessage != null) {
Text(
znsState.errorMessage!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall
)
}
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(filteredVereine) { verein ->
val isSelected = selectedVereinId == verein.id
val isSelected = selectedVereinId.toString() == verein.id
Surface(
onClick = { onVereinSelected(verein.id) },
onClick = {
// Falls es ein Cloud-Verein ist, in den lokalen Store übernehmen
if (Store.vereine.none { it.oepsNummer == verein.oepsNummer }) {
Store.addVerein(verein.name, verein.oepsNummer, verein.ort ?: "")
}
val localId = Store.vereine.find { it.oepsNummer == verein.oepsNummer }?.id ?: 0L
onVereinSelected(localId)
},
shape = MaterialTheme.shapes.small,
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
border = if (isSelected) null else androidx.compose.foundation.BorderStroke(
@@ -474,9 +508,18 @@ fun Step1Veranstalter(
style = MaterialTheme.typography.labelSmall
)
}
if (Store.vereine.none { it.oepsNummer == verein.oepsNummer }) {
Icon(
Icons.Default.CloudDownload,
contentDescription = "Cloud",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}
if (isSelected) Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}