feat(workflow): introduce Event-First workflow with improved UX and test data seeding

- Replaced `VeranstalterAuswahlV2` with `VeranstaltungenUebersichtV2` for a direct entry point to event management.
- Integrated onboarding directly into the event creation flow (`VeranstaltungKonfigV2`).
- Added realistic test data (`StoreV2.seed()`) for instant workflow testing.
- Updated initial navigation flow (`DesktopApp.kt`) to prioritize the event-first approach.
- Enhanced screen and component interactions to streamline the user journey in offline-first mode.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-03-31 13:34:35 +02:00
parent 683ef956fc
commit 1699c24875
7 changed files with 282 additions and 33 deletions

View File

@ -0,0 +1,57 @@
---
type: Journal
status: ACTIVE
owner: Curator
last_update: 2026-03-31
---
# Session Log: Event-First Workflow & UX-Polish (Initialer Schliff)
## Kontext
Im Rahmen der MVP-Phase wurde der Fokus auf den "Event-First" Workflow gelegt. Ziel ist es, dass die App direkt mit der
Turnierverwaltung (Offline-First) startet, ohne den Nutzer durch ein separates Onboarding oder Login zu zwingen, solange
er lokal arbeitet.
## Durchgeführte Änderungen
### 1. Navigation & App-Start
- **Direkter Einstieg:** Die App startet nun direkt im Screen `AppScreen.Veranstaltungen`.
- **Anpassung DesktopApp.kt:** Das Login-Gate wurde so erweitert, dass alle für den Turnier-Workflow relevanten
Screens (Veranstaltungen, Veranstalter, Turniere) auch ohne Authentifizierung zugänglich sind.
### 2. Veranstaltungen-Übersicht (Gesamtliste)
- **Neuer Screen `VeranstaltungenUebersichtV2`:** Implementierung einer zentralen Übersicht, die alle im lokalen Store
vorhandenen Veranstaltungen über alle Veranstalter hinweg anzeigt.
- **Funktionalität:**
- Listendarstellung mit Titel, Verein, Datum und Status.
- Navigation zum "Cockpit" einer Veranstaltung (`VeranstaltungUebersicht`).
- Button zur Neuanlage einer Veranstaltung (leitet zur Veranstalter-Auswahl weiter).
### 3. Integriertes Onboarding
- **Wizard-Erweiterung:** Das Geräte-Onboarding (Name & Sicherheitsschlüssel) wurde direkt in den
`VeranstaltungKonfigV2`-Wizard integriert. Nutzer müssen die Hardware-Informationen erst angeben, wenn sie die erste
Veranstaltung anlegen wollen.
### 4. Testdaten (Seed)
- **StoreV2.seed():** Es wurden realistische Testdaten für "Neumarkt 2026" und "Linz 2026" inklusive zugehöriger
Turniere angelegt, um den Workflow sofort testbar zu machen.
## Betroffene Dateien
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt`
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt`
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt` (Neu:
`VeranstaltungenUebersichtV2`)
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt` (Neu: `allEvents()`, `seed()`)
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt` (Aufruf `seed()`)
## Nächste Schritte
- [ ] Verifikation der Detail-Ansicht für Turniere.
- [ ] Implementierung der mDNS Discovery für die lokale Vernetzung.
- [ ] ADR für das PDF-Rendering entwerfen.

View File

@ -37,10 +37,16 @@ fun DesktopApp() {
val authState by authTokenManager.authState.collectAsState() val authState by authTokenManager.authState.collectAsState()
// Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt // Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.Onboarding) { // Vision_03 Update: Wir starten direkt in der Veranstaltungs-Übersicht (Offline-First)
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.Veranstaltungen
&& currentScreen !is AppScreen.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu
&& currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.VeranstaltungKonfig
&& currentScreen !is AppScreen.VeranstaltungUebersicht && currentScreen !is AppScreen.TurnierDetail
&& currentScreen !is AppScreen.TurnierNeu
) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Wenn noch keine Authentifizierung vorhanden ist, zuerst Onboarding anzeigen // Standard: Direkt zur Veranstaltungs-Übersicht (Offline-First-Modus)
nav.navigateToScreen(AppScreen.Onboarding) nav.navigateToScreen(AppScreen.Veranstaltungen)
} }
} }

View File

@ -5,8 +5,6 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.singleWindowApplication import androidx.compose.ui.window.singleWindowApplication
import at.mocode.frontend.features.pferde.presentation.PferdeScreen
import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
/** /**
* Hot-Reload Preview Entry Point * Hot-Reload Preview Entry Point
@ -32,7 +30,7 @@ private fun PreviewContent() {
// ReiterScreen(viewModel = ReiterViewModel()) // ReiterScreen(viewModel = ReiterViewModel())
// --- PFERDE --- // --- PFERDE ---
PferdeScreen(viewModel = PferdeViewModel()) // PferdeScreen(viewModel = PferdeViewModel())
// ── Hier den gewünschten Screen eintragen ────────────────────── // ── Hier den gewünschten Screen eintragen ──────────────────────
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {}) // VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})

View File

@ -39,6 +39,8 @@ fun main() = application {
) )
} }
println("[DesktopApp] KOIN initialisiert") println("[DesktopApp] KOIN initialisiert")
// Testdaten für Prototyp laden
at.mocode.desktop.v2.StoreV2.seed()
} catch (e: Exception) { } catch (e: Exception) {
println("[DesktopApp] Koin-Warnung: ${e.message}") println("[DesktopApp] Koin-Warnung: ${e.message}")
} }

View File

@ -26,7 +26,6 @@ import at.mocode.turnier.feature.presentation.TurnierDetailScreen
import at.mocode.turnier.feature.presentation.TurnierWizardV2 import at.mocode.turnier.feature.presentation.TurnierWizardV2
import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore
import at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore import at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlV2
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
@ -301,19 +300,17 @@ private fun DesktopContentArea(
// Root-Screen: Leitet in V2-Fluss ab // Root-Screen: Leitet in V2-Fluss ab
is AppScreen.Veranstaltungen -> { is AppScreen.Veranstaltungen -> {
// Direkt zur Veranstalter-Auswahl V2 at.mocode.desktop.v2.VeranstaltungenUebersichtV2(
VeranstalterAuswahlV2( onEventOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, eId)) },
onZurueck = { /* bleibt root */ }, onNewEvent = { onNavigate(AppScreen.VeranstalterAuswahl) }
onWeiter = { vId -> onNavigate(AppScreen.VeranstalterDetail(vId)) },
onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) },
) )
} }
// Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht // Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahlV2( is AppScreen.VeranstalterAuswahl -> at.mocode.desktop.v2.VeranstalterAuswahlV2(
onZurueck = { onNavigate(AppScreen.Veranstaltungen) }, onBack = { onNavigate(AppScreen.Veranstaltungen) },
onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) }, onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) },
onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) }, onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
) )
is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen( is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen(

View File

@ -27,6 +27,60 @@ object StoreV2 {
private val veranstaltungen: MutableMap<Long, SnapshotStateList<VeranstaltungV2>> = mutableMapOf() private val veranstaltungen: MutableMap<Long, SnapshotStateList<VeranstaltungV2>> = mutableMapOf()
fun seed() {
// Falls bereits Daten da sind (außer den statischen Vereinen), nichts tun
if (veranstaltungen.isNotEmpty()) return
// 1. Neumarkt 2026 (ID 100)
val neumarktId = 100L
addEventFirst(
1, VeranstaltungV2(
id = neumarktId,
veranstalterId = 1,
titel = "Frühjahrsturnier Neumarkt/M. 2026",
datumVon = "2026-04-10",
datumBis = "2026-04-12",
status = "Nennungsphase"
)
)
// Turniere für Neumarkt
TurnierStoreV2.add(
neumarktId,
TurnierV2(101, neumarktId, 26128, "CSN-C-NEU CSNP-C-NEU", "2026-04-10", "2026-04-12")
)
TurnierStoreV2.add(
neumarktId,
TurnierV2(102, neumarktId, 26129, "CDN-C-NEU CDNP-C-NEU", "2026-04-10", "2026-04-12")
)
// 2. Linz 2026 (ID 200)
val linzId = 200L
addEventFirst(
2, VeranstaltungV2(
id = linzId,
veranstalterId = 2,
titel = "Linzer Pferdefestival",
datumVon = "2026-05-20",
datumBis = "2026-05-24",
status = "In Vorbereitung"
)
)
TurnierStoreV2.add(linzId, TurnierV2(201, linzId, 26500, "CSN-B*", "2026-05-20", "2026-05-24"))
// 3. Ein historisches Event (ID 300)
addEventFirst(
1, VeranstaltungV2(
id = 300L,
veranstalterId = 1,
titel = "Herbst-Turnier 2025",
datumVon = "2025-09-15",
datumBis = "2025-09-17",
status = "Abgeschlossen"
)
)
}
fun eventsFor(vereinId: Long): SnapshotStateList<VeranstaltungV2> = fun eventsFor(vereinId: Long): SnapshotStateList<VeranstaltungV2> =
veranstaltungen.getOrPut(vereinId) { mutableStateListOf() } veranstaltungen.getOrPut(vereinId) { mutableStateListOf() }
@ -39,4 +93,6 @@ object StoreV2 {
val idx = list.indexOfFirst { it.id == veranstaltungId } val idx = list.indexOfFirst { it.id == veranstaltungId }
if (idx >= 0) list.removeAt(idx) if (idx >= 0) list.removeAt(idx)
} }
fun allEvents(): List<VeranstaltungV2> = veranstaltungen.values.flatten()
} }

View File

@ -6,6 +6,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -13,8 +15,71 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable
fun VeranstaltungenUebersichtV2(
onEventOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
onNewEvent: () -> Unit
) {
DesktopThemeV2 {
val events = remember { StoreV2.allEvents() }
val vereine = StoreV2.vereine
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Alle Veranstaltungen", style = MaterialTheme.typography.headlineMedium)
Button(onClick = onNewEvent) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Neue Veranstaltung")
}
}
if (events.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Veranstaltungen gefunden.", color = Color.Gray)
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(events.sortedByDescending { it.datumVon }) { event ->
val verein = vereine.find { it.id == event.veranstalterId }
Card(
modifier = Modifier.fillMaxWidth().clickable { onEventOpen(event.veranstalterId, event.id) },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(event.titel, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"${verein?.name ?: "Unbekannter Verein"} | ${event.datumVon} bis ${event.datumBis ?: ""}",
style = MaterialTheme.typography.bodySmall
)
}
Text(
event.status,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
}
}
}
}
}
@Composable @Composable
fun VeranstaltungKonfigV2( fun VeranstaltungKonfigV2(
veranstalterId: Long, veranstalterId: Long,
@ -22,34 +87,102 @@ fun VeranstaltungKonfigV2(
onSaved: (Long) -> Unit, onSaved: (Long) -> Unit,
) { ) {
DesktopThemeV2 { DesktopThemeV2 {
var currentStep by remember { mutableStateOf(1) }
var geraetName by remember { mutableStateOf("") }
var securityKey by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück", contentDescription = "Zurück",
modifier = Modifier.clickable { onBack() }) modifier = Modifier.clickable { onBack() })
Text("Neue Veranstaltung", style = MaterialTheme.typography.titleLarge) Text(
if (currentStep == 1) "Neue Veranstaltung: Geräteschutz" else "Neue Veranstaltung: Basisdaten",
style = MaterialTheme.typography.titleLarge
)
} }
var titel by remember { mutableStateOf("") } if (currentStep == 1) {
var von by remember { mutableStateOf("") } // --- STEP 1: Device Onboarding ---
var bis by remember { mutableStateOf("") } Text(
"Bevor du eine Veranstaltung anlegst, musst du dieses Gerät benennen und einen lokalen Sicherheitsschlüssel festlegen.",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
OutlinedTextField(value = titel, onValueChange = { titel = it }, label = { Text("Titel (Pflicht)") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { value = geraetName,
OutlinedTextField(value = von, onValueChange = { von = it }, label = { Text("von (YYYY-MM-DD)") }, modifier = Modifier.weight(1f)) onValueChange = { geraetName = it },
OutlinedTextField(value = bis, onValueChange = { bis = it }, label = { Text("bis (YYYY-MM-DD)") }, modifier = Modifier.weight(1f)) label = { Text("Gerätename (z.B. Meldestelle-1)") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = securityKey,
onValueChange = { securityKey = it },
label = { Text("Lokaler Sicherheitsschlüssel") },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation()
)
val step1Enabled = geraetName.isNotBlank() && securityKey.length >= 8
Button(onClick = { currentStep = 2 }, enabled = step1Enabled) {
Text("Weiter zu den Veranstaltungsdaten")
}
if (securityKey.isNotEmpty() && securityKey.length < 8) {
Text(
"Der Schlüssel muss mindestens 8 Zeichen lang sein.",
color = Color(0xFFB00020),
style = MaterialTheme.typography.labelSmall
)
}
} else {
// --- STEP 2: Event Data ---
var titel by remember { mutableStateOf("") }
var von by remember { mutableStateOf("") }
var bis by remember { mutableStateOf("") }
OutlinedTextField(
value = titel,
onValueChange = { titel = it },
label = { Text("Titel (Pflicht)") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = von,
onValueChange = { von = it },
label = { Text("von (YYYY-MM-DD)") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = bis,
onValueChange = { bis = it },
label = { Text("bis (YYYY-MM-DD)") },
modifier = Modifier.weight(1f)
)
}
val validDates = von.isNotBlank() && (bis.isBlank() || bis >= von)
if (!validDates && von.isNotEmpty()) Text(
"bis-Datum darf nicht vor von-Datum liegen",
color = Color(0xFFB00020)
)
val enabled = titel.trim().isNotEmpty() && validDates
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { currentStep = 1 }) { Text("Zurück") }
Button(onClick = {
val id = System.currentTimeMillis()
StoreV2.addEventFirst(
veranstalterId,
VeranstaltungV2(id, veranstalterId, titel.trim(), von.trim(), bis.trim().ifBlank { null })
)
onSaved(id)
}, enabled = enabled) { Text("Veranstaltung anlegen") }
}
} }
val validDates = von.isNotBlank() && (bis.isBlank() || bis >= von)
if (!validDates) Text("bis-Datum darf nicht vor von-Datum liegen", color = Color(0xFFB00020))
val enabled = titel.trim().isNotEmpty() && validDates
Button(onClick = {
val id = System.currentTimeMillis()
StoreV2.addEventFirst(veranstalterId, VeranstaltungV2(id, veranstalterId, titel.trim(), von.trim(), bis.trim().ifBlank { null }))
onSaved(id)
}, enabled = enabled) { Text("Speichern") }
} }
} }
} }