chore: entferne nicht genutzte NennungsMaske-Komponente, extrahiere AktionsButtonLeiste in separaten Komponentenordner
This commit is contained in:
+31
-7
@@ -8,25 +8,37 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.features.deviceinitialization.domain.DeviceInitializationValidator
|
||||
import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
|
||||
|
||||
@Composable
|
||||
fun DeviceInitializationScreen(
|
||||
viewModel: DeviceInitializationViewModel
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val (roleSelectorFocus, nextButtonFocus) = remember { FocusRequester.createRefs() }
|
||||
|
||||
// Automatische Discovery starten, wenn wir auf Schritt 0 sind
|
||||
LaunchedEffect(uiState.currentStep) {
|
||||
if (uiState.currentStep == 0) viewModel.startDiscovery()
|
||||
if (uiState.currentStep == 0) {
|
||||
viewModel.startDiscovery()
|
||||
roleSelectorFocus.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
@@ -57,12 +69,24 @@ fun DeviceInitializationScreen(
|
||||
|
||||
NetworkRoleSelector(
|
||||
selectedRole = uiState.settings.networkRole,
|
||||
onRoleSelected = { viewModel.setNetworkRole(it) }
|
||||
onRoleSelected = {
|
||||
viewModel.setNetworkRole(it)
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
},
|
||||
modifier = Modifier.focusRequester(roleSelectorFocus)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.nextStep() },
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.focusRequester(nextButtonFocus)
|
||||
.onKeyEvent {
|
||||
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||
viewModel.nextStep()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
Text("Weiter")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
|
||||
|
||||
+11
-1
@@ -32,28 +32,37 @@ class DeviceInitializationViewModel(
|
||||
}
|
||||
|
||||
fun nextStep() {
|
||||
println("[DeviceInit] Übergang zu Schritt ${uiState.value.currentStep + 1}")
|
||||
_uiState.update { it.copy(currentStep = it.currentStep + 1) }
|
||||
}
|
||||
|
||||
fun previousStep() {
|
||||
println("[DeviceInit] Zurück zu Schritt ${(uiState.value.currentStep - 1).coerceAtLeast(0)}")
|
||||
_uiState.update { it.copy(currentStep = (it.currentStep - 1).coerceAtLeast(0)) }
|
||||
}
|
||||
|
||||
fun updateSettings(update: (DeviceInitializationSettings) -> DeviceInitializationSettings) {
|
||||
_uiState.update { it.copy(settings = update(it.settings)) }
|
||||
_uiState.update {
|
||||
val newSettings = update(it.settings)
|
||||
it.copy(settings = newSettings)
|
||||
}
|
||||
}
|
||||
|
||||
fun setNetworkRole(role: NetworkRole) {
|
||||
println("[DeviceInit] Netzwerk-Rolle gesetzt: $role")
|
||||
updateSettings { it.copy(networkRole = role) }
|
||||
}
|
||||
|
||||
fun addExpectedClient(name: String, role: NetworkRole) {
|
||||
println("[DeviceInit] Erwarteter Client hinzugefügt: $name ($role)")
|
||||
updateSettings {
|
||||
it.copy(expectedClients = it.expectedClients + ExpectedClient(name, role))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeExpectedClient(index: Int) {
|
||||
val client = _uiState.value.settings.expectedClients.getOrNull(index)
|
||||
println("[DeviceInit] Erwarteter Client entfernt: ${client?.name}")
|
||||
updateSettings {
|
||||
val newList = it.expectedClients.toMutableList().apply { removeAt(index) }
|
||||
it.copy(expectedClients = newList)
|
||||
@@ -61,6 +70,7 @@ class DeviceInitializationViewModel(
|
||||
}
|
||||
|
||||
fun completeInitialization() {
|
||||
println("[DeviceInit] Konfiguration abgeschlossen. Speichere Einstellungen...")
|
||||
onInitializationComplete(_uiState.value.settings)
|
||||
}
|
||||
}
|
||||
|
||||
+22
-7
@@ -8,27 +8,41 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
|
||||
|
||||
@Composable
|
||||
fun NetworkRoleSelector(
|
||||
selectedRole: NetworkRole,
|
||||
onRoleSelected: (NetworkRole) -> Unit
|
||||
onRoleSelected: (NetworkRole) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
NetworkRoleCard(
|
||||
title = "Master (Host)",
|
||||
description = "Verwaltet die zentrale Datenbank und koordiniert den Sync.",
|
||||
isSelected = selectedRole == NetworkRole.MASTER,
|
||||
onClick = { onRoleSelected(NetworkRole.MASTER) }
|
||||
onClick = { onRoleSelected(NetworkRole.MASTER) },
|
||||
modifier = Modifier.onKeyEvent {
|
||||
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||
onRoleSelected(NetworkRole.MASTER)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
)
|
||||
|
||||
NetworkRoleCard(
|
||||
title = "Client",
|
||||
description = "Verbindet sich mit einem Master-Gerät im lokalen Netzwerk.",
|
||||
isSelected = selectedRole == NetworkRole.CLIENT,
|
||||
onClick = { onRoleSelected(NetworkRole.CLIENT) }
|
||||
onClick = { onRoleSelected(NetworkRole.CLIENT) },
|
||||
modifier = Modifier.onKeyEvent {
|
||||
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||
onRoleSelected(NetworkRole.CLIENT)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,18 +52,19 @@ private fun NetworkRoleCard(
|
||||
title: String,
|
||||
description: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = onClick
|
||||
onClick = null
|
||||
)
|
||||
Column {
|
||||
Text(title, style = MaterialTheme.typography.labelLarge)
|
||||
|
||||
+144
-60
@@ -1,6 +1,8 @@
|
||||
package at.mocode.frontend.features.deviceinitialization.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
|
||||
@@ -13,7 +15,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component3
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component4
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component5
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -26,8 +34,6 @@ import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
|
||||
import java.io.File
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.UIManager
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
|
||||
@Composable
|
||||
actual fun DeviceInitializationConfig(
|
||||
@@ -36,7 +42,11 @@ actual fun DeviceInitializationConfig(
|
||||
) {
|
||||
val settings = uiState.settings
|
||||
val focusManager = LocalFocusManager.current
|
||||
val (deviceNameFocus, sharedKeyFocus, backupPathFocus) = remember { FocusRequester.createRefs() }
|
||||
val (deviceNameFocus, sharedKeyFocus, backupPathFocus, clientNameFocus, clientRoleFocus) = remember { FocusRequester.createRefs() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
deviceNameFocus.requestFocus()
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
@@ -50,8 +60,15 @@ actual fun DeviceInitializationConfig(
|
||||
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
|
||||
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { sharedKeyFocus.requestFocus() }),
|
||||
modifier = Modifier.focusRequester(deviceNameFocus)
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
@@ -63,15 +80,31 @@ actual fun DeviceInitializationConfig(
|
||||
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
|
||||
errorText = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = {
|
||||
if (settings.networkRole == NetworkRole.MASTER) {
|
||||
backupPathFocus.requestFocus()
|
||||
} else {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = if (settings.networkRole == NetworkRole.MASTER) ImeAction.Next else ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Next) },
|
||||
onDone = {
|
||||
if (DeviceInitializationValidator.canContinue(settings)) {
|
||||
viewModel.completeInitialization()
|
||||
} else {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
}),
|
||||
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(
|
||||
@@ -88,31 +121,24 @@ 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),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Next) }
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus).onKeyEvent {
|
||||
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||
selectBackupPath(settings.backupPath) { selectedPath ->
|
||||
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Next) }
|
||||
),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
|
||||
val chooser = JFileChooser().apply {
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
dialogTitle = "Backup-Verzeichnis wählen"
|
||||
if (settings.backupPath.isNotEmpty()) {
|
||||
val currentDir = File(settings.backupPath)
|
||||
if (currentDir.exists()) currentDirectory = currentDir
|
||||
}
|
||||
}
|
||||
val result = chooser.showOpenDialog(null)
|
||||
if (result == JFileChooser.APPROVE_OPTION) {
|
||||
val selectedPath = chooser.selectedFile.absolutePath
|
||||
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
||||
println("[DeviceInit] Backup-Verzeichnis gewählt: $selectedPath")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[DeviceInit] [Error] Fehler beim Öffnen des Verzeichnis-Wählers: ${e.message}")
|
||||
selectBackupPath(settings.backupPath) { selectedPath ->
|
||||
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Outlined.FolderOpen, contentDescription = "Verzeichnis wählen")
|
||||
@@ -185,33 +211,27 @@ actual fun DeviceInitializationConfig(
|
||||
var newClientName by remember { mutableStateOf("") }
|
||||
var newClientRole by remember { mutableStateOf(NetworkRole.RICHTER) }
|
||||
var showAddClient by remember { mutableStateOf(false) }
|
||||
val addClientNameFocus = remember { FocusRequester() }
|
||||
|
||||
if (showAddClient) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LaunchedEffect(Unit) { addClientNameFocus.requestFocus() }
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newClientName,
|
||||
onValueChange = { newClientName = it },
|
||||
label = { Text("Gerätename des Clients") },
|
||||
modifier = Modifier.weight(1f).focusRequester(addClientNameFocus),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||
)
|
||||
|
||||
MsEnumDropdown(
|
||||
label = "Rolle",
|
||||
options = NetworkRole.entries.filter { it != NetworkRole.MASTER }.toTypedArray(),
|
||||
selectedOption = newClientRole,
|
||||
onOptionSelected = { newClientRole = it },
|
||||
modifier = Modifier.weight(0.5f)
|
||||
)
|
||||
}
|
||||
LaunchedEffect(Unit) { clientNameFocus.requestFocus() }
|
||||
ClientEntryRow(
|
||||
name = newClientName,
|
||||
onNameChange = { newClientName = it },
|
||||
role = newClientRole,
|
||||
onRoleChange = { newClientRole = it },
|
||||
focusManager = focusManager,
|
||||
clientNameFocus = clientNameFocus,
|
||||
clientRoleFocus = clientRoleFocus,
|
||||
onEnter = {
|
||||
if (newClientName.isNotBlank()) {
|
||||
viewModel.addExpectedClient(newClientName, newClientRole)
|
||||
println("[DeviceInit] Client hinzugefügt: $newClientName ($newClientRole)")
|
||||
newClientName = ""
|
||||
showAddClient = false
|
||||
}
|
||||
}
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
@@ -282,6 +302,48 @@ actual fun DeviceInitializationConfig(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClientEntryRow(
|
||||
name: String,
|
||||
onNameChange: (String) -> Unit,
|
||||
role: NetworkRole,
|
||||
onRoleChange: (NetworkRole) -> Unit,
|
||||
focusManager: androidx.compose.ui.focus.FocusManager,
|
||||
clientNameFocus: FocusRequester,
|
||||
clientRoleFocus: FocusRequester,
|
||||
onEnter: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
label = { Text("Gerätename des Clients") },
|
||||
modifier = Modifier.weight(1f).focusRequester(clientNameFocus),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||
)
|
||||
|
||||
MsEnumDropdown(
|
||||
label = "Rolle",
|
||||
options = NetworkRole.entries.filter { it != NetworkRole.MASTER }.toTypedArray(),
|
||||
selectedOption = role,
|
||||
onOptionSelected = onRoleChange,
|
||||
modifier = Modifier.weight(0.5f).focusRequester(clientRoleFocus).onKeyEvent {
|
||||
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||
onEnter()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MsSettingsField(
|
||||
value: String,
|
||||
@@ -314,3 +376,25 @@ private fun MsSettingsField(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -40,6 +40,9 @@ data class Bewerb(
|
||||
// --- Startwunsch ---
|
||||
enum class Startwunsch { VORNE, HINTEN, KEINE_PRAEFERENZ }
|
||||
|
||||
enum class NennungTab { REITER, PFERD, BEWERBE }
|
||||
enum class VerkaufTab { VERKAUF, BUCHUNGEN }
|
||||
|
||||
// --- Nennung ---
|
||||
data class Nennung(
|
||||
val tag: String,
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package at.mocode.frontend.features.nennung.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.features.nennung.domain.*
|
||||
import at.mocode.frontend.features.nennung.presentation.components.*
|
||||
import at.mocode.frontend.features.nennung.presentation.online.OnlineNennungEingang
|
||||
import at.mocode.frontend.features.nennung.presentation.tabs.*
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun NennungManagementScreen(
|
||||
viewModel: NennungViewModel,
|
||||
onStartlisteOeffnen: () -> Unit = {},
|
||||
onErgebnisseOeffnen: () -> Unit = {},
|
||||
onAbrechnungOeffnen: () -> Unit = {},
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
// Status-Snackbar
|
||||
state.statusMeldung?.let { meldung ->
|
||||
LaunchedEffect(meldung) {
|
||||
kotlinx.coroutines.delay(3000.milliseconds)
|
||||
viewModel.statusMeldungDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
// --- Status-Banner ---
|
||||
state.statusMeldung?.let { meldung ->
|
||||
Surface(
|
||||
color = if (meldung.startsWith("✅")) Color(0xFF388E3C)
|
||||
else if (meldung.startsWith("⚠️")) Color(0xFFF57C00)
|
||||
else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = meldung,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Zeile 1: Online-Nennungen (nur wenn vorhanden, 20% Höhe) ---
|
||||
if (state.onlineNennungen.isNotEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(150.dp)) {
|
||||
OnlineNennungEingang(
|
||||
state = state,
|
||||
onRefresh = viewModel::loadOnlineNennungen,
|
||||
onUebernehmen = viewModel::uebernehmeOnlineNennung
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
}
|
||||
|
||||
// --- Zeile 2: Pferd/Reiter + Verkauf/Buchungen (40% Höhe) ---
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(0.4f)
|
||||
) {
|
||||
// Linke Hälfte: Pferd & Reiter Suche (60%)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(0.6f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
PferdReiterEingabe(
|
||||
state = state,
|
||||
onPferdSucheChanged = viewModel::onPferdSucheChanged,
|
||||
onPferdSelected = viewModel::onPferdSelected,
|
||||
onPferdLeeren = viewModel::onPferdLeeren,
|
||||
onReiterSucheChanged = viewModel::onReiterSucheChanged,
|
||||
onReiterSelected = viewModel::onReiterSelected,
|
||||
onReiterLeeren = viewModel::onReiterLeeren,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxHeight().width(1.dp),
|
||||
thickness = DividerDefaults.Thickness,
|
||||
color = DividerDefaults.color
|
||||
)
|
||||
|
||||
// Rechte Hälfte: Verkauf/Buchungen (40%)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(0.4f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
VerkaufBuchungenPanel(
|
||||
state = state,
|
||||
onTabChanged = viewModel::onVerkaufTabChanged,
|
||||
onMengeChanged = viewModel::onVerkaufMengeChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
|
||||
// --- Zeile 3: Aktions-Buttons (fix) ---
|
||||
AktionsButtonLeiste(
|
||||
canNennen = state.selectedPferd != null && state.selectedReiter != null,
|
||||
onStartlisteOeffnen = onStartlisteOeffnen,
|
||||
onErgebnisseOeffnen = onErgebnisseOeffnen,
|
||||
onAbrechnungOeffnen = onAbrechnungOeffnen,
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
|
||||
// --- Zeile 4: Nennungstabelle + Bewerbsliste (50% Höhe) ---
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(0.5f)
|
||||
) {
|
||||
// Links: Nennungsübersicht (60%)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(0.6f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
NennungenTabelle(
|
||||
state = state,
|
||||
nennungen = viewModel.nennungenFuerAktuellen(),
|
||||
onTabChanged = viewModel::onNennungTabChanged,
|
||||
onStornieren = viewModel::nennungStornieren,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxHeight().width(1.dp),
|
||||
thickness = DividerDefaults.Thickness,
|
||||
color = DividerDefaults.color
|
||||
)
|
||||
|
||||
// Rechts: Bewerbsliste (40%)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(0.4f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
BewerbslistePanel(
|
||||
bewerbe = viewModel.gefilterteBewerbe(),
|
||||
nennungen = state.nennungen,
|
||||
selectedPferd = state.selectedPferd,
|
||||
selectedReiter = state.selectedReiter,
|
||||
spartFilter = state.spartFilter,
|
||||
onSpartFilterChanged = viewModel::onSpartFilterChanged,
|
||||
onNennung = { bewerb -> viewModel.nennungDurchfuehren(bewerb) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-3
@@ -35,9 +35,6 @@ data class NennungUiState(
|
||||
val isOnlineLoading: Boolean = false
|
||||
)
|
||||
|
||||
enum class NennungTab { REITER, PFERD, BEWERBE }
|
||||
enum class VerkaufTab { VERKAUF, BUCHUNGEN }
|
||||
|
||||
class NennungViewModel : ViewModel(), KoinComponent {
|
||||
|
||||
private val apiClient: HttpClient by inject(named("apiClient"))
|
||||
|
||||
-832
@@ -1,832 +0,0 @@
|
||||
package at.mocode.frontend.features.nennung.presentation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.features.nennung.domain.*
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private var lastClickTime: Long = 0L
|
||||
private var lastClickedBewerb: Int? = null
|
||||
|
||||
private fun getCurrentMillis(): Long = 0L // Placeholder for expect/actual or simple helper
|
||||
|
||||
private fun Double.round(decimals: Int): Double {
|
||||
var multiplier = 1.0
|
||||
repeat(decimals) { multiplier *= 10 }
|
||||
return kotlin.math.round(this * multiplier) / multiplier
|
||||
}
|
||||
|
||||
// Farben für Startwunsch-Markierung
|
||||
private val FarbeVorne = Color(0xFFE8F5E9) // Grün
|
||||
private val FarbeHinten = Color(0xFFE3F2FD) // Blau
|
||||
private val FarbeDressur = Color(0xFF3F51B5) // Indigo
|
||||
private val FarbeSpringen = Color(0xFFE65100) // Orange
|
||||
|
||||
@Composable
|
||||
fun NennungsMaske(
|
||||
viewModel: NennungViewModel,
|
||||
onStartlisteOeffnen: () -> Unit = {},
|
||||
onErgebnisseOeffnen: () -> Unit = {},
|
||||
onAbrechnungOeffnen: () -> Unit = {},
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
// Status-Snackbar
|
||||
state.statusMeldung?.let { meldung ->
|
||||
LaunchedEffect(meldung) {
|
||||
kotlinx.coroutines.delay(3000.milliseconds)
|
||||
viewModel.statusMeldungDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
// --- Status-Banner ---
|
||||
state.statusMeldung?.let { meldung ->
|
||||
Surface(
|
||||
color = if (meldung.startsWith("✅")) Color(0xFF388E3C)
|
||||
else if (meldung.startsWith("⚠️")) Color(0xFFF57C00)
|
||||
else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = meldung,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Zeile 1: Pferd/Reiter + Verkauf/Buchungen (50% Höhe) ---
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(0.5f)
|
||||
) {
|
||||
// Linke Hälfte: Pferd & Reiter Suche (60%)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(0.6f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
PferdReiterEingabe(
|
||||
state = state,
|
||||
onPferdSucheChanged = viewModel::onPferdSucheChanged,
|
||||
onPferdSelected = viewModel::onPferdSelected,
|
||||
onPferdLeeren = viewModel::onPferdLeeren,
|
||||
onReiterSucheChanged = viewModel::onReiterSucheChanged,
|
||||
onReiterSelected = viewModel::onReiterSelected,
|
||||
onReiterLeeren = viewModel::onReiterLeeren,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxHeight().width(1.dp),
|
||||
thickness = DividerDefaults.Thickness,
|
||||
color = DividerDefaults.color
|
||||
)
|
||||
|
||||
// Rechte Hälfte: Verkauf/Buchungen (40%)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(0.4f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
VerkaufBuchungenPanel(
|
||||
state = state,
|
||||
onTabChanged = viewModel::onVerkaufTabChanged,
|
||||
onMengeChanged = viewModel::onVerkaufMengeChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
|
||||
// --- Zeile 2: Aktions-Buttons (fix) ---
|
||||
AktionsButtonLeiste(
|
||||
canNennen = state.selectedPferd != null && state.selectedReiter != null,
|
||||
onStartlisteOeffnen = onStartlisteOeffnen,
|
||||
onErgebnisseOeffnen = onErgebnisseOeffnen,
|
||||
onAbrechnungOeffnen = onAbrechnungOeffnen,
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
|
||||
// --- Zeile 3: Nennungstabelle + Bewerbsliste (50% Höhe) ---
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(0.5f)
|
||||
) {
|
||||
// Links: Nennungsübersicht (60%)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(0.6f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
NennungenTabelle(
|
||||
state = state,
|
||||
nennungen = viewModel.nennungenFuerAktuellen(),
|
||||
onTabChanged = viewModel::onNennungTabChanged,
|
||||
onStornieren = viewModel::nennungStornieren,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxHeight().width(1.dp),
|
||||
thickness = DividerDefaults.Thickness,
|
||||
color = DividerDefaults.color
|
||||
)
|
||||
|
||||
// Rechts: Bewerbsliste (40%)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(0.4f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
BewerbslistePanel(
|
||||
bewerbe = viewModel.gefilterteBewerbe(),
|
||||
nennungen = state.nennungen,
|
||||
selectedPferd = state.selectedPferd,
|
||||
selectedReiter = state.selectedReiter,
|
||||
spartFilter = state.spartFilter,
|
||||
onSpartFilterChanged = viewModel::onSpartFilterChanged,
|
||||
onNennung = { bewerb -> viewModel.nennungDurchfuehren(bewerb) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pferd & Reiter Eingabe
|
||||
// ---------------------------------------------------------------------------
|
||||
@Composable
|
||||
private fun PferdReiterEingabe(
|
||||
state: NennungUiState,
|
||||
onPferdSucheChanged: (String) -> Unit,
|
||||
onPferdSelected: (Pferd) -> Unit,
|
||||
onPferdLeeren: () -> Unit,
|
||||
onReiterSucheChanged: (String) -> Unit,
|
||||
onReiterSelected: (Reiter) -> Unit,
|
||||
onReiterLeeren: () -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
// --- Pferd ---
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
SuchfeldMitVorschlaegen(
|
||||
label = "Pferd:",
|
||||
value = state.pferdSuche,
|
||||
onValueChange = onPferdSucheChanged,
|
||||
onLeeren = onPferdLeeren,
|
||||
vorschlaege = state.pferdVorschlaege.map { "${it.kopfNr} – ${it.name}" },
|
||||
onVorschlagSelected = { idx -> onPferdSelected(state.pferdVorschlaege[idx]) },
|
||||
)
|
||||
state.selectedPferd?.let { pferd ->
|
||||
MetaDatenBox {
|
||||
MetaZeile("Rasse:", pferd.rasse)
|
||||
MetaZeile("Farbe:", pferd.farbe)
|
||||
MetaZeile("Besitzer:", pferd.besitzer)
|
||||
if (pferd.stallBox.isNotEmpty()) MetaZeile("Box:", pferd.stallBox)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(top = 4.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.weight(1f).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("Neu anlegen", fontSize = 10.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.weight(1f).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("Bearbeiten", fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxHeight().width(1.dp),
|
||||
thickness = DividerDefaults.Thickness,
|
||||
color = DividerDefaults.color
|
||||
)
|
||||
|
||||
// --- Reiter ---
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
SuchfeldMitVorschlaegen(
|
||||
label = "Reiter:",
|
||||
value = state.reiterSuche,
|
||||
onValueChange = onReiterSucheChanged,
|
||||
onLeeren = onReiterLeeren,
|
||||
vorschlaege = state.reiterVorschlaege.map { "${it.kopfNr} – ${it.vollname}" },
|
||||
onVorschlagSelected = { idx -> onReiterSelected(state.reiterVorschlaege[idx]) },
|
||||
)
|
||||
state.selectedReiter?.let { reiter ->
|
||||
MetaDatenBox {
|
||||
MetaZeile("Verein:", reiter.verein)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Lizenz:", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(reiter.lizenzNr, fontSize = 10.sp, fontWeight = FontWeight.Medium)
|
||||
Surface(
|
||||
color = if (reiter.lizenzGueltig) Color(0xFF388E3C) else MaterialTheme.colorScheme.error,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Text(
|
||||
text = if (reiter.lizenzGueltig) "Gültig" else "ABGELAUFEN",
|
||||
color = Color.White,
|
||||
fontSize = 9.sp,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Konto:", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
text = "${reiter.kontoSaldo.round(2)} €",
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (reiter.kontoSaldo < 0) MaterialTheme.colorScheme.error else Color(0xFF388E3C),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(top = 4.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.weight(1f).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("Neu anlegen", fontSize = 10.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.weight(1f).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("Bearbeiten", fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuchfeldMitVorschlaegen(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onLeeren: () -> Unit,
|
||||
vorschlaege: List<String>,
|
||||
onVorschlagSelected: (Int) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Text(label, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
placeholder = { Text("Kopfnummer oder Name", fontSize = 11.sp) },
|
||||
textStyle = LocalTextStyle.current.copy(fontSize = 11.sp),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onLeeren,
|
||||
modifier = Modifier.height(36.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||
) {
|
||||
Text("Leeren", fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
if (vorschlaege.isNotEmpty()) {
|
||||
Surface(shadowElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 120.dp)) {
|
||||
itemsIndexed(vorschlaege) { idx, vorschlag ->
|
||||
Text(
|
||||
text = vorschlag,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onVorschlagSelected(idx) }
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
if (idx < vorschlaege.lastIndex) {
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaDatenBox(content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(6.dp), verticalArrangement = Arrangement.spacedBy(2.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaZeile(label: String, value: String) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, fontSize = 10.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aktions-Button-Leiste
|
||||
// ---------------------------------------------------------------------------
|
||||
@Composable
|
||||
private fun AktionsButtonLeiste(
|
||||
canNennen: Boolean,
|
||||
onStartlisteOeffnen: () -> Unit,
|
||||
onErgebnisseOeffnen: () -> Unit,
|
||||
onAbrechnungOeffnen: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Haupt-Aktion: Nennung durchführen (wird von Bewerbsliste getriggert via Doppelklick)
|
||||
Surface(
|
||||
color = if (canNennen) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle, contentDescription = null, modifier = Modifier.size(14.dp),
|
||||
tint = if (canNennen) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"Nennung: Doppelklick auf Bewerb [F5]",
|
||||
fontSize = 10.sp,
|
||||
color = if (canNennen) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
SmallActionButton("Startliste", Icons.AutoMirrored.Filled.List, "F7", onStartlisteOeffnen)
|
||||
SmallActionButton("Ergebnisse", Icons.Default.EmojiEvents, "F8", onErgebnisseOeffnen)
|
||||
SmallActionButton("Abrechnung", Icons.Default.Receipt, "F9", onAbrechnungOeffnen)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmallActionButton(
|
||||
label: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
shortcut: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(28.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(12.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("$label [$shortcut]", fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nennungen-Tabelle (unten links)
|
||||
// ---------------------------------------------------------------------------
|
||||
@Composable
|
||||
private fun NennungenTabelle(
|
||||
state: NennungUiState,
|
||||
nennungen: List<Nennung>,
|
||||
onTabChanged: (NennungTab) -> Unit,
|
||||
onStornieren: (Nennung) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Tabs
|
||||
PrimaryTabRow(selectedTabIndex = state.activeNennungTab.ordinal, modifier = Modifier.height(32.dp)) {
|
||||
NennungTab.entries.forEach { tab ->
|
||||
Tab(
|
||||
selected = state.activeNennungTab == tab,
|
||||
onClick = { onTabChanged(tab) },
|
||||
modifier = Modifier.height(32.dp),
|
||||
) {
|
||||
Text(tab.name, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Aktualisieren", modifier = Modifier.size(14.dp))
|
||||
}
|
||||
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("${nennungen.size} Nennungen", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(
|
||||
onClick = {},
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(24.dp)
|
||||
) {
|
||||
Text("Positionieren", fontSize = 10.sp)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {},
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(24.dp)
|
||||
) {
|
||||
Text("Stornieren", fontSize = 10.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
|
||||
// Tabellen-Header
|
||||
TabellenHeader(
|
||||
listOf("Tag", "Pl.", "Bewerb", "Bewerbsname", "Startwunsch", "Pferd"),
|
||||
listOf(30f, 25f, 45f, 1f, 70f, 80f)
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
|
||||
// Tabellen-Inhalt
|
||||
if (nennungen.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Nennungen vorhanden", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(nennungen) { idx, nennung ->
|
||||
val bgColor = when (nennung.startwunsch) {
|
||||
Startwunsch.VORNE -> FarbeVorne
|
||||
Startwunsch.HINTEN -> FarbeHinten
|
||||
else -> if (idx % 2 == 0) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(nennung.tag, fontSize = 10.sp, modifier = Modifier.width(30.dp))
|
||||
Text("${nennung.platz}", fontSize = 10.sp, modifier = Modifier.width(25.dp))
|
||||
Text("${nennung.bewerbNr}", fontSize = 10.sp, modifier = Modifier.width(45.dp))
|
||||
Text(
|
||||
nennung.bewerbName,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
when (nennung.startwunsch) {
|
||||
Startwunsch.VORNE -> "Vorne"
|
||||
Startwunsch.HINTEN -> "Hinten"
|
||||
else -> "–"
|
||||
},
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.width(70.dp),
|
||||
)
|
||||
Text(
|
||||
nennung.pferdName,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.width(80.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bewerbsliste (unten rechts)
|
||||
// ---------------------------------------------------------------------------
|
||||
@Composable
|
||||
private fun BewerbslistePanel(
|
||||
bewerbe: List<Bewerb>,
|
||||
nennungen: List<Nennung>,
|
||||
selectedPferd: Pferd?,
|
||||
selectedReiter: Reiter?,
|
||||
spartFilter: Sparte?,
|
||||
onSpartFilterChanged: (Sparte?) -> Unit,
|
||||
onNennung: (Bewerb) -> Unit,
|
||||
) {
|
||||
val canNennen = selectedPferd != null && selectedReiter != null
|
||||
var lastClickTime by remember { mutableStateOf(0L) }
|
||||
var lastClickedBewerb by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Überschrift + Filter
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Bewerbsübersicht", fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
// Sparte-Filter
|
||||
FilterChipKlein("Alle", spartFilter == null) { onSpartFilterChanged(null) }
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
FilterChipKlein("D", spartFilter == Sparte.DRESSUR) { onSpartFilterChanged(Sparte.DRESSUR) }
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
FilterChipKlein("S", spartFilter == Sparte.SPRINGEN) { onSpartFilterChanged(Sparte.SPRINGEN) }
|
||||
}
|
||||
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("${bewerbe.size} Bewerbe", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
|
||||
// Tabellen-Header
|
||||
TabellenHeader(
|
||||
listOf("Tag", "Pl.", "Bewerb", "Beginn", "Nenn.", "Bewerbsname"),
|
||||
listOf(28f, 22f, 45f, 45f, 35f, 1f)
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
|
||||
// Tabellen-Inhalt
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(bewerbe) { idx, bewerb ->
|
||||
val bereitsGenannt = canNennen && nennungen.any {
|
||||
it.bewerbNr == bewerb.nr &&
|
||||
it.pferdName == selectedPferd.name &&
|
||||
it.reiterName == selectedReiter.vollname
|
||||
}
|
||||
val bgColor = when {
|
||||
bereitsGenannt -> Color(0xFFBBDEFB) // Blau = bereits gemeldet
|
||||
idx % 2 == 0 -> Color.Transparent
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bgColor)
|
||||
.clickable(enabled = canNennen) {
|
||||
// Time calculation disabled for Wasm-Main stability test
|
||||
onNennung(bewerb)
|
||||
}
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(bewerb.tag, fontSize = 10.sp, modifier = Modifier.width(28.dp))
|
||||
Text("${bewerb.platz}", fontSize = 10.sp, modifier = Modifier.width(22.dp))
|
||||
// Bewerb-Nr mit Sparte-Farbe
|
||||
Text(
|
||||
"${bewerb.nr}",
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (bewerb.sparte == Sparte.DRESSUR) FarbeDressur else FarbeSpringen,
|
||||
modifier = Modifier.width(45.dp),
|
||||
)
|
||||
Text(bewerb.beginn, fontSize = 10.sp, modifier = Modifier.width(45.dp))
|
||||
Text("${bewerb.anzahlNennungen}", fontSize = 10.sp, modifier = Modifier.width(35.dp))
|
||||
Text(
|
||||
bewerb.name,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
||||
}
|
||||
}
|
||||
|
||||
if (!canNennen) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Text(
|
||||
"Bitte wählen Sie zuerst ein Pferd und einen Reiter aus",
|
||||
fontSize = 10.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterChipKlein(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontSize = 9.sp,
|
||||
color = if (selected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verkauf & Buchungen Panel (oben rechts)
|
||||
// ---------------------------------------------------------------------------
|
||||
@Composable
|
||||
private fun VerkaufBuchungenPanel(
|
||||
state: NennungUiState,
|
||||
onTabChanged: (VerkaufTab) -> Unit,
|
||||
onMengeChanged: (VerkaufArtikel, Int) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
PrimaryTabRow(selectedTabIndex = state.activeVerkaufTab.ordinal, modifier = Modifier.height(32.dp)) {
|
||||
VerkaufTab.entries.forEach { tab ->
|
||||
Tab(
|
||||
selected = state.activeVerkaufTab == tab,
|
||||
onClick = { onTabChanged(tab) },
|
||||
modifier = Modifier.height(32.dp),
|
||||
) {
|
||||
Text(tab.name, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (state.activeVerkaufTab) {
|
||||
VerkaufTab.VERKAUF -> VerkaufTabInhalt(state.verkaufArtikel, onMengeChanged)
|
||||
VerkaufTab.BUCHUNGEN -> BuchungenTabInhalt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerkaufTabInhalt(artikel: List<VerkaufArtikel>, onMengeChanged: (VerkaufArtikel, Int) -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("${artikel.size} Artikel", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(
|
||||
onClick = {},
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(24.dp)
|
||||
) {
|
||||
Text("Rückgängig", fontSize = 10.sp)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {},
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(24.dp)
|
||||
) {
|
||||
Text("Speichern", fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
TabellenHeader(
|
||||
listOf("KNr", "+", "Menge", "–", "Buchungstext", "Betrag", "Gebucht"),
|
||||
listOf(30f, 20f, 45f, 20f, 1f, 55f, 55f)
|
||||
)
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(artikel) { idx, art ->
|
||||
val bgColor = when {
|
||||
art.buchungstext == "Belastung" || art.buchungstext == "Gutschrift" -> Color(0xFFFFFDE7)
|
||||
idx % 2 == 0 -> Color.Transparent
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(bgColor).padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(art.knr, fontSize = 10.sp, modifier = Modifier.width(30.dp))
|
||||
IconButton(onClick = { onMengeChanged(art, 1) }, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Add, contentDescription = "+", modifier = Modifier.size(12.dp))
|
||||
}
|
||||
Text("${art.menge}", fontSize = 10.sp, modifier = Modifier.width(45.dp), fontWeight = FontWeight.Medium)
|
||||
IconButton(onClick = { onMengeChanged(art, -1) }, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Remove, contentDescription = "–", modifier = Modifier.size(12.dp))
|
||||
}
|
||||
Text(
|
||||
art.buchungstext,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text("${art.betrag.round(2)}", fontSize = 10.sp, modifier = Modifier.width(55.dp))
|
||||
Text("${art.gebucht.round(2)}", fontSize = 10.sp, modifier = Modifier.width(55.dp))
|
||||
}
|
||||
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuchungenTabInhalt() {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("0 Buchungen", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(
|
||||
onClick = {},
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(24.dp)
|
||||
) {
|
||||
Text("Stornieren", fontSize = 10.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
TabellenHeader(listOf("Kopfnr", "Menge", "Buchungstext", "Soll", "Haben"), listOf(55f, 45f, 1f, 55f, 55f))
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Buchungen vorhanden", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hilfs-Composable: Tabellen-Header
|
||||
// ---------------------------------------------------------------------------
|
||||
@Composable
|
||||
private fun TabellenHeader(spalten: List<String>, breiten: List<Float>) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
spalten.forEachIndexed { idx, name ->
|
||||
val breite = breiten.getOrNull(idx) ?: 1f
|
||||
if (breite == 1f) {
|
||||
Text(name, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
} else {
|
||||
Text(name, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(breite.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package at.mocode.frontend.features.nennung.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.EmojiEvents
|
||||
import androidx.compose.material.icons.filled.Receipt
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun AktionsButtonLeiste(
|
||||
canNennen: Boolean,
|
||||
onStartlisteOeffnen: () -> Unit,
|
||||
onErgebnisseOeffnen: () -> Unit,
|
||||
onAbrechnungOeffnen: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Haupt-Aktion: Nennung durchführen (wird von Bewerbsliste getriggert via Doppelklick)
|
||||
Surface(
|
||||
color = if (canNennen) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle, contentDescription = null, modifier = Modifier.size(14.dp),
|
||||
tint = if (canNennen) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"Nennung: Doppelklick auf Bewerb [F5]",
|
||||
fontSize = 10.sp,
|
||||
color = if (canNennen) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
SmallActionButton("Startliste", Icons.AutoMirrored.Filled.List, "F7", onStartlisteOeffnen)
|
||||
SmallActionButton("Ergebnisse", Icons.Default.EmojiEvents, "F8", onErgebnisseOeffnen)
|
||||
SmallActionButton("Abrechnung", Icons.Default.Receipt, "F9", onAbrechnungOeffnen)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmallActionButton(
|
||||
label: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
shortcut: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(28.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(12.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("$label [$shortcut]", fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
package at.mocode.frontend.features.nennung.presentation.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 androidx.compose.ui.window.Popup
|
||||
import at.mocode.frontend.features.nennung.domain.Pferd
|
||||
import at.mocode.frontend.features.nennung.domain.Reiter
|
||||
import at.mocode.frontend.features.nennung.presentation.NennungUiState
|
||||
|
||||
@Composable
|
||||
fun PferdReiterEingabe(
|
||||
state: NennungUiState,
|
||||
onPferdSucheChanged: (String) -> Unit,
|
||||
onPferdSelected: (Pferd) -> Unit,
|
||||
onPferdLeeren: () -> Unit,
|
||||
onReiterSucheChanged: (String) -> Unit,
|
||||
onReiterSelected: (Reiter) -> Unit,
|
||||
onReiterLeeren: () -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
// --- Pferd ---
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
SuchfeldMitVorschlaegen(
|
||||
label = "Pferd:",
|
||||
value = state.pferdSuche,
|
||||
onValueChange = onPferdSucheChanged,
|
||||
onLeeren = onPferdLeeren,
|
||||
vorschlaege = state.pferdVorschlaege.map { "${it.kopfNr} – ${it.name}" },
|
||||
onVorschlagSelected = { idx -> onPferdSelected(state.pferdVorschlaege[idx]) },
|
||||
)
|
||||
state.selectedPferd?.let { pferd ->
|
||||
MetaDatenBox {
|
||||
MetaZeile("Rasse:", pferd.rasse)
|
||||
MetaZeile("Farbe:", pferd.farbe)
|
||||
MetaZeile("Besitzer:", pferd.besitzer)
|
||||
if (pferd.stallBox.isNotEmpty()) MetaZeile("Box:", pferd.stallBox)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(top = 4.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.weight(1f).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("Neu anlegen", fontSize = 10.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.weight(1f).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("Bearbeiten", fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxHeight().width(1.dp),
|
||||
thickness = DividerDefaults.Thickness,
|
||||
color = DividerDefaults.color
|
||||
)
|
||||
|
||||
// --- Reiter ---
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
SuchfeldMitVorschlaegen(
|
||||
label = "Reiter:",
|
||||
value = state.reiterSuche,
|
||||
onValueChange = onReiterSucheChanged,
|
||||
onLeeren = onReiterLeeren,
|
||||
vorschlaege = state.reiterVorschlaege.map { "${it.kopfNr} – ${it.vollname}" },
|
||||
onVorschlagSelected = { idx -> onReiterSelected(state.reiterVorschlaege[idx]) },
|
||||
)
|
||||
state.selectedReiter?.let { reiter ->
|
||||
MetaDatenBox {
|
||||
MetaZeile("Verein:", reiter.verein)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Lizenz:", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(reiter.lizenzNr, fontSize = 10.sp, fontWeight = FontWeight.Medium)
|
||||
Surface(
|
||||
color = if (reiter.lizenzGueltig) Color(0xFF388E3C) else MaterialTheme.colorScheme.error,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Text(
|
||||
text = if (reiter.lizenzGueltig) "Gültig" else "ABGELAUFEN",
|
||||
color = Color.White,
|
||||
fontSize = 9.sp,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Konto:", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
text = "${(kotlin.math.round(reiter.kontoSaldo * 100) / 100.0)} €",
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (reiter.kontoSaldo < 0) MaterialTheme.colorScheme.error else Color(0xFF388E3C),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(top = 4.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.weight(1f).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("Neu anlegen", fontSize = 10.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.weight(1f).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("Bearbeiten", fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SuchfeldMitVorschlaegen(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onLeeren: () -> Unit,
|
||||
vorschlaege: List<String>,
|
||||
onVorschlagSelected: (Int) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(label, fontSize = 11.sp) },
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
|
||||
singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, modifier = Modifier.size(16.dp)) },
|
||||
trailingIcon = {
|
||||
if (value.isNotEmpty()) {
|
||||
IconButton(onClick = onLeeren, modifier = Modifier.size(16.dp)) {
|
||||
Icon(Icons.Default.Clear, null)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
)
|
||||
|
||||
if (vorschlaege.isNotEmpty()) {
|
||||
Popup(alignment = Alignment.TopStart) {
|
||||
Surface(
|
||||
modifier = Modifier.width(300.dp).heightIn(max = 200.dp),
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 4.dp,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
LazyColumn {
|
||||
itemsIndexed(vorschlaege) { idx, vorschlag ->
|
||||
Text(
|
||||
text = vorschlag,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onVorschlagSelected(idx) }
|
||||
.padding(8.dp),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
if (idx < vorschlaege.size - 1) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaDatenBox(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), MaterialTheme.shapes.small)
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaZeile(label: String, value: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, fontSize = 10.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package at.mocode.frontend.features.nennung.presentation.online
|
||||
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.features.nennung.domain.OnlineNennung
|
||||
import at.mocode.frontend.features.nennung.presentation.NennungUiState
|
||||
|
||||
@Composable
|
||||
fun OnlineNennungEingang(
|
||||
state: NennungUiState,
|
||||
onRefresh: () -> Unit,
|
||||
onUebernehmen: (OnlineNennung) -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Online-Nennungen (Eingang)", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (state.isOnlineLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
IconButton(onClick = onRefresh, modifier = Modifier.size(24.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh", modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
if (state.onlineNennungen.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
if (state.isOnlineLoading) "Lade Daten..." else "Keine neuen Online-Nennungen",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.onlineNennungen) { nennung ->
|
||||
OnlineNennungItem(nennung, onUebernehmen)
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnlineNennungItem(
|
||||
nennung: OnlineNennung,
|
||||
onUebernehmen: (OnlineNennung) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"${nennung.vorname} ${nennung.nachname}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
"Pferd: ${nennung.pferdName} (${nennung.pferdAlter} J.)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"Bewerbe: ${nennung.bewerbe}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { onUebernehmen(nennung) },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(28.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Download, null, modifier = Modifier.size(12.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Übernehmen", fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
package at.mocode.frontend.features.nennung.presentation.tabs
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.features.nennung.domain.*
|
||||
import at.mocode.frontend.features.nennung.presentation.NennungUiState
|
||||
|
||||
// Farben für Startwunsch-Markierung (aus NennungsMaske.kt)
|
||||
private val FarbeVorne = Color(0xFFE8F5E9) // Grün
|
||||
private val FarbeHinten = Color(0xFFE3F2FD) // Blau
|
||||
private val FarbeDressur = Color(0xFF3F51B5) // Indigo
|
||||
private val FarbeSpringen = Color(0xFFE65100) // Orange
|
||||
|
||||
@Composable
|
||||
fun NennungenTabelle(
|
||||
state: NennungUiState,
|
||||
nennungen: List<Nennung>,
|
||||
onTabChanged: (NennungTab) -> Unit,
|
||||
onStornieren: (Nennung) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Tabs
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
PrimaryTabRow(selectedTabIndex = state.activeNennungTab.ordinal, modifier = Modifier.height(32.dp)) {
|
||||
NennungTab.entries.forEach { tab ->
|
||||
Tab(
|
||||
selected = state.activeNennungTab == tab,
|
||||
onClick = { onTabChanged(tab) },
|
||||
modifier = Modifier.height(32.dp),
|
||||
) {
|
||||
Text(tab.name, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Aktualisieren", modifier = Modifier.size(14.dp))
|
||||
}
|
||||
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("${nennungen.size} Nennungen", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(
|
||||
onClick = {},
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(24.dp)
|
||||
) {
|
||||
Text("Positionieren", fontSize = 10.sp)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {},
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(24.dp)
|
||||
) {
|
||||
Text("Stornieren", fontSize = 10.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
|
||||
// Tabellen-Header
|
||||
TabellenHeader(
|
||||
listOf("Tag", "Pl.", "Bewerb", "Bewerbsname", "Startwunsch", "Pferd"),
|
||||
listOf(30f, 25f, 45f, 1f, 70f, 80f)
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
|
||||
// Tabellen-Inhalt
|
||||
if (nennungen.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Nennungen vorhanden", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(nennungen) { idx, nennung ->
|
||||
val bgColor = when (nennung.startwunsch) {
|
||||
Startwunsch.VORNE -> FarbeVorne
|
||||
Startwunsch.HINTEN -> FarbeHinten
|
||||
else -> if (idx % 2 == 0) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(nennung.tag, fontSize = 10.sp, modifier = Modifier.width(30.dp))
|
||||
Text("${nennung.platz}", fontSize = 10.sp, modifier = Modifier.width(25.dp))
|
||||
Text("${nennung.bewerbNr}", fontSize = 10.sp, modifier = Modifier.width(45.dp))
|
||||
Text(
|
||||
nennung.bewerbName,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
when (nennung.startwunsch) {
|
||||
Startwunsch.VORNE -> "Vorne"
|
||||
Startwunsch.HINTEN -> "Hinten"
|
||||
else -> "–"
|
||||
},
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.width(70.dp),
|
||||
)
|
||||
Text(
|
||||
nennung.pferdName,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.width(80.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BewerbslistePanel(
|
||||
bewerbe: List<Bewerb>,
|
||||
nennungen: List<Nennung>,
|
||||
selectedPferd: Pferd?,
|
||||
selectedReiter: Reiter?,
|
||||
spartFilter: Sparte?,
|
||||
onSpartFilterChanged: (Sparte?) -> Unit,
|
||||
onNennung: (Bewerb) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Filter-Leiste
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text("Filter:", fontSize = 10.sp, fontWeight = FontWeight.Bold)
|
||||
FilterChipKlein("Alle", spartFilter == null) { onSpartFilterChanged(null) }
|
||||
FilterChipKlein("Dressur", spartFilter == Sparte.DRESSUR) { onSpartFilterChanged(Sparte.DRESSUR) }
|
||||
FilterChipKlein("Springen", spartFilter == Sparte.SPRINGEN) { onSpartFilterChanged(Sparte.SPRINGEN) }
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("${bewerbe.size} Bewerbe", fontSize = 10.sp)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
TabellenHeader(
|
||||
listOf("Nr", "Tag", "Pl.", "Zeit", "Bewerbsbezeichnung", "S", "Kl.", "N"),
|
||||
listOf(25f, 30f, 25f, 40f, 1f, 20f, 25f, 25f)
|
||||
)
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(bewerbe) { idx, bew ->
|
||||
val bereitsGenannt = nennungen.any { it.bewerbNr == bew.nr && it.pferdName == selectedPferd?.name && it.reiterName == selectedReiter?.vollname }
|
||||
val bgColor = if (idx % 2 == 0) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bgColor)
|
||||
.clickable(enabled = !bereitsGenannt && selectedPferd != null && selectedReiter != null) { onNennung(bew) }
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("${bew.nr}", fontSize = 10.sp, modifier = Modifier.width(25.dp), fontWeight = FontWeight.Bold)
|
||||
Text(bew.tag, fontSize = 10.sp, modifier = Modifier.width(30.dp))
|
||||
Text("${bew.platz}", fontSize = 10.sp, modifier = Modifier.width(25.dp))
|
||||
Text(bew.beginn, fontSize = 10.sp, modifier = Modifier.width(40.dp))
|
||||
Text(
|
||||
bew.name,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (bereitsGenannt) MaterialTheme.colorScheme.primary else Color.Unspecified
|
||||
)
|
||||
Text(
|
||||
text = if (bew.sparte == Sparte.DRESSUR) "D" else "S",
|
||||
fontSize = 9.sp,
|
||||
modifier = Modifier.width(20.dp),
|
||||
color = if (bew.sparte == Sparte.DRESSUR) FarbeDressur else FarbeSpringen,
|
||||
fontWeight = FontWeight.Black
|
||||
)
|
||||
Text(bew.klasse, fontSize = 10.sp, modifier = Modifier.width(25.dp))
|
||||
Text("${bew.anzahlNennungen}", fontSize = 10.sp, modifier = Modifier.width(25.dp), textAlign = androidx.compose.ui.text.style.TextAlign.End)
|
||||
}
|
||||
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterChipKlein(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
border = if (selected) null else BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontSize = 9.sp,
|
||||
color = if (selected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabellenHeader(spalten: List<String>, breiten: List<Float>) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
spalten.forEachIndexed { idx, label ->
|
||||
val modifier = if (breiten[idx] == 1f) Modifier.weight(1f) else Modifier.width(breiten[idx].dp)
|
||||
Text(
|
||||
text = label,
|
||||
modifier = modifier,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package at.mocode.frontend.features.nennung.presentation.tabs
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.features.nennung.domain.*
|
||||
import at.mocode.frontend.features.nennung.presentation.NennungUiState
|
||||
|
||||
@Composable
|
||||
fun VerkaufBuchungenPanel(
|
||||
state: NennungUiState,
|
||||
onTabChanged: (VerkaufTab) -> Unit,
|
||||
onMengeChanged: (VerkaufArtikel, Int) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
PrimaryTabRow(selectedTabIndex = state.activeVerkaufTab.ordinal, modifier = Modifier.height(32.dp)) {
|
||||
VerkaufTab.entries.forEach { tab ->
|
||||
Tab(
|
||||
selected = state.activeVerkaufTab == tab,
|
||||
onClick = { onTabChanged(tab) },
|
||||
modifier = Modifier.height(32.dp),
|
||||
) {
|
||||
Text(tab.name, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (state.activeVerkaufTab) {
|
||||
VerkaufTab.VERKAUF -> VerkaufTabInhalt(state.verkaufArtikel, onMengeChanged)
|
||||
VerkaufTab.BUCHUNGEN -> BuchungenTabInhalt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerkaufTabInhalt(artikel: List<VerkaufArtikel>, onMengeChanged: (VerkaufArtikel, Int) -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("${artikel.size} Artikel", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(
|
||||
onClick = {},
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(24.dp)
|
||||
) {
|
||||
Text("Rückgängig", fontSize = 10.sp)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {},
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(24.dp)
|
||||
) {
|
||||
Text("Speichern", fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
TabellenHeader(
|
||||
listOf("KNr", "+", "Menge", "–", "Buchungstext", "Betrag", "Gebucht"),
|
||||
listOf(30f, 20f, 45f, 20f, 1f, 55f, 55f)
|
||||
)
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(artikel) { idx, art ->
|
||||
val bgColor = when {
|
||||
art.buchungstext == "Belastung" || art.buchungstext == "Gutschrift" -> Color(0xFFFFFDE7)
|
||||
idx % 2 == 0 -> Color.Transparent
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(bgColor).padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(art.knr, fontSize = 10.sp, modifier = Modifier.width(30.dp))
|
||||
IconButton(onClick = { onMengeChanged(art, 1) }, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Add, contentDescription = "+", modifier = Modifier.size(12.dp))
|
||||
}
|
||||
Text("${art.menge}", fontSize = 10.sp, modifier = Modifier.width(45.dp), fontWeight = FontWeight.Medium)
|
||||
IconButton(onClick = { onMengeChanged(art, -1) }, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Remove, contentDescription = "–", modifier = Modifier.size(12.dp))
|
||||
}
|
||||
Text(
|
||||
art.buchungstext,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text("${(kotlin.math.round(art.betrag * 100) / 100.0)}", fontSize = 10.sp, modifier = Modifier.width(55.dp))
|
||||
Text("${(kotlin.math.round(art.gebucht * 100) / 100.0)}", fontSize = 10.sp, modifier = Modifier.width(55.dp))
|
||||
}
|
||||
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuchungenTabInhalt() {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("0 Buchungen", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
TabellenHeader(
|
||||
listOf("Datum", "Uhrzeit", "User", "Buchungstext", "Betrag", "G", "Z"),
|
||||
listOf(60f, 50f, 40f, 1f, 55f, 20f, 20f)
|
||||
)
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Buchungen vorhanden", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabellenHeader(spalten: List<String>, breiten: List<Float>) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
spalten.forEachIndexed { idx, label ->
|
||||
val modifier = if (breiten[idx] == 1f) Modifier.weight(1f) else Modifier.width(breiten[idx].dp)
|
||||
Text(
|
||||
text = label,
|
||||
modifier = modifier,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+139
-29
@@ -6,8 +6,12 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||
import at.mocode.frontend.core.domain.repository.MasterdataRepository
|
||||
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||
import at.mocode.frontend.core.domain.zns.ZnsImportState
|
||||
import at.mocode.frontend.core.domain.zns.ZnsRemoteFunktionaer
|
||||
import at.mocode.frontend.core.domain.zns.ZnsRemotePferd
|
||||
import at.mocode.frontend.core.domain.zns.ZnsRemoteReiter
|
||||
import at.mocode.frontend.core.domain.zns.ZnsRemoteVerein
|
||||
import at.mocode.frontend.core.network.NetworkConfig
|
||||
import io.ktor.client.*
|
||||
@@ -44,6 +48,34 @@ internal data class VereinRemoteDto(
|
||||
val bundesland: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class ReiterRemoteDto(
|
||||
val reiterId: String,
|
||||
val satznummer: String? = null,
|
||||
val nachname: String,
|
||||
val vorname: String,
|
||||
val reiterLizenz: String? = null,
|
||||
val lizenzKlasse: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class HorseRemoteDto(
|
||||
val pferdId: String,
|
||||
val kopfnummer: String? = null,
|
||||
val pferdeName: String,
|
||||
val lebensnummer: String? = null,
|
||||
val geschlecht: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class FunktionaerRemoteDto(
|
||||
val funktionaerId: String,
|
||||
val satzId: String,
|
||||
val satzNummer: Int,
|
||||
val name: String? = null,
|
||||
val qualifikationen: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
private val TERMINAL_STATES = setOf("ABGESCHLOSSEN", "FEHLER")
|
||||
private const val POLLING_INTERVAL_MS = 2000L
|
||||
private const val MAX_VISIBLE_ERRORS = 50
|
||||
@@ -51,6 +83,7 @@ private const val MAX_VISIBLE_ERRORS = 50
|
||||
class ZnsImportViewModel(
|
||||
private val httpClient: HttpClient,
|
||||
private val authTokenManager: AuthTokenManager,
|
||||
private val repository: MasterdataRepository,
|
||||
) : ViewModel(), ZnsImportProvider {
|
||||
|
||||
override var state by mutableStateOf(ZnsImportState())
|
||||
@@ -81,6 +114,7 @@ class ZnsImportViewModel(
|
||||
jobId = null, progress = 0, progressDetail = "", errors = emptyList()
|
||||
)
|
||||
try {
|
||||
println("[ZNS] Starte Import Mode=$mode Datei=${file.absolutePath}")
|
||||
val token = authTokenManager.authState.value.token
|
||||
val response: HttpResponse = httpClient.post("${NetworkConfig.baseUrl}/api/v1/import/zns") {
|
||||
parameter("mode", mode)
|
||||
@@ -94,15 +128,31 @@ class ZnsImportViewModel(
|
||||
})
|
||||
}))
|
||||
}
|
||||
println("[ZNS] Upload Response: ${response.status}")
|
||||
if (response.status == HttpStatusCode.Accepted) {
|
||||
val body = json.decodeFromString<ImportStartResponse>(response.bodyAsText())
|
||||
val responseText = response.bodyAsText()
|
||||
println("[DEBUG_LOG] Import Started Response: $responseText")
|
||||
val body = try {
|
||||
json.decodeFromString<ImportStartResponse>(responseText)
|
||||
} catch (e: Exception) {
|
||||
println("[DEBUG_LOG] JSON Decoding failed (Import Start): ${e.message}")
|
||||
throw Exception("Fehler beim Starten des Imports (Server-Antwort ungültig).")
|
||||
}
|
||||
state = state.copy(isUploading = false, jobId = body.jobId, jobStatus = "AUSSTEHEND")
|
||||
startPolling(body.jobId)
|
||||
} else {
|
||||
state = state.copy(isUploading = false, errorMessage = "Upload fehlgeschlagen: HTTP ${response.status.value}")
|
||||
val errorText = try { response.bodyAsText() } catch (e: Exception) { "Keine Fehlerdetails verfügbar" }
|
||||
println("[ZNS] Upload Fehler: ${response.status} -> $errorText")
|
||||
state = state.copy(isUploading = false, errorMessage = "Upload fehlgeschlagen: HTTP ${response.status.value} ($errorText)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state = state.copy(isUploading = false, errorMessage = "Fehler beim Upload: ${e.message}")
|
||||
println("[ZNS] Exception beim Upload: ${e.message}")
|
||||
e.printStackTrace()
|
||||
val displayMessage = when {
|
||||
e.message?.contains("Connect") == true -> "Verbindung zum Server fehlgeschlagen. Ist das Backend gestartet?"
|
||||
else -> e.message ?: "Unbekannter Fehler beim Upload"
|
||||
}
|
||||
state = state.copy(isUploading = false, errorMessage = displayMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,42 +195,80 @@ class ZnsImportViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncFromCloud(onResult: (List<ZnsRemoteVerein>) -> Unit) {
|
||||
override fun syncFromCloud(onResult: (
|
||||
List<ZnsRemoteVerein>,
|
||||
List<ZnsRemoteReiter>,
|
||||
List<ZnsRemotePferd>,
|
||||
List<ZnsRemoteFunktionaer>
|
||||
) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(isSyncing = true, errorMessage = null)
|
||||
try {
|
||||
println("[ZNS] Starte Cloud-Sync")
|
||||
val token = authTokenManager.authState.value.token
|
||||
// Wir laden die Top 1000 Vereine für den Sync (einfache Implementierung)
|
||||
val response: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/verein") {
|
||||
|
||||
// 1. Vereine
|
||||
val vResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/verein") {
|
||||
parameter("limit", 1000)
|
||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
val results = json.decodeFromString<List<VereinRemoteDto>>(response.bodyAsText())
|
||||
val domainResults = results.map {
|
||||
val vResults = if (vResponse.status.isSuccess()) {
|
||||
json.decodeFromString<List<VereinRemoteDto>>(vResponse.bodyAsText()).map {
|
||||
ZnsRemoteVerein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.bundesland)
|
||||
}
|
||||
} else emptyList()
|
||||
|
||||
val now = java.time.LocalDateTime.now()
|
||||
val version = now.format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))
|
||||
|
||||
state = state.copy(
|
||||
isSyncing = false,
|
||||
lastSyncVersion = version,
|
||||
isFinished = true
|
||||
)
|
||||
onResult(domainResults)
|
||||
} else if (response.status == HttpStatusCode.Unauthorized) {
|
||||
state = state.copy(
|
||||
isSyncing = false,
|
||||
errorMessage = "Nicht autorisiert (HTTP 401). Bitte prüfen Sie Ihren Sicherheitsschlüssel im Setup."
|
||||
)
|
||||
} else {
|
||||
state = state.copy(isSyncing = false, errorMessage = "Sync fehlgeschlagen: HTTP ${response.status.value}")
|
||||
// 2. Reiter
|
||||
val rResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/reiter") {
|
||||
parameter("limit", 1000)
|
||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
val rResults = if (rResponse.status.isSuccess()) {
|
||||
json.decodeFromString<List<ReiterRemoteDto>>(rResponse.bodyAsText()).map {
|
||||
ZnsRemoteReiter(it.reiterId, it.satznummer, it.nachname, it.vorname, it.reiterLizenz, it.lizenzKlasse)
|
||||
}
|
||||
} else emptyList()
|
||||
|
||||
// 3. Pferde
|
||||
val pResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/horse") {
|
||||
parameter("limit", 1000)
|
||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
val pResults = if (pResponse.status.isSuccess()) {
|
||||
json.decodeFromString<List<HorseRemoteDto>>(pResponse.bodyAsText()).map {
|
||||
ZnsRemotePferd(it.pferdId, it.kopfnummer, it.pferdeName, it.lebensnummer, it.geschlecht)
|
||||
}
|
||||
} else emptyList()
|
||||
|
||||
// 4. Funktionäre
|
||||
val fResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/funktionaer") {
|
||||
parameter("limit", 1000)
|
||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
val fResults = if (fResponse.status.isSuccess()) {
|
||||
json.decodeFromString<List<FunktionaerRemoteDto>>(fResponse.bodyAsText()).map {
|
||||
ZnsRemoteFunktionaer(it.funktionaerId, it.satzId, it.satzNummer, it.name, it.qualifikationen)
|
||||
}
|
||||
} else emptyList()
|
||||
|
||||
val now = java.time.LocalDateTime.now()
|
||||
val version = now.format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))
|
||||
|
||||
state = state.copy(
|
||||
isSyncing = false,
|
||||
lastSyncVersion = version,
|
||||
isFinished = true
|
||||
)
|
||||
onResult(vResults, rResults, pResults, fResults)
|
||||
|
||||
} catch (e: Exception) {
|
||||
state = state.copy(isSyncing = false, errorMessage = "Fehler beim Cloud-Sync: ${e.message}")
|
||||
println("[ZNS] Exception beim Sync: ${e.message}")
|
||||
e.printStackTrace()
|
||||
val displayMessage = when {
|
||||
e.message?.contains("Connect") == true -> "Verbindung zum Server fehlgeschlagen. Ist das Backend gestartet?"
|
||||
else -> e.message ?: "Unbekannter Fehler beim Cloud-Sync"
|
||||
}
|
||||
state = state.copy(isSyncing = false, errorMessage = displayMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,7 +283,13 @@ class ZnsImportViewModel(
|
||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
val status = json.decodeFromString<JobStatusResponse>(response.bodyAsText())
|
||||
val responseText = response.bodyAsText()
|
||||
val status = try {
|
||||
json.decodeFromString<JobStatusResponse>(responseText)
|
||||
} catch (e: Exception) {
|
||||
println("[DEBUG_LOG] Polling JSON Decoding failed: ${e.message}")
|
||||
throw Exception("Status-Format ungültig.")
|
||||
}
|
||||
state = state.copy(
|
||||
jobStatus = status.status,
|
||||
progress = status.fortschritt,
|
||||
@@ -204,9 +298,12 @@ class ZnsImportViewModel(
|
||||
isFinished = status.status in TERMINAL_STATES,
|
||||
)
|
||||
if (status.status in TERMINAL_STATES) break
|
||||
} else {
|
||||
println("[ZNS] Polling Fehler: ${response.status}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state = state.copy(errorMessage = "Polling-Fehler: ${e.message}", isFinished = true)
|
||||
println("[ZNS] Polling Exception: ${e.message}")
|
||||
state = state.copy(errorMessage = "Status-Abfrage fehlgeschlagen: ${e.message}", isFinished = true)
|
||||
break
|
||||
}
|
||||
delay(POLLING_INTERVAL_MS.milliseconds)
|
||||
@@ -214,6 +311,19 @@ class ZnsImportViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
override fun addSyncResults(
|
||||
vereine: List<ZnsRemoteVerein>,
|
||||
reiter: List<ZnsRemoteReiter>,
|
||||
pferde: List<ZnsRemotePferd>,
|
||||
funktionaere: List<ZnsRemoteFunktionaer>
|
||||
) {
|
||||
println("[ZNS] Sync-Ergebnisse empfangen: ${vereine.size} V, ${reiter.size} R, ${pferde.size} P, ${funktionaere.size} F")
|
||||
repository.saveVereine(vereine)
|
||||
repository.saveReiter(reiter)
|
||||
repository.savePferde(pferde)
|
||||
repository.saveFunktionaere(funktionaere)
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
pollingJob?.cancel()
|
||||
state = ZnsImportState()
|
||||
|
||||
+2
-2
@@ -6,6 +6,6 @@ import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val znsImportModule = module {
|
||||
factory<ZnsImportProvider> { ZnsImportViewModel(get(named("apiClient")), get()) }
|
||||
factory { ZnsImportViewModel(get(named("apiClient")), get()) }
|
||||
factory<ZnsImportProvider> { ZnsImportViewModel(get(named("apiClient")), get(), get()) }
|
||||
factory { ZnsImportViewModel(get(named("apiClient")), get(), get()) }
|
||||
}
|
||||
|
||||
+97
-33
@@ -19,17 +19,22 @@ 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
|
||||
|
||||
@Composable
|
||||
fun StammdatenImportScreen(
|
||||
viewModel: ZnsImportViewModel = koinViewModel(),
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val state = viewModel.state
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
.padding(24.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Titel
|
||||
@@ -56,14 +61,20 @@ fun StammdatenImportScreen(
|
||||
value = state.selectedFilePath ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
placeholder = { Text("Keine Datei ausgewählt…") },
|
||||
placeholder = { Text("ZNS-Datei auswählen (.zip, .dat)...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val path = pickZipFile()
|
||||
if (path != null) viewModel.onFileSelected(path)
|
||||
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),
|
||||
) {
|
||||
@@ -99,10 +110,65 @@ fun StammdatenImportScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text("Cloud-Synchronisation", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Stammdaten direkt vom OEPS-Server laden",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.syncFromCloud { vereine, reiter, pferde, funktionaere ->
|
||||
println("[ZNS] Sync Abschluss: ${vereine.size} V, ${reiter.size} R, ${pferde.size} P, ${funktionaere.size} F")
|
||||
viewModel.addSyncResults(vereine, reiter, pferde, funktionaere)
|
||||
}
|
||||
},
|
||||
enabled = !state.isSyncing,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
|
||||
) {
|
||||
if (state.isSyncing) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Default.CloudSync, contentDescription = null)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Cloud-Sync")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fehler-Banner
|
||||
// Cloud-Sync Status
|
||||
if (state.lastSyncVersion != null) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Info, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Text(
|
||||
"Letzter erfolgreicher Cloud-Sync: ${state.lastSyncVersion}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.errorMessage != null) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -208,7 +274,7 @@ fun StammdatenImportScreen(
|
||||
// Fehler-Liste
|
||||
if (state.errors.isNotEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -229,25 +295,33 @@ fun StammdatenImportScreen(
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(state.errors) { error ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text("•", color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(state.errors) { error ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text("•", color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
androidx.compose.foundation.VerticalScrollbar(
|
||||
adapter = androidx.compose.foundation.rememberScrollbarAdapter(lazyListState),
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,13 +354,3 @@ private fun StatusChip(status: String?) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Öffnet einen nativen JFileChooser (JVM-only) und gibt den Pfad der gewählten ZIP zurück. */
|
||||
private fun pickZipFile(): String? {
|
||||
val chooser = JFileChooser()
|
||||
chooser.dialogTitle = "ZNS.zip auswählen"
|
||||
chooser.fileFilter = FileNameExtensionFilter("ZIP-Archiv (*.zip)", "zip")
|
||||
chooser.isAcceptAllFileFilterUsed = false
|
||||
val result = chooser.showOpenDialog(null)
|
||||
return if (result == JFileChooser.APPROVE_OPTION) chooser.selectedFile.absolutePath else null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user