diff --git a/docs/99_Journal/2026-04-20_Desktop_UX_Navigation_Refinement.md b/docs/99_Journal/2026-04-20_Desktop_UX_Navigation_Refinement.md new file mode 100644 index 00000000..44ead21b --- /dev/null +++ b/docs/99_Journal/2026-04-20_Desktop_UX_Navigation_Refinement.md @@ -0,0 +1,25 @@ +# Journal: 20. April 2026 - Desktop UX & Navigation Refinement + +## 🏗️ Desktop-App: UX & Eingabe-Optimierung + +* **Tastatur-Navigation (Fokus-Flow):** + * **Device-Setup:** In `DeviceInitializationConfig.jvm.kt` wurde das Verhalten der **Enter-Taste** korrigiert. Sie führt nun konsistent zum nächsten Eingabefeld (Gerätename -> Schlüssel -> Pfad) oder schließt den Prozess ab, anstatt Zeilenumbrüche in einzeiligen Feldern zu erzeugen. + * **Veranstaltungs-Konfig:** Das Formular nutzt nun `MsTextField` mit dedizierten `KeyboardActions`. Der Fokus springt beim Drücken von **Enter** oder **Tab** logisch zum nächsten Feld. + +* **Neuer Date-Picker:** + * Implementierung einer kompakten, Desktop-optimierten Komponente `MsDatePickerField`. + * Ersetzt die manuellen Text-Eingabefelder für den Veranstaltungs-Zeitraum ("von" / "bis") durch einen visuellen Kalender-Dialog. + * Erhöht die Datenqualität durch standardisiertes Datumsformat (ISO 8601). + +## 🧭 Navigation & Stabilität + +* **Robuste Neuanlage:** + * Der direkte Aufruf von `VeranstaltungKonfig(veranstalterId=0)` aus der Gesamtübersicht wurde unterbunden. + * User werden nun zuerst zur **Veranstalter-Auswahl** geleitet, um eine valide Kontext-ID sicherzustellen. +* **Fehler-Handling:** + * Die `InvalidContextNotice` (Fehlermeldung bei ungültigen IDs) wurde verbessert. Der Button "Zur Auswahl" führt nun kontextsensitiv entweder zurück zur Veranstalter-Auswahl oder zum Veranstalter-Profil, anstatt den User im "Nichts" stehen zu lassen. +* **UI-Kompaktheit:** + * Alle Formularfelder in der Veranstaltungs-Konfiguration wurden auf den `compact`-Modus (44dp Höhe) umgestellt, um dem High-Density Standard des Projekts zu entsprechen. + +## 🧹 Curator Hinweis +Die gemeldeten UX-Blocker in der Geräte-Konfiguration und bei der Veranstaltungs-Neuanlage sind behoben. Der neue Date-Picker erfüllt den Wunsch nach einer komfortableren Datumsauswahl und verhindert Tippfehler im Zeitraum-Format. diff --git a/docs/99_Journal/2026-04-20_Vereins_Verwaltung_Logo_Adresse.md b/docs/99_Journal/2026-04-20_Vereins_Verwaltung_Logo_Adresse.md index 81fc3023..1e2aaa48 100644 --- a/docs/99_Journal/2026-04-20_Vereins_Verwaltung_Logo_Adresse.md +++ b/docs/99_Journal/2026-04-20_Vereins_Verwaltung_Logo_Adresse.md @@ -1,15 +1,25 @@ # Journal-Eintrag: Vereins-Verwaltung Erweiterung (Logo & Adresse) **Datum:** 20. April 2026 -**Status:** In Umsetzung / Teilweise abgeschlossen -**Beteiligte Agenten:** 🏗️ [Lead Architect], 🎨 [Frontend Expert], 🧹 [Curator] +**Status:** Abgeschlossen (Bugfix & Feature-Integration) +**Beteiligte Agenten:** 🏗️ [Lead Architect], 🎨 [Frontend Expert], 🧹 [Curator], 🧐 [QA Specialist] ## 📝 Zusammenfassung -Die Vereins-Verwaltung wurde um detaillierte Adressdaten und ein verbessertes Logo-Management erweitert. Dies unterstützt die Professionalisierung der Stammdaten und verbessert die UX durch direkte Integration von Google Maps. +Die Vereins-Verwaltung wurde um detaillierte Adressdaten und ein funktionales Logo-Management erweitert. Ein kritischer Bug, der zum Einfrieren der App beim Datei-Import führte, wurde behoben. Logos werden nun in der Vorschau korrekt gerendert. ## 🛠️ Technische Änderungen -### 1. Domain-Modell (`Verein.kt`) +### 0. Bugfix: Logo-Picker UI-Freeze +* **Problem:** Der `FileDialog` (AWT) blockierte den Main-Thread, was zum Einfrieren der App führte. +* **Lösung:** Auslagerung des Dialog-Aufrufs in einen asynchronen `Dispatchers.IO` Kontext in `LogoUploadZone.jvm.kt`. +* **Stabilität:** Integration von Try-Catch Blöcken und detailliertem Logging für den Datei-Import-Prozess. + +### 1. Feature: Logo-Rendering (Base64) +* **Implementation:** Einführung einer `expect/actual` Funktion `decodeBase64ToImage`. +* **JVM-Logic:** Nutzung von `org.jetbrains.skia.Image` zur Dekodierung der Base64-Bytes in eine `ImageBitmap`. +* **UI-Integration:** Die `VereinCardPreview` rendert nun das Vereinslogo direkt aus dem gespeicherten Base64-String mittels `androidx.compose.foundation.Image`. + +### 2. Domain-Modell (`Verein.kt`) * Erweiterung um Felder: `strasse`, `hausnummer`, `bundesland` (Enum). * Neues Feld `logoBase64` für die Offline-Speicherung von optimierten Vereinslogos. * Einführung des Enums `Bundesland` mit den 9 österreichischen Bundesländern zur Sicherstellung der Datenqualität (ÖTO-konform). @@ -29,15 +39,15 @@ Die Vereins-Verwaltung wurde um detaillierte Adressdaten und ein verbessertes Lo * Einsatz des `MsEnumDropdown` für die Bundesland-Auswahl. * Vorbereitung einer "Logo-Upload-Zone" mit visuellem Feedback für Drag-and-Drop / FilePicker. -## 🔍 Verifikation (Vorschau) -* [x] Domain-Modell kompiliert. -* [x] ViewModel-Logik deckt alle neuen Felder ab. -* [x] UI-Layout ist für High-Density Enterprise-UIs optimiert (44dp Standard). +## 🔍 Verifikation +* [x] Bugfix: Datei-Dialog friert die UI nicht mehr ein (IO-Dispatcher). +* [x] Feature: Base64-Logo wird in der Card-Vorschau gerendert. +* [x] Feature: Logging im ViewModel und Logo-Service implementiert. +* [x] UI: Kompakte Adressfelder und Google-Maps-Link funktionieren. ## 📌 Nächste Schritte -* Implementierung der tatsächlichen Bild-Skalierung und Konvertierung (JVM-spezifisch) im `VereinViewModel`. -* Anbindung des nativen `JFileChooser` für den Logo-Import. -* Finalisierung der Drag-and-Drop Logik (`onExternalDrag`). +* Implementierung einer tatsächlichen Bild-Skalierung vor dem Base64-Encoding, um Datenbank-Größe zu optimieren. +* Finalisierung der Drag-and-Drop Logik (`onExternalDrag`), sobald Bibliotheks-Support stabil ist. --- *Dokumentiert durch den Curator.* diff --git a/docs/Neumarkt2026/Neumarkt-Logo.png b/docs/Neumarkt2026/Neumarkt-Logo.png new file mode 100644 index 00000000..e19ed1c9 Binary files /dev/null and b/docs/Neumarkt2026/Neumarkt-Logo.png differ diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsDatePicker.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsDatePicker.kt new file mode 100644 index 00000000..7eea8c46 --- /dev/null +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsDatePicker.kt @@ -0,0 +1,197 @@ +package at.mocode.frontend.core.designsystem.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock + +/** + * Ein einfacher DatePicker-Dialog für Compose Desktop. + * Da Material3 DatePicker unter Desktop teils Probleme macht oder zu groß ist, + * nutzen wir hier eine kompakte Eigenimplementierung. + */ +@Composable +fun MsDatePickerField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + isError: Boolean = false, + errorMessage: String? = null +) { + var showDialog by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + MsTextField( + value = value, + onValueChange = { /* Schreibgeschützt via Dialog */ }, + label = label, + placeholder = "YYYY-MM-DD", + readOnly = true, + isError = isError, + errorMessage = errorMessage, + trailingIcon = Icons.Default.CalendarMonth, + onTrailingIconClick = { showDialog = true }, + modifier = Modifier.clickable { showDialog = true } + ) + + if (showDialog) { + MsDatePickerDialog( + initialDate = value, + onDismiss = { showDialog = false }, + onDateSelected = { + onValueChange(it) + showDialog = false + } + ) + } + } +} + +@Composable +fun MsDatePickerDialog( + initialDate: String, + onDismiss: () -> Unit, + onDateSelected: (String) -> Unit +) { + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + val parsedDate = try { + LocalDate.parse(initialDate) + } catch (_: Exception) { + now + } + + var currentMonth by remember { mutableStateOf(parsedDate.month) } + var currentYear by remember { mutableStateOf(parsedDate.year) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.width(300.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column(Modifier.padding(16.dp)) { + // Header: Monat/Jahr Auswahl + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { + if (currentMonth == Month.JANUARY) { + currentMonth = Month.DECEMBER + currentYear-- + } else { + val months = Month.entries + currentMonth = months[currentMonth.ordinal - 1] + } + }) { + Text("<") + } + + Text( + "${currentMonth.name} $currentYear", + style = MaterialTheme.typography.titleMedium + ) + + IconButton(onClick = { + if (currentMonth == Month.DECEMBER) { + currentMonth = Month.JANUARY + currentYear++ + } else { + val months = Month.entries + currentMonth = months[currentMonth.ordinal + 1] + } + }) { + Text(">") + } + } + + Spacer(Modifier.height(8.dp)) + + // Kalender-Grid + val daysInMonth = getDaysInMonth(currentMonth, currentYear) + val firstDayOfWeek = LocalDate(currentYear, currentMonth, 1).dayOfWeek.ordinal // 0=Monday + + Row(Modifier.fillMaxWidth()) { + listOf("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So").forEach { + Text( + it, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall, + color = Color.Gray, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } + + val totalSlots = 42 // 6 Wochen + Column { + for (week in 0 until 6) { + Row(Modifier.fillMaxWidth()) { + for (day in 0 until 7) { + val slotIndex = week * 7 + day + val dayNum = slotIndex - firstDayOfWeek + 1 + if (dayNum in 1..daysInMonth) { + val isSelected = parsedDate.day == dayNum && + parsedDate.month == currentMonth && + parsedDate.year == currentYear + + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .padding(2.dp) + .background( + if (isSelected) MaterialTheme.colorScheme.primary + else Color.Transparent, + MaterialTheme.shapes.small + ) + .clickable { + val selected = LocalDate(currentYear, currentMonth, dayNum) + onDateSelected(selected.toString()) + }, + contentAlignment = Alignment.Center + ) { + Text( + dayNum.toString(), + style = MaterialTheme.typography.bodySmall, + color = if (isSelected) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface + ) + } + } else { + Spacer(Modifier.weight(1f).aspectRatio(1f)) + } + } + } + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } + } + } + } +} + +private fun getDaysInMonth(month: Month, year: Int): Int { + return when (month) { + Month.FEBRUARY -> if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) 29 else 28 + Month.APRIL, Month.JUNE, Month.SEPTEMBER, Month.NOVEMBER -> 30 + else -> 31 + } +} diff --git a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt index 5bfdc714..ebf5b050 100644 --- a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt +++ b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt @@ -63,7 +63,12 @@ actual fun DeviceInitializationConfig( errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.", keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), - modifier = Modifier.focusRequester(deviceNameFocus) + modifier = Modifier.focusRequester(deviceNameFocus).onKeyEvent { + if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + focusManager.moveFocus(FocusDirection.Next) + true + } else false + } ) var passwordVisible by remember { mutableStateOf(false) } @@ -88,7 +93,16 @@ actual fun DeviceInitializationConfig( } } ), - modifier = Modifier.focusRequester(sharedKeyFocus), + modifier = Modifier.focusRequester(sharedKeyFocus).onKeyEvent { + if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + if (settings.networkRole == NetworkRole.MASTER) { + focusManager.moveFocus(FocusDirection.Next) + } else if (DeviceInitializationValidator.canContinue(settings)) { + viewModel.completeInitialization() + } + true + } else false + }, trailingIcon = { IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( @@ -105,11 +119,17 @@ actual fun DeviceInitializationConfig( onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } }, label = { Text("Backup-Verzeichnis (Pfad)") }, placeholder = { Text("/pfad/zu/den/backups") }, - modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus), + modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus).onKeyEvent { + if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + focusManager.moveFocus(FocusDirection.Next) + true + } else false + }, + singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) } - ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) } + ), trailingIcon = { IconButton(onClick = { selectBackupPath(settings.backupPath) { selectedPath -> diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstaltungKonfigScreen.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstaltungKonfigScreen.kt index d973752b..9228dda5 100644 --- a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstaltungKonfigScreen.kt +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstaltungKonfigScreen.kt @@ -2,15 +2,21 @@ package at.mocode.frontend.features.veranstalter.presentation import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import at.mocode.frontend.core.designsystem.components.MsDatePickerField +import at.mocode.frontend.core.designsystem.components.MsTextField /** * Formular zum Anlegen einer neuen Veranstaltung (Titel + Datumspfad). Pflichtfelder: Titel, Datum von/bis. @@ -26,8 +32,9 @@ fun VeranstaltungKonfigScreen( var datumVon by remember { mutableStateOf("") } var datumBis by remember { mutableStateOf("") } + val focusManager = LocalFocusManager.current val datesPresent = datumVon.isNotBlank() && datumBis.isNotBlank() - // Einfache Validierung: YYYY-MM-DD Format erzwingen wir hier nicht strikt; wenn beide gesetzt, prüfen wir lexikografisch + // Einfache Validierung: YYYY-MM-DD Format erzwingen wir hier nicht strikt; wenn beide gesetzt sind, prüfen wir lexikografisch val dateOrderOk = !datesPresent || datumBis >= datumVon val valid = titel.isNotBlank() && datesPresent && dateOrderOk @@ -61,37 +68,36 @@ fun VeranstaltungKonfigScreen( Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Text("Stammdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) - OutlinedTextField( + MsTextField( value = titel, onValueChange = { titel = it }, - label = { Text("Titel *") }, + label = "Titel *", + placeholder = "z.B. Frühjahrsturnier 2026", singleLine = true, modifier = Modifier.fillMaxWidth(), isError = titel.isBlank(), + imeAction = ImeAction.Next, + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }) ) Text("Zeitraum", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( + MsDatePickerField( + label = "von *", value = datumVon, onValueChange = { datumVon = it }, - label = { Text("von (YYYY-MM-DD) *") }, - singleLine = true, modifier = Modifier.weight(1f), isError = datumVon.isBlank(), ) - OutlinedTextField( + MsDatePickerField( + label = "bis *", value = datumBis, onValueChange = { datumBis = it }, - label = { Text("bis (YYYY-MM-DD) *") }, - singleLine = true, modifier = Modifier.weight(1f), isError = datumBis.isBlank() || (datesPresent && !dateOrderOk), + errorMessage = if (datesPresent && !dateOrderOk) "Ungültiger Zeitraum" else null ) } - if (datesPresent && !dateOrderOk) { - Text("Das bis-Datum darf nicht vor dem von-Datum liegen.", color = MaterialTheme.colorScheme.error, fontSize = 12.sp) - } } } diff --git a/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinScreens.kt b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinScreens.kt index bcd7c029..85b6ce14 100644 --- a/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinScreens.kt +++ b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinScreens.kt @@ -10,13 +10,13 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Business import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Map import androidx.compose.material3.* import androidx.compose.runtime.* 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.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -25,6 +25,17 @@ import at.mocode.frontend.core.designsystem.models.PlaceholderContent import at.mocode.frontend.features.verein.domain.Bundesland import at.mocode.frontend.features.verein.domain.Verein import at.mocode.frontend.features.verein.domain.VereinStatus +import kotlinx.coroutines.launch +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +expect fun decodeBase64ToImage(base64: String): ImageBitmap? + +@Composable +expect fun LogoUploadZone( + modifier: Modifier = Modifier, + onFileSelected: (ByteArray) -> Unit +) @Composable fun VereinScreen( @@ -54,6 +65,7 @@ fun VereinScreen( hausnummer = if (uiState.isEditing) uiState.editHausnummer else uiState.selectedVerein?.hausnummer, bundesland = if (uiState.isEditing) uiState.editBundesland else uiState.selectedVerein?.bundesland, logoUrl = if (uiState.isEditing) uiState.editLogoUrl else uiState.selectedVerein?.logoUrl, + logoBase64 = if (uiState.isEditing) uiState.editLogoBase64 else uiState.selectedVerein?.logoBase64, status = if (uiState.isEditing) uiState.editStatus else uiState.selectedVerein?.status ?: VereinStatus.AKTIV ) @@ -74,6 +86,7 @@ fun VereinScreen( onBundeslandChange = viewModel::onEditBundeslandChange, onStatusChange = viewModel::onEditStatusChange, onLogoUrlChange = viewModel::onEditLogoUrlChange, + onLogoFileSelected = viewModel::onLogoFileSelected, onSave = viewModel::onSave, onCancel = viewModel::onCancel ) @@ -99,6 +112,7 @@ private fun VereinCardPreview( hausnummer: String?, bundesland: String?, logoUrl: String?, + logoBase64: String?, status: VereinStatus ) { val uriHandler = LocalUriHandler.current @@ -122,10 +136,22 @@ private fun VereinCardPreview( .border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), CircleShape), contentAlignment = Alignment.Center ) { - if (!logoUrl.isNullOrBlank()) { - Icon(Icons.Default.Business, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary) + if (!logoBase64.isNullOrBlank()) { + val bitmap = remember(logoBase64) { decodeBase64ToImage(logoBase64) } + if (bitmap != null) { + androidx.compose.foundation.Image( + bitmap = bitmap, + contentDescription = "Vereinslogo", + modifier = Modifier.fillMaxSize().clip(CircleShape), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } else { + Icon(Icons.Default.Image, "Logo Fehler", modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.error) + } + } else if (!logoUrl.isNullOrBlank()) { + Icon(Icons.Default.Business, "Logo URL", modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary) } else { - Icon(Icons.Default.Business, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary) + Icon(Icons.Default.Business, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) } } @@ -261,6 +287,7 @@ private fun VereinEditorContent( onBundeslandChange: (String) -> Unit, onStatusChange: (VereinStatus) -> Unit, onLogoUrlChange: (String) -> Unit, + onLogoFileSelected: (ByteArray) -> Unit, onSave: () -> Unit, onCancel: () -> Unit ) { @@ -279,40 +306,28 @@ private fun VereinEditorContent( value = uiState.editName, onValueChange = onNameChange, label = "Name (Kurz)", - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + compact = true ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(8.dp)) MsTextField( value = uiState.editLangname, onValueChange = onLangnameChange, label = "Vollständiger Name", - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + compact = true ) } // Logo Upload Sektion - Column( + LogoUploadZone( modifier = Modifier - .width(200.dp) - .height(120.dp) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon(Icons.Default.Image, null, tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) - Text("Logo hierher ziehen", style = MaterialTheme.typography.labelSmall, color = Color.Gray) - Spacer(Modifier.height(4.dp)) - MsButton( - text = "Wählen", - onClick = { /* FilePicker Call via JFileChooser (JVM only) */ }, - variant = ButtonVariant.SECONDARY, - size = ButtonSize.SMALL - ) - } + .width(180.dp) + .height(110.dp), + onFileSelected = onLogoFileSelected + ) } Spacer(Modifier.height(16.dp)) @@ -322,7 +337,8 @@ private fun VereinEditorContent( value = uiState.editOepsNr, onValueChange = onOepsNrChange, label = "OePS-Nr", - modifier = Modifier.weight(1f) + modifier = Modifier.weight(0.5f), + compact = true ) MsEnumDropdown( label = "Status", @@ -330,49 +346,53 @@ private fun VereinEditorContent( selectedOption = uiState.editStatus, onOptionSelected = onStatusChange, optionLabel = { it.label }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(0.5f) ) } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(12.dp)) - Text("Adresse", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) + Text("Adresse", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(4.dp)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { MsTextField( value = uiState.editStrasse, onValueChange = onStrasseChange, label = "Straße", - modifier = Modifier.weight(0.7f) + modifier = Modifier.weight(0.7f), + compact = true ) MsTextField( value = uiState.editHausnummer, onValueChange = onHausnummerChange, label = "Nr.", - modifier = Modifier.weight(0.3f) + modifier = Modifier.weight(0.3f), + compact = true ) } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { MsTextField( value = uiState.editPlz, onValueChange = onPlzChange, label = "PLZ", - modifier = Modifier.weight(0.2f) + modifier = Modifier.weight(0.2f), + compact = true ) MsTextField( value = uiState.editOrt, onValueChange = onOrtChange, label = "Ort", - modifier = Modifier.weight(0.4f) + modifier = Modifier.weight(0.4f), + compact = true ) MsEnumDropdown( label = "Bundesland", options = Bundesland.entries.toTypedArray(), - selectedOption = Bundesland.entries.find { it.label == uiState.editBundesland }, + selectedOption = Bundesland.entries.find { it.label == uiState.editBundesland } ?: Bundesland.WIEN, onOptionSelected = { onBundeslandChange(it.label) }, optionLabel = { it.label }, modifier = Modifier.weight(0.4f) diff --git a/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinViewModel.kt b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinViewModel.kt index ce1aee2f..3a3ea1f2 100644 --- a/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinViewModel.kt +++ b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinViewModel.kt @@ -9,6 +9,8 @@ import at.mocode.frontend.features.verein.domain.Verein import at.mocode.frontend.features.verein.domain.VereinRepository import at.mocode.frontend.features.verein.domain.VereinStatus import kotlinx.coroutines.launch +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi /** * UI-State für die Vereins-Verwaltung. @@ -157,6 +159,18 @@ open class VereinViewModel( uiState = uiState.copy(editStatus = value) } + @OptIn(ExperimentalEncodingApi::class) + fun onLogoFileSelected(bytes: ByteArray) { + println("[VereinViewModel] Logo Datei empfangen, konvertiere zu Base64...") + try { + val base64 = Base64.encode(bytes) + uiState = uiState.copy(editLogoBase64 = base64) + println("[VereinViewModel] Logo erfolgreich in Base64 konvertiert (Länge: ${base64.length})") + } catch (e: Exception) { + println("[VereinViewModel] Fehler bei Base64 Konvertierung: ${e.message}") + } + } + fun onSave() { uiState = uiState.copy(isLoading = true, error = null) val verein = (uiState.selectedVerein ?: Verein( diff --git a/frontend/features/verein-feature/src/jvmMain/kotlin/at/mocode/frontend/features/verein/presentation/LogoUploadZone.jvm.kt b/frontend/features/verein-feature/src/jvmMain/kotlin/at/mocode/frontend/features/verein/presentation/LogoUploadZone.jvm.kt new file mode 100644 index 00000000..09df0f2a --- /dev/null +++ b/frontend/features/verein-feature/src/jvmMain/kotlin/at/mocode/frontend/features/verein/presentation/LogoUploadZone.jvm.kt @@ -0,0 +1,102 @@ +package at.mocode.frontend.features.verein.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.Image +import java.awt.FileDialog +import java.awt.Frame +import java.awt.Window +import java.io.File +import javax.swing.SwingUtilities +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +actual fun decodeBase64ToImage(base64: String): ImageBitmap? { + return try { + val bytes = Base64.decode(base64) + Image.makeFromEncoded(bytes).toComposeImageBitmap() + } catch (e: Exception) { + null + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +actual fun LogoUploadZone( + modifier: Modifier, + onFileSelected: (ByteArray) -> Unit +) { + val scope = rememberCoroutineScope() + + Box( + modifier = modifier + .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, + verticalArrangement = Arrangement.Center + ) { + Icon( + Icons.Default.Image, + null, + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + modifier = Modifier.size(32.dp) + ) + Text("Logo auswählen", style = MaterialTheme.typography.labelSmall, color = Color.Gray) + Spacer(Modifier.height(4.dp)) + MsButton( + text = "Datei wählen", + onClick = { + scope.launch(Dispatchers.IO) { + try { + println("[LogoUpload] Öffne Datei-Dialog...") + val fileDialog = FileDialog(null as Frame?, "Logo auswählen", FileDialog.LOAD) + fileDialog.isVisible = true + val directory = fileDialog.directory + val file = fileDialog.file + if (directory != null && file != null) { + val selectedFile = File(directory, file) + println("[LogoUpload] Datei ausgewählt: ${selectedFile.absolutePath}") + val bytes = selectedFile.readBytes() + println("[LogoUpload] Bytes gelesen: ${bytes.size}") + onFileSelected(bytes) + } else { + println("[LogoUpload] Auswahl abgebrochen") + } + } catch (e: Exception) { + println("[LogoUpload] FEHLER: ${e.message}") + e.printStackTrace() + } + } + }, + variant = ButtonVariant.SECONDARY, + size = ButtonSize.SMALL + ) + } + } +} diff --git a/frontend/features/verein-feature/src/wasmJsMain/kotlin/at/mocode/frontend/features/verein/presentation/LogoUploadZone.wasm.kt b/frontend/features/verein-feature/src/wasmJsMain/kotlin/at/mocode/frontend/features/verein/presentation/LogoUploadZone.wasm.kt new file mode 100644 index 00000000..b41a2ff6 --- /dev/null +++ b/frontend/features/verein-feature/src/wasmJsMain/kotlin/at/mocode/frontend/features/verein/presentation/LogoUploadZone.wasm.kt @@ -0,0 +1,18 @@ +package at.mocode.frontend.features.verein.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +import androidx.compose.ui.graphics.ImageBitmap +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +actual fun decodeBase64ToImage(base64: String): ImageBitmap? = null + +@Composable +actual fun LogoUploadZone( + modifier: Modifier, + onFileSelected: (ByteArray) -> Unit +) { + // Nicht implementiert für WasmJs +} diff --git a/frontend/shells/meldestelle-desktop/settings.json b/frontend/shells/meldestelle-desktop/settings.json index 462cdf40..2fd53b82 100644 --- a/frontend/shells/meldestelle-desktop/settings.json +++ b/frontend/shells/meldestelle-desktop/settings.json @@ -1,5 +1,5 @@ { - "deviceName": "Meldestelle\n", + "deviceName": "Meldestelle", "sharedKey": "Password", "backupPath": "/mocode/meldestelle/docs/temp", "networkRole": "MASTER", diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt index bca15fac..97c8d302 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt @@ -552,7 +552,10 @@ private fun DesktopContentArea( is AppScreen.VeranstaltungVerwaltung -> { VeranstaltungVerwaltung( onVeranstaltungOpen = { vId: Long, eId: Long -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) }, - onNewVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig()) }, + onNewVeranstaltung = { + // Wenn wir direkt aus der Übersicht kommen, erst Veranstalter wählen lassen + onNavigate(AppScreen.VeranstalterAuswahl) + }, onNavigateToPferde = { onNavigate(AppScreen.PferdVerwaltung) }, onNavigateToReiter = { onNavigate(AppScreen.ReiterVerwaltung) }, onNavigateToVereine = { onNavigate(AppScreen.VereinVerwaltung) }, @@ -698,12 +701,12 @@ private fun DesktopContentArea( if (Store.vereine.none { it.id == vId }) { InvalidContextNotice( message = "Veranstalter (ID=$vId) nicht gefunden.", - onBack = onBack + onBack = { onNavigate(AppScreen.VeranstalterAuswahl) } ) } else if (Store.eventsFor(vId).none { it.id == evtId }) { InvalidContextNotice( message = "Veranstaltung (ID=$evtId) gehört nicht zu Veranstalter #$vId.", - onBack = onBack + onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) } ) } else { VeranstaltungProfilScreen(