refactor(desktop, core): Onboarding zu DeviceInitialization umbenannt, Navigation und Screens angepasst

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-18 11:10:01 +02:00
parent 315517f03f
commit 7bbb991e69
24 changed files with 742 additions and 222 deletions
@@ -1,12 +1,12 @@
{
"geraetName": "Meldestelle",
"sharedKey": "Meldestelle",
"backupPath": "/mocode/Meldestelle/docs/temp",
"backupPath": "/home/stefan/WsMeldestelle/Meldestelle/meldestelle/docs/temp",
"networkRole": "MASTER",
"expectedClients": [
{
"name": "Zeithnehmer",
"role": "ZEITNEHMER"
"name": "Richter-Turm",
"role": "RICHTER"
}
]
}
@@ -35,18 +35,18 @@ fun DesktopApp() {
val currentScreen by nav.currentScreen.collectAsState()
val loginViewModel: LoginViewModel = koinViewModel()
// Onboarding-Check beim Start
// DeviceInitialization-Check beim Start
LaunchedEffect(Unit) {
if (!SettingsManager.isConfigured()) {
nav.navigateToScreen(AppScreen.Onboarding)
nav.navigateToScreen(AppScreen.DeviceInitialization)
}
}
val authState by authTokenManager.authState.collectAsState()
// Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt
// Vision_03 Update: Wir starten mit Onboarding
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.Onboarding
// Login-Gate: Nicht-authentifizierte Screens → Login, außer DeviceInitialization ist erlaubt
// Vision_03 Update: Wir starten mit DeviceInitialization
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.DeviceInitialization
&& currentScreen !is AppScreen.VeranstaltungVerwaltung
&& currentScreen !is AppScreen.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu
&& currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.VeranstaltungKonfig
@@ -57,10 +57,11 @@ fun DesktopApp() {
&& currentScreen !is AppScreen.VereinVerwaltung
&& currentScreen !is AppScreen.StammdatenImport
&& currentScreen !is AppScreen.NennungsEingang
&& currentScreen !is AppScreen.ConnectivityCheck
) {
LaunchedEffect(Unit) {
// Standard: Start im Onboarding
nav.navigateToScreen(AppScreen.Onboarding)
// Standard: Start im DeviceInitialization
nav.navigateToScreen(AppScreen.DeviceInitialization)
}
}
@@ -71,7 +72,7 @@ fun DesktopApp() {
val returnTo = screen.returnTo ?: AppScreen.VeranstaltungVerwaltung
nav.navigateToScreen(returnTo)
},
onBack = { /* Desktop hat keine Landing-Page */ },
onBack = { /* Desktop hat keine PortalDashboard-Page */ },
)
else -> {
@@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow
* Hält den aktuellen Screen als StateFlow, den DesktopApp beobachtet.
*/
class DesktopNavigationPort : NavigationPort {
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Onboarding)
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.DeviceInitialization)
override val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
// Backstack zur Speicherung des Verlaufs
@@ -29,7 +29,7 @@ class DesktopNavigationPort : NavigationPort {
val current = _currentScreen.value
if (current != screen) {
backStack.add(current)
// Begrenzung des Backstacks auf z.B. 50 Einträge
// Begrenzung des Backstacks auf z. B. 50 Einträge
if (backStack.size > 50) backStack.removeAt(0)
}
_currentScreen.value = screen
@@ -41,8 +41,8 @@ class DesktopNavigationPort : NavigationPort {
println("[DesktopNav] navigateBack -> $previousScreen")
_currentScreen.value = previousScreen
} else {
println("[DesktopNav] navigateBack -> Stack leer, bleibe bei Onboarding")
_currentScreen.value = AppScreen.Onboarding
println("[DesktopNav] navigateBack -> Stack leer, bleibe bei DeviceInitialization")
_currentScreen.value = AppScreen.DeviceInitialization
}
}
}
@@ -60,7 +60,6 @@ import org.koin.compose.viewmodel.koinViewModel
import kotlin.time.Duration.Companion.milliseconds
// Primärfarbe der TopBar (kann später ins Theme ausgelagert werden)
private val TopBarColor = Color(0xFF1E3A8A)
private val TopBarTextColor = Color.White
/**
@@ -79,14 +78,14 @@ fun DesktopMainLayout(
onLogout: () -> Unit,
) {
println("[Navigation] Rendering Screen: ${currentScreen::class.simpleName} (Details: $currentScreen)")
// Onboarding-Daten (On-the-fly geladen oder Default)
// DeviceInitialization-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)
// Automatische Umleitung zum DeviceInitialization, 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)
if (!onboardingSettings.isConfigured && currentScreen !is AppScreen.DeviceInitialization) {
println("[DesktopNav] Setup fehlt -> Umleitung zum DeviceInitialization")
onNavigate(AppScreen.DeviceInitialization)
}
}
@@ -121,7 +120,7 @@ fun DesktopMainLayout(
HorizontalDivider(thickness = Dimens.BorderThin, color = MaterialTheme.colorScheme.outlineVariant)
DesktopFooterBar(
settings = onboardingSettings,
onSetupClick = { onNavigate(AppScreen.Onboarding) }
onSetupClick = { onNavigate(AppScreen.DeviceInitialization) }
)
}
}
@@ -182,9 +181,9 @@ private fun DesktopNavRail(
NavRailItem(
icon = Icons.Default.WifiTethering,
label = "Sync",
selected = currentScreen is AppScreen.Ping,
onClick = { onNavigate(AppScreen.Ping) }
label = "ConnectivityCheck",
selected = currentScreen is AppScreen.ConnectivityCheck,
onClick = { onNavigate(AppScreen.ConnectivityCheck) }
)
Spacer(Modifier.weight(1f))
@@ -192,8 +191,8 @@ private fun DesktopNavRail(
NavRailItem(
icon = Icons.Default.AppRegistration,
label = "Setup",
selected = currentScreen is AppScreen.Onboarding,
onClick = { onNavigate(AppScreen.Onboarding) }
selected = currentScreen is AppScreen.DeviceInitialization,
onClick = { onNavigate(AppScreen.DeviceInitialization) }
)
}
}
@@ -264,7 +263,7 @@ private fun DesktopTopHeader(
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (currentScreen !is AppScreen.Onboarding) {
if (currentScreen !is AppScreen.DeviceInitialization) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
@@ -439,10 +438,10 @@ private fun BreadcrumbContent(
)
}
is AppScreen.Ping -> {
is AppScreen.ConnectivityCheck -> {
BreadcrumbSeparator()
Text(
text = "Ping Service",
text = "Konnektivitäts-Diagnose",
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -511,27 +510,6 @@ private fun InvalidContextNotice(message: String, onBack: () -> Unit) {
}
}
@Composable
fun PlaceholderScreen(
title: String,
onBack: () -> Unit,
onAction: (() -> Unit)? = null,
actionLabel: String = "Aktion ausführen"
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(title, style = MaterialTheme.typography.headlineMedium)
Text("Dieser Screen ist noch in Arbeit (Placeholder)", color = Color.Gray)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onBack) { Text("Zurück") }
if (onAction != null) {
Button(onClick = onAction) { Text(actionLabel) }
}
}
}
}
}
/**
* Content-Bereich: rendert den passenden Screen je nach aktuellem AppScreen.
*/
@@ -544,9 +522,9 @@ private fun DesktopContentArea(
onSettingsChange: (OnboardingSettings) -> Unit,
) {
when (currentScreen) {
// Onboarding (Geräte-Setup)
is AppScreen.Onboarding -> {
println("[Screen] Rendering Onboarding")
// DeviceInitialization (Geräte-Setup)
is AppScreen.DeviceInitialization -> {
println("[Screen] Rendering DeviceInitialization")
OnboardingScreen(
settings = settings,
onSettingsChange = onSettingsChange,
@@ -656,9 +634,8 @@ private fun DesktopContentArea(
)
/*
is AppScreen.VeranstaltungProfil -> PlaceholderScreen("Veranstaltung-Profil #${currentScreen.id}",
onBack = { onNavigate(AppScreen.VeranstaltungVerwaltung) }
)
is AppScreen.VeranstaltungProfil -> VeranstaltungProfilScreen(id = currentScreen.id,
onBack = onBack)
*/
// Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht
@@ -812,13 +789,25 @@ private fun DesktopContentArea(
}
}
// Ping-Screen
is AppScreen.Ping -> {
println("[Screen] Rendering Ping")
// ConnectivityCheck-Screen
is AppScreen.ConnectivityCheck -> {
println("[Screen] Rendering ConnectivityCheck")
val pingViewModel: PingViewModel = koinInject()
PingScreen(
viewModel = pingViewModel,
onBack = onBack,
onNavigateToLogin = { onNavigate(AppScreen.Login(returnTo = AppScreen.ConnectivityCheck)) }
)
}
// Login-Screen (Integration)
is AppScreen.Login -> {
println("[Screen] Rendering Login")
val loginViewModel: at.mocode.frontend.core.auth.presentation.LoginViewModel = koinInject()
at.mocode.frontend.core.auth.presentation.LoginScreen(
viewModel = loginViewModel,
onLoginSuccess = onBack,
onBack = onBack
)
}
@@ -856,7 +845,7 @@ private fun DesktopContentArea(
SeriesScreen(title = "Cups", onBack = onBack)
}
is AppScreen.Nennung -> {
is AppScreen.EntryManagement -> {
val nennungViewModel: NennungViewModel = koinViewModel()
NennungsMaske(
viewModel = nennungViewModel,
@@ -215,7 +215,7 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
fun NennungDetailDialog(mail: OnlineNennungMail, onDismiss: () -> Unit, onMarkProcessed: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Details zur Online-Nennung") },
title = { Text("Details zur Online-EntryManagement") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
DetailRow("Absender", mail.sender)
@@ -1,13 +1,13 @@
package at.mocode.desktop.screens.onboarding
/**
* Validierungslogik für den Onboarding-Wizard.
* Validierungslogik für den DeviceInitialization-Wizard.
*
* Extrahiert aus `OnboardingScreen` für isolierte Unit-Tests (B-2).
* Regeln gemäß Onboarding-Spezifikation:
* Regeln gemäß DeviceInitialization-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)
* - Backup-Pfad: Darf nicht leer sein und muss existieren (Prüfung optional hier)
* - Sync-Intervall: zwischen 1 und 60 Minuten
*/
object OnboardingValidator {
@@ -18,9 +18,6 @@ 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
@@ -35,7 +32,7 @@ object OnboardingValidator {
/**
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und
* der „Weiter"-Button aktiviert werden darf.
* der „Weiter-Button aktiviert werden darf.
*/
fun canContinue(settings: OnboardingSettings): Boolean {
val basicValid = isNameValid(settings.geraetName) &&
@@ -5,9 +5,9 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* B-2 Test-Suite: Onboarding-Wizard Edge-Cases
* B-2 Test-Suite: DeviceInitialization-Wizard Edge-Cases
*
* Testet die Validierungslogik des Onboarding-Wizards isoliert via [OnboardingValidator].
* Testet die Validierungslogik des DeviceInitialization-Wizards isoliert via [OnboardingValidator].
* Die `rememberSaveable`-Regression (Zurück-Navigation behält Felder) ist durch den
* Fix in OnboardingScreen.kt (remember → rememberSaveable) abgesichert; ein
* Compose-UI-Test dafür ist auf JVM-Desktop ohne Instrumentation nicht möglich.
@@ -144,7 +144,7 @@ class OnboardingValidatorTest {
assertTrue(second)
}
// ─── rememberSaveable Regressions-Dokumentation ─────────────────────────────
// ─── rememberSavable Regressions-Dokumentation ─────────────────────────────
@Test
fun `B2 Regression rememberSaveable - Validator akzeptiert vorausgefüllte Werte nach Ruecknavigation`() {