Compare commits

...

8 Commits

Author SHA1 Message Date
19ba044ec0 feat: integriere WizardRuntime in EventWizardViewModel und erweitere Schritt-Logik
Some checks failed
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (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
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 17:44:31 +02:00
9556e0ac67 test: füge Unit-Tests für WizardRuntime hinzu
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 17:03:41 +02:00
4692bd186c chore: füge Core-Wizard-Modul hinzu und integriere in Veranstaltung-Feature
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 17:02:28 +02:00
b11432df16 chore: implementiere Ping-Screen mit UI-Logik, ViewModel und Preview-Komponenten
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 17:02:15 +02:00
319cb52160 chore: implementiere Ping-Feature mit Repository, Sync-Service und API Client
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 17:02:03 +02:00
a35dfa1434 chore: füge Event-Wizard-Screen und Schritt-Logik für neue Veranstaltungen hinzu
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 17:01:49 +02:00
237c71e5a0 chore: implementiere Wizard-Framework mit State- und Flow-Logik sowie Feature-Flags für Migration
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 17:01:31 +02:00
ec124e9acd chore(docs): füge ADRs 0025–0027 und Wizard-DSL-Referenz hinzu, aktualisiere Roadmap und ADR-Index
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 16:21:22 +02:00
28 changed files with 1989 additions and 20 deletions

View File

@ -79,6 +79,7 @@
<option value="$PROJECT_DIR$/frontend/core/navigation" />
<option value="$PROJECT_DIR$/frontend/core/network" />
<option value="$PROJECT_DIR$/frontend/core/sync" />
<option value="$PROJECT_DIR$/frontend/core/wizard" />
<option value="$PROJECT_DIR$/frontend/features" />
<option value="$PROJECT_DIR$/frontend/features/billing-feature" />
<option value="$PROJECT_DIR$/frontend/features/device-initialization" />

View File

@ -2,7 +2,7 @@
type: Roadmap
status: ACTIVE
owner: Lead Architect
last_update: 2026-04-20
last_update: 2026-04-21
---
# MASTER ROADMAP: Meldestelle
@ -175,6 +175,58 @@ und über definierte Schnittstellen kommunizieren.
---
## 3. Initiative: Wizard-Orchestrator & Offline-Drafts (Q2/Q3 2026)
🏗️ Verantwortlich: Lead Architect · 🎨 Frontend · 🖌️ UI/UX · 👷 Backend · 🧐 QA · 🧹 Curator
Ziel: Konsolidierung aller „Wizards“ auf ein deklaratives Orchestrierungs-Framework (Graph + Guards + Effects), vereinheitlichte Validierung und Offline-Draft-Fähigkeit inkl. DeltaSync. Desktop-first, tastaturbedienbar, testbar.
### 3.1 Kernbausteine
- Orchestrator Runtime & DSL: `StepId`, `WizardContext`, `WizardState`, `Guard`, `Transition`, `StepEffects`.
- WizardScaffold: Breadcrumb, Kontext-Chips, Footer mit Hotkeys (Enter/Shift+Enter/Alt+S), Fehler-Summary.
- DraftStore: Autosave pro Step (`onLeave`), Resume, `flowVersion`, Konfliktanzeige.
- DevTools: strukturierte Transition-Logs, Graph-Export (DOT/PlantUML).
Referenzen/Dokumente:
- ADR0025: Wizard-Orchestrator (StateMachine, DSL, Guards, Effects) → `docs/01_Architecture/adr/0025-wizard-orchestrator-de.md`
- ADR0026: Step-Validation-Policy (sync vs. async, Fehlersichtbarkeit, Hotkeys) → `docs/01_Architecture/adr/0026-validation-policy-de.md`
- ADR0027: Draft-Domain & DeltaSync (Versionierung, Konfliktlösung, Idempotenz) → `docs/01_Architecture/adr/0027-draft-domain-and-delta-sync-de.md`
- Reference: WizardDSL README (Beispiel-Flow Event) → `docs/01_Architecture/Reference/Wizard-DSL-README.md`
### 3.2 Migrationsstrategie (Strangler)
1) Parallelbetrieb: Neuer Orchestrator in `frontend/core/wizard`; bestehende VMs delegieren schrittweise.
2) Inkrement 1: EventFlow zunächst 2 Steps (ZNS_CHECK, VERANSTALTER_SELECTION), dann alle 6 Steps.
3) FeatureFlag `WizardRuntimeEnabled` für risikoarmen Rollout.
### 3.3 Phasenplanung (Auszug)
- Phase 1 (Core & Tooling, 23 Wochen): Runtime/DSL, DevLogs, GraphExport, ScaffoldMVP, UnitTests.
- Phase 2 (EventFlow, 23 Wochen): `EventStep/Acc/Guards`, FlowDSL, VMDelegation, Validierung, Autosave/Resume.
- Phase 3 (Backend, 24 Wochen): Draft-/ValidateAPIs, OfflineQueue, DeltaSync für Turniere.
- Phase 4 (Skalierung, 610 Wochen, parallel): Weitere Flows je Bounded Context.
- Phase 57 (23 + 12 + 12 Wochen): UXHärtung, Observability/RolloutGates, Stabilisierung & Abschaltung Altlogik.
Grobe Gesamtdauer: 1729 Wochen je nach Parallelisierung.
### 3.4 Akzeptanzkriterien (DoD Initiative)
- Alle priorisierten Flows laufen über Orchestrator; Next/Back/History deterministisch; GraphExport aktuell.
- DraftStore produktiv; Resume deterministisch; DeltaSync idempotent; Konflikte nichtblockierend sichtbar.
- ValidierungsPolicy konsistent; TastaturBedienung vollständig; PerformanceGates eingehalten.
- ADR0025/0026/0027 veröffentlicht; WizardDSLReference vorhanden; CI grün; Metriken/Alerts aktiv.
### 3.5 10TageStartplan
- Tag 12: Runtime/DSLSkelett, ScaffoldMVP, FeatureFlag, README Skeleton.
- Tag 3: EventStep/Acc/Guards, EventFlow (2 Steps), VMDelegation minimal.
- Tag 4: Tests Runtime/Guards, GraphExport, DevLogs.
- Tag 56: META_DATA/ANSPRECHPERSON migrieren, ValidierungsAPI, FehlerSummary.
- Tag 7: DraftStore lokal (Autosave/Resume), PropertyTest Resume.
- Tag 8: TURNIER_ANLAGE einbetten, Sync via `onComplete`.
- Tag 9: SUMMARY + Finalisierung, Offload in OfflineQueue (Stub).
- Tag 10: ADR0025/0026/0027 Review+Merge; JournalEintrag.
Journal: `docs/99_Journal/2026-04-21_Wizard-Orchestrator_Roadmap_Anchoring.md`
---
## 3. Aktuelle Phase
### PHASE 5: P2-Contexts & Integration ✅ ABGESCHLOSSEN

View File

@ -0,0 +1,107 @@
---
type: Reference
status: ACTIVE
owner: Lead Architect
date: 2026-04-21
---
# WizardDSL & Orchestrator Referenz
## Ziel
Deklarative Beschreibung von WizardFlows als Graph (Steps, Guards, Transitions) mit klaren SideEffects und OfflineDraftUnterstützung.
## KernInterfaces (Skizze)
```kotlin
interface StepId
data class WizardContext(
val origin: AppScreen,
val role: String?,
val isOnline: Boolean,
val stats: MasterdataStats?
)
data class WizardState<S: StepId, A>(
val current: S,
val history: List<S> = emptyList(),
val acc: A,
val errors: List<String> = emptyList()
)
typealias Guard<S, A> = (WizardContext, A) -> Boolean
data class Transition<S: StepId>(val target: S, val guard: Guard<S, *>? = null, val id: String)
interface StepEffects<S: StepId, A> {
suspend fun onEnter(ctx: WizardContext, state: WizardState<S, A>) {}
suspend fun onLeave(ctx: WizardContext, state: WizardState<S, A>) {}
suspend fun onComplete(ctx: WizardContext, state: WizardState<S, A>) {}
}
```
## DSL (Skizze)
```kotlin
class FlowBuilder<S: StepId, A> {
fun step(id: S, block: StepBuilder<S, A>.() -> Unit) { /* … */ }
}
class StepBuilder<S: StepId, A> {
fun onEnter(block: suspend (WizardContext, WizardState<S, A>) -> Unit) { /* … */ }
fun whenGuard(id: String, g: Guard<S, A>, go: S) { /* … */ }
fun otherwise(go: S) { /* … */ }
}
fun <S: StepId, A> flow(start: S, build: FlowBuilder<S, A>.() -> Unit): WizardRuntime<S, A> { /* … */ }
```
## Beispiel EventFlow (Auszug)
```kotlin
sealed interface EventStep: StepId {
data object ZnsCheck: EventStep
data object VeranstalterSelection: EventStep
data object AnsprechpersonMapping: EventStep
data object MetaData: EventStep
data object TurnierAnlage: EventStep
data object Summary: EventStep
}
data class EventAcc(
val veranstalterId: Uuid? = null,
val veranstalterNr: String = "",
val veranstalterName: String = "",
val ansprechpersonSatznr: String = "",
val name: String = "",
val ort: String = "",
val start: LocalDate? = null,
val end: LocalDate? = null,
val turniere: List<TurnierEntry> = emptyList()
)
object EventGuards {
val hasZns: Guard<EventStep, EventAcc> = { ctx, _ -> (ctx.stats?.vereinCount ?: 0) > 0 }
val needsContactPerson: Guard<EventStep, EventAcc> = { _, acc -> acc.veranstalterId == null || acc.veranstalterNr.startsWith("ORG-") }
}
val EventFlow = flow<EventStep, EventAcc>(start = EventStep.ZnsCheck) {
step(EventStep.ZnsCheck) {
onEnter { ctx, _ -> /* prefetch stats */ }
whenGuard("hasZns", EventGuards.hasZns, go = EventStep.VeranstalterSelection)
otherwise(go = EventStep.VeranstalterSelection)
}
step(EventStep.VeranstalterSelection) {
whenGuard("needsContact", EventGuards.needsContactPerson, go = EventStep.AnsprechpersonMapping)
otherwise(go = EventStep.MetaData)
}
}
```
## DevTools
- Strukturierte Logs je Transition (from, to, guard-id, result, duration).
- GraphExport (DOT/PlantUML) aus der DSL für Doku & Reviews.
## Tests (Empfehlungen)
- Unit: Guards (100% BranchAbdeckung), RuntimeHistory.
- Property: ResumeDeterminismus (Draft → korrekter Step).
- Snapshot: ComposePanels mit Beispielkontexten.
## Verweise
- ADR0025 Orchestrator · ADR0026 ValidationPolicy · ADR0027 DraftDomain & DeltaSync
- Roadmap: `docs/01_Architecture/MASTER_ROADMAP.md#3-initiative-wizard-orchestrator--offline-drafts-q2q3-2026`

View File

@ -0,0 +1,30 @@
---
type: ADR
status: PROPOSED
owner: Lead Architect
date: 2026-04-21
---
# ADR-0025 — Wizard-Orchestrator (State-Machine, DSL, Guards, Effects)
## Kontext
- Aktuelle Wizard-Implementierungen sind linear (`next/previous`) und hart verdrahtet in ViewModels.
- Anforderungen: Kontextabhängige Pfade, OfflineFirst (Autosave/Resume), testbare Regeln, einheitliche UX.
## Entscheidung
- Einführung eines generischen Orchestrators mit deklarativem Flow:
- `StepId` (typsicher je Flow), `WizardContext`, `WizardState`, `Guard`, `Transition`, `StepEffects`.
- KotlinDSL: `flow { step { whenGuard(id) go target; otherwise go … } }`.
- SideEffects an Hooks (`onEnter/onLeave/onComplete`) für Prefetch, Autosave, Finalisierung.
## Konsequenzen
- Vorteile: zentrale, testbare Navigationslogik; konsistente DraftPolicy; bessere Übersicht/Erweiterbarkeit.
- Risiken: Initialer Overhead, Lernkurve, GuardSprawl; mitigiert durch README, DevTools, Linting/Namenskonventionen.
## Umsetzung
- Modul `frontend/core/wizard` (runtime, dsl, devtools, draft) und `WizardScaffold` (Breadcrumb, Footer, Hotkeys).
- StranglerMigration beginnend mit EventFlow; FeatureFlag `WizardRuntimeEnabled`.
## Verweise
- RoadmapAbschnitt: `docs/01_Architecture/MASTER_ROADMAP.md#3-initiative-wizard-orchestrator--offline-drafts-q2q3-2026`
- Reference: `docs/01_Architecture/Reference/Wizard-DSL-README.md`

View File

@ -0,0 +1,28 @@
---
type: ADR
status: PROPOSED
owner: Lead Architect
date: 2026-04-21
---
# ADR-0026 — Step-Validation-Policy (Sync vs. Async, Fehlersichtbarkeit, Hotkeys)
## Kontext
- Validierungen sind heute uneinheitlich verteilt (UI/Backend) und nicht klar priorisiert (blockierend vs. Hinweis).
## Entscheidung
- Sync-Validierung pro Step als pure Funktion (schnell, offline, blockierend für „Weiter“).
- Async-Validierungen (Server/DB) als nicht-blockierende Hinweise mit Retry; Ergebnisse im Footer aggregiert.
- Einheitliche Hotkeys: Enter=Weiter, Shift+Enter=Zurück, Alt+S=Speichern (Draft), Alt+J=Step-Sprung.
## Konsequenzen
- Konsistente Nutzererwartung, klare Trennung von Fehlerklassen, bessere Testbarkeit.
- Erfordert Footer-Fehlersummary und Dev-Overlay (Guard/Validation-Trace).
## Umsetzung
- API `validate(accumulator): ValidationResult` je Step.
- Async-Pipeline mit Debounce (250ms) und Status-Pills; keine UI-Blocker bei Netzwerkfehlern.
## Verweise
- RoadmapAbschnitt: `docs/01_Architecture/MASTER_ROADMAP.md#3-initiative-wizard-orchestrator--offline-drafts-q2q3-2026`
- Reference: `docs/01_Architecture/Reference/Wizard-DSL-README.md`

View File

@ -0,0 +1,31 @@
---
type: ADR
status: PROPOSED
owner: Lead Architect
date: 2026-04-21
---
# ADR-0027 — Draft-Domain & DeltaSync (Versionierung, Konfliktlösung, Idempotenz)
## Kontext
- OfflineFirst verlangt lokale Drafts, ResumeFähigkeit und robuste Synchronisation bei instabiler Konnektivität.
- Heute: kein einheitliches DraftModell, keine standardisierte Schrittweise Speicherung.
## Entscheidung
- Einführung einer DraftDomain mit Version/ETag und SchrittPatchs:
- `POST /api/events/drafts` (idempotent über IdempotencyKey) erstellt/vereinigt Drafts.
- `PATCH /api/events/drafts/{id}` speichert stepweise Deltas (nur geänderte Felder seit `version`).
- `POST /api/events` finalisiert (ServerValidierung, Erstellung `veranstaltungId`).
- Client OfflineQueue mit Retry/Backoff; Konflikte als nichtblockierende Hinweise.
## Konsequenzen
- Reproduzierbare, idempotente Speicherung; geringere Payloads; klare Konfliktauflösung.
- Erfordert Versionierung im DraftStore und Migrationspfade bei FlowÄnderungen (`flowVersion`).
## Umsetzung
- Frontend: DraftStore (Autosave `onLeave`), DeltaBuilder je Step, Queue mit IdempotencyKey.
- Backend: Versionsverwaltung, IfMatch/ETag Unterstützung, ConflictDetect (409) mit MergeHinweisen.
## Verweise
- RoadmapAbschnitt: `docs/01_Architecture/MASTER_ROADMAP.md#3-initiative-wizard-orchestrator--offline-drafts-q2q3-2026`
- Contracts: folgen nach APISkizzierung (`contracts/` Verzeichnis)

View File

@ -19,4 +19,8 @@ Namensschema: ADR-XXX-title.md mit fortlaufender Nummerierung.
- ADR-0016 API-Design & Anti-Corruption Layer (ACL)
- ADR-0020 Lokale Netzwerk-Kommunikation und Daten-Isolierung
- ADR-0025 Wizard-Orchestrator (State-Machine, DSL, Guards, Effects)
- ADR-0026 Step-Validation-Policy (Sync vs. Async, Fehlersichtbarkeit, Hotkeys)
- ADR-0027 Draft-Domain & DeltaSync (Versionierung, Konfliktlösung, Idempotenz)
Siehe Template: ADR-000-template.md.

View File

@ -0,0 +1,28 @@
---
type: Journal
status: DONE
owner: Curator
date: 2026-04-21
---
# Journal — Roadmap „WizardOrchestrator & OfflineDrafts“ verankert
## Kontext
Die in der Session ausgearbeitete Roadmap zur Migration auf einen WizardOrchestrator wurde in `docs/` verankert.
## Artefakte
- MASTER_ROADMAP aktualisiert (Initiative Abschnitt):
- `docs/01_Architecture/MASTER_ROADMAP.md#3-initiative-wizard-orchestrator--offline-drafts-q2q3-2026`
- Neue ADRs (Status: PROPOSED):
- ADR0025 WizardOrchestrator → `docs/01_Architecture/adr/0025-wizard-orchestrator-de.md`
- ADR0026 StepValidationPolicy → `docs/01_Architecture/adr/0026-validation-policy-de.md`
- ADR0027 DraftDomain & DeltaSync → `docs/01_Architecture/adr/0027-draft-domain-and-delta-sync-de.md`
- Referenz:
- WizardDSL README → `docs/01_Architecture/Reference/Wizard-DSL-README.md`
- ADRIndex ergänzt:
- `docs/01_Architecture/adr/README.md`
## Nächste Schritte (Review)
- Lead Architect: ADR0025/26/27 Review & Nummernfreigabe.
- Frontend: Spike „Runtime/DSLSkelett“ gemäß Roadmap (Tag 12).
- Backend: APISkizzen für Drafts/Validate in `contracts/` vorbereiten.

View File

@ -0,0 +1,10 @@
package at.mocode.frontend.core.domain.config
/**
* Zentrale Feature-Flags für die Wizard-Orchestrator-Migration.
* Standard: AUS, damit bestehende Logik aktiv bleibt.
*/
object WizardFeatureFlags {
// Kann später via Settings/DI überschrieben werden
var WizardRuntimeEnabled: Boolean = false
}

View File

@ -0,0 +1,52 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
group = "at.mocode.frontend.core"
version = "1.0.0"
kotlin {
jvm()
wasmJs {
binaries.library()
browser {
testTask {
enabled = false
}
}
}
sourceSets {
commonMain.dependencies {
implementation(projects.frontend.core.navigation)
implementation(projects.frontend.core.domain)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
jvmMain.dependencies {
implementation(projects.frontend.core.navigation)
implementation(projects.frontend.core.domain)
}
jvmTest.dependencies {
implementation(libs.kotlin.test)
}
}
sourceSets.commonTest.dependencies {
implementation(kotlin("test"))
}
}

View File

@ -0,0 +1,81 @@
package at.mocode.frontend.core.wizard.dsl
import at.mocode.frontend.core.wizard.runtime.*
class FlowBuilder<S : StepId, A>(private val start: S) {
internal val steps = mutableMapOf<S, StepDef<S, A>>()
fun step(id: S, block: StepBuilder<S, A>.() -> Unit) {
val sb = StepBuilder<S, A>()
sb.block()
steps[id] = StepDef(
onEnter = sb.onEnter,
onLeave = sb.onLeave,
transitions = sb.transitions.toList(),
otherwise = sb.otherwise
)
}
fun build(): WizardRuntime<S, A> = SimpleWizardRuntime(start, steps.toMap())
}
class StepBuilder<S : StepId, A> {
internal var onEnter: (suspend (WizardContext, WizardState<S, A>) -> Unit)? = null
internal var onLeave: (suspend (WizardContext, WizardState<S, A>) -> Unit)? = null
internal val transitions = mutableListOf<Transition<S>>()
internal var otherwise: S? = null
fun onEnter(block: suspend (WizardContext, WizardState<S, A>) -> Unit) {
onEnter = block
}
fun onLeave(block: suspend (WizardContext, WizardState<S, A>) -> Unit) {
onLeave = block
}
fun whenGuard(id: String, g: Guard<S, A>, go: S) {
transitions += Transition(id = id, target = go, guard = g)
}
fun otherwise(go: S) {
otherwise = go
}
}
internal data class StepDef<S : StepId, A>(
val onEnter: (suspend (WizardContext, WizardState<S, A>) -> Unit)? = null,
val onLeave: (suspend (WizardContext, WizardState<S, A>) -> Unit)? = null,
val transitions: List<Transition<S>> = emptyList(),
val otherwise: S? = null
)
private class SimpleWizardRuntime<S : StepId, A>(
override val start: S,
private val steps: Map<S, StepDef<S, A>>
) : WizardRuntime<S, A> {
override fun next(ctx: WizardContext, state: WizardState<S, A>): WizardState<S, A> {
val def = steps[state.current]
if (def == null) return state // unknown step, no-op
val target = def.transitions.firstOrNull { tr ->
@Suppress("UNCHECKED_CAST")
(tr.guard as? Guard<S, A>)?.invoke(ctx, state.acc) ?: false
}?.target ?: def.otherwise ?: state.current
if (target == state.current) return state
return state.copy(current = target, history = state.history + state.current)
}
override fun back(state: WizardState<S, A>): WizardState<S, A> {
val history = state.history
if (history.isEmpty()) return state
val prev = history.last()
return state.copy(current = prev, history = history.dropLast(1))
}
}
fun <S : StepId, A> flow(start: S, build: FlowBuilder<S, A>.() -> Unit): WizardRuntime<S, A> {
val fb = FlowBuilder<S, A>(start)
fb.apply(build)
return fb.build()
}

View File

@ -0,0 +1,41 @@
package at.mocode.frontend.core.wizard.runtime
import at.mocode.frontend.core.domain.repository.MasterdataStats
import at.mocode.frontend.core.navigation.AppScreen
// Base marker for step IDs (sealed interface implementations per flow)
interface StepId
data class WizardContext(
val origin: AppScreen,
val role: String? = null,
val isOnline: Boolean = true,
val stats: MasterdataStats? = null
)
data class WizardState<S : StepId, A>(
val current: S,
val history: List<S> = emptyList(),
val acc: A,
val errors: List<String> = emptyList()
)
typealias Guard<S, A> = (WizardContext, A) -> Boolean
data class Transition<S : StepId>(
val id: String,
val target: S,
val guard: Guard<S, *>? = null
)
interface StepEffects<S : StepId, A> {
suspend fun onEnter(ctx: WizardContext, state: WizardState<S, A>) {}
suspend fun onLeave(ctx: WizardContext, state: WizardState<S, A>) {}
suspend fun onComplete(ctx: WizardContext, state: WizardState<S, A>) {}
}
interface WizardRuntime<S : StepId, A> {
val start: S
fun next(ctx: WizardContext, state: WizardState<S, A>): WizardState<S, A>
fun back(state: WizardState<S, A>): WizardState<S, A>
}

View File

@ -0,0 +1,30 @@
package at.mocode.frontend.core.wizard.samples
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.wizard.dsl.flow
import at.mocode.frontend.core.wizard.runtime.Guard
import at.mocode.frontend.core.wizard.runtime.StepId
import at.mocode.frontend.core.wizard.runtime.WizardState
// Minimaler, selbstenthaltener Demo-Flow (2 Steps) für den Spike
sealed interface DemoEventStep : StepId {
data object ZnsCheck : DemoEventStep
data object VeranstalterSelection : DemoEventStep
}
data class DemoEventAcc(val dummy: String = "")
object DemoEventGuards {
val hasZns: Guard<DemoEventStep, DemoEventAcc> = { ctx, _ -> (ctx.stats?.vereinCount ?: 0) > 0 }
}
val DemoEventFlow = flow<DemoEventStep, DemoEventAcc>(start = DemoEventStep.ZnsCheck) {
step(DemoEventStep.ZnsCheck) {
whenGuard("hasZns", DemoEventGuards.hasZns, go = DemoEventStep.VeranstalterSelection)
otherwise(DemoEventStep.VeranstalterSelection)
}
}
// Hilfsfunktion für einfache manuelle Nutzung im Spike
fun demoStartState(origin: AppScreen, acc: DemoEventAcc = DemoEventAcc()): WizardState<DemoEventStep, DemoEventAcc> =
WizardState(current = DemoEventStep.ZnsCheck, acc = acc)

View File

@ -0,0 +1,47 @@
package at.mocode.frontend.core.wizard
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.wizard.runtime.WizardContext
import at.mocode.frontend.core.wizard.samples.*
import kotlin.test.Test
import kotlin.test.assertEquals
class WizardRuntimeTest {
@Test
fun next_goes_to_selection_when_hasZns_true() {
val ctx = WizardContext(
origin = AppScreen.Home,
isOnline = true,
stats = at.mocode.frontend.core.domain.repository.MasterdataStats(
lastImport = null,
vereinCount = 1,
reiterCount = 0,
pferdCount = 0,
funktionaerCount = 0
)
)
val state0 = demoStartState(AppScreen.Home)
val state1 = DemoEventFlow.next(ctx, state0)
assertEquals(DemoEventStep.VeranstalterSelection, state1.current)
assertEquals(listOf(DemoEventStep.ZnsCheck), state1.history)
}
@Test
fun back_returns_previous_when_history_exists() {
val ctx = WizardContext(
origin = AppScreen.Home,
isOnline = true,
stats = at.mocode.frontend.core.domain.repository.MasterdataStats(
lastImport = null,
vereinCount = 1,
reiterCount = 0,
pferdCount = 0,
funktionaerCount = 0
)
)
val s0 = demoStartState(AppScreen.Home)
val s1 = DemoEventFlow.next(ctx, s0)
val s2 = DemoEventFlow.back(s1)
assertEquals(DemoEventStep.ZnsCheck, s2.current)
}
}

View File

@ -0,0 +1,50 @@
package at.mocode.frontend.core.wizard
import at.mocode.frontend.core.domain.repository.MasterdataStats
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.wizard.runtime.WizardContext
import at.mocode.frontend.core.wizard.samples.DemoEventFlow
import at.mocode.frontend.core.wizard.samples.DemoEventStep
import at.mocode.frontend.core.wizard.samples.demoStartState
import kotlin.test.Test
import kotlin.test.assertEquals
class WizardRuntimeJvmTest {
@Test
fun next_goes_to_selection_when_hasZns_true() {
val ctx = WizardContext(
origin = AppScreen.Home,
isOnline = true,
stats = MasterdataStats(
lastImport = null,
vereinCount = 1,
reiterCount = 0,
pferdCount = 0,
funktionaerCount = 0
)
)
val state0 = demoStartState(AppScreen.Home)
val state1 = DemoEventFlow.next(ctx, state0)
assertEquals(DemoEventStep.VeranstalterSelection, state1.current)
assertEquals(listOf(DemoEventStep.ZnsCheck), state1.history)
}
@Test
fun back_returns_previous_when_history_exists() {
val ctx = WizardContext(
origin = AppScreen.Home,
isOnline = true,
stats = MasterdataStats(
lastImport = null,
vereinCount = 1,
reiterCount = 0,
pferdCount = 0,
funktionaerCount = 0
)
)
val s0 = demoStartState(AppScreen.Home)
val s1 = DemoEventFlow.next(ctx, s0)
val s2 = DemoEventFlow.back(s1)
assertEquals(DemoEventStep.ZnsCheck, s2.current)
}
}

View File

@ -0,0 +1,41 @@
package at.mocode.frontend.features.ping.data
import at.mocode.ping.api.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
/**
* PingApi-Implementierung, die einen bereitgestellten HttpClient verwendet (z. B. den per Dependency Injection
* bereitgestellten "apiClient").
*/
class PingApiKoinClient(private val client: HttpClient) : PingApi {
override suspend fun simplePing(): PingResponse {
return client.get("/api/ping/simple").body()
}
override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse {
return client.get("/api/ping/enhanced") {
url.parameters.append("simulate", simulate.toString())
}.body()
}
override suspend fun healthCheck(): HealthResponse {
return client.get("/api/ping/health").body()
}
override suspend fun publicPing(): PingResponse {
return client.get("/api/ping/public").body()
}
override suspend fun securePing(): PingResponse {
return client.get("/api/ping/secure").body()
}
override suspend fun syncPings(since: Long): List<PingEvent> {
return client.get("/api/ping/sync") {
url.parameters.append("since", since.toString())
}.body()
}
}

View File

@ -0,0 +1,43 @@
package at.mocode.frontend.features.ping.data
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.sync.SyncableRepository
import at.mocode.ping.api.PingEvent
/**
** ARCH-BLUEPRINT: Dieses Repository implementiert das generische Syncable Repository
** für eine bestimmte Entität und überbrückt so die Lücke zwischen dem Sync-Core und der
** lokalen Datenbank.
*/
class PingEventRepositoryImpl(
private val db: AppDatabase
) : SyncableRepository<PingEvent> {
// Der `since`-Parameter für unsere Synchronisierung ist der Zeitstempel des letzten Ereignisses.
// Das Backend erwartet einen Long (Timestamp), keinen String (UUID).
override suspend fun getLatestSince(): String? {
println("PingEventRepositoryImpl: getLatestSince called - fetching latest timestamp")
// Wir holen den letzten Timestamp aus der DB.
val lastModified = db.appDatabaseQueries.selectLatestPingEventTimestamp().awaitAsOneOrNull()
// Wir geben ihn als String zurück, da das Interface String? erwartet.
// Der SyncManager wird ihn als Parameter "since" an den Request hängen.
// Das Backend erwartet "since" als Long, aber HTTP Parameter sind Strings.
// Spring Boot konvertiert "123456789" automatisch in Long 123456789.
return lastModified?.toString()
}
override suspend fun upsert(items: List<PingEvent>) {
// Führen Sie Massenoperationen immer innerhalb einer Transaktion durch.
db.transaction {
items.forEach { event ->
db.appDatabaseQueries.upsertPingEvent(
id = event.id,
message = event.message,
last_modified = event.lastModified
)
}
}
}
}

View File

@ -0,0 +1,26 @@
package at.mocode.frontend.features.ping.domain
import at.mocode.frontend.core.sync.SyncManager
import at.mocode.frontend.core.sync.SyncableRepository
import at.mocode.ping.api.PingEvent
/**
* Interface für den Ping-Sync-Dienst zur einfacheren Prüfung und Entkopplung.
*/
interface PingSyncService {
suspend fun syncPings()
}
/**
* Implementierung des PingSyncService unter Verwendung des generischen SyncManager.
*/
class PingSyncServiceImpl(
private val syncManager: SyncManager,
private val repository: SyncableRepository<PingEvent>
) : PingSyncService {
override suspend fun syncPings() {
// Corrected endpoint: /api/ping/sync (singular)
syncManager.performSync(repository, "/api/ping/sync")
}
}

View File

@ -0,0 +1,102 @@
package at.mocode.frontend.features.ping.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.Dimens
/**
* Eine modulare Gruppe von Test-Buttons für die Konnektivitäts-Diagnose.
* Plug-and-Play fähig für Ping-Screen oder Sidebar.
*/
@Composable
fun PingActionGroup(
viewModel: PingViewModel,
modifier: Modifier = Modifier
) {
val uiState = viewModel.uiState
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
Text(
text = "DIAGNOSE-TESTS",
style = MaterialTheme.typography.labelSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
modifier = Modifier.padding(bottom = Dimens.SpacingXS)
)
// Grid-ähnliches Layout für die Buttons
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
PingTestButton(
text = "Simple Ping",
icon = Icons.Default.NetworkCheck,
onClick = { viewModel.performSimplePing() },
isLoading = uiState.isLoading,
modifier = Modifier.weight(1f)
)
PingTestButton(
text = "Secure Ping",
icon = Icons.Default.Lock,
onClick = { viewModel.performSecurePing() },
isLoading = uiState.isLoading,
modifier = Modifier.weight(1f)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
PingTestButton(
text = "Health Check",
icon = Icons.Default.HealthAndSafety,
onClick = { viewModel.performHealthCheck() },
isLoading = uiState.isLoading,
modifier = Modifier.weight(1f)
)
PingTestButton(
text = "Delta Sync",
icon = Icons.Default.Sync,
onClick = { viewModel.triggerSync() },
isLoading = uiState.isSyncing,
modifier = Modifier.weight(1f)
)
}
// Zusätzlicher Button für Enhanced Ping (Circuit Breaker Test)
OutlinedButton(
onClick = { viewModel.performEnhancedPing() },
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isLoading
) {
Text("Enhanced Ping (Simulation)", fontSize = 12.sp)
}
}
}
@Composable
private fun PingTestButton(
text: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
isLoading: Boolean,
modifier: Modifier = Modifier
) {
Button(
onClick = onClick,
modifier = modifier.height(48.dp),
enabled = !isLoading,
contentPadding = PaddingValues(horizontal = Dimens.SpacingS)
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(Dimens.SpacingXS))
Text(text, fontSize = 12.sp, maxLines = 1)
}
}

View File

@ -0,0 +1,232 @@
package at.mocode.frontend.features.ping.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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.unit.dp
import at.mocode.frontend.core.auth.presentation.AuthStatusCard
import at.mocode.frontend.core.auth.presentation.LoginViewModel
import at.mocode.frontend.core.designsystem.components.MsCard
import at.mocode.frontend.core.designsystem.theme.Dimens
import org.koin.compose.koinInject
@Composable
fun PingScreen(
viewModel: PingViewModel,
onBack: () -> Unit = {},
onNavigateToLogin: () -> Unit = {}
) {
val uiState = viewModel.uiState
val authViewModel: LoginViewModel = koinInject()
// Wir nutzen jetzt das globale Theme (Hintergrund kommt vom Theme)
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(Dimens.SpacingS) // Globales Spacing
) {
// 1. Header
PingHeader(
onBack = onBack,
isSyncing = uiState.isSyncing,
isLoading = uiState.isLoading
)
Spacer(Modifier.height(Dimens.SpacingS))
// 2. Auth Status Area (Plug-and-Play)
AuthStatusCard(
viewModel = authViewModel,
onLoginClick = onNavigateToLogin
)
Spacer(Modifier.height(Dimens.SpacingS))
// 3. Main Dashboard Area (Split View)
Row(modifier = Modifier.weight(1f)) {
// Left Panel: Controls & Status Grid (60%)
Column(
modifier = Modifier
.weight(0.6f)
.fillMaxHeight()
.padding(end = Dimens.SpacingS)
) {
PingActionGroup(viewModel)
Spacer(Modifier.height(Dimens.SpacingS))
StatusGrid(uiState)
}
// Right Panel: Terminal Log (40%)
TerminalConsole(
logs = uiState.logs,
onClear = { viewModel.clearLogs() },
modifier = Modifier
.weight(0.4f)
.fillMaxHeight()
)
}
Spacer(Modifier.height(Dimens.SpacingXS))
// 4. Footer
PingStatusBar(uiState.lastSyncResult)
}
}
@Composable
private fun PingHeader(
onBack: () -> Unit,
isSyncing: Boolean,
isLoading: Boolean
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().height(40.dp)
) {
Text(
"KONNEKTIVITÄTS-DIAGNOSE // DASHBOARD",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f).padding(start = Dimens.SpacingS)
)
if (isLoading) {
StatusBadge("BUSY", Color(0xFFFFA000)) // Amber
Spacer(Modifier.width(Dimens.SpacingS))
}
if (isSyncing) {
StatusBadge("SYNCING", MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(Dimens.SpacingS))
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
StatusBadge("IDLE", Color(0xFF388E3C)) // Green
}
}
}
@Composable
private fun StatusBadge(text: String, color: Color) {
Surface(
color = color.copy(alpha = 0.1f),
contentColor = color,
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, color)
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
@Composable
private fun StatusGrid(uiState: PingUiState) {
Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
// Row 1
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
MsCard(modifier = Modifier.weight(1f)) {
StatusHeader("SIMPLE / SECURE PING")
if (uiState.simplePingResponse != null) {
KeyValueRow("Status", uiState.simplePingResponse.status)
KeyValueRow("Service", uiState.simplePingResponse.service)
KeyValueRow("Time", uiState.simplePingResponse.timestamp)
} else {
EmptyStateText()
}
}
MsCard(modifier = Modifier.weight(1f)) {
StatusHeader("HEALTH CHECK")
if (uiState.healthResponse != null) {
KeyValueRow("Status", uiState.healthResponse.status)
KeyValueRow("Healthy", uiState.healthResponse.healthy.toString())
KeyValueRow("Service", uiState.healthResponse.service)
} else {
EmptyStateText()
}
}
}
// Row 2
MsCard(modifier = Modifier.fillMaxWidth()) {
StatusHeader("ENHANCED PING (RESILIENCE)")
if (uiState.enhancedPingResponse != null) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
KeyValueRow("Status", uiState.enhancedPingResponse.status)
KeyValueRow("Timestamp", uiState.enhancedPingResponse.timestamp)
}
Column(modifier = Modifier.weight(1f)) {
KeyValueRow("Circuit Breaker", uiState.enhancedPingResponse.circuitBreakerState)
KeyValueRow("Latency", "${uiState.enhancedPingResponse.responseTime}ms")
}
}
} else {
EmptyStateText()
}
}
}
}
@Composable
private fun StatusHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = Dimens.SpacingXS)
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
Spacer(Modifier.height(Dimens.SpacingXS))
}
@Composable
private fun EmptyStateText() {
Text("No Data", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun KeyValueRow(key: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = "$key:",
modifier = Modifier.width(100.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
private fun PingStatusBar(lastSync: String?) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = lastSync ?: "Ready",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = Dimens.SpacingS, vertical = 2.dp)
)
}
}

View File

@ -0,0 +1,156 @@
package at.mocode.frontend.features.ping.presentation
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.features.ping.domain.PingSyncService
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
data class LogEntry(
val timestamp: String,
val source: String,
val message: String,
val isError: Boolean = false
)
data class PingUiState(
val isLoading: Boolean = false,
val simplePingResponse: PingResponse? = null,
val enhancedPingResponse: EnhancedPingResponse? = null,
val healthResponse: HealthResponse? = null,
val errorMessage: String? = null,
val isSyncing: Boolean = false,
val lastSyncResult: String? = null,
val logs: List<LogEntry> = emptyList()
)
open class PingViewModel(
private val apiClient: PingApi,
private val syncService: PingSyncService
) : ViewModel() {
var uiState by mutableStateOf(PingUiState())
internal set
private fun addLog(source: String, message: String, isError: Boolean = false) {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val timeString = "${now.hour.toString().padStart(2, '0')}:${now.minute.toString().padStart(2, '0')}:${
now.second.toString().padStart(2, '0')
}"
val entry = LogEntry(timeString, source, message, isError)
uiState = uiState.copy(logs = listOf(entry) + uiState.logs) // Prepend for newest first
}
fun performSimplePing() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("SimplePing", "Sending request...")
try {
val response = apiClient.simplePing()
uiState = uiState.copy(
isLoading = false,
simplePingResponse = response
)
addLog("SimplePing", "Success: ${response.status} from ${response.service}")
} catch (e: Exception) {
val msg = "Simple ping failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("SimplePing", "Failed: ${e.message}", isError = true)
}
}
}
fun performEnhancedPing(simulate: Boolean = false) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("EnhancedPing", "Sending request (simulate=$simulate)...")
try {
val response = apiClient.enhancedPing(simulate)
uiState = uiState.copy(
isLoading = false,
enhancedPingResponse = response
)
addLog("EnhancedPing", "Success: CB=${response.circuitBreakerState}, Time=${response.responseTime}ms")
} catch (e: Exception) {
val msg = "Enhanced ping failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("EnhancedPing", "Failed: ${e.message}", isError = true)
}
}
}
fun performHealthCheck() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("HealthCheck", "Checking system health...")
try {
val response = apiClient.healthCheck()
uiState = uiState.copy(
isLoading = false,
healthResponse = response
)
addLog("HealthCheck", "Status: ${response.status}, Healthy: ${response.healthy}")
} catch (e: Exception) {
val msg = "Health check failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("HealthCheck", "Failed: ${e.message}", isError = true)
}
}
}
fun performSecurePing() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("SecurePing", "Sending authenticated request...")
try {
val response = apiClient.securePing()
uiState = uiState.copy(
isLoading = false,
simplePingResponse = response
)
addLog("SecurePing", "Success: Authorized access granted.")
} catch (e: Exception) {
val msg = "Secure ping failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("SecurePing", "Access Denied/Error: ${e.message}", isError = true)
}
}
}
fun triggerSync() {
viewModelScope.launch {
uiState = uiState.copy(isSyncing = true, errorMessage = null)
addLog("Sync", "Starting delta sync...")
try {
syncService.syncPings()
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
uiState = uiState.copy(
isSyncing = false,
lastSyncResult = "Sync successful at $now"
)
addLog("Sync", "Sync completed successfully.")
} catch (e: Exception) {
val msg = "Sync failed: ${e.message}"
uiState = uiState.copy(isSyncing = false, errorMessage = msg)
addLog("Sync", "Sync failed: ${e.message}", isError = true)
}
}
}
fun clearLogs() {
uiState = uiState.copy(logs = emptyList())
}
fun clearError() {
uiState = uiState.copy(errorMessage = null)
}
}

View File

@ -0,0 +1,66 @@
package at.mocode.frontend.features.ping.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.Dimens
/**
* Eine universelle Terminal-Konsole zur Anzeige von Log-Einträgen.
* Plug-and-Play ist fähig für verschiedene Features (Ping, Sync, Auth-Logs).
*/
@Composable
fun TerminalConsole(
logs: List<LogEntry>,
modifier: Modifier = Modifier,
onClear: () -> Unit = {}
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = Dimens.SpacingXS),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("EVENT LOG", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
TextButton(
onClick = onClear,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(24.dp)
) {
Text("CLEAR", style = MaterialTheme.typography.labelSmall)
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF1E1E1E)) // Terminallook (Dunkel)
.padding(Dimens.SpacingXS)
) {
items(logs) { log ->
val color = if (log.isError) Color(0xFFFF5555) else Color(0xFF55FF55)
Text(
text = "[${log.timestamp}] [${log.source}] ${log.message}",
color = color,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
lineHeight = 14.sp
)
}
}
}
}

View File

@ -0,0 +1,105 @@
package at.mocode.frontend.features.ping.presentation
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import at.mocode.frontend.core.designsystem.preview.ComponentPreview
import at.mocode.frontend.features.ping.domain.PingSyncService
import at.mocode.ping.api.*
// ─────────────────────────────────────────────────────────────────────────────
// Fake-Implementierungen für Preview (kein Koin, kein Netzwerk nötig)
// ─────────────────────────────────────────────────────────────────────────────
private val fakePingResponse = PingResponse(
status = "OK", timestamp = "2026-03-26T12:00:00Z", service = "ping-service"
)
private val fakeEnhancedResponse = EnhancedPingResponse(
status = "OK", timestamp = "2026-03-26T12:00:00Z", service = "ping-service",
circuitBreakerState = "CLOSED", responseTime = 42L
)
private val fakeHealthResponse = HealthResponse(
status = "UP", timestamp = "2026-03-26T12:00:00Z", service = "ping-service", healthy = true
)
private object FakePingApi : PingApi {
override suspend fun simplePing() = fakePingResponse
override suspend fun enhancedPing(simulate: Boolean) = fakeEnhancedResponse
override suspend fun healthCheck() = fakeHealthResponse
override suspend fun publicPing() = fakePingResponse
override suspend fun securePing() = fakePingResponse
override suspend fun syncPings(since: Long): List<PingEvent> = emptyList()
}
private object FakePingSyncService : PingSyncService {
override suspend fun syncPings() { /* no-op */
}
}
// Subclass um uiState für Preview direkt setzen zu können
private class PreviewPingViewModel(state: PingUiState) :
PingViewModel(FakePingApi, FakePingSyncService) {
init {
uiState = state
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Previews
// ─────────────────────────────────────────────────────────────────────────────
@ComponentPreview
@Composable
fun PreviewPingScreen_Empty() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(PingUiState()),
onBack = {}
)
}
}
@ComponentPreview
@Composable
fun PreviewPingScreen_WithData() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(
PingUiState(
simplePingResponse = fakePingResponse,
healthResponse = fakeHealthResponse,
logs = listOf(
LogEntry("12:00:01", "SimplePing", "Success: OK from ping-service"),
LogEntry("12:00:00", "HealthCheck", "Status: UP, Healthy: true"),
)
)
),
onBack = {}
)
}
}
@ComponentPreview
@Composable
fun PreviewPingScreen_Loading() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(PingUiState(isLoading = true, isSyncing = true)),
onBack = {}
)
}
}
@ComponentPreview
@Composable
fun PreviewPingScreen_Error() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(
PingUiState(errorMessage = "Connection refused: Backend nicht erreichbar")
),
onBack = {}
)
}
}

View File

@ -30,6 +30,8 @@ kotlin {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.navigation)
implementation(projects.frontend.core.wizard)
implementation(projects.core.coreDomain)
implementation(projects.frontend.core.auth)
implementation(projects.frontend.features.vereinFeature)

View File

@ -0,0 +1,7 @@
package at.mocode.veranstaltung.feature.wizard
// Platzhalter für den Event-Flow.
// Hinweis: Der echte Flow lebt zunächst als Demo in :frontend:core:wizard (samples),
// bis die VM-Delegation hinter dem Feature-Flag integriert wird.
object EventWizardPlaceholder

View File

@ -0,0 +1,534 @@
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
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.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsFilePicker
import at.mocode.frontend.core.designsystem.components.MsTextField
import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.features.turnier.presentation.TurnierWizard
import at.mocode.frontend.features.zns.import.presentation.StammdatenImportScreen
import kotlin.uuid.ExperimentalUuidApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class)
@Composable
fun EventWizardScreen(
viewModel: EventWizardViewModel,
onBack: () -> Unit,
onFinish: () -> Unit,
onNavigateToVeranstalterNeu: () -> Unit = {}
) {
val state = viewModel.state
Scaffold(
topBar = {
Column {
TopAppBar(
title = { Text("Neues Event anlegen") },
navigationIcon = {
IconButton(onClick = {
if (state.currentStep == WizardStep.ZNS_CHECK) onBack()
else viewModel.previousStep()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück")
}
}
)
LinearProgressIndicator(
progress = { (state.currentStep.ordinal + 1).toFloat() / WizardStep.entries.size.toFloat() },
modifier = Modifier.fillMaxWidth()
)
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Sticky Preview Card
VorschauCard(state = state)
Box(
modifier = Modifier
.fillMaxSize()
.padding(Dimens.SpacingL)
.verticalScroll(rememberScrollState())
) {
when (state.currentStep) {
WizardStep.ZNS_CHECK -> ZnsCheckStep(viewModel)
WizardStep.VERANSTALTER_SELECTION -> VeranstalterSelectionStep(viewModel, onNavigateToVeranstalterNeu)
WizardStep.ANSPRECHPERSON_MAPPING -> AnsprechpersonMappingStep(viewModel)
WizardStep.META_DATA -> MetaDataStep(viewModel)
WizardStep.TURNIER_ANLAGE -> TurnierAnlageStep(viewModel)
WizardStep.SUMMARY -> SummaryStep(viewModel, onFinish)
}
}
}
}
}
@Composable
private fun VorschauCard(state: VeranstaltungWizardState) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(Dimens.SpacingM),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier.padding(Dimens.SpacingM),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
) {
// Placeholder für Logo
Box(
modifier = Modifier
.size(64.dp)
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text("LOGO", style = MaterialTheme.typography.labelSmall)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = state.name.ifBlank { "Neues Event" },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = state.veranstalterName.ifBlank { "Kein Veranstalter gewählt" },
style = MaterialTheme.typography.bodyMedium
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = state.ort.ifBlank { "Ort noch nicht festgelegt" },
style = MaterialTheme.typography.bodySmall
)
Text(
text = "| ${state.startDatum ?: ""} - ${state.endDatum ?: ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (state.ansprechpersonName.isNotBlank()) {
Text(
text = "Ansprechperson: ${state.ansprechpersonName} (${state.ansprechpersonSatznummer})",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun ZnsCheckStep(viewModel: EventWizardViewModel) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 1: Stammdaten-Verfügbarkeit prüfen", style = MaterialTheme.typography.titleLarge)
// Stats Anzeige
state.stammdatenStats?.let { stats ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Stammdaten-Status", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Letzter Import:")
Text(stats.lastImport ?: "Nie", fontWeight = FontWeight.Medium)
}
HorizontalDivider(
modifier = Modifier.alpha(0.5f),
thickness = DividerDefaults.Thickness,
color = DividerDefaults.color
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Vereine:")
Text("${stats.vereinCount}", fontWeight = FontWeight.Medium)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Reiter:")
Text("${stats.reiterCount}", fontWeight = FontWeight.Medium)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Pferde:")
Text("${stats.pferdCount}", fontWeight = FontWeight.Medium)
}
}
}
}
if (!state.isZnsAvailable && !state.isCheckingStats) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Warning, null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.width(12.dp))
Column {
Text(
"🚨 Stammdaten fehlen!",
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
Text("Bitte importieren Sie die aktuelle ZNS.zip, um fortzufahren.")
}
}
}
// Plug-and-Play Integration des Importers
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 500.dp)
.padding(vertical = 8.dp)
) {
StammdatenImportScreen(onBack = {})
}
Button(
onClick = { viewModel.checkStammdatenStatus() },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Refresh, null)
Spacer(Modifier.width(8.dp))
Text("Import-Status aktualisieren")
}
}
}
if (state.isZnsAvailable) {
Button(
onClick = { viewModel.nextStep() },
modifier = Modifier.fillMaxWidth()
) {
Text("Weiter zur Veranstalter-Wahl")
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class)
@Composable
private fun VeranstalterSelectionStep(
viewModel: EventWizardViewModel,
onNavigateToVeranstalterNeu: () -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 2: Veranstalter auswählen", style = MaterialTheme.typography.titleLarge)
Text("Suchen Sie nach dem Verein (Name oder OEPS-Nummer).")
MsTextField(
value = searchQuery,
onValueChange = {
searchQuery = it
if (it.length >= 3) {
viewModel.searchVeranstalterByOepsNr(it)
}
},
label = "Verein suchen (z.B. 6-009)",
placeholder = "OEPS-Nummer eingeben...",
modifier = Modifier.fillMaxWidth()
)
if (viewModel.state.veranstalterId != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(Icons.Default.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Column(modifier = Modifier.weight(1f)) {
Text(
viewModel.state.veranstalterName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text("OEPS-Nr: ${viewModel.state.veranstalterVereinsNummer}")
}
Button(onClick = { viewModel.nextStep() }) {
Text("Auswählen & Weiter")
}
}
}
}
if (viewModel.state.znsSearchResults.isNotEmpty()) {
Text("Gefundene Vereine in den Stammdaten:", style = MaterialTheme.typography.labelMedium)
viewModel.state.znsSearchResults.forEach { znsVerein ->
Card(
modifier = Modifier.fillMaxWidth(),
onClick = { viewModel.selectZnsVerein(znsVerein) }
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(Icons.Default.Add, null)
Column {
Text(znsVerein.name, fontWeight = FontWeight.Medium)
Text("OEPS-Nr: ${znsVerein.oepsNummer} | ${znsVerein.ort ?: ""}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
if (viewModel.state.veranstalterId == null && viewModel.state.znsSearchResults.isEmpty()) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Geben Sie mindestens 3 Zeichen der OEPS-Nummer ein, um die Stammdaten zu durchsuchen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
Text("Verein nicht gefunden?", style = MaterialTheme.typography.labelLarge)
Button(
onClick = onNavigateToVeranstalterNeu,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)
) {
Icon(Icons.Default.Add, null)
Spacer(Modifier.width(8.dp))
Text("Diesen Verein als neuen Veranstalter anlegen")
}
// Fallback/Demo Button
OutlinedButton(
onClick = { viewModel.searchVeranstalterByOepsNr("6-009") }
) {
Text("Beispiel: 6-009 suchen")
}
}
}
}
}
@Composable
private fun AnsprechpersonMappingStep(viewModel: EventWizardViewModel) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 3: Ansprechperson festlegen", style = MaterialTheme.typography.titleLarge)
Text("Wer ist für diese Veranstaltung verantwortlich?")
Button(onClick = {
viewModel.setAnsprechperson("12345", "Ursula Stroblmair")
viewModel.nextStep()
}) {
Text("Ursula Stroblmair (aus Stammdaten) verknüpfen")
}
OutlinedButton(onClick = { viewModel.nextStep() }) {
Text("Neue Person anlegen (Offline-Profil)")
}
}
}
@Composable
private fun MetaDataStep(viewModel: EventWizardViewModel) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 4: Veranstaltungs-Parameter", style = MaterialTheme.typography.titleLarge)
MsTextField(
value = state.name,
onValueChange = { viewModel.updateMetaData(it, state.ort, state.startDatum, state.endDatum, state.logoUrl) },
label = "Name der Veranstaltung",
placeholder = "z.B. Oster-Turnier 2026",
modifier = Modifier.fillMaxWidth()
)
MsTextField(
value = state.ort,
onValueChange = { viewModel.updateMetaData(state.name, it, state.startDatum, state.endDatum, state.logoUrl) },
label = "Veranstaltungs-Ort",
placeholder = "z.B. Reitanlage Musterstadt",
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text("Von", style = MaterialTheme.typography.labelMedium)
// Hier kommt ein DatePicker, wir simulieren das Datum
OutlinedButton(
onClick = { /* DatePicker Logik */ },
modifier = Modifier.fillMaxWidth()
) {
Text(state.startDatum?.toString() ?: "Datum wählen")
}
}
Column(modifier = Modifier.weight(1f)) {
Text("Bis (optional)", style = MaterialTheme.typography.labelMedium)
OutlinedButton(
onClick = { /* DatePicker Logik */ },
modifier = Modifier.fillMaxWidth()
) {
Text(state.endDatum?.toString() ?: "Datum wählen")
}
}
}
MsFilePicker(
label = "Veranstaltungs-Logo (optional)",
selectedPath = state.logoUrl,
onFileSelected = { viewModel.updateMetaData(state.name, state.ort, state.startDatum, state.endDatum, it) },
fileExtensions = listOf("png", "jpg", "jpeg", "svg"),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
Button(
onClick = { viewModel.nextStep() },
modifier = Modifier.align(Alignment.End),
enabled = state.name.isNotBlank() && state.ort.isNotBlank() && state.startDatum != null
) {
Text("Weiter zur Turnier-Anlage")
}
}
}
@Composable
private fun TurnierAnlageStep(viewModel: EventWizardViewModel) {
val state = viewModel.state
val turnierViewModel = viewModel.turnierWizardViewModel
var showWizard by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 5: Turniere & Ausschreibung", style = MaterialTheme.typography.titleLarge)
if (showWizard) {
Card(modifier = Modifier.fillMaxWidth().height(600.dp)) {
TurnierWizard(
viewModel = turnierViewModel,
veranstaltungId = 0, // Mock-Modus
onBack = { showWizard = false },
onFinish = {
showWizard = false
viewModel.addTurnier(turnierViewModel.state.turnierNr, "ZNS Ausschreibung")
}
)
}
} else {
Text("Fügen Sie die pferdesportlichen Veranstaltungen (Turniere) hinzu.")
state.turniere.forEachIndexed { index, turnier ->
ListItem(
headlineContent = { Text("Turnier #${index + 1}: ${turnier.nummer}") },
trailingContent = {
IconButton(onClick = { viewModel.removeTurnier(index) }) {
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
}
}
)
}
OutlinedButton(
onClick = { showWizard = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Add, null)
Spacer(Modifier.width(8.dp))
Text("Neues Turnier mit Wizard anlegen")
}
}
Spacer(Modifier.height(16.dp))
Button(
onClick = { viewModel.nextStep() },
modifier = Modifier.align(Alignment.End),
enabled = !showWizard && state.turniere.isNotEmpty()
) {
Text("Weiter zur Zusammenfassung")
}
}
}
@Composable
private fun SummaryStep(viewModel: EventWizardViewModel, onFinish: () -> Unit) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 6: Zusammenfassung", style = MaterialTheme.typography.titleLarge)
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Überprüfen Sie Ihre Angaben, bevor Sie die Veranstaltung final anlegen.",
style = MaterialTheme.typography.bodyMedium
)
HorizontalDivider()
SummaryItem("Veranstaltung", state.name)
SummaryItem("Veranstalter", "${state.veranstalterName} (${state.veranstalterVereinsNummer})")
SummaryItem("Ansprechperson", state.ansprechpersonName)
SummaryItem("Ort", state.ort)
SummaryItem("Zeitraum", "${state.startDatum} - ${state.endDatum ?: ""}")
HorizontalDivider()
Text("Turniere:", fontWeight = FontWeight.Bold)
state.turniere.forEach { turnier ->
Text("• Turnier-Nr: ${turnier.nummer}", style = MaterialTheme.typography.bodySmall)
}
}
}
Spacer(Modifier.height(24.dp))
Button(
onClick = {
viewModel.saveVeranstaltung()
onFinish()
},
modifier = Modifier.fillMaxWidth(),
enabled = !state.isSaving
) {
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary)
} else {
Text("Veranstaltung jetzt anlegen")
}
}
}
}
@Composable
private fun SummaryItem(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}

View File

@ -7,11 +7,18 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.core.domain.serialization.UuidSerializer
import at.mocode.frontend.core.auth.data.local.AuthTokenManager
import at.mocode.frontend.core.domain.config.WizardFeatureFlags
import at.mocode.frontend.core.domain.repository.MasterdataRepository
import at.mocode.frontend.core.domain.repository.MasterdataStats
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.core.domain.zns.ZnsRemoteVerein
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.network.NetworkConfig
import at.mocode.frontend.core.wizard.runtime.WizardContext
import at.mocode.frontend.core.wizard.runtime.WizardState
import at.mocode.frontend.core.wizard.samples.DemoEventAcc
import at.mocode.frontend.core.wizard.samples.DemoEventFlow
import at.mocode.frontend.core.wizard.samples.DemoEventStep
import at.mocode.frontend.features.turnier.presentation.TurnierWizardViewModel
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.verein.domain.VereinRepository
@ -78,6 +85,9 @@ class EventWizardViewModel(
var state by mutableStateOf(VeranstaltungWizardState())
private set
// --- Orchestrator-Integration (minimal, 2 Steps) ---
private var wizardState: WizardState<DemoEventStep, DemoEventAcc>? = null
init {
checkZnsAvailability()
checkStammdatenStatus()
@ -87,6 +97,12 @@ class EventWizardViewModel(
if (veranstalterIdParam != null) {
loadVeranstalterContext(veranstalterIdParam)
}
// Initialisiere WizardRuntime-State (hinter Feature-Flag nutzbar)
if (WizardFeatureFlags.WizardRuntimeEnabled) {
@Suppress("UNCHECKED_CAST")
wizardState = WizardState(current = (DemoEventStep.ZnsCheck as DemoEventStep), acc = DemoEventAcc())
}
}
private fun loadVeranstalterContext(id: Long) {
@ -94,7 +110,7 @@ class EventWizardViewModel(
val result = veranstalterRepository.getById(id)
result.onSuccess { v ->
setVeranstalter(
id = Uuid.random(), // Hier müsste eigentlich die Verein-UUID rein, falls vorhanden, sonst random für Neu-Anlage
id = Uuid.random(), // Hier müsste eigentlich die Vereins-UUID rein, falls vorhanden, sonst random für Neu-Anlage
nummer = v.oepsNummer,
name = v.name,
standardOrt = v.ort,
@ -168,29 +184,75 @@ class EventWizardViewModel(
}
fun nextStep() {
state = state.copy(
currentStep = when (state.currentStep) {
WizardStep.ZNS_CHECK -> WizardStep.VERANSTALTER_SELECTION
WizardStep.VERANSTALTER_SELECTION -> WizardStep.ANSPRECHPERSON_MAPPING
WizardStep.ANSPRECHPERSON_MAPPING -> WizardStep.META_DATA
WizardStep.META_DATA -> WizardStep.TURNIER_ANLAGE
WizardStep.TURNIER_ANLAGE -> WizardStep.SUMMARY
WizardStep.SUMMARY -> WizardStep.SUMMARY
if (WizardFeatureFlags.WizardRuntimeEnabled && isHandledByRuntime(state.currentStep)) {
val ctx = buildWizardContext()
val currentRuntimeState = ensureWizardStateInitialized()
val next = DemoEventFlow.next(ctx, currentRuntimeState)
wizardState = next
val mapped = mapToWizardStep(next.current)
if (mapped != null) {
state = state.copy(currentStep = mapped)
return
}
)
// Fallback, sollte eigentlich nicht eintreten
}
state = state.copy(currentStep = when (state.currentStep) {
WizardStep.ZNS_CHECK -> WizardStep.VERANSTALTER_SELECTION
WizardStep.VERANSTALTER_SELECTION -> WizardStep.ANSPRECHPERSON_MAPPING
WizardStep.ANSPRECHPERSON_MAPPING -> WizardStep.META_DATA
WizardStep.META_DATA -> WizardStep.TURNIER_ANLAGE
WizardStep.TURNIER_ANLAGE -> WizardStep.SUMMARY
WizardStep.SUMMARY -> WizardStep.SUMMARY
})
}
fun previousStep() {
state = state.copy(
currentStep = when (state.currentStep) {
WizardStep.ZNS_CHECK -> WizardStep.ZNS_CHECK
WizardStep.VERANSTALTER_SELECTION -> WizardStep.ZNS_CHECK
WizardStep.ANSPRECHPERSON_MAPPING -> WizardStep.VERANSTALTER_SELECTION
WizardStep.META_DATA -> WizardStep.ANSPRECHPERSON_MAPPING
WizardStep.TURNIER_ANLAGE -> WizardStep.META_DATA
WizardStep.SUMMARY -> WizardStep.TURNIER_ANLAGE
if (WizardFeatureFlags.WizardRuntimeEnabled && isHandledByRuntime(state.currentStep)) {
val currentRuntimeState = ensureWizardStateInitialized()
val prev = DemoEventFlow.back(currentRuntimeState)
wizardState = prev
val mapped = mapToWizardStep(prev.current)
if (mapped != null) {
state = state.copy(currentStep = mapped)
return
}
)
}
state = state.copy(currentStep = when (state.currentStep) {
WizardStep.ZNS_CHECK -> WizardStep.ZNS_CHECK
WizardStep.VERANSTALTER_SELECTION -> WizardStep.ZNS_CHECK
WizardStep.ANSPRECHPERSON_MAPPING -> WizardStep.VERANSTALTER_SELECTION
WizardStep.META_DATA -> WizardStep.ANSPRECHPERSON_MAPPING
WizardStep.TURNIER_ANLAGE -> WizardStep.META_DATA
WizardStep.SUMMARY -> WizardStep.TURNIER_ANLAGE
})
}
private fun buildWizardContext(): WizardContext = WizardContext(
origin = AppScreen.EventVerwaltung, // Platzhalter; kann später aus echtem Einstieg befüllt werden
role = null,
isOnline = true,
stats = state.stammdatenStats
)
private fun ensureWizardStateInitialized(): WizardState<DemoEventStep, DemoEventAcc> {
val existing = wizardState
if (existing != null) return existing
@Suppress("UNCHECKED_CAST")
val initial = WizardState(current = (DemoEventStep.ZnsCheck as DemoEventStep), acc = DemoEventAcc())
wizardState = initial
return initial
}
private fun isHandledByRuntime(step: WizardStep): Boolean = when (step) {
WizardStep.ZNS_CHECK, WizardStep.VERANSTALTER_SELECTION -> true
else -> false
}
private fun mapToWizardStep(step: DemoEventStep): WizardStep? = when (step) {
DemoEventStep.ZnsCheck -> WizardStep.ZNS_CHECK
DemoEventStep.VeranstalterSelection -> WizardStep.VERANSTALTER_SELECTION
}
fun setVeranstalter(id: Uuid, nummer: String, name: String, standardOrt: String, logo: String?) {

View File

@ -142,6 +142,7 @@ include(":frontend:core:navigation")
include(":frontend:core:network")
include(":frontend:core:local-db")
include(":frontend:core:sync")
include(":frontend:core:wizard")
// --- FEATURES ---
include(":frontend:features:ping-feature")