chore: implementiere MsFilePicker-Komponente, ersetze veraltete Input-Felder in Geräteneukonfiguration und ZNS-Importer, verbessere Vereinskarten-Darstellung und Detail-UX, behebe Tippfehler in settings.json

This commit is contained in:
2026-04-20 02:00:31 +02:00
parent 9fe889b2c1
commit d4aeba4666
11 changed files with 337 additions and 248 deletions
@@ -1,25 +1,28 @@
# Journal: 20. April 2026 - Desktop UX & Navigation Refinement # Journal: 20. April 2026 - Desktop UX & Navigation Refinement
## 🏗️ Desktop-App: UX & Eingabe-Optimierung ## 🏗️ Desktop-App: UX & Eingabe-Optimierung (Update)
* **Tastatur-Navigation (Fokus-Flow):** * **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. * **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.
* **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. * **Standardisierung:** Konsistente Nutzung von `MsTextField` in allen neuen Screens (`VeranstalterNeu`, `ZnsImport`).
* **Neuer Date-Picker:** * **MsFilePicker (Zentrale Komponente):**
* Implementierung einer kompakten, Desktop-optimierten Komponente `MsDatePickerField`. * Einführung einer plattformübergreifenden `MsFilePicker`-Komponente.
* Ersetzt die manuellen Text-Eingabefelder für den Veranstaltungs-Zeitraum ("von" / "bis") durch einen visuellen Kalender-Dialog. * **Desktop (JVM):** Nutzt den nativen `FileDialog` für Dateiauswahlen (Look & Feel) und `JFileChooser` für Verzeichnisse.
* Erhöht die Datenqualität durch standardisiertes Datumsformat (ISO 8601). * **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 ## 🧭 Navigation & Stabilität
* **Robuste Neuanlage:** * **Veranstalter-Profil (Vereins-Integration):**
* Der direkte Aufruf von `VeranstaltungKonfig(veranstalterId=0)` aus der Gesamtübersicht wurde unterbunden. * Integration einer detaillierten Vereins-Vorschau (Card) im `VeranstalterDetailScreen`.
* User werden nun zuerst zur **Veranstalter-Auswahl** geleitet, um eine valide Kontext-ID sicherzustellen. * Navigation zum Vereins-Editor direkt aus dem Veranstalter-Profil ("Bearbeiten"-Button).
* **Fehler-Handling:** * **UI-Konsistenz:**
* 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. * Einführung eines einheitlichen "Zurück"-Buttons (Pfeil-Icon) in der Header-Zeile aller Detail- und Konfigurations-Screens.
* **UI-Kompaktheit:** * Kompakte Darstellung von Suchergebnissen in der Vereins-Suche (inkl. Logo-Vorschau).
* 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 ## 🧹 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. 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.
@@ -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<String> = emptyList(),
directoryOnly: Boolean = false,
enabled: Boolean = true,
modifier: Modifier = Modifier
)
@@ -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<String>,
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
)
}
}
@@ -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<String>,
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
)
}
@@ -4,11 +4,9 @@ package at.mocode.frontend.features.device.initialization.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete 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.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.* 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.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsEnumDropdown 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.DeviceInitializationValidator
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
import java.io.File
import javax.swing.JFileChooser
import javax.swing.UIManager
@Composable @Composable
actual fun DeviceInitializationConfig( actual fun DeviceInitializationConfig(
@@ -54,35 +51,28 @@ actual fun DeviceInitializationConfig(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("⚙️ Geräte-Konfiguration (${settings.networkRole})", style = MaterialTheme.typography.titleMedium) Text("⚙️ Geräte-Konfiguration (${settings.networkRole})", style = MaterialTheme.typography.titleMedium)
MsSettingsField( MsTextField(
value = settings.deviceName, value = settings.deviceName,
onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } }, onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } },
label = "Gerätename", label = "Gerätename",
placeholder = "z.B. Meldestelle-PC-1", placeholder = "z.B. Meldestelle-PC-1",
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName), isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.", errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
modifier = Modifier.focusRequester(deviceNameFocus).onKeyEvent { modifier = Modifier.focusRequester(deviceNameFocus)
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
focusManager.moveFocus(FocusDirection.Next)
true
} else false
}
) )
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
MsSettingsField( MsTextField(
value = settings.sharedKey, value = settings.sharedKey,
onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } }, onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } },
label = "Sicherheitsschlüssel (Sync-Key)", label = "Sicherheitsschlüssel (Sync-Key)",
placeholder = "Mindestens 8 Zeichen", placeholder = "Mindestens 8 Zeichen",
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey), 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(), 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( keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) }, onNext = { focusManager.moveFocus(FocusDirection.Next) },
onDone = { onDone = {
@@ -93,53 +83,20 @@ actual fun DeviceInitializationConfig(
} }
} }
), ),
modifier = Modifier.focusRequester(sharedKeyFocus).onKeyEvent { modifier = Modifier.focusRequester(sharedKeyFocus),
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
if (settings.networkRole == NetworkRole.MASTER) { onTrailingIconClick = { passwordVisible = !passwordVisible }
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"
)
}
}
) )
if (settings.networkRole == NetworkRole.MASTER) { if (settings.networkRole == NetworkRole.MASTER) {
OutlinedTextField( MsFilePicker(
value = settings.backupPath, label = "Backup-Verzeichnis (Pfad)",
onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } }, selectedPath = settings.backupPath,
label = { Text("Backup-Verzeichnis (Pfad)") }, onFileSelected = { selectedPath ->
placeholder = { Text("/pfad/zu/den/backups") }, viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus).onKeyEvent {
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
focusManager.moveFocus(FocusDirection.Next)
true
} else false
}, },
singleLine = true, directoryOnly = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), modifier = Modifier.focusRequester(backupPathFocus)
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)
) )
Text("Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.labelMedium) Text("Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.labelMedium)
@@ -313,12 +270,12 @@ private fun ClientEntryRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
OutlinedTextField( MsTextField(
value = name, value = name,
onValueChange = onNameChange, onValueChange = onNameChange,
label = { Text("Gerätename des Clients") }, label = "Gerätename des Clients",
modifier = Modifier.weight(1f).focusRequester(clientNameFocus), modifier = Modifier.weight(1f).focusRequester(clientNameFocus),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.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}")
}
}
@@ -27,6 +27,7 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation(projects.frontend.features.vereinFeature)
implementation(projects.frontend.core.designSystem) implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.network) implementation(projects.frontend.core.network)
implementation(projects.frontend.core.domain) implementation(projects.frontend.core.domain)
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -124,17 +125,26 @@ fun VeranstalterDetailScreen(
} }
Column(modifier = Modifier.fillMaxSize()) { 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 ───────────────────────────────────────── // ── Veranstalter-Header-Card ─────────────────────────────────────────
Surface( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
color = Color.White, colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, Color(0xFFE2E8F0)), border = BorderStroke(1.dp, Color(0xFFE2E8F0)),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(16.dp),
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
@@ -186,12 +196,12 @@ fun VeranstalterDetailScreen(
} }
// Profil bearbeiten // Profil bearbeiten
OutlinedButton( OutlinedButton(
onClick = { /* TODO */ }, onClick = { /* Navigation zu Vereinen */ },
border = BorderStroke(1.dp, Color(0xFFD1D5DB)), border = BorderStroke(1.dp, Color(0xFFD1D5DB)),
) { ) {
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(14.dp)) Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("Profil bearbeiten", fontSize = 13.sp) Text("Bearbeiten", fontSize = 13.sp)
} }
} }
} }
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.components.MsTextField
/** /**
* Formular zum Anlegen eines neuen Veranstalters (Vision_03: Screenshot 21). * Formular zum Anlegen eines neuen Veranstalters (Vision_03: Screenshot 21).
@@ -47,18 +49,27 @@ fun VeranstalterNeuScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
// Header // Header
Column(modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp)) { Row(
Text( modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp),
text = "Neuen Veranstalter anlegen", verticalAlignment = Alignment.CenterVertically,
fontSize = 22.sp, horizontalArrangement = Arrangement.spacedBy(12.dp)
fontWeight = FontWeight.Bold, ) {
) IconButton(onClick = onAbbrechen) {
Spacer(Modifier.height(4.dp)) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
Text( }
text = "Legen Sie einen neuen Veranstalter (Verein) mit OEPS-Daten an. Nach dem Speichern werden automatisch Login-Daten generiert.", Column {
fontSize = 13.sp, Text(
color = Color(0xFF6B7280), 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 // Info-Banner
@@ -110,65 +121,46 @@ fun VeranstalterNeuScreen(
// --- Vereinsdaten --- // --- Vereinsdaten ---
Text("Vereinsdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Text("Vereinsdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField( MsTextField(
value = vereinsname, value = vereinsname,
onValueChange = { vereinsname = it }, onValueChange = { vereinsname = it },
label = { Text("Vereinsname *") }, label = "Vereinsname *",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
) )
Column { MsTextField(
OutlinedTextField( value = oepsNummer,
value = oepsNummer, onValueChange = { oepsNummer = it },
onValueChange = { oepsNummer = it }, label = "OEPS-Nummer *",
label = { Text("OEPS-Nummer *") }, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(), helperText = "Offizielle Vereinsnummer des OEPS"
singleLine = true, )
)
Text(
text = "Offizielle Vereinsnummer des OEPS",
fontSize = 11.sp,
color = Color(0xFF2563EB),
modifier = Modifier.padding(start = 4.dp, top = 2.dp),
)
}
HorizontalDivider() HorizontalDivider()
// --- Kontaktdaten --- // --- Kontaktdaten ---
Text("Kontaktdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Text("Kontaktdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField( MsTextField(
value = ansprechpartner, value = ansprechpartner,
onValueChange = { ansprechpartner = it }, onValueChange = { ansprechpartner = it },
label = { Text("Ansprechpartner *") }, label = "Ansprechpartner *",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
) )
Column { MsTextField(
OutlinedTextField( value = email,
value = email, onValueChange = { email = it },
onValueChange = { email = it }, label = "E-Mail *",
label = { Text("E-Mail *") }, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(), helperText = "Login-Daten werden an diese Adresse verschickt"
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),
)
}
OutlinedTextField( MsTextField(
value = telefon, value = telefon,
onValueChange = { telefon = it }, onValueChange = { telefon = it },
label = { Text("Telefon") }, label = "Telefon",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
) )
HorizontalDivider() HorizontalDivider()
@@ -176,28 +168,25 @@ fun VeranstalterNeuScreen(
// --- Adresse --- // --- Adresse ---
Text("Adresse", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Text("Adresse", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField( MsTextField(
value = strasse, value = strasse,
onValueChange = { strasse = it }, onValueChange = { strasse = it },
label = { Text("Straße & Hausnummer") }, label = "Straße & Hausnummer",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField( MsTextField(
value = plz, value = plz,
onValueChange = { plz = it }, onValueChange = { plz = it },
label = { Text("PLZ") }, label = "PLZ",
modifier = Modifier.width(120.dp), modifier = Modifier.width(120.dp),
singleLine = true,
) )
OutlinedTextField( MsTextField(
value = ort, value = ort,
onValueChange = { ort = it }, onValueChange = { ort = it },
label = { Text("Ort") }, label = "Ort",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
singleLine = true,
) )
} }
} }
@@ -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 @Composable
private fun VereinCardPreview( private fun VereinCardPreview(
name: String, name: String,
@@ -113,7 +134,8 @@ private fun VereinCardPreview(
bundesland: String?, bundesland: String?,
logoUrl: String?, logoUrl: String?,
logoBase64: String?, logoBase64: String?,
status: VereinStatus status: VereinStatus,
onEdit: (() -> Unit)? = null
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
@@ -209,6 +231,15 @@ private fun VereinCardPreview(
size = ButtonSize.SMALL 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, items = uiState.searchResults,
columns = listOf( columns = listOf(
MsColumnDefinition( MsColumnDefinition(
title = "Name", title = "Verein",
weight = 1.5f, weight = 2f,
cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) } cellRenderer = {
), Row(
MsColumnDefinition( verticalAlignment = Alignment.CenterVertically,
title = "Ort", horizontalArrangement = Arrangement.spacedBy(12.dp),
weight = 1f, modifier = Modifier.padding(vertical = 4.dp)
cellRenderer = { Text(it.ort ?: "-", style = MaterialTheme.typography.bodySmall) } ) {
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( MsColumnDefinition(
title = "OePS-Nr", title = "OePS-Nr",
@@ -4,7 +4,9 @@ import androidx.compose.foundation.background
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.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.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@@ -14,14 +16,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsFilePicker
import at.mocode.frontend.features.zns.import.ZnsImportViewModel import at.mocode.frontend.features.zns.import.ZnsImportViewModel
import org.koin.compose.viewmodel.koinViewModel 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 import java.io.File
@Composable @Composable
@@ -53,36 +50,40 @@ fun StammdatenImportScreen(
// Datei-Auswahl // Datei-Auswahl
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Datei auswählen", style = MaterialTheme.typography.titleMedium) Text("ZNS-Datei auswählen", style = MaterialTheme.typography.titleMedium)
Row( Text(
verticalAlignment = Alignment.CenterVertically, "Wählen Sie entweder die gesamte ZNS.zip oder eine einzelne .dat Datei (z.B. VEREIN01.dat).",
horizontalArrangement = Arrangement.spacedBy(8.dp), style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.onSurfaceVariant
) { )
OutlinedTextField(
value = state.selectedFilePath ?: "", MsFilePicker(
onValueChange = {}, label = "Pfad zur ZNS-Datei",
readOnly = true, selectedPath = state.selectedFilePath,
placeholder = { Text("ZNS-Datei auswählen (.zip, .dat)...") }, onFileSelected = { viewModel.onFileSelected(it) },
modifier = Modifier.weight(1f), fileExtensions = listOf("zip", "dat"),
singleLine = true, enabled = !state.isUploading && !(!state.isFinished && state.jobId != null)
) )
Button(
onClick = { if (state.isUploading || (state.jobId != null && !state.isFinished)) {
val chooser = JFileChooser() Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
chooser.dialogTitle = "ZNS-Datei auswählen" LinearProgressIndicator(
chooser.fileFilter = FileNameExtensionFilter("ZNS Dateien (*.zip, *.dat)", "zip", "dat") progress = { (state.progress / 100f).coerceIn(0f, 1f) },
chooser.isAcceptAllFileFilterUsed = false modifier = Modifier.fillMaxWidth(),
val result = chooser.showOpenDialog(null) color = MaterialTheme.colorScheme.primary,
if (result == JFileChooser.APPROVE_OPTION) { trackColor = MaterialTheme.colorScheme.primaryContainer
viewModel.onFileSelected(chooser.selectedFile.absolutePath) )
} Text(
}, text = if (state.isUploading) "Datei wird hochgeladen..." else "Import wird verarbeitet... (${state.progress}%)",
enabled = !state.isUploading && !(!state.isFinished && state.jobId != null), style = MaterialTheme.typography.labelSmall
) { )
Icon(Icons.Default.FolderOpen, contentDescription = null) if (state.progressDetail.isNotBlank()) {
Spacer(Modifier.width(4.dp)) Text(
Text("Durchsuchen") text = state.progressDetail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }
@@ -1,6 +1,6 @@
{ {
"deviceName": "Meldestelle", "deviceName": "Meldestelle",
"sharedKey": "Password", "sharedKey": "Paassword",
"backupPath": "/mocode/meldestelle/docs/temp", "backupPath": "/mocode/meldestelle/docs/temp",
"networkRole": "MASTER", "networkRole": "MASTER",
"expectedClients": [ "expectedClients": [