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
@@ -49,25 +49,40 @@ fun DesktopApp() {
// Login-Gate: Nicht-authentifizierte Screens → Login, außer DeviceInitialization ist erlaubt
// Vision_03 Update: Wir starten mit DeviceInitialization
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.DeviceInitialization
&& currentScreen !is AppScreen.EventVerwaltung
&& currentScreen !is AppScreen.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu
&& currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.EventKonfig
&& currentScreen !is AppScreen.EventProfil && currentScreen !is AppScreen.TurnierDetail
&& currentScreen !is AppScreen.TurnierNeu
&& currentScreen !is AppScreen.ReiterVerwaltung && currentScreen !is AppScreen.Reiter
&& currentScreen !is AppScreen.PferdVerwaltung && currentScreen !is AppScreen.Pferde
&& currentScreen !is AppScreen.VereinVerwaltung && currentScreen !is AppScreen.Vereine
&& currentScreen !is AppScreen.FunktionaerVerwaltung && currentScreen !is AppScreen.FunktionaerProfil
&& currentScreen !is AppScreen.ReiterProfil
&& currentScreen !is AppScreen.PferdProfil
&& currentScreen !is AppScreen.VereinProfil
&& currentScreen !is AppScreen.StammdatenImport
&& currentScreen !is AppScreen.NennungsEingang
&& currentScreen !is AppScreen.EventNeu
&& currentScreen !is AppScreen.ConnectivityCheck
&& currentScreen !is AppScreen.Dashboard
) {
val isAllowedScreen = currentScreen is AppScreen.Login ||
currentScreen is AppScreen.DeviceInitialization ||
currentScreen is AppScreen.EventVerwaltung ||
currentScreen is AppScreen.VeranstalterAuswahl ||
currentScreen is AppScreen.VeranstalterNeu ||
currentScreen is AppScreen.VeranstalterVerwaltung ||
currentScreen is AppScreen.VeranstalterDetail ||
currentScreen is AppScreen.VeranstalterProfil ||
currentScreen is AppScreen.VeranstalterProfilEdit ||
currentScreen is AppScreen.EventKonfig ||
currentScreen is AppScreen.EventProfil ||
currentScreen is AppScreen.EventDetail ||
currentScreen is AppScreen.EventNeu ||
currentScreen is AppScreen.TurnierDetail ||
currentScreen is AppScreen.TurnierNeu ||
currentScreen is AppScreen.ReiterVerwaltung ||
currentScreen is AppScreen.Reiter ||
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) {
if (!DeviceInitializationSettingsManager.isConfigured()) {
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)
LaunchedEffect(currentScreen) {
LaunchedEffect(onboardingSettings.isConfigured, currentScreen) {
if (!onboardingSettings.isConfigured && currentScreen !is AppScreen.DeviceInitialization) {
println("[DesktopNav] Setup fehlt -> Umleitung zum 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
is AppScreen.EventVerwaltung -> {
VeranstaltungenScreen(
onVeranstaltungNeu = { onNavigate(AppScreen.EventNeu) },
onVeranstaltungNeu = { onNavigate(AppScreen.EventNeu()) },
onVeranstaltungOeffnen = { vId: Long, eId: Long -> onNavigate(AppScreen.EventProfil(vId, eId)) }
)
}
@@ -177,7 +177,7 @@ fun DesktopContentArea(
veranstalterId = currentScreen.id,
onBack = onBack,
onZurVeranstaltung = { evtId: Long -> onNavigate(AppScreen.EventProfil(currentScreen.id, evtId)) },
onNeuVeranstaltung = { onNavigate(AppScreen.EventNeu) },
onNeuVeranstaltung = { onNavigate(AppScreen.EventKonfig(currentScreen.id)) },
onEditVeranstalter = { id ->
onNavigate(AppScreen.VeranstalterProfilEdit(id))
}
@@ -194,7 +194,7 @@ fun DesktopContentArea(
// Neuer Flow: Veranstalter auswählen → Event-Wizard
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahl(
onBack = onBack,
onWeiter = { _ -> onNavigate(AppScreen.EventNeu) },
onWeiter = { _ -> onNavigate(AppScreen.EventNeu()) },
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
)
@@ -1,12 +1,11 @@
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.lazy.LazyColumn
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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@@ -15,15 +14,30 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 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.ZnsImportState
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterWizardIntent
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterWizardViewModel
import at.mocode.frontend.shell.desktop.data.Store
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.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)
@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
fun Step2VeranstalterDetails(viewModel: VeranstalterWizardViewModel) {
val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Ergänzen Sie die Kontaktdaten für diesen Veranstalter.", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = state.name,
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateName(it)) },
label = { Text("Name des Veranstalters / Vereins") },
modifier = Modifier.fillMaxWidth()
// --- VORSCHAU ---
VeranstalterCardPreview(
name = state.name,
ort = state.ort,
oepsNummer = state.oepsNummer,
ansprechpartner = state.ansprechpartner,
email = state.email,
logoBase64 = state.logoBase64,
status = state.loginStatus
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
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)
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = state.name,
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateName(it)) },
label = { Text("Name des Veranstalters / Vereins") },
modifier = Modifier.fillMaxWidth()
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
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()