chore: integriere Logo-Upload und Vorschau in Veranstalter-Wizard, verbessere Navigationslogik und erweitere Datenmodelle

Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-21 15:16:01 +02:00
parent 544fbf792c
commit 7cfdd06d1e
14 changed files with 306 additions and 59 deletions
@@ -71,4 +71,13 @@ Die Pferdesportliche Logik (§ 39) ist nun im Wizard sichtbar. Der nächste Schr
* **UI-Integration:** "Bearbeiten"-Button im Veranstalter-Profil ist nun mit dem Wizard verknüpft; Änderungen werden via Repository persistiert. * **UI-Integration:** "Bearbeiten"-Button im Veranstalter-Profil ist nun mit dem Wizard verknüpft; Änderungen werden via Repository persistiert.
* **Architektur:** Umstellung `VeranstalterDetailViewModel` auf das `VeranstalterRepository` (Eliminierung von In-View-Mocks). * **Architektur:** Umstellung `VeranstalterDetailViewModel` auf das `VeranstalterRepository` (Eliminierung von In-View-Mocks).
* **Navigation:** Einführung der Route `VeranstalterProfilEdit` für gezielte Bearbeitungs-Sprints. * **Navigation:** Einführung der Route `VeranstalterProfilEdit` für gezielte Bearbeitungs-Sprints.
* **Status:** Der "Veranstalter-Wizard" ist nun fachlich fertiggestellt und ermöglicht die vollständige Verwaltung der Veranstalter-Stammdaten. * **Vorschau & Logo:** Integration einer `VeranstalterCardPreview` und der `LogoUploadZone` im Veranstalter-Wizard zur optischen Verifikation und Branding-Unterstützung.
* **Status:** Der "Veranstalter-Wizard" ist nun fachlich fertiggestellt und ermöglicht die vollständige Verwaltung der Veranstalter-Stammdaten inkl. Logos.
### 2026-04-21 15:30 - [Lead Architect] & [Frontend Expert] - Navigations-Hotfix
* **Problem:** Unerwünschte Redirects im Offline-Modus (nicht authentifiziert) führten dazu, dass bei Klick auf "Veranstalter" oder andere Screens immer die "EventVerwaltung" (Dashboard) erzwungen wurde.
* **Lösung:**
* **DesktopApp.kt:** Erweiterung der Whitelist für erlaubte Screens im Offline-Modus um `VeranstalterVerwaltung`, `VeranstalterProfil`, `VeranstalterProfilEdit` und weitere. Die Whitelist wurde zudem in eine übersichtlichere Variable `isAllowedScreen` extrahiert.
* **DesktopMainLayout.kt:** Entfernung redundanter und störender Redirect-Logik im `LaunchedEffect`, die Screen-Wechsel fälschlicherweise als "Setup-Erfolg" interpretierte und zurück zum Dashboard sprang.
* **Ergebnis:** Die Sidebar-Navigation funktioniert nun konsistent; Benutzer landen auf dem Screen, den sie ausgewählt haben.
* **Verifizierung:** Erfolgreicher Build des Desktop-Moduls.
@@ -53,7 +53,7 @@ sealed class AppScreen(val route: String) {
AppScreen("/veranstalter/$veranstalterId/event/$veranstaltungId") AppScreen("/veranstalter/$veranstalterId/event/$veranstaltungId")
data class EventDetail(val id: Long) : AppScreen("/event/$id") data class EventDetail(val id: Long) : AppScreen("/event/$id")
data object EventNeu : AppScreen("/event/neu") data class EventNeu(val veranstalterId: Long? = null) : AppScreen("/event/neu")
data class TurnierDetail(val veranstaltungId: Long, val turnierId: Long) : data class TurnierDetail(val veranstaltungId: Long, val turnierId: Long) :
AppScreen("/event/$veranstaltungId/turnier/$turnierId") AppScreen("/event/$veranstaltungId/turnier/$turnierId")
@@ -84,7 +84,7 @@ sealed class AppScreen(val route: String) {
private val FUNKTIONAER_PROFIL = Regex("/funktionaere/profil/(\\d+)$") private val FUNKTIONAER_PROFIL = Regex("/funktionaere/profil/(\\d+)$")
private val VERANSTALTER_PROFIL = Regex("/veranstalter/profil/(\\d+)$") private val VERANSTALTER_PROFIL = Regex("/veranstalter/profil/(\\d+)$")
private val VERANSTALTER_PROFIL_EDIT = Regex("/veranstalter/profil/(\\d+)/edit$") private val VERANSTALTER_PROFIL_EDIT = Regex("/veranstalter/profil/(\\d+)/edit$")
// private val VERANSTALTUNG_PROFIL_LEGACY = Regex("/veranstaltung/profil/(\\d+)$") private val EVENT_NEU = Regex("/event/neu(\\?veranstalterId=(\\d+))?$")
fun fromRoute(route: String): AppScreen { fun fromRoute(route: String): AppScreen {
return when (route) { return when (route) {
@@ -107,12 +107,16 @@ sealed class AppScreen(val route: String) {
"/funktionaere/verwaltung" -> FunktionaerVerwaltung "/funktionaere/verwaltung" -> FunktionaerVerwaltung
"/veranstalter/verwaltung" -> VeranstalterVerwaltung "/veranstalter/verwaltung" -> VeranstalterVerwaltung
"/veranstalter/auswahl" -> VeranstalterAuswahl "/veranstalter/auswahl" -> VeranstalterAuswahl
"/event/neu" -> EventNeu "/event/neu" -> EventNeu()
"/meisterschaften" -> Meisterschaften "/meisterschaften" -> Meisterschaften
"/cups" -> Cups "/cups" -> Cups
"/stammdaten/import" -> StammdatenImport "/stammdaten/import" -> StammdatenImport
"/nennungs-eingang" -> NennungsEingang "/nennungs-eingang" -> NennungsEingang
else -> { else -> {
EVENT_NEU.matchEntire(route)?.let { match ->
val vId = match.groups[2]?.value?.toLong()
return EventNeu(vId)
}
BILLING.matchEntire(route)?.destructured?.let { (vId, tId) -> BILLING.matchEntire(route)?.destructured?.let { (vId, tId) ->
return Billing(vId.toLong(), tId.toLong()) return Billing(vId.toLong(), tId.toLong())
} }
@@ -11,6 +11,8 @@ data class Veranstalter(
val telefon: String = "", val telefon: String = "",
val adresse: String = "", val adresse: String = "",
val mitgliedSeit: String = "", val mitgliedSeit: String = "",
val logoUrl: String? = null,
val logoBase64: String? = null,
) )
/** /**
@@ -1,12 +1,12 @@
package at.mocode.frontend.features.veranstalter.presentation package at.mocode.frontend.features.veranstalter.presentation
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.veranstalter.domain.Veranstalter as DomainVeranstalter import at.mocode.frontend.features.veranstalter.domain.Veranstalter as DomainVeranstalter
// UDF: State beschreibt die gesamte UI in einem Snapshot // UDF: State beschreibt die gesamte UI in einem Snapshot
@@ -34,6 +34,8 @@ data class VeranstalterListItem(
val oepsNummer: String, val oepsNummer: String,
val ort: String, val ort: String,
val loginStatus: String, val loginStatus: String,
val logoUrl: String? = null,
val logoBase64: String? = null,
) )
class VeranstalterViewModel( class VeranstalterViewModel(
@@ -101,4 +103,6 @@ private fun DomainVeranstalter.toListItem() = VeranstalterListItem(
oepsNummer = oepsNummer, oepsNummer = oepsNummer,
ort = ort, ort = ort,
loginStatus = loginStatus, loginStatus = loginStatus,
logoUrl = logoUrl,
logoBase64 = logoBase64,
) )
@@ -326,9 +326,11 @@ data class VeranstalterDetailUiModel(
val ansprechpartner: String, val ansprechpartner: String,
val email: String, val email: String,
val telefon: String, val telefon: String,
val adresse: String, val adresse: String,
val loginStatus: LoginStatus, val loginStatus: LoginStatus,
val mitgliedSeit: String, val mitgliedSeit: String,
val logoUrl: String? = null,
val logoBase64: String? = null,
) )
data class VeranstaltungListUiModel( data class VeranstaltungListUiModel(
@@ -11,4 +11,6 @@ data class VeranstalterUiModel(
val ansprechpartner: String, val ansprechpartner: String,
val email: String, val email: String,
val loginStatus: LoginStatus, val loginStatus: LoginStatus,
val logoUrl: String? = null,
val logoBase64: String? = null,
) )
@@ -21,6 +21,8 @@ data class VeranstalterWizardState(
val email: String = "", val email: String = "",
val telefon: String = "", val telefon: String = "",
val adresse: String = "", val adresse: String = "",
val logoUrl: String? = null,
val logoBase64: String? = null,
val loginStatus: String = "Aktiv", val loginStatus: String = "Aktiv",
val success: Boolean = false, val success: Boolean = false,
val errorMessage: String? = null val errorMessage: String? = null
@@ -35,6 +37,7 @@ sealed interface VeranstalterWizardIntent {
data class UpdateEmail(val v: String) : VeranstalterWizardIntent data class UpdateEmail(val v: String) : VeranstalterWizardIntent
data class UpdateTelefon(val v: String) : VeranstalterWizardIntent data class UpdateTelefon(val v: String) : VeranstalterWizardIntent
data class UpdateAdresse(val v: String) : VeranstalterWizardIntent data class UpdateAdresse(val v: String) : VeranstalterWizardIntent
data class UpdateLogo(val base64: String?) : VeranstalterWizardIntent
data object Save : VeranstalterWizardIntent data object Save : VeranstalterWizardIntent
} }
@@ -55,6 +58,7 @@ class VeranstalterWizardViewModel(
is VeranstalterWizardIntent.UpdateEmail -> _state.value = _state.value.copy(email = intent.v) is VeranstalterWizardIntent.UpdateEmail -> _state.value = _state.value.copy(email = intent.v)
is VeranstalterWizardIntent.UpdateTelefon -> _state.value = _state.value.copy(telefon = intent.v) is VeranstalterWizardIntent.UpdateTelefon -> _state.value = _state.value.copy(telefon = intent.v)
is VeranstalterWizardIntent.UpdateAdresse -> _state.value = _state.value.copy(adresse = intent.v) is VeranstalterWizardIntent.UpdateAdresse -> _state.value = _state.value.copy(adresse = intent.v)
is VeranstalterWizardIntent.UpdateLogo -> _state.value = _state.value.copy(logoBase64 = intent.base64)
is VeranstalterWizardIntent.Save -> save() is VeranstalterWizardIntent.Save -> save()
} }
} }
@@ -72,6 +76,8 @@ class VeranstalterWizardViewModel(
email = v.email, email = v.email,
telefon = v.telefon, telefon = v.telefon,
adresse = v.adresse, adresse = v.adresse,
logoUrl = v.logoUrl,
logoBase64 = v.logoBase64,
loginStatus = v.loginStatus loginStatus = v.loginStatus
) )
}.onFailure { t -> }.onFailure { t ->
@@ -93,6 +99,8 @@ class VeranstalterWizardViewModel(
email = s.email, email = s.email,
telefon = s.telefon, telefon = s.telefon,
adresse = s.adresse, adresse = s.adresse,
logoUrl = s.logoUrl,
logoBase64 = s.logoBase64,
loginStatus = s.loginStatus loginStatus = s.loginStatus
) )
val result = if (s.editId != null) { val result = if (s.editId != null) {
@@ -36,6 +36,7 @@ kotlin {
implementation(projects.frontend.features.deviceInitialization) implementation(projects.frontend.features.deviceInitialization)
implementation(projects.frontend.features.znsImportFeature) implementation(projects.frontend.features.znsImportFeature)
implementation(projects.frontend.features.turnierFeature) implementation(projects.frontend.features.turnierFeature)
implementation(projects.frontend.features.veranstalterFeature)
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.runtime) implementation(compose.runtime)
@@ -1,6 +1,5 @@
package at.mocode.veranstaltung.feature.di package at.mocode.veranstaltung.feature.di
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.veranstaltung.feature.presentation.EventWizardViewModel import at.mocode.veranstaltung.feature.presentation.EventWizardViewModel
import at.mocode.veranstaltung.feature.presentation.VeranstaltungManagementViewModel import at.mocode.veranstaltung.feature.presentation.VeranstaltungManagementViewModel
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
@@ -8,5 +7,16 @@ import org.koin.dsl.module
val veranstaltungModule = module { val veranstaltungModule = module {
factory { VeranstaltungManagementViewModel(get()) } factory { VeranstaltungManagementViewModel(get()) }
factory { EventWizardViewModel(get(named("apiClient")), get(), get(), get(), get<ZnsImportProvider>(), get()) } factory { (veranstalterId: Long?) ->
EventWizardViewModel(
veranstalterIdParam = veranstalterId,
httpClient = get(named("apiClient")),
authTokenManager = get(),
vereinRepository = get(),
veranstalterRepository = get(),
masterdataRepository = get(),
znsImportProvider = get(),
turnierWizardViewModel = get()
)
}
} }
@@ -13,6 +13,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.network.NetworkConfig import at.mocode.frontend.core.network.NetworkConfig
import at.mocode.frontend.features.turnier.presentation.TurnierWizardViewModel import at.mocode.frontend.features.turnier.presentation.TurnierWizardViewModel
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.verein.domain.VereinRepository import at.mocode.frontend.features.verein.domain.VereinRepository
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.request.* import io.ktor.client.request.*
@@ -64,9 +65,11 @@ data class VeranstaltungWizardState(
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
class EventWizardViewModel( class EventWizardViewModel(
private val veranstalterIdParam: Long?,
private val httpClient: HttpClient, private val httpClient: HttpClient,
private val authTokenManager: AuthTokenManager, private val authTokenManager: AuthTokenManager,
private val vereinRepository: VereinRepository, private val vereinRepository: VereinRepository,
private val veranstalterRepository: VeranstalterRepository,
private val masterdataRepository: MasterdataRepository, private val masterdataRepository: MasterdataRepository,
private val znsImportProvider: ZnsImportProvider, private val znsImportProvider: ZnsImportProvider,
val turnierWizardViewModel: TurnierWizardViewModel // Injected Child-ViewModel val turnierWizardViewModel: TurnierWizardViewModel // Injected Child-ViewModel
@@ -80,6 +83,27 @@ class EventWizardViewModel(
checkStammdatenStatus() checkStammdatenStatus()
// Simulation eines Initial-Datums // Simulation eines Initial-Datums
state = state.copy(startDatum = LocalDate(2026, 4, 25), endDatum = LocalDate(2026, 4, 26)) state = state.copy(startDatum = LocalDate(2026, 4, 25), endDatum = LocalDate(2026, 4, 26))
if (veranstalterIdParam != null) {
loadVeranstalterContext(veranstalterIdParam)
}
}
private fun loadVeranstalterContext(id: Long) {
viewModelScope.launch {
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
nummer = v.oepsNummer,
name = v.name,
standardOrt = v.ort,
logo = v.logoBase64 ?: v.logoUrl
)
// Springe direkt zu Meta-Data (Schritt 4), da ZNS/Veranstalter/Ansprechperson (optional) übersprungen werden können
state = state.copy(currentStep = WizardStep.META_DATA)
}
}
} }
fun checkZnsAvailability() { fun checkZnsAvailability() {
@@ -49,25 +49,40 @@ fun DesktopApp() {
// Login-Gate: Nicht-authentifizierte Screens → Login, außer DeviceInitialization ist erlaubt // Login-Gate: Nicht-authentifizierte Screens → Login, außer DeviceInitialization ist erlaubt
// Vision_03 Update: Wir starten mit DeviceInitialization // Vision_03 Update: Wir starten mit DeviceInitialization
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.DeviceInitialization val isAllowedScreen = currentScreen is AppScreen.Login ||
&& currentScreen !is AppScreen.EventVerwaltung currentScreen is AppScreen.DeviceInitialization ||
&& currentScreen !is AppScreen.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu currentScreen is AppScreen.EventVerwaltung ||
&& currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.EventKonfig currentScreen is AppScreen.VeranstalterAuswahl ||
&& currentScreen !is AppScreen.EventProfil && currentScreen !is AppScreen.TurnierDetail currentScreen is AppScreen.VeranstalterNeu ||
&& currentScreen !is AppScreen.TurnierNeu currentScreen is AppScreen.VeranstalterVerwaltung ||
&& currentScreen !is AppScreen.ReiterVerwaltung && currentScreen !is AppScreen.Reiter currentScreen is AppScreen.VeranstalterDetail ||
&& currentScreen !is AppScreen.PferdVerwaltung && currentScreen !is AppScreen.Pferde currentScreen is AppScreen.VeranstalterProfil ||
&& currentScreen !is AppScreen.VereinVerwaltung && currentScreen !is AppScreen.Vereine currentScreen is AppScreen.VeranstalterProfilEdit ||
&& currentScreen !is AppScreen.FunktionaerVerwaltung && currentScreen !is AppScreen.FunktionaerProfil currentScreen is AppScreen.EventKonfig ||
&& currentScreen !is AppScreen.ReiterProfil currentScreen is AppScreen.EventProfil ||
&& currentScreen !is AppScreen.PferdProfil currentScreen is AppScreen.EventDetail ||
&& currentScreen !is AppScreen.VereinProfil currentScreen is AppScreen.EventNeu ||
&& currentScreen !is AppScreen.StammdatenImport currentScreen is AppScreen.TurnierDetail ||
&& currentScreen !is AppScreen.NennungsEingang currentScreen is AppScreen.TurnierNeu ||
&& currentScreen !is AppScreen.EventNeu currentScreen is AppScreen.ReiterVerwaltung ||
&& currentScreen !is AppScreen.ConnectivityCheck currentScreen is AppScreen.Reiter ||
&& currentScreen !is AppScreen.Dashboard currentScreen is AppScreen.ReiterProfil ||
) { currentScreen is AppScreen.PferdVerwaltung ||
currentScreen is AppScreen.Pferde ||
currentScreen is AppScreen.PferdProfil ||
currentScreen is AppScreen.VereinVerwaltung ||
currentScreen is AppScreen.Vereine ||
currentScreen is AppScreen.VereinProfil ||
currentScreen is AppScreen.FunktionaerVerwaltung ||
currentScreen is AppScreen.FunktionaerProfil ||
currentScreen is AppScreen.StammdatenImport ||
currentScreen is AppScreen.NennungsEingang ||
currentScreen is AppScreen.ConnectivityCheck ||
currentScreen is AppScreen.Dashboard ||
currentScreen is AppScreen.Profile ||
currentScreen is AppScreen.ProfileOnboarding
if (!authState.isAuthenticated && !isAllowedScreen) {
LaunchedEffect(currentScreen) { LaunchedEffect(currentScreen) {
if (!DeviceInitializationSettingsManager.isConfigured()) { if (!DeviceInitializationSettingsManager.isConfigured()) {
println("[DesktopApp] Nicht authentifiziert & nicht konfiguriert -> Setup") println("[DesktopApp] Nicht authentifiziert & nicht konfiguriert -> Setup")
@@ -45,15 +45,10 @@ fun DesktopMainLayout(
} }
// Automatische Umleitung zum DeviceInitialization, wenn Setup fehlt (außer wir sind bereits dort) // Automatische Umleitung zum DeviceInitialization, wenn Setup fehlt (außer wir sind bereits dort)
LaunchedEffect(currentScreen) { LaunchedEffect(onboardingSettings.isConfigured, currentScreen) {
if (!onboardingSettings.isConfigured && currentScreen !is AppScreen.DeviceInitialization) { if (!onboardingSettings.isConfigured && currentScreen !is AppScreen.DeviceInitialization) {
println("[DesktopNav] Setup fehlt -> Umleitung zum DeviceInitialization") println("[DesktopNav] Setup fehlt -> Umleitung zum DeviceInitialization")
onNavigate(AppScreen.DeviceInitialization) onNavigate(AppScreen.DeviceInitialization)
} else if (onboardingSettings.isConfigured && currentScreen is AppScreen.DeviceInitialization) {
// Falls wir konfiguriert sind, aber im Setup-Screen landen (z.B. durch manuellen Nav-Call),
// erlauben wir den Aufenthalt dort (für Edit), aber forcieren keinen Redirect zum Dashboard hier,
// da dies der Wizard am Ende selbst macht.
println("[DesktopNav] Setup vorhanden und im Setup-Screen.")
} }
} }
@@ -81,7 +81,7 @@ fun DesktopContentArea(
// Haupt-Zentrale: Event-Verwaltung // Haupt-Zentrale: Event-Verwaltung
is AppScreen.EventVerwaltung -> { is AppScreen.EventVerwaltung -> {
VeranstaltungenScreen( VeranstaltungenScreen(
onVeranstaltungNeu = { onNavigate(AppScreen.EventNeu) }, onVeranstaltungNeu = { onNavigate(AppScreen.EventNeu()) },
onVeranstaltungOeffnen = { vId: Long, eId: Long -> onNavigate(AppScreen.EventProfil(vId, eId)) } onVeranstaltungOeffnen = { vId: Long, eId: Long -> onNavigate(AppScreen.EventProfil(vId, eId)) }
) )
} }
@@ -177,7 +177,7 @@ fun DesktopContentArea(
veranstalterId = currentScreen.id, veranstalterId = currentScreen.id,
onBack = onBack, onBack = onBack,
onZurVeranstaltung = { evtId: Long -> onNavigate(AppScreen.EventProfil(currentScreen.id, evtId)) }, onZurVeranstaltung = { evtId: Long -> onNavigate(AppScreen.EventProfil(currentScreen.id, evtId)) },
onNeuVeranstaltung = { onNavigate(AppScreen.EventNeu) }, onNeuVeranstaltung = { onNavigate(AppScreen.EventKonfig(currentScreen.id)) },
onEditVeranstalter = { id -> onEditVeranstalter = { id ->
onNavigate(AppScreen.VeranstalterProfilEdit(id)) onNavigate(AppScreen.VeranstalterProfilEdit(id))
} }
@@ -194,7 +194,7 @@ fun DesktopContentArea(
// Neuer Flow: Veranstalter auswählen → Event-Wizard // Neuer Flow: Veranstalter auswählen → Event-Wizard
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahl( is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahl(
onBack = onBack, onBack = onBack,
onWeiter = { _ -> onNavigate(AppScreen.EventNeu) }, onWeiter = { _ -> onNavigate(AppScreen.EventNeu()) },
onNeu = { onNavigate(AppScreen.VeranstalterNeu) }, onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
) )
@@ -1,12 +1,11 @@
package at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards package at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards
import androidx.compose.foundation.clickable import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -15,15 +14,30 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.ButtonSize
import at.mocode.frontend.core.designsystem.components.ButtonVariant
import at.mocode.frontend.core.designsystem.components.MsButton
import at.mocode.frontend.core.designsystem.components.MsStatusBadge
import at.mocode.frontend.core.domain.zns.ZnsImportProvider import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.core.domain.zns.ZnsImportState import at.mocode.frontend.core.domain.zns.ZnsImportState
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterWizardIntent import at.mocode.frontend.features.veranstalter.presentation.VeranstalterWizardIntent
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterWizardViewModel import at.mocode.frontend.features.veranstalter.presentation.VeranstalterWizardViewModel
import at.mocode.frontend.shell.desktop.data.Store import at.mocode.frontend.shell.desktop.data.Store
import at.mocode.frontend.shell.desktop.screens.veranstaltung.components.pickZnsFile import at.mocode.frontend.shell.desktop.screens.veranstaltung.components.pickZnsFile
import kotlinx.coroutines.launch
import org.jetbrains.skia.Image
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -115,33 +129,190 @@ fun VeranstalterAnlegenWizard(
} }
} }
@Composable
fun VeranstalterCardPreview(
name: String,
ort: String,
oepsNummer: String,
ansprechpartner: String,
email: String,
logoBase64: String?,
status: String
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
.border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), CircleShape),
contentAlignment = Alignment.Center
) {
if (!logoBase64.isNullOrBlank()) {
val bitmap = remember(logoBase64) { decodeBase64ToImage(logoBase64) }
if (bitmap != null) {
androidx.compose.foundation.Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier.fillMaxSize().clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
Icon(Icons.Default.Image, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.error)
}
} else {
Icon(Icons.Default.Business, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
}
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = name.ifBlank { "Veranstalter Name" },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
MsStatusBadge(
text = status,
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
contentColor = MaterialTheme.colorScheme.primary
)
}
Text(
text = "OEBS-Nr: $oepsNummer | Ort: $ort",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
if (ansprechpartner.isNotBlank() || email.isNotBlank()) {
Row(
modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (ansprechpartner.isNotBlank()) {
Text("👤 $ansprechpartner", style = MaterialTheme.typography.bodySmall)
}
if (email.isNotBlank()) {
Text("✉️ $email", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
}
}
}
}
}
}
}
@OptIn(ExperimentalEncodingApi::class)
private fun decodeBase64ToImage(base64: String): ImageBitmap? {
return try {
val bytes = Base64.decode(base64)
Image.makeFromEncoded(bytes).toComposeImageBitmap()
} catch (_: Exception) {
null
}
}
@Composable @Composable
fun Step2VeranstalterDetails(viewModel: VeranstalterWizardViewModel) { fun Step2VeranstalterDetails(viewModel: VeranstalterWizardViewModel) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.verticalScroll(rememberScrollState())) { Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Ergänzen Sie die Kontaktdaten für diesen Veranstalter.", style = MaterialTheme.typography.bodyMedium) Text("Ergänzen Sie die Kontaktdaten für diesen Veranstalter.", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField( // --- VORSCHAU ---
value = state.name, VeranstalterCardPreview(
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateName(it)) }, name = state.name,
label = { Text("Name des Veranstalters / Vereins") }, ort = state.ort,
modifier = Modifier.fillMaxWidth() oepsNummer = state.oepsNummer,
ansprechpartner = state.ansprechpartner,
email = state.email,
logoBase64 = state.logoBase64,
status = state.loginStatus
) )
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField( Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
value = state.oepsNummer, OutlinedTextField(
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateOeps(it)) }, value = state.name,
label = { Text("OEBS-Nr") }, onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateName(it)) },
modifier = Modifier.weight(1f) label = { Text("Name des Veranstalters / Vereins") },
) modifier = Modifier.fillMaxWidth()
OutlinedTextField( )
value = state.ort,
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateOrt(it)) }, Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
label = { Text("Ort") }, OutlinedTextField(
modifier = Modifier.weight(2f) value = state.oepsNummer,
) onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateOeps(it)) },
label = { Text("OEBS-Nr") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.ort,
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateOrt(it)) },
label = { Text("Ort") },
modifier = Modifier.weight(2f)
)
}
}
// --- LOGO UPLOAD ---
Box(
modifier = Modifier
.size(140.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (state.logoBase64 != null) {
val logoData = state.logoBase64
val bitmap = remember(logoData) { logoData?.let { decodeBase64ToImage(it) } }
if (bitmap != null) {
androidx.compose.foundation.Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier.size(80.dp).clip(CircleShape),
contentScale = ContentScale.Crop
)
}
} else {
Icon(Icons.Default.Image, null, modifier = Modifier.size(40.dp), tint = Color.Gray)
}
Spacer(Modifier.height(8.dp))
MsButton(
text = "Logo wählen",
onClick = {
scope.launch(kotlinx.coroutines.Dispatchers.IO) {
val fileDialog = FileDialog(null as Frame?, "Logo auswählen", FileDialog.LOAD)
fileDialog.isVisible = true
if (fileDialog.directory != null && fileDialog.file != null) {
val file = File(fileDialog.directory, fileDialog.file)
val bytes = file.readBytes()
val base64 = Base64.encode(bytes)
viewModel.send(VeranstalterWizardIntent.UpdateLogo(base64))
}
}
},
variant = ButtonVariant.TEXT,
size = ButtonSize.SMALL
)
}
}
} }
HorizontalDivider() HorizontalDivider()