From d66bd63cc97d43fbccd86d80cee8d87680dda2ce Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Tue, 21 Apr 2026 18:26:31 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20f=C3=BCge=20DraftStore=20und=20Speicher?= =?UTF-8?q?n/Resume=20von=20Wizard-Status=20hinzu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: StefanMoCoAt --- .../frontend/core/wizard/draft/DraftStore.kt | 15 ++++++ .../core/wizard/samples/EventFlowSample.kt | 8 +-- .../frontend/core/wizard/ui/WizardHotkeys.kt | 53 +++++++++++++----- .../core/wizard/WizardRuntimeJvmTest.kt | 44 +++++++++++++++ .../feature/presentation/EventWizardScreen.kt | 2 +- .../presentation/EventWizardViewModel.kt | 54 ++++++++++++++++++- 6 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 frontend/core/wizard/src/commonMain/kotlin/at/mocode/frontend/core/wizard/draft/DraftStore.kt diff --git a/frontend/core/wizard/src/commonMain/kotlin/at/mocode/frontend/core/wizard/draft/DraftStore.kt b/frontend/core/wizard/src/commonMain/kotlin/at/mocode/frontend/core/wizard/draft/DraftStore.kt new file mode 100644 index 00000000..7f8488e0 --- /dev/null +++ b/frontend/core/wizard/src/commonMain/kotlin/at/mocode/frontend/core/wizard/draft/DraftStore.kt @@ -0,0 +1,15 @@ +package at.mocode.frontend.core.wizard.draft + +/** + * Minimaler DraftStore (MVP): in‑Memory, versioniert per Key. Persistenz folgt später. + */ +object DraftStoreMemory { + private data class Entry(val version: Int, val stepId: String) + private val store = mutableMapOf() + + fun save(flowKey: String, version: Int, stepId: String) { + store[flowKey] = Entry(version, stepId) + } + + fun load(flowKey: String): Pair? = store[flowKey]?.let { it.version to it.stepId } +} diff --git a/frontend/core/wizard/src/commonMain/kotlin/at/mocode/frontend/core/wizard/samples/EventFlowSample.kt b/frontend/core/wizard/src/commonMain/kotlin/at/mocode/frontend/core/wizard/samples/EventFlowSample.kt index ef64e13a..dd02dc76 100644 --- a/frontend/core/wizard/src/commonMain/kotlin/at/mocode/frontend/core/wizard/samples/EventFlowSample.kt +++ b/frontend/core/wizard/src/commonMain/kotlin/at/mocode/frontend/core/wizard/samples/EventFlowSample.kt @@ -21,9 +21,11 @@ data class DemoEventAcc( object DemoEventGuards { val hasZns: Guard = { ctx, _ -> (ctx.stats?.vereinCount ?: 0) > 0 } - // Platzhalter-Guard: aktuell stets true, damit Verhalten dem Legacy-Pfad entspricht. - // Wird später durch echte Domänenlogik ersetzt (Veranstalter-Typ/ID etc.). - val needsContactPerson: Guard = { _, _ -> true } + // Heuristik für Demo: Kontaktperson nötig, wenn keine Veranstalter-ID vorhanden + // oder die Nummer ein Organisations-Präfix trägt (z. B. „ORG-“) + val needsContactPerson: Guard = { _, acc -> + acc.veranstalterId == null || acc.veranstalterNr.startsWith("ORG-") + } } val DemoEventFlow = flow(start = DemoEventStep.ZnsCheck) { diff --git a/frontend/core/wizard/src/jvmMain/kotlin/at/mocode/frontend/core/wizard/ui/WizardHotkeys.kt b/frontend/core/wizard/src/jvmMain/kotlin/at/mocode/frontend/core/wizard/ui/WizardHotkeys.kt index 7c16c852..a4b53221 100644 --- a/frontend/core/wizard/src/jvmMain/kotlin/at/mocode/frontend/core/wizard/ui/WizardHotkeys.kt +++ b/frontend/core/wizard/src/jvmMain/kotlin/at/mocode/frontend/core/wizard/ui/WizardHotkeys.kt @@ -1,6 +1,9 @@ package at.mocode.frontend.core.wizard.ui +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.* /** * Desktop-spezifische Variante. Hotkeys werden in einem Folge‑Inkrement ergänzt, @@ -20,17 +23,41 @@ fun WizardScaffoldWithHotkeys( finishLabel: String = "Fertig", content: @Composable () -> Unit ) { - WizardScaffold( - steps = steps, - currentIndex = currentIndex, - canBack = canBack, - canNext = canNext, - onBack = onBack, - onNext = onNext, - onSaveDraft = onSaveDraft, - nextLabel = nextLabel, - backLabel = backLabel, - finishLabel = finishLabel, - content = content - ) + Box( + modifier = Modifier.onPreviewKeyEvent { evt -> + if (evt.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false + when { + // Alt+S: Draft speichern (falls vorhanden) + evt.isAltPressed && evt.key == Key.S -> { + onSaveDraft?.invoke() + true + } + // Shift+Enter: Zurück + evt.isShiftPressed && evt.key == Key.Enter -> { + if (canBack) onBack() + true + } + // Enter: Weiter/Fertig + evt.key == Key.Enter -> { + if (canNext) onNext() + true + } + else -> false + } + } + ) { + WizardScaffold( + steps = steps, + currentIndex = currentIndex, + canBack = canBack, + canNext = canNext, + onBack = onBack, + onNext = onNext, + onSaveDraft = onSaveDraft, + nextLabel = nextLabel, + backLabel = backLabel, + finishLabel = finishLabel, + content = content + ) + } } diff --git a/frontend/core/wizard/src/jvmTest/kotlin/at/mocode/frontend/core/wizard/WizardRuntimeJvmTest.kt b/frontend/core/wizard/src/jvmTest/kotlin/at/mocode/frontend/core/wizard/WizardRuntimeJvmTest.kt index 25d06a14..1ee0d0cd 100644 --- a/frontend/core/wizard/src/jvmTest/kotlin/at/mocode/frontend/core/wizard/WizardRuntimeJvmTest.kt +++ b/frontend/core/wizard/src/jvmTest/kotlin/at/mocode/frontend/core/wizard/WizardRuntimeJvmTest.kt @@ -3,6 +3,7 @@ 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.DemoEventAcc import at.mocode.frontend.core.wizard.samples.DemoEventFlow import at.mocode.frontend.core.wizard.samples.DemoEventStep import at.mocode.frontend.core.wizard.samples.demoStartState @@ -47,4 +48,47 @@ class WizardRuntimeJvmTest { val s2 = DemoEventFlow.back(s1) assertEquals(DemoEventStep.ZnsCheck, s2.current) } + + @Test + fun selection_goes_to_ansprechperson_when_guard_true_by_default() { + 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) // -> VeranstalterSelection + val s2 = DemoEventFlow.next(ctx, s1) + assertEquals(DemoEventStep.AnsprechpersonMapping, s2.current) + } + + @Test + fun selection_goes_to_meta_when_guard_false_by_acc() { + val ctx = WizardContext( + origin = AppScreen.Home, + isOnline = true, + stats = MasterdataStats( + lastImport = null, + vereinCount = 1, + reiterCount = 0, + pferdCount = 0, + funktionaerCount = 0 + ) + ) + // Manuell einen State anlegen, der bereits auf VeranstalterSelection steht + val acc = DemoEventAcc(veranstalterId = "id-1", veranstalterNr = "V-123") + val s1 = at.mocode.frontend.core.wizard.runtime.WizardState( + current = DemoEventStep.VeranstalterSelection, + history = listOf(DemoEventStep.ZnsCheck), + acc = acc + ) + val s2 = DemoEventFlow.next(ctx, s1) + assertEquals(DemoEventStep.MetaData, s2.current) + } } diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardScreen.kt index 5155ee59..9da8c02b 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardScreen.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardScreen.kt @@ -42,7 +42,7 @@ fun EventWizardScreen( if (state.currentStep == WizardStep.ZNS_CHECK) onBack() else viewModel.previousStep() }, onNext = { viewModel.nextStep() }, - onSaveDraft = null, // Wird in einem Folge-Inkrement angebunden + onSaveDraft = { viewModel.saveDraft() }, onFinish = onFinish, onNavigateToVeranstalterNeu = onNavigateToVeranstalterNeu, renderStep = { diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardViewModel.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardViewModel.kt index 927e5879..2cfef38e 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardViewModel.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardViewModel.kt @@ -14,6 +14,7 @@ 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.draft.DraftStoreMemory import at.mocode.frontend.core.wizard.runtime.WizardContext import at.mocode.frontend.core.wizard.runtime.WizardState import at.mocode.frontend.core.wizard.samples.DemoEventAcc @@ -87,6 +88,8 @@ class EventWizardViewModel( // --- Orchestrator-Integration (minimal, 2 Steps) --- private var wizardState: WizardState? = null + private val draftFlowKey = "event_wizard_v1" + private val draftFlowVersion = 1 init { checkZnsAvailability() @@ -100,8 +103,19 @@ class EventWizardViewModel( // Initialisiere WizardRuntime-State (hinter Feature-Flag nutzbar) if (WizardFeatureFlags.WizardRuntimeEnabled) { - @Suppress("UNCHECKED_CAST") - wizardState = WizardState(current = (DemoEventStep.ZnsCheck as DemoEventStep), acc = DemoEventAcc()) + // Resume Draft, falls vorhanden + val draft = DraftStoreMemory.load(draftFlowKey) + if (draft != null) { + val step = parseWizardStep(draft.second) + // Mappe auf Runtime-Step + val runtimeStep = mapFromWizardStep(step) + @Suppress("UNCHECKED_CAST") + wizardState = WizardState(current = (runtimeStep as DemoEventStep), acc = DemoEventAcc()) + state = state.copy(currentStep = step) + } else { + @Suppress("UNCHECKED_CAST") + wizardState = WizardState(current = (DemoEventStep.ZnsCheck as DemoEventStep), acc = DemoEventAcc()) + } } } @@ -192,6 +206,7 @@ class EventWizardViewModel( val mapped = mapToWizardStep(next.current) if (mapped != null) { state = state.copy(currentStep = mapped) + saveDraftInternal(mapped) return } // Fallback, sollte eigentlich nicht eintreten @@ -215,6 +230,7 @@ class EventWizardViewModel( val mapped = mapToWizardStep(prev.current) if (mapped != null) { state = state.copy(currentStep = mapped) + saveDraftInternal(mapped) return } } @@ -242,6 +258,16 @@ class EventWizardViewModel( @Suppress("UNCHECKED_CAST") val initial = WizardState(current = (DemoEventStep.ZnsCheck as DemoEventStep), acc = DemoEventAcc()) wizardState = initial + // Beim ersten Zugriff auch evtl. gespeicherten Draft berücksichtigen + DraftStoreMemory.load(draftFlowKey)?.let { (_, stepName) -> + val parsed = parseWizardStep(stepName) + val runtime = mapFromWizardStep(parsed) + @Suppress("UNCHECKED_CAST") + val resumed = initial.copy(current = (runtime as DemoEventStep)) + wizardState = resumed + state = state.copy(currentStep = parsed) + return resumed + } return initial } @@ -260,6 +286,30 @@ class EventWizardViewModel( DemoEventStep.MetaData -> WizardStep.META_DATA } + private fun mapFromWizardStep(step: WizardStep): DemoEventStep = when (step) { + WizardStep.ZNS_CHECK -> DemoEventStep.ZnsCheck + WizardStep.VERANSTALTER_SELECTION -> DemoEventStep.VeranstalterSelection + WizardStep.ANSPRECHPERSON_MAPPING -> DemoEventStep.AnsprechpersonMapping + WizardStep.META_DATA -> DemoEventStep.MetaData + // Noch nicht im Runtime-Flow migriert: mappe konservativ auf letzten bekannten Schritt + WizardStep.TURNIER_ANLAGE -> DemoEventStep.MetaData + WizardStep.SUMMARY -> DemoEventStep.MetaData + } + + private fun parseWizardStep(name: String): WizardStep = try { + WizardStep.valueOf(name) + } catch (_: Exception) { + WizardStep.ZNS_CHECK + } + + private fun saveDraftInternal(step: WizardStep) { + DraftStoreMemory.save(draftFlowKey, draftFlowVersion, step.name) + } + + fun saveDraft() { + saveDraftInternal(state.currentStep) + } + fun setVeranstalter(id: Uuid, nummer: String, name: String, standardOrt: String, logo: String?) { state = state.copy( veranstalterId = id,