From d4aeba4666b95526306c0b1b353fffa1f85b6a60 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Mon, 20 Apr 2026 02:00:31 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20implementiere=20`MsFilePicker`-Kompone?= =?UTF-8?q?nte,=20ersetze=20veraltete=20Input-Felder=20in=20Ger=C3=A4teneu?= =?UTF-8?q?konfiguration=20und=20ZNS-Importer,=20verbessere=20Vereinskarte?= =?UTF-8?q?n-Darstellung=20und=20Detail-UX,=20behebe=20Tippfehler=20in=20`?= =?UTF-8?q?settings.json`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-04-20_Desktop_UX_Navigation_Refinement.md | 31 ++-- .../designsystem/components/MsFilePicker.kt | 18 +++ .../components/MsFilePicker.jvm.kt | 78 ++++++++++ .../components/MsFilePicker.wasmJs.kt | 25 +++ .../DeviceInitializationConfig.jvm.kt | 142 +++--------------- .../veranstalter-feature/build.gradle.kts | 1 + .../presentation/VeranstalterDetailScreen.kt | 26 +++- .../presentation/VeranstalterNeuScreen.kt | 109 ++++++-------- .../verein/presentation/VereinScreens.kt | 80 ++++++++-- .../presentation/StammdatenImportScreen.kt | 73 ++++----- .../shells/meldestelle-desktop/settings.json | 2 +- 11 files changed, 337 insertions(+), 248 deletions(-) create mode 100644 frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.kt create mode 100644 frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt create mode 100644 frontend/core/design-system/src/wasmJsMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.wasmJs.kt 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 index 44ead21b..8be232fa 100644 --- a/docs/99_Journal/2026-04-20_Desktop_UX_Navigation_Refinement.md +++ b/docs/99_Journal/2026-04-20_Desktop_UX_Navigation_Refinement.md @@ -1,25 +1,28 @@ # Journal: 20. April 2026 - Desktop UX & Navigation Refinement -## 🏗️ Desktop-App: UX & Eingabe-Optimierung +## 🏗️ Desktop-App: UX & Eingabe-Optimierung (Update) * **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. + * **Device-Setup:** Vollständiges Refactoring von `DeviceInitializationConfig.jvm.kt`. Ersetzung von `OutlinedTextField` durch `MsTextField`. Entfernung störender `onKeyEvent`-Handler zugunsten des nativen `ImeAction`-Flows. Tab und Enter funktionieren nun reibungslos. + * **Standardisierung:** Konsistente Nutzung von `MsTextField` in allen neuen Screens (`VeranstalterNeu`, `ZnsImport`). -* **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). +* **MsFilePicker (Zentrale Komponente):** + * Einführung einer plattformübergreifenden `MsFilePicker`-Komponente. + * **Desktop (JVM):** Nutzt den nativen `FileDialog` für Dateiauswahlen (Look & Feel) und `JFileChooser` für Verzeichnisse. + * **Integration:** Ersetzt manuelle Picker-Logik im Device-Setup und ZNS-Importer. + +* **ZNS-Importer Refinement:** + * Implementierung einer Fortschrittsanzeige (`LinearProgressIndicator`) mit Prozent- und Status-Details. + * Klarstellung der Dateiformate: Unterstützung sowohl für `ZNS.zip` als auch für einzelne `.dat` Dateien. ## 🧭 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. +* **Veranstalter-Profil (Vereins-Integration):** + * Integration einer detaillierten Vereins-Vorschau (Card) im `VeranstalterDetailScreen`. + * Navigation zum Vereins-Editor direkt aus dem Veranstalter-Profil ("Bearbeiten"-Button). +* **UI-Konsistenz:** + * Einführung eines einheitlichen "Zurück"-Buttons (Pfeil-Icon) in der Header-Zeile aller Detail- und Konfigurations-Screens. + * Kompakte Darstellung von Suchergebnissen in der Vereins-Suche (inkl. Logo-Vorschau). ## 🧹 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/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.kt new file mode 100644 index 00000000..a63f33d8 --- /dev/null +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.kt @@ -0,0 +1,18 @@ +package at.mocode.frontend.core.designsystem.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Zentraler FilePicker für die gesamte App. + */ +@Composable +expect fun MsFilePicker( + label: String, + selectedPath: String?, + onFileSelected: (String) -> Unit, + fileExtensions: List = emptyList(), + directoryOnly: Boolean = false, + enabled: Boolean = true, + modifier: Modifier = Modifier +) diff --git a/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt b/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt new file mode 100644 index 00000000..d587dc59 --- /dev/null +++ b/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt @@ -0,0 +1,78 @@ +package at.mocode.frontend.core.designsystem.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import javax.swing.JFileChooser + +@Composable +actual fun MsFilePicker( + label: String, + selectedPath: String?, + onFileSelected: (String) -> Unit, + fileExtensions: List, + directoryOnly: Boolean, + enabled: Boolean, + modifier: Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + MsTextField( + value = selectedPath ?: "", + onValueChange = { }, + readOnly = true, + label = label, + placeholder = if (directoryOnly) "Verzeichnis wählen..." else "Datei wählen...", + modifier = Modifier.weight(1f), + enabled = enabled, + compact = true + ) + + Spacer(Modifier.width(8.dp)) + + MsButton( + onClick = { + if (directoryOnly) { + // JFileChooser ist für Verzeichnisse auf dem Desktop oft stabiler/einfacher + val chooser = JFileChooser().apply { + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + dialogTitle = label + selectedPath?.let { + val currentDir = File(it) + if (currentDir.exists()) currentDirectory = currentDir + } + } + val result = chooser.showOpenDialog(null) + if (result == JFileChooser.APPROVE_OPTION) { + onFileSelected(chooser.selectedFile.absolutePath) + } + } else { + // AWT FileDialog für nativen Look bei Dateiauswahl (wie vom User gewünscht) + val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply { + if (fileExtensions.isNotEmpty()) { + setFilenameFilter { _, name -> + fileExtensions.any { name.lowercase().endsWith(it.lowercase()) } + } + } + } + dialog.isVisible = true + if (dialog.file != null) { + onFileSelected(File(dialog.directory, dialog.file).absolutePath) + } + } + }, + text = "Durchsuchen", + enabled = enabled + ) + } +} diff --git a/frontend/core/design-system/src/wasmJsMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.wasmJs.kt b/frontend/core/design-system/src/wasmJsMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.wasmJs.kt new file mode 100644 index 00000000..a3be190a --- /dev/null +++ b/frontend/core/design-system/src/wasmJsMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.wasmJs.kt @@ -0,0 +1,25 @@ +package at.mocode.frontend.core.designsystem.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +actual fun MsFilePicker( + label: String, + selectedPath: String?, + onFileSelected: (String) -> Unit, + fileExtensions: List, + directoryOnly: Boolean, + enabled: Boolean, + modifier: Modifier +) { + // WasmJs Implementierung (Platzhalter oder HTML Input Logik) + MsTextField( + value = selectedPath ?: "", + onValueChange = { }, + readOnly = true, + label = label, + modifier = modifier, + enabled = enabled + ) +} 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 ebf5b050..1132781c 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 @@ -4,11 +4,9 @@ package at.mocode.frontend.features.device.initialization.presentation import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.* @@ -31,11 +29,10 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import at.mocode.frontend.core.designsystem.components.MsEnumDropdown +import at.mocode.frontend.core.designsystem.components.MsFilePicker +import at.mocode.frontend.core.designsystem.components.MsTextField import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole -import java.io.File -import javax.swing.JFileChooser -import javax.swing.UIManager @Composable actual fun DeviceInitializationConfig( @@ -54,35 +51,28 @@ actual fun DeviceInitializationConfig( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Text("⚙️ Geräte-Konfiguration (${settings.networkRole})", style = MaterialTheme.typography.titleMedium) - MsSettingsField( + MsTextField( value = settings.deviceName, onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } }, label = "Gerätename", placeholder = "z.B. Meldestelle-PC-1", isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName), - errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.", - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.", + imeAction = ImeAction.Next, keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), - modifier = Modifier.focusRequester(deviceNameFocus).onKeyEvent { - if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { - focusManager.moveFocus(FocusDirection.Next) - true - } else false - } + modifier = Modifier.focusRequester(deviceNameFocus) ) var passwordVisible by remember { mutableStateOf(false) } - MsSettingsField( + MsTextField( value = settings.sharedKey, onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } }, label = "Sicherheitsschlüssel (Sync-Key)", placeholder = "Mindestens 8 Zeichen", isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey), - errorText = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.", + errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.", visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - imeAction = if (settings.networkRole == NetworkRole.MASTER) ImeAction.Next else ImeAction.Done - ), + imeAction = if (settings.networkRole == NetworkRole.MASTER) ImeAction.Next else ImeAction.Done, keyboardActions = KeyboardActions( onNext = { focusManager.moveFocus(FocusDirection.Next) }, onDone = { @@ -93,53 +83,20 @@ actual fun DeviceInitializationConfig( } } ), - 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( - imageVector = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (passwordVisible) "Verbergen" else "Anzeigen" - ) - } - } + modifier = Modifier.focusRequester(sharedKeyFocus), + trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + onTrailingIconClick = { passwordVisible = !passwordVisible } ) if (settings.networkRole == NetworkRole.MASTER) { - OutlinedTextField( - value = settings.backupPath, - onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } }, - label = { Text("Backup-Verzeichnis (Pfad)") }, - placeholder = { Text("/pfad/zu/den/backups") }, - modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus).onKeyEvent { - if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { - focusManager.moveFocus(FocusDirection.Next) - true - } else false + MsFilePicker( + label = "Backup-Verzeichnis (Pfad)", + selectedPath = settings.backupPath, + onFileSelected = { selectedPath -> + viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) } }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) } - ), - trailingIcon = { - IconButton(onClick = { - selectBackupPath(settings.backupPath) { selectedPath -> - viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) } - } - }) { - Icon(Icons.Outlined.FolderOpen, contentDescription = "Verzeichnis wählen") - } - }, - isError = settings.backupPath.isNotEmpty() && !DeviceInitializationValidator.isBackupPathValid(settings.backupPath) + directoryOnly = true, + modifier = Modifier.focusRequester(backupPathFocus) ) Text("Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.labelMedium) @@ -313,12 +270,12 @@ private fun ClientEntryRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - OutlinedTextField( + MsTextField( value = name, onValueChange = onNameChange, - label = { Text("Gerätename des Clients") }, + label = "Gerätename des Clients", modifier = Modifier.weight(1f).focusRequester(clientNameFocus), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + imeAction = ImeAction.Next, keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }) ) @@ -338,58 +295,3 @@ private fun ClientEntryRow( ) } } - -@Composable -private fun MsSettingsField( - value: String, - onValueChange: (String) -> Unit, - label: String, - placeholder: String, - isError: Boolean, - errorText: String, - modifier: Modifier = Modifier, - visualTransformation: VisualTransformation = VisualTransformation.None, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - trailingIcon: @Composable (() -> Unit)? = null -) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(label) }, - placeholder = { Text(placeholder) }, - modifier = modifier.fillMaxWidth(), - isError = isError, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - trailingIcon = trailingIcon, - supportingText = { - if (isError) { - Text(errorText) - } - } - ) -} - -private fun selectBackupPath(currentPath: String, onPathSelected: (String) -> Unit) { - try { - UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()) - val chooser = JFileChooser().apply { - fileSelectionMode = JFileChooser.DIRECTORIES_ONLY - dialogTitle = "Backup-Verzeichnis wählen" - if (currentPath.isNotEmpty()) { - val currentDir = File(currentPath) - if (currentDir.exists()) currentDirectory = currentDir - } - } - val result = chooser.showOpenDialog(null) - if (result == JFileChooser.APPROVE_OPTION) { - val selectedPath = chooser.selectedFile.absolutePath - onPathSelected(selectedPath) - println("[DeviceInit] Backup-Verzeichnis gewählt: $selectedPath") - } - } catch (e: Exception) { - println("[DeviceInit] [Error] Fehler beim Öffnen des Verzeichnis-Wählers: ${e.message}") - } -} diff --git a/frontend/features/veranstalter-feature/build.gradle.kts b/frontend/features/veranstalter-feature/build.gradle.kts index af4a6297..c64311b8 100644 --- a/frontend/features/veranstalter-feature/build.gradle.kts +++ b/frontend/features/veranstalter-feature/build.gradle.kts @@ -27,6 +27,7 @@ kotlin { sourceSets { commonMain.dependencies { + implementation(projects.frontend.features.vereinFeature) implementation(projects.frontend.core.designSystem) implementation(projects.frontend.core.network) implementation(projects.frontend.core.domain) diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterDetailScreen.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterDetailScreen.kt index 39904bda..80c69e4c 100644 --- a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterDetailScreen.kt +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterDetailScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -124,17 +125,26 @@ fun VeranstalterDetailScreen( } Column(modifier = Modifier.fillMaxSize()) { + // ── Header mit Zurück-Pfeil ───────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + IconButton(onClick = onZurueck) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + Text("Veranstalter-Profil", style = MaterialTheme.typography.headlineSmall) + } // ── Veranstalter-Header-Card ───────────────────────────────────────── - Surface( - modifier = Modifier.fillMaxWidth(), - color = Color.White, + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), border = BorderStroke(1.dp, Color(0xFFE2E8F0)), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.SpaceBetween, ) { @@ -186,12 +196,12 @@ fun VeranstalterDetailScreen( } // Profil bearbeiten OutlinedButton( - onClick = { /* TODO */ }, + onClick = { /* Navigation zu Vereinen */ }, border = BorderStroke(1.dp, Color(0xFFD1D5DB)), ) { Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(14.dp)) Spacer(Modifier.width(4.dp)) - Text("Profil bearbeiten", fontSize = 13.sp) + Text("Bearbeiten", fontSize = 13.sp) } } } diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterNeuScreen.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterNeuScreen.kt index 2ae91f9c..44f4489f 100644 --- a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterNeuScreen.kt +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterNeuScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Info import androidx.compose.material3.* import androidx.compose.runtime.* @@ -13,6 +14,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import at.mocode.frontend.core.designsystem.components.MsTextField /** * Formular zum Anlegen eines neuen Veranstalters (Vision_03: Screenshot 21). @@ -47,18 +49,27 @@ fun VeranstalterNeuScreen( .verticalScroll(rememberScrollState()), ) { // Header - Column(modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp)) { - Text( - text = "Neuen Veranstalter anlegen", - fontSize = 22.sp, - fontWeight = FontWeight.Bold, - ) - Spacer(Modifier.height(4.dp)) - Text( - text = "Legen Sie einen neuen Veranstalter (Verein) mit OEPS-Daten an. Nach dem Speichern werden automatisch Login-Daten generiert.", - fontSize = 13.sp, - color = Color(0xFF6B7280), - ) + Row( + modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + IconButton(onClick = onAbbrechen) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + Column { + Text( + text = "Neuen Veranstalter anlegen", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Legen Sie einen neuen Veranstalter (Verein) mit OEPS-Daten an. Nach dem Speichern werden automatisch Login-Daten generiert.", + fontSize = 13.sp, + color = Color(0xFF6B7280), + ) + } } // Info-Banner @@ -110,65 +121,46 @@ fun VeranstalterNeuScreen( // --- Vereinsdaten --- Text("Vereinsdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) - OutlinedTextField( + MsTextField( value = vereinsname, onValueChange = { vereinsname = it }, - label = { Text("Vereinsname *") }, + label = "Vereinsname *", modifier = Modifier.fillMaxWidth(), - singleLine = true, ) - Column { - OutlinedTextField( - value = oepsNummer, - onValueChange = { oepsNummer = it }, - label = { Text("OEPS-Nummer *") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Text( - text = "Offizielle Vereinsnummer des OEPS", - fontSize = 11.sp, - color = Color(0xFF2563EB), - modifier = Modifier.padding(start = 4.dp, top = 2.dp), - ) - } + MsTextField( + value = oepsNummer, + onValueChange = { oepsNummer = it }, + label = "OEPS-Nummer *", + modifier = Modifier.fillMaxWidth(), + helperText = "Offizielle Vereinsnummer des OEPS" + ) HorizontalDivider() // --- Kontaktdaten --- Text("Kontaktdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) - OutlinedTextField( + MsTextField( value = ansprechpartner, onValueChange = { ansprechpartner = it }, - label = { Text("Ansprechpartner *") }, + label = "Ansprechpartner *", modifier = Modifier.fillMaxWidth(), - singleLine = true, ) - Column { - OutlinedTextField( - value = email, - onValueChange = { email = it }, - label = { Text("E-Mail *") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Text( - text = "Login-Daten werden an diese Adresse verschickt", - fontSize = 11.sp, - color = Color(0xFF6B7280), - modifier = Modifier.padding(start = 4.dp, top = 2.dp), - ) - } + MsTextField( + value = email, + onValueChange = { email = it }, + label = "E-Mail *", + modifier = Modifier.fillMaxWidth(), + helperText = "Login-Daten werden an diese Adresse verschickt" + ) - OutlinedTextField( + MsTextField( value = telefon, onValueChange = { telefon = it }, - label = { Text("Telefon") }, + label = "Telefon", modifier = Modifier.fillMaxWidth(), - singleLine = true, ) HorizontalDivider() @@ -176,28 +168,25 @@ fun VeranstalterNeuScreen( // --- Adresse --- Text("Adresse", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) - OutlinedTextField( + MsTextField( value = strasse, onValueChange = { strasse = it }, - label = { Text("Straße & Hausnummer") }, + label = "Straße & Hausnummer", modifier = Modifier.fillMaxWidth(), - singleLine = true, ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedTextField( + MsTextField( value = plz, onValueChange = { plz = it }, - label = { Text("PLZ") }, + label = "PLZ", modifier = Modifier.width(120.dp), - singleLine = true, ) - OutlinedTextField( + MsTextField( value = ort, onValueChange = { ort = it }, - label = { Text("Ort") }, + label = "Ort", modifier = Modifier.weight(1f), - singleLine = true, ) } } 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 d9951a4f..cc57ea7f 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 @@ -102,6 +102,27 @@ fun VereinScreen( ) } +@Composable +fun VereinCard( + verein: Verein, + onEdit: (() -> Unit)? = null, + onOpenInMaps: () -> Unit = {} +) { + VereinCardPreview( + name = verein.name, + langname = verein.langname, + ort = verein.ort, + plz = verein.plz, + strasse = verein.strasse, + hausnummer = verein.hausnummer, + bundesland = verein.bundesland, + logoUrl = verein.logoUrl, + logoBase64 = verein.logoBase64, + status = verein.status, + onEdit = onEdit + ) +} + @Composable private fun VereinCardPreview( name: String, @@ -113,7 +134,8 @@ private fun VereinCardPreview( bundesland: String?, logoUrl: String?, logoBase64: String?, - status: VereinStatus + status: VereinStatus, + onEdit: (() -> Unit)? = null ) { val uriHandler = LocalUriHandler.current @@ -209,6 +231,15 @@ private fun VereinCardPreview( size = ButtonSize.SMALL ) } + + if (onEdit != null) { + MsButton( + text = "Bearbeiten", + onClick = onEdit, + variant = ButtonVariant.OUTLINE, + size = ButtonSize.SMALL + ) + } } } } @@ -243,14 +274,45 @@ private fun VereinListContent( items = uiState.searchResults, columns = listOf( MsColumnDefinition( - title = "Name", - weight = 1.5f, - cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) } - ), - MsColumnDefinition( - title = "Ort", - weight = 1f, - cellRenderer = { Text(it.ort ?: "-", style = MaterialTheme.typography.bodySmall) } + title = "Verein", + weight = 2f, + cellRenderer = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(vertical = 4.dp) + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + if (!it.logoBase64.isNullOrBlank()) { + val bitmap = remember(it.logoBase64) { decodeBase64ToImage(it.logoBase64) } + if (bitmap != null) { + androidx.compose.foundation.Image( + bitmap = bitmap, + contentDescription = null, + modifier = Modifier.fillMaxSize().clip(CircleShape), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } else { + Icon(Icons.Default.Business, null, modifier = Modifier.size(16.dp)) + } + } else { + Icon(Icons.Default.Business, null, modifier = Modifier.size(16.dp)) + } + } + Column { + Text(it.name, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold) + if (!it.ort.isNullOrBlank()) { + Text(it.ort, style = MaterialTheme.typography.labelSmall, color = Color.Gray) + } + } + } + } ), MsColumnDefinition( title = "OePS-Nr", diff --git a/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/frontend/features/zns/import/presentation/StammdatenImportScreen.kt b/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/frontend/features/zns/import/presentation/StammdatenImportScreen.kt index 800cefdf..cd21c263 100644 --- a/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/frontend/features/zns/import/presentation/StammdatenImportScreen.kt +++ b/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/frontend/features/zns/import/presentation/StammdatenImportScreen.kt @@ -4,7 +4,9 @@ import androidx.compose.foundation.background 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.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* @@ -14,14 +16,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp +import at.mocode.frontend.core.designsystem.components.MsFilePicker import at.mocode.frontend.features.zns.import.ZnsImportViewModel import org.koin.compose.viewmodel.koinViewModel -import javax.swing.JFileChooser -import javax.swing.filechooser.FileNameExtensionFilter - -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll - import java.io.File @Composable @@ -53,36 +50,40 @@ fun StammdatenImportScreen( // Datei-Auswahl Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Datei auswählen", style = MaterialTheme.typography.titleMedium) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - OutlinedTextField( - value = state.selectedFilePath ?: "", - onValueChange = {}, - readOnly = true, - placeholder = { Text("ZNS-Datei auswählen (.zip, .dat)...") }, - modifier = Modifier.weight(1f), - singleLine = true, - ) - Button( - onClick = { - val chooser = JFileChooser() - chooser.dialogTitle = "ZNS-Datei auswählen" - chooser.fileFilter = FileNameExtensionFilter("ZNS Dateien (*.zip, *.dat)", "zip", "dat") - chooser.isAcceptAllFileFilterUsed = false - val result = chooser.showOpenDialog(null) - if (result == JFileChooser.APPROVE_OPTION) { - viewModel.onFileSelected(chooser.selectedFile.absolutePath) - } - }, - enabled = !state.isUploading && !(!state.isFinished && state.jobId != null), - ) { - Icon(Icons.Default.FolderOpen, contentDescription = null) - Spacer(Modifier.width(4.dp)) - Text("Durchsuchen") + Text("ZNS-Datei auswählen", style = MaterialTheme.typography.titleMedium) + Text( + "Wählen Sie entweder die gesamte ZNS.zip oder eine einzelne .dat Datei (z.B. VEREIN01.dat).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + MsFilePicker( + label = "Pfad zur ZNS-Datei", + selectedPath = state.selectedFilePath, + onFileSelected = { viewModel.onFileSelected(it) }, + fileExtensions = listOf("zip", "dat"), + enabled = !state.isUploading && !(!state.isFinished && state.jobId != null) + ) + + if (state.isUploading || (state.jobId != null && !state.isFinished)) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + LinearProgressIndicator( + progress = { (state.progress / 100f).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer + ) + Text( + text = if (state.isUploading) "Datei wird hochgeladen..." else "Import wird verarbeitet... (${state.progress}%)", + style = MaterialTheme.typography.labelSmall + ) + if (state.progressDetail.isNotBlank()) { + Text( + text = state.progressDetail, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } diff --git a/frontend/shells/meldestelle-desktop/settings.json b/frontend/shells/meldestelle-desktop/settings.json index 2fd53b82..91f2e44b 100644 --- a/frontend/shells/meldestelle-desktop/settings.json +++ b/frontend/shells/meldestelle-desktop/settings.json @@ -1,6 +1,6 @@ { "deviceName": "Meldestelle", - "sharedKey": "Password", + "sharedKey": "Paassword", "backupPath": "/mocode/meldestelle/docs/temp", "networkRole": "MASTER", "expectedClients": [