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:
Stefan Mogeritsch 2026-04-20 02:00:31 +02:00
parent 9fe889b2c1
commit d4aeba4666
11 changed files with 337 additions and 248 deletions

View File

@ -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.

View File

@ -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
)

View File

@ -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
)
}
}

View File

@ -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
)
}

View File

@ -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}")
}
}

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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,
)
}
}

View File

@ -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",

View File

@ -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
)
}
}
}

View File

@ -1,6 +1,6 @@
{
"deviceName": "Meldestelle",
"sharedKey": "Password",
"sharedKey": "Paassword",
"backupPath": "/mocode/meldestelle/docs/temp",
"networkRole": "MASTER",
"expectedClients": [