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:
parent
683ef956fc
commit
1699c24875
|
|
@ -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.
|
||||
|
|
@ -37,10 +37,16 @@ fun DesktopApp() {
|
|||
val authState by authTokenManager.authState.collectAsState()
|
||||
|
||||
// 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) {
|
||||
// Wenn noch keine Authentifizierung vorhanden ist, zuerst Onboarding anzeigen
|
||||
nav.navigateToScreen(AppScreen.Onboarding)
|
||||
// Standard: Direkt zur Veranstaltungs-Übersicht (Offline-First-Modus)
|
||||
nav.navigateToScreen(AppScreen.Veranstaltungen)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import androidx.compose.material3.Surface
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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
|
||||
|
|
@ -32,7 +30,7 @@ private fun PreviewContent() {
|
|||
// ReiterScreen(viewModel = ReiterViewModel())
|
||||
|
||||
// --- PFERDE ---
|
||||
PferdeScreen(viewModel = PferdeViewModel())
|
||||
// PferdeScreen(viewModel = PferdeViewModel())
|
||||
|
||||
// ── Hier den gewünschten Screen eintragen ──────────────────────
|
||||
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ fun main() = application {
|
|||
)
|
||||
}
|
||||
println("[DesktopApp] KOIN initialisiert")
|
||||
// Testdaten für Prototyp laden
|
||||
at.mocode.desktop.v2.StoreV2.seed()
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopApp] Koin-Warnung: ${e.message}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import at.mocode.turnier.feature.presentation.TurnierDetailScreen
|
|||
import at.mocode.turnier.feature.presentation.TurnierWizardV2
|
||||
import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore
|
||||
import at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore
|
||||
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlV2
|
||||
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
|
||||
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
|
||||
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
|
||||
|
|
@ -301,19 +300,17 @@ private fun DesktopContentArea(
|
|||
|
||||
// Root-Screen: Leitet in V2-Fluss ab
|
||||
is AppScreen.Veranstaltungen -> {
|
||||
// Direkt zur Veranstalter-Auswahl V2
|
||||
VeranstalterAuswahlV2(
|
||||
onZurueck = { /* bleibt root */ },
|
||||
onWeiter = { vId -> onNavigate(AppScreen.VeranstalterDetail(vId)) },
|
||||
onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) },
|
||||
at.mocode.desktop.v2.VeranstaltungenUebersichtV2(
|
||||
onEventOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, eId)) },
|
||||
onNewEvent = { onNavigate(AppScreen.VeranstalterAuswahl) }
|
||||
)
|
||||
}
|
||||
|
||||
// Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht
|
||||
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahlV2(
|
||||
onZurueck = { onNavigate(AppScreen.Veranstaltungen) },
|
||||
is AppScreen.VeranstalterAuswahl -> at.mocode.desktop.v2.VeranstalterAuswahlV2(
|
||||
onBack = { onNavigate(AppScreen.Veranstaltungen) },
|
||||
onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) },
|
||||
onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) },
|
||||
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
|
||||
)
|
||||
|
||||
is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,60 @@ object StoreV2 {
|
|||
|
||||
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> =
|
||||
veranstaltungen.getOrPut(vereinId) { mutableStateListOf() }
|
||||
|
||||
|
|
@ -39,4 +93,6 @@ object StoreV2 {
|
|||
val idx = list.indexOfFirst { it.id == veranstaltungId }
|
||||
if (idx >= 0) list.removeAt(idx)
|
||||
}
|
||||
|
||||
fun allEvents(): List<VeranstaltungV2> = veranstaltungen.values.flatten()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
|
|
@ -13,8 +15,71 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
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
|
||||
fun VeranstaltungKonfigV2(
|
||||
veranstalterId: Long,
|
||||
|
|
@ -22,34 +87,102 @@ fun VeranstaltungKonfigV2(
|
|||
onSaved: (Long) -> Unit,
|
||||
) {
|
||||
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)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Zurück",
|
||||
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("") }
|
||||
var von by remember { mutableStateOf("") }
|
||||
var bis by remember { mutableStateOf("") }
|
||||
if (currentStep == 1) {
|
||||
// --- STEP 1: Device Onboarding ---
|
||||
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())
|
||||
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))
|
||||
OutlinedTextField(
|
||||
value = geraetName,
|
||||
onValueChange = { geraetName = it },
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user