Compare commits
8 Commits
0ab1807235
...
19ba044ec0
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ba044ec0 | |||
| 9556e0ac67 | |||
| 4692bd186c | |||
| b11432df16 | |||
| 319cb52160 | |||
| a35dfa1434 | |||
| 237c71e5a0 | |||
| ec124e9acd |
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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. Delta‑Sync. 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:
|
||||
- ADR‑0025: Wizard-Orchestrator (State‑Machine, DSL, Guards, Effects) → `docs/01_Architecture/adr/0025-wizard-orchestrator-de.md`
|
||||
- ADR‑0026: Step-Validation-Policy (sync vs. async, Fehlersichtbarkeit, Hotkeys) → `docs/01_Architecture/adr/0026-validation-policy-de.md`
|
||||
- ADR‑0027: Draft-Domain & Delta‑Sync (Versionierung, Konfliktlösung, Idempotenz) → `docs/01_Architecture/adr/0027-draft-domain-and-delta-sync-de.md`
|
||||
- Reference: Wizard‑DSL 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: Event‑Flow – zunächst 2 Steps (ZNS_CHECK, VERANSTALTER_SELECTION), dann alle 6 Steps.
|
||||
3) Feature‑Flag `WizardRuntimeEnabled` für risikoarmen Rollout.
|
||||
|
||||
### 3.3 Phasenplanung (Auszug)
|
||||
- Phase 1 (Core & Tooling, 2–3 Wochen): Runtime/DSL, DevLogs, Graph‑Export, Scaffold‑MVP, Unit‑Tests.
|
||||
- Phase 2 (Event‑Flow, 2–3 Wochen): `EventStep/Acc/Guards`, Flow‑DSL, VM‑Delegation, Validierung, Autosave/Resume.
|
||||
- Phase 3 (Backend, 2–4 Wochen): Draft-/Validate‑APIs, Offline‑Queue, Delta‑Sync für Turniere.
|
||||
- Phase 4 (Skalierung, 6–10 Wochen, parallel): Weitere Flows je Bounded Context.
|
||||
- Phase 5–7 (2–3 + 1–2 + 1–2 Wochen): UX‑Härtung, Observability/Rollout‑Gates, Stabilisierung & Abschaltung Altlogik.
|
||||
|
||||
Grobe Gesamtdauer: 17–29 Wochen je nach Parallelisierung.
|
||||
|
||||
### 3.4 Akzeptanzkriterien (DoD Initiative)
|
||||
- Alle priorisierten Flows laufen über Orchestrator; Next/Back/History deterministisch; Graph‑Export aktuell.
|
||||
- DraftStore produktiv; Resume deterministisch; Delta‑Sync idempotent; Konflikte nicht‑blockierend sichtbar.
|
||||
- Validierungs‑Policy konsistent; Tastatur‑Bedienung vollständig; Performance‑Gates eingehalten.
|
||||
- ADR‑0025/0026/0027 veröffentlicht; Wizard‑DSL‑Reference vorhanden; CI grün; Metriken/Alerts aktiv.
|
||||
|
||||
### 3.5 10‑Tage‑Startplan
|
||||
- Tag 1–2: Runtime/DSL‑Skelett, Scaffold‑MVP, Feature‑Flag, README Skeleton.
|
||||
- Tag 3: EventStep/Acc/Guards, EventFlow (2 Steps), VM‑Delegation minimal.
|
||||
- Tag 4: Tests Runtime/Guards, Graph‑Export, Dev‑Logs.
|
||||
- Tag 5–6: META_DATA/ANSPRECHPERSON migrieren, Validierungs‑API, Fehler‑Summary.
|
||||
- Tag 7: DraftStore lokal (Autosave/Resume), Property‑Test Resume.
|
||||
- Tag 8: TURNIER_ANLAGE einbetten, Sync via `onComplete`.
|
||||
- Tag 9: SUMMARY + Finalisierung, Offload in Offline‑Queue (Stub).
|
||||
- Tag 10: ADR‑0025/0026/0027 Review+Merge; Journal‑Eintrag.
|
||||
|
||||
Journal: `docs/99_Journal/2026-04-21_Wizard-Orchestrator_Roadmap_Anchoring.md`
|
||||
|
||||
---
|
||||
|
||||
## 3. Aktuelle Phase
|
||||
|
||||
### PHASE 5: P2-Contexts & Integration ✅ ABGESCHLOSSEN
|
||||
|
|
|
|||
107
docs/01_Architecture/Reference/Wizard-DSL-README.md
Normal file
107
docs/01_Architecture/Reference/Wizard-DSL-README.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
---
|
||||
type: Reference
|
||||
status: ACTIVE
|
||||
owner: Lead Architect
|
||||
date: 2026-04-21
|
||||
---
|
||||
|
||||
# Wizard‑DSL & Orchestrator – Referenz
|
||||
|
||||
## Ziel
|
||||
Deklarative Beschreibung von Wizard‑Flows als Graph (Steps, Guards, Transitions) mit klaren Side‑Effects und Offline‑Draft‑Unterstützung.
|
||||
|
||||
## Kern‑Interfaces (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 – Event‑Flow (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).
|
||||
- Graph‑Export (DOT/PlantUML) aus der DSL für Doku & Reviews.
|
||||
|
||||
## Tests (Empfehlungen)
|
||||
- Unit: Guards (100% Branch‑Abdeckung), Runtime‑History.
|
||||
- Property: Resume‑Determinismus (Draft → korrekter Step).
|
||||
- Snapshot: Compose‑Panels mit Beispielkontexten.
|
||||
|
||||
## Verweise
|
||||
- ADR‑0025 Orchestrator · ADR‑0026 Validation‑Policy · ADR‑0027 Draft‑Domain & Delta‑Sync
|
||||
- Roadmap: `docs/01_Architecture/MASTER_ROADMAP.md#3-initiative-wizard-orchestrator--offline-drafts-q2q3-2026`
|
||||
30
docs/01_Architecture/adr/0025-wizard-orchestrator-de.md
Normal file
30
docs/01_Architecture/adr/0025-wizard-orchestrator-de.md
Normal 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, Offline‑First (Autosave/Resume), testbare Regeln, einheitliche UX.
|
||||
|
||||
## Entscheidung
|
||||
- Einführung eines generischen Orchestrators mit deklarativem Flow:
|
||||
- `StepId` (typsicher je Flow), `WizardContext`, `WizardState`, `Guard`, `Transition`, `StepEffects`.
|
||||
- Kotlin‑DSL: `flow { step { whenGuard(id) go target; otherwise go … } }`.
|
||||
- Side‑Effects an Hooks (`onEnter/onLeave/onComplete`) für Prefetch, Autosave, Finalisierung.
|
||||
|
||||
## Konsequenzen
|
||||
- Vorteile: zentrale, testbare Navigationslogik; konsistente Draft‑Policy; bessere Übersicht/Erweiterbarkeit.
|
||||
- Risiken: Initialer Overhead, Lernkurve, Guard‑Sprawl; mitigiert durch README, DevTools, Linting/Namenskonventionen.
|
||||
|
||||
## Umsetzung
|
||||
- Modul `frontend/core/wizard` (runtime, dsl, devtools, draft) und `WizardScaffold` (Breadcrumb, Footer, Hotkeys).
|
||||
- Strangler‑Migration beginnend mit Event‑Flow; Feature‑Flag `WizardRuntimeEnabled`.
|
||||
|
||||
## Verweise
|
||||
- Roadmap‑Abschnitt: `docs/01_Architecture/MASTER_ROADMAP.md#3-initiative-wizard-orchestrator--offline-drafts-q2q3-2026`
|
||||
- Reference: `docs/01_Architecture/Reference/Wizard-DSL-README.md`
|
||||
28
docs/01_Architecture/adr/0026-validation-policy-de.md
Normal file
28
docs/01_Architecture/adr/0026-validation-policy-de.md
Normal 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
|
||||
- Roadmap‑Abschnitt: `docs/01_Architecture/MASTER_ROADMAP.md#3-initiative-wizard-orchestrator--offline-drafts-q2q3-2026`
|
||||
- Reference: `docs/01_Architecture/Reference/Wizard-DSL-README.md`
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
type: ADR
|
||||
status: PROPOSED
|
||||
owner: Lead Architect
|
||||
date: 2026-04-21
|
||||
---
|
||||
|
||||
# ADR-0027 — Draft-Domain & Delta‑Sync (Versionierung, Konfliktlösung, Idempotenz)
|
||||
|
||||
## Kontext
|
||||
- Offline‑First verlangt lokale Drafts, Resume‑Fähigkeit und robuste Synchronisation bei instabiler Konnektivität.
|
||||
- Heute: kein einheitliches Draft‑Modell, keine standardisierte Schritt‑weise Speicherung.
|
||||
|
||||
## Entscheidung
|
||||
- Einführung einer Draft‑Domain mit Version/ETag und Schritt‑Patchs:
|
||||
- `POST /api/events/drafts` (idempotent über Idempotency‑Key) erstellt/vereinigt Drafts.
|
||||
- `PATCH /api/events/drafts/{id}` speichert step‑weise Deltas (nur geänderte Felder seit `version`).
|
||||
- `POST /api/events` finalisiert (Server‑Validierung, Erstellung `veranstaltungId`).
|
||||
- Client Offline‑Queue mit Retry/Backoff; Konflikte als nicht‑blockierende 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`), Delta‑Builder je Step, Queue mit Idempotency‑Key.
|
||||
- Backend: Versionsverwaltung, If‑Match/ETag Unterstützung, Conflict‑Detect (409) mit Merge‑Hinweisen.
|
||||
|
||||
## Verweise
|
||||
- Roadmap‑Abschnitt: `docs/01_Architecture/MASTER_ROADMAP.md#3-initiative-wizard-orchestrator--offline-drafts-q2q3-2026`
|
||||
- Contracts: folgen nach API‑Skizzierung (`contracts/` Verzeichnis)
|
||||
|
|
@ -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 & Delta‑Sync (Versionierung, Konfliktlösung, Idempotenz)
|
||||
|
||||
Siehe Template: ADR-000-template.md.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
type: Journal
|
||||
status: DONE
|
||||
owner: Curator
|
||||
date: 2026-04-21
|
||||
---
|
||||
|
||||
# Journal — Roadmap „Wizard‑Orchestrator & Offline‑Drafts“ verankert
|
||||
|
||||
## Kontext
|
||||
Die in der Session ausgearbeitete Roadmap zur Migration auf einen Wizard‑Orchestrator 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):
|
||||
- ADR‑0025 Wizard‑Orchestrator → `docs/01_Architecture/adr/0025-wizard-orchestrator-de.md`
|
||||
- ADR‑0026 Step‑Validation‑Policy → `docs/01_Architecture/adr/0026-validation-policy-de.md`
|
||||
- ADR‑0027 Draft‑Domain & Delta‑Sync → `docs/01_Architecture/adr/0027-draft-domain-and-delta-sync-de.md`
|
||||
- Referenz:
|
||||
- Wizard‑DSL README → `docs/01_Architecture/Reference/Wizard-DSL-README.md`
|
||||
- ADR‑Index ergänzt:
|
||||
- `docs/01_Architecture/adr/README.md`
|
||||
|
||||
## Nächste Schritte (Review)
|
||||
- Lead Architect: ADR‑0025/26/27 Review & Nummernfreigabe.
|
||||
- Frontend: Spike „Runtime/DSL‑Skelett“ gemäß Roadmap (Tag 1–2).
|
||||
- Backend: API‑Skizzen für Drafts/Validate in `contracts/` vorbereiten.
|
||||
|
|
@ -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
|
||||
}
|
||||
52
frontend/core/wizard/build.gradle.kts
Normal file
52
frontend/core/wizard/build.gradle.kts
Normal 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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user