feat: füge DraftStore und Speichern/Resume von Wizard-Status hinzu

Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-04-21 18:26:31 +02:00
parent 3b4e3db51d
commit d66bd63cc9
6 changed files with 157 additions and 19 deletions

View File

@ -0,0 +1,15 @@
package at.mocode.frontend.core.wizard.draft
/**
* Minimaler DraftStore (MVP): inMemory, versioniert per Key. Persistenz folgt später.
*/
object DraftStoreMemory {
private data class Entry(val version: Int, val stepId: String)
private val store = mutableMapOf<String, Entry>()
fun save(flowKey: String, version: Int, stepId: String) {
store[flowKey] = Entry(version, stepId)
}
fun load(flowKey: String): Pair<Int, String>? = store[flowKey]?.let { it.version to it.stepId }
}

View File

@ -21,9 +21,11 @@ data class DemoEventAcc(
object DemoEventGuards { object DemoEventGuards {
val hasZns: Guard<DemoEventStep, DemoEventAcc> = { ctx, _ -> (ctx.stats?.vereinCount ?: 0) > 0 } val hasZns: Guard<DemoEventStep, DemoEventAcc> = { ctx, _ -> (ctx.stats?.vereinCount ?: 0) > 0 }
// Platzhalter-Guard: aktuell stets true, damit Verhalten dem Legacy-Pfad entspricht. // Heuristik für Demo: Kontaktperson nötig, wenn keine Veranstalter-ID vorhanden
// Wird später durch echte Domänenlogik ersetzt (Veranstalter-Typ/ID etc.). // oder die Nummer ein Organisations-Präfix trägt (z. B. „ORG-“)
val needsContactPerson: Guard<DemoEventStep, DemoEventAcc> = { _, _ -> true } val needsContactPerson: Guard<DemoEventStep, DemoEventAcc> = { _, acc ->
acc.veranstalterId == null || acc.veranstalterNr.startsWith("ORG-")
}
} }
val DemoEventFlow = flow<DemoEventStep, DemoEventAcc>(start = DemoEventStep.ZnsCheck) { val DemoEventFlow = flow<DemoEventStep, DemoEventAcc>(start = DemoEventStep.ZnsCheck) {

View File

@ -1,6 +1,9 @@
package at.mocode.frontend.core.wizard.ui package at.mocode.frontend.core.wizard.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.*
/** /**
* Desktop-spezifische Variante. Hotkeys werden in einem FolgeInkrement ergänzt, * Desktop-spezifische Variante. Hotkeys werden in einem FolgeInkrement ergänzt,
@ -19,6 +22,29 @@ fun WizardScaffoldWithHotkeys(
backLabel: String = "Zurück", backLabel: String = "Zurück",
finishLabel: String = "Fertig", finishLabel: String = "Fertig",
content: @Composable () -> Unit content: @Composable () -> Unit
) {
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( WizardScaffold(
steps = steps, steps = steps,
@ -34,3 +60,4 @@ fun WizardScaffoldWithHotkeys(
content = content content = content
) )
} }
}

View File

@ -3,6 +3,7 @@ package at.mocode.frontend.core.wizard
import at.mocode.frontend.core.domain.repository.MasterdataStats import at.mocode.frontend.core.domain.repository.MasterdataStats
import at.mocode.frontend.core.navigation.AppScreen import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.wizard.runtime.WizardContext 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.DemoEventFlow
import at.mocode.frontend.core.wizard.samples.DemoEventStep import at.mocode.frontend.core.wizard.samples.DemoEventStep
import at.mocode.frontend.core.wizard.samples.demoStartState import at.mocode.frontend.core.wizard.samples.demoStartState
@ -47,4 +48,47 @@ class WizardRuntimeJvmTest {
val s2 = DemoEventFlow.back(s1) val s2 = DemoEventFlow.back(s1)
assertEquals(DemoEventStep.ZnsCheck, s2.current) 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)
}
} }

View File

@ -42,7 +42,7 @@ fun EventWizardScreen(
if (state.currentStep == WizardStep.ZNS_CHECK) onBack() else viewModel.previousStep() if (state.currentStep == WizardStep.ZNS_CHECK) onBack() else viewModel.previousStep()
}, },
onNext = { viewModel.nextStep() }, onNext = { viewModel.nextStep() },
onSaveDraft = null, // Wird in einem Folge-Inkrement angebunden onSaveDraft = { viewModel.saveDraft() },
onFinish = onFinish, onFinish = onFinish,
onNavigateToVeranstalterNeu = onNavigateToVeranstalterNeu, onNavigateToVeranstalterNeu = onNavigateToVeranstalterNeu,
renderStep = { renderStep = {

View File

@ -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.domain.zns.ZnsRemoteVerein
import at.mocode.frontend.core.navigation.AppScreen import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.network.NetworkConfig 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.WizardContext
import at.mocode.frontend.core.wizard.runtime.WizardState import at.mocode.frontend.core.wizard.runtime.WizardState
import at.mocode.frontend.core.wizard.samples.DemoEventAcc import at.mocode.frontend.core.wizard.samples.DemoEventAcc
@ -87,6 +88,8 @@ class EventWizardViewModel(
// --- Orchestrator-Integration (minimal, 2 Steps) --- // --- Orchestrator-Integration (minimal, 2 Steps) ---
private var wizardState: WizardState<DemoEventStep, DemoEventAcc>? = null private var wizardState: WizardState<DemoEventStep, DemoEventAcc>? = null
private val draftFlowKey = "event_wizard_v1"
private val draftFlowVersion = 1
init { init {
checkZnsAvailability() checkZnsAvailability()
@ -100,10 +103,21 @@ class EventWizardViewModel(
// Initialisiere WizardRuntime-State (hinter Feature-Flag nutzbar) // Initialisiere WizardRuntime-State (hinter Feature-Flag nutzbar)
if (WizardFeatureFlags.WizardRuntimeEnabled) { if (WizardFeatureFlags.WizardRuntimeEnabled) {
// 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") @Suppress("UNCHECKED_CAST")
wizardState = WizardState(current = (DemoEventStep.ZnsCheck as DemoEventStep), acc = DemoEventAcc()) wizardState = WizardState(current = (DemoEventStep.ZnsCheck as DemoEventStep), acc = DemoEventAcc())
} }
} }
}
private fun loadVeranstalterContext(id: Long) { private fun loadVeranstalterContext(id: Long) {
viewModelScope.launch { viewModelScope.launch {
@ -192,6 +206,7 @@ class EventWizardViewModel(
val mapped = mapToWizardStep(next.current) val mapped = mapToWizardStep(next.current)
if (mapped != null) { if (mapped != null) {
state = state.copy(currentStep = mapped) state = state.copy(currentStep = mapped)
saveDraftInternal(mapped)
return return
} }
// Fallback, sollte eigentlich nicht eintreten // Fallback, sollte eigentlich nicht eintreten
@ -215,6 +230,7 @@ class EventWizardViewModel(
val mapped = mapToWizardStep(prev.current) val mapped = mapToWizardStep(prev.current)
if (mapped != null) { if (mapped != null) {
state = state.copy(currentStep = mapped) state = state.copy(currentStep = mapped)
saveDraftInternal(mapped)
return return
} }
} }
@ -242,6 +258,16 @@ class EventWizardViewModel(
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val initial = WizardState(current = (DemoEventStep.ZnsCheck as DemoEventStep), acc = DemoEventAcc()) val initial = WizardState(current = (DemoEventStep.ZnsCheck as DemoEventStep), acc = DemoEventAcc())
wizardState = initial 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 return initial
} }
@ -260,6 +286,30 @@ class EventWizardViewModel(
DemoEventStep.MetaData -> WizardStep.META_DATA 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?) { fun setVeranstalter(id: Uuid, nummer: String, name: String, standardOrt: String, logo: String?) {
state = state.copy( state = state.copy(
veranstalterId = id, veranstalterId = id,