chore: implementiere Logo-Upload-Zone mit Base64-Unterstützung, verbessere Vereinsverwaltung mit kompakten Feldern und nutzerspezifischen Uploadoptionen, optimiere Desktop-UX und Navigation

This commit is contained in:
2026-04-20 01:20:16 +02:00
parent dfaa2e8545
commit 85ac1cae9c
12 changed files with 485 additions and 70 deletions
@@ -63,7 +63,12 @@ actual fun DeviceInitializationConfig(
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
modifier = Modifier.focusRequester(deviceNameFocus)
modifier = Modifier.focusRequester(deviceNameFocus).onKeyEvent {
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
focusManager.moveFocus(FocusDirection.Next)
true
} else false
}
)
var passwordVisible by remember { mutableStateOf(false) }
@@ -88,7 +93,16 @@ actual fun DeviceInitializationConfig(
}
}
),
modifier = Modifier.focusRequester(sharedKeyFocus),
modifier = Modifier.focusRequester(sharedKeyFocus).onKeyEvent {
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
if (settings.networkRole == NetworkRole.MASTER) {
focusManager.moveFocus(FocusDirection.Next)
} else if (DeviceInitializationValidator.canContinue(settings)) {
viewModel.completeInitialization()
}
true
} else false
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
@@ -105,11 +119,17 @@ actual fun DeviceInitializationConfig(
onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } },
label = { Text("Backup-Verzeichnis (Pfad)") },
placeholder = { Text("/pfad/zu/den/backups") },
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus),
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus).onKeyEvent {
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
focusManager.moveFocus(FocusDirection.Next)
true
} else false
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) }
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) }
),
trailingIcon = {
IconButton(onClick = {
selectBackupPath(settings.backupPath) { selectedPath ->
@@ -2,15 +2,21 @@ package at.mocode.frontend.features.veranstalter.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.components.MsDatePickerField
import at.mocode.frontend.core.designsystem.components.MsTextField
/**
* Formular zum Anlegen einer neuen Veranstaltung (Titel + Datumspfad). Pflichtfelder: Titel, Datum von/bis.
@@ -26,8 +32,9 @@ fun VeranstaltungKonfigScreen(
var datumVon by remember { mutableStateOf("") }
var datumBis by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
val datesPresent = datumVon.isNotBlank() && datumBis.isNotBlank()
// Einfache Validierung: YYYY-MM-DD Format erzwingen wir hier nicht strikt; wenn beide gesetzt, prüfen wir lexikografisch
// Einfache Validierung: YYYY-MM-DD Format erzwingen wir hier nicht strikt; wenn beide gesetzt sind, prüfen wir lexikografisch
val dateOrderOk = !datesPresent || datumBis >= datumVon
val valid = titel.isNotBlank() && datesPresent && dateOrderOk
@@ -61,37 +68,36 @@ fun VeranstaltungKonfigScreen(
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Stammdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField(
MsTextField(
value = titel,
onValueChange = { titel = it },
label = { Text("Titel *") },
label = "Titel *",
placeholder = "z.B. Frühjahrsturnier 2026",
singleLine = true,
modifier = Modifier.fillMaxWidth(),
isError = titel.isBlank(),
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
)
Text("Zeitraum", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
MsDatePickerField(
label = "von *",
value = datumVon,
onValueChange = { datumVon = it },
label = { Text("von (YYYY-MM-DD) *") },
singleLine = true,
modifier = Modifier.weight(1f),
isError = datumVon.isBlank(),
)
OutlinedTextField(
MsDatePickerField(
label = "bis *",
value = datumBis,
onValueChange = { datumBis = it },
label = { Text("bis (YYYY-MM-DD) *") },
singleLine = true,
modifier = Modifier.weight(1f),
isError = datumBis.isBlank() || (datesPresent && !dateOrderOk),
errorMessage = if (datesPresent && !dateOrderOk) "Ungültiger Zeitraum" else null
)
}
if (datesPresent && !dateOrderOk) {
Text("Das bis-Datum darf nicht vor dem von-Datum liegen.", color = MaterialTheme.colorScheme.error, fontSize = 12.sp)
}
}
}
@@ -10,13 +10,13 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Business
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Map
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -25,6 +25,17 @@ import at.mocode.frontend.core.designsystem.models.PlaceholderContent
import at.mocode.frontend.features.verein.domain.Bundesland
import at.mocode.frontend.features.verein.domain.Verein
import at.mocode.frontend.features.verein.domain.VereinStatus
import kotlinx.coroutines.launch
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalEncodingApi::class)
expect fun decodeBase64ToImage(base64: String): ImageBitmap?
@Composable
expect fun LogoUploadZone(
modifier: Modifier = Modifier,
onFileSelected: (ByteArray) -> Unit
)
@Composable
fun VereinScreen(
@@ -54,6 +65,7 @@ fun VereinScreen(
hausnummer = if (uiState.isEditing) uiState.editHausnummer else uiState.selectedVerein?.hausnummer,
bundesland = if (uiState.isEditing) uiState.editBundesland else uiState.selectedVerein?.bundesland,
logoUrl = if (uiState.isEditing) uiState.editLogoUrl else uiState.selectedVerein?.logoUrl,
logoBase64 = if (uiState.isEditing) uiState.editLogoBase64 else uiState.selectedVerein?.logoBase64,
status = if (uiState.isEditing) uiState.editStatus else uiState.selectedVerein?.status ?: VereinStatus.AKTIV
)
@@ -74,6 +86,7 @@ fun VereinScreen(
onBundeslandChange = viewModel::onEditBundeslandChange,
onStatusChange = viewModel::onEditStatusChange,
onLogoUrlChange = viewModel::onEditLogoUrlChange,
onLogoFileSelected = viewModel::onLogoFileSelected,
onSave = viewModel::onSave,
onCancel = viewModel::onCancel
)
@@ -99,6 +112,7 @@ private fun VereinCardPreview(
hausnummer: String?,
bundesland: String?,
logoUrl: String?,
logoBase64: String?,
status: VereinStatus
) {
val uriHandler = LocalUriHandler.current
@@ -122,10 +136,22 @@ private fun VereinCardPreview(
.border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), CircleShape),
contentAlignment = Alignment.Center
) {
if (!logoUrl.isNullOrBlank()) {
Icon(Icons.Default.Business, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary)
if (!logoBase64.isNullOrBlank()) {
val bitmap = remember(logoBase64) { decodeBase64ToImage(logoBase64) }
if (bitmap != null) {
androidx.compose.foundation.Image(
bitmap = bitmap,
contentDescription = "Vereinslogo",
modifier = Modifier.fillMaxSize().clip(CircleShape),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
} else {
Icon(Icons.Default.Image, "Logo Fehler", modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.error)
}
} else if (!logoUrl.isNullOrBlank()) {
Icon(Icons.Default.Business, "Logo URL", modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary)
} else {
Icon(Icons.Default.Business, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary)
Icon(Icons.Default.Business, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
}
}
@@ -261,6 +287,7 @@ private fun VereinEditorContent(
onBundeslandChange: (String) -> Unit,
onStatusChange: (VereinStatus) -> Unit,
onLogoUrlChange: (String) -> Unit,
onLogoFileSelected: (ByteArray) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit
) {
@@ -279,40 +306,28 @@ private fun VereinEditorContent(
value = uiState.editName,
onValueChange = onNameChange,
label = "Name (Kurz)",
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
compact = true
)
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(8.dp))
MsTextField(
value = uiState.editLangname,
onValueChange = onLangnameChange,
label = "Vollständiger Name",
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
compact = true
)
}
// Logo Upload Sektion
Column(
LogoUploadZone(
modifier = Modifier
.width(200.dp)
.height(120.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Image, null, tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
Text("Logo hierher ziehen", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Spacer(Modifier.height(4.dp))
MsButton(
text = "Wählen",
onClick = { /* FilePicker Call via JFileChooser (JVM only) */ },
variant = ButtonVariant.SECONDARY,
size = ButtonSize.SMALL
)
}
.width(180.dp)
.height(110.dp),
onFileSelected = onLogoFileSelected
)
}
Spacer(Modifier.height(16.dp))
@@ -322,7 +337,8 @@ private fun VereinEditorContent(
value = uiState.editOepsNr,
onValueChange = onOepsNrChange,
label = "OePS-Nr",
modifier = Modifier.weight(1f)
modifier = Modifier.weight(0.5f),
compact = true
)
MsEnumDropdown(
label = "Status",
@@ -330,49 +346,53 @@ private fun VereinEditorContent(
selectedOption = uiState.editStatus,
onOptionSelected = onStatusChange,
optionLabel = { it.label },
modifier = Modifier.weight(1f)
modifier = Modifier.weight(0.5f)
)
}
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(12.dp))
Text("Adresse", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text("Adresse", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editStrasse,
onValueChange = onStrasseChange,
label = "Straße",
modifier = Modifier.weight(0.7f)
modifier = Modifier.weight(0.7f),
compact = true
)
MsTextField(
value = uiState.editHausnummer,
onValueChange = onHausnummerChange,
label = "Nr.",
modifier = Modifier.weight(0.3f)
modifier = Modifier.weight(0.3f),
compact = true
)
}
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editPlz,
onValueChange = onPlzChange,
label = "PLZ",
modifier = Modifier.weight(0.2f)
modifier = Modifier.weight(0.2f),
compact = true
)
MsTextField(
value = uiState.editOrt,
onValueChange = onOrtChange,
label = "Ort",
modifier = Modifier.weight(0.4f)
modifier = Modifier.weight(0.4f),
compact = true
)
MsEnumDropdown(
label = "Bundesland",
options = Bundesland.entries.toTypedArray(),
selectedOption = Bundesland.entries.find { it.label == uiState.editBundesland },
selectedOption = Bundesland.entries.find { it.label == uiState.editBundesland } ?: Bundesland.WIEN,
onOptionSelected = { onBundeslandChange(it.label) },
optionLabel = { it.label },
modifier = Modifier.weight(0.4f)
@@ -9,6 +9,8 @@ import at.mocode.frontend.features.verein.domain.Verein
import at.mocode.frontend.features.verein.domain.VereinRepository
import at.mocode.frontend.features.verein.domain.VereinStatus
import kotlinx.coroutines.launch
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* UI-State für die Vereins-Verwaltung.
@@ -157,6 +159,18 @@ open class VereinViewModel(
uiState = uiState.copy(editStatus = value)
}
@OptIn(ExperimentalEncodingApi::class)
fun onLogoFileSelected(bytes: ByteArray) {
println("[VereinViewModel] Logo Datei empfangen, konvertiere zu Base64...")
try {
val base64 = Base64.encode(bytes)
uiState = uiState.copy(editLogoBase64 = base64)
println("[VereinViewModel] Logo erfolgreich in Base64 konvertiert (Länge: ${base64.length})")
} catch (e: Exception) {
println("[VereinViewModel] Fehler bei Base64 Konvertierung: ${e.message}")
}
}
fun onSave() {
uiState = uiState.copy(isLoading = true, error = null)
val verein = (uiState.selectedVerein ?: Verein(
@@ -0,0 +1,102 @@
package at.mocode.frontend.features.verein.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.ButtonSize
import at.mocode.frontend.core.designsystem.components.ButtonVariant
import at.mocode.frontend.core.designsystem.components.MsButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import androidx.compose.ui.awt.ComposeWindow
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.Image
import java.awt.FileDialog
import java.awt.Frame
import java.awt.Window
import java.io.File
import javax.swing.SwingUtilities
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalEncodingApi::class)
actual fun decodeBase64ToImage(base64: String): ImageBitmap? {
return try {
val bytes = Base64.decode(base64)
Image.makeFromEncoded(bytes).toComposeImageBitmap()
} catch (e: Exception) {
null
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun LogoUploadZone(
modifier: Modifier,
onFileSelected: (ByteArray) -> Unit
) {
val scope = rememberCoroutineScope()
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.Image,
null,
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
modifier = Modifier.size(32.dp)
)
Text("Logo auswählen", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Spacer(Modifier.height(4.dp))
MsButton(
text = "Datei wählen",
onClick = {
scope.launch(Dispatchers.IO) {
try {
println("[LogoUpload] Öffne Datei-Dialog...")
val fileDialog = FileDialog(null as Frame?, "Logo auswählen", FileDialog.LOAD)
fileDialog.isVisible = true
val directory = fileDialog.directory
val file = fileDialog.file
if (directory != null && file != null) {
val selectedFile = File(directory, file)
println("[LogoUpload] Datei ausgewählt: ${selectedFile.absolutePath}")
val bytes = selectedFile.readBytes()
println("[LogoUpload] Bytes gelesen: ${bytes.size}")
onFileSelected(bytes)
} else {
println("[LogoUpload] Auswahl abgebrochen")
}
} catch (e: Exception) {
println("[LogoUpload] FEHLER: ${e.message}")
e.printStackTrace()
}
}
},
variant = ButtonVariant.SECONDARY,
size = ButtonSize.SMALL
)
}
}
}
@@ -0,0 +1,18 @@
package at.mocode.frontend.features.verein.presentation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalEncodingApi::class)
actual fun decodeBase64ToImage(base64: String): ImageBitmap? = null
@Composable
actual fun LogoUploadZone(
modifier: Modifier,
onFileSelected: (ByteArray) -> Unit
) {
// Nicht implementiert für WasmJs
}