feat(ui): add reusable components for FIGMA-based UI system

- Implemented new reusable components including Label, Input, InputOTP, HoverCard, Popover, Pagination, NavigationMenu, Menubar, ScrollArea, Resizable, RadioGroup, and Progress under `docs/06_Frontend/FIGMA/src/app/components/ui/`.
- Enhanced structural organization to improve scalability and maintainability.
- Updated `settings.gradle.kts` to include the new module `frontend:features:nennung-feature`.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-21 13:40:37 +01:00
parent 575ef18034
commit 439551951b
84 changed files with 8898 additions and 3 deletions
@@ -13,7 +13,7 @@ sealed class AppScreen(val route: String) {
data object Profile : AppScreen("/profile")
data object OrganizerProfile : AppScreen("/organizer/profile")
data object AuthCallback : AppScreen("/auth/callback")
data object Nennung : AppScreen("/nennung")
companion object {
fun fromRoute(route: String): AppScreen {
return when (route) {
@@ -26,6 +26,7 @@ sealed class AppScreen(val route: String) {
"/profile" -> Profile
"/organizer/profile" -> OrganizerProfile
"/auth/callback" -> AuthCallback
"/nennung" -> Nennung
else -> Landing // Default fallback
}
}
@@ -0,0 +1,37 @@
/**
* Feature-Modul: Nennungs-Maske (Desktop-only)
* Kapselt die gesamte UI und Logik für die Nennungserfassung am Turnier.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
jvm()
sourceSets {
commonMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(libs.bundles.kmp.common)
implementation(libs.bundles.compose.common)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
}
}
}
@@ -0,0 +1,9 @@
package at.mocode.nennung.feature.di
import at.mocode.nennung.feature.presentation.NennungViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
val nennungFeatureModule = module {
viewModel { NennungViewModel() }
}
@@ -0,0 +1,112 @@
package at.mocode.nennung.feature.domain
// --- Pferd ---
data class Pferd(
val kopfNr: String,
val name: String,
val rasse: String = "",
val farbe: String = "",
val besitzer: String = "",
val stallBox: String = "",
)
// --- Reiter ---
data class Reiter(
val kopfNr: String,
val vorname: String,
val nachname: String,
val verein: String = "",
val lizenzNr: String = "",
val lizenzGueltig: Boolean = true,
val kontoSaldo: Double = 0.0,
) {
val vollname: String get() = "$vorname $nachname"
}
// --- Bewerb ---
enum class Sparte { DRESSUR, SPRINGEN }
data class Bewerb(
val nr: Int,
val tag: String,
val platz: Int,
val beginn: String,
val name: String,
val sparte: Sparte,
val klasse: String,
val anzahlNennungen: Int = 0,
)
// --- Startwunsch ---
enum class Startwunsch { VORNE, HINTEN, KEINE_PRAEFERENZ }
// --- Nennung ---
data class Nennung(
val tag: String,
val platz: Int,
val bewerbNr: Int,
val bewerbName: String,
val pferdName: String,
val reiterName: String,
val startwunsch: Startwunsch = Startwunsch.KEINE_PRAEFERENZ,
)
// --- Verkauf-Artikel ---
data class VerkaufArtikel(
val knr: String = "",
val buchungstext: String,
val einzelpreis: Double,
val menge: Int = 0,
val gebucht: Double = 0.0,
) {
val betrag: Double get() = menge * einzelpreis
}
// --- Mock-Daten (werden später durch echte API ersetzt) ---
object NennungMockData {
val bewerbe = listOf(
Bewerb(1, "So", 1, "08:00", "Dressurreiterprüfung Ratepass", Sparte.DRESSUR, "E"),
Bewerb(2, "So", 1, "08:20", "Dressurreiterprüfung Katecnadel", Sparte.DRESSUR, "E"),
Bewerb(3, "So", 1, "08:40", "Dressurreiterprüfung ldf. (ldf.)", Sparte.DRESSUR, "A"),
Bewerb(4, "So", 1, "09:00", "Dressurprüfung ldf. (ldf.)", Sparte.DRESSUR, "L"),
Bewerb(5, "So", 1, "09:20", "Führzügelklasse", Sparte.DRESSUR, "Kl."),
Bewerb(6, "So", 1, "09:40", "First Ridden", Sparte.DRESSUR, "Kl."),
Bewerb(7, "So", 1, "10:00", "Pony Dressurprüfung Kl. A", Sparte.DRESSUR, "A"),
Bewerb(8, "So", 1, "10:20", "Dressurreiterprüfung Kl. A", Sparte.DRESSUR, "A"),
Bewerb(9, "So", 2, "11:00", "Stilspringen Kl. A", Sparte.SPRINGEN, "A"),
Bewerb(10, "So", 2, "11:30", "Stilspringen Kl. L", Sparte.SPRINGEN, "L"),
Bewerb(11, "So", 2, "13:00", "Springprüfung Kl. M", Sparte.SPRINGEN, "M"),
Bewerb(12, "So", 2, "14:00", "Springprüfung Kl. S", Sparte.SPRINGEN, "S"),
)
val pferde = listOf(
Pferd("1001", "Amadeus", "Warmblut", "Braun", "Müller Hans", "Box 3"),
Pferd("1002", "Bella", "Haflinger", "Fuchs", "Huber Maria", "Box 7"),
Pferd("1003", "Casanova", "Lipizzaner", "Schimmel", "Gruber Josef", "Box 12"),
Pferd("1004", "Donner", "Trakehner", "Rappe", "Wagner Anna", ""),
Pferd("1005", "Estrella", "Andalusier", "Schimmel", "Bauer Klaus", "Box 2"),
)
val reiter = listOf(
Reiter("2001", "Hans", "Müller", "RV Neumarkt", "AT-12345", true, 0.0),
Reiter("2002", "Maria", "Huber", "RV Salzburg", "AT-23456", true, -45.0),
Reiter("2003", "Josef", "Gruber", "RV Wien", "AT-34567", false, 0.0),
Reiter("2004", "Anna", "Wagner", "RV Graz", "AT-45678", true, 120.0),
Reiter("2005", "Klaus", "Bauer", "RV Linz", "AT-56789", true, 0.0),
)
val verkaufArtikel = listOf(
VerkaufArtikel("", "Belastung", 0.0),
VerkaufArtikel("", "Gutschrift", 0.0),
VerkaufArtikel("BP", "Boxenpauschale", 115.0),
VerkaufArtikel("AN", "Ansage", 2.0),
VerkaufArtikel("FT", "Füttern", 3.0),
VerkaufArtikel("HE", "Heu", 13.0),
VerkaufArtikel("SP", "Späne", 15.0),
VerkaufArtikel("ST", "Stroh", 5.0),
VerkaufArtikel("EL", "Strom", 50.0),
VerkaufArtikel("YN", "Y-Nummer", 35.0),
VerkaufArtikel("ZN", "Z-Nummer", 10.0),
)
}
@@ -0,0 +1,168 @@
package at.mocode.nennung.feature.presentation
import at.mocode.nennung.feature.domain.*
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
// --- UI State ---
data class NennungUiState(
val pferdSuche: String = "",
val reiterSuche: String = "",
val selectedPferd: Pferd? = null,
val selectedReiter: Reiter? = null,
val pferdVorschlaege: List<Pferd> = emptyList(),
val reiterVorschlaege: List<Reiter> = emptyList(),
val nennungen: List<Nennung> = emptyList(),
val bewerbe: List<Bewerb> = NennungMockData.bewerbe,
val verkaufArtikel: List<VerkaufArtikel> = NennungMockData.verkaufArtikel,
val spartFilter: Sparte? = null,
val activeNennungTab: NennungTab = NennungTab.REITER,
val activeVerkaufTab: VerkaufTab = VerkaufTab.VERKAUF,
val statusMeldung: String? = null,
)
enum class NennungTab { REITER, PFERD, BEWERBE }
enum class VerkaufTab { VERKAUF, BUCHUNGEN }
class NennungViewModel : ViewModel() {
private val _uiState = MutableStateFlow(NennungUiState())
val uiState: StateFlow<NennungUiState> = _uiState.asStateFlow()
// --- Pferd-Suche ---
fun onPferdSucheChanged(query: String) {
val vorschlaege = if (query.length >= 2) {
NennungMockData.pferde.filter {
it.kopfNr.startsWith(query) || it.name.contains(query, ignoreCase = true)
}
} else emptyList()
_uiState.update { it.copy(pferdSuche = query, pferdVorschlaege = vorschlaege, selectedPferd = null) }
}
fun onPferdSelected(pferd: Pferd) {
_uiState.update {
it.copy(
selectedPferd = pferd,
pferdSuche = "${pferd.kopfNr} ${pferd.name}",
pferdVorschlaege = emptyList(),
)
}
}
fun onPferdLeeren() {
_uiState.update { it.copy(pferdSuche = "", selectedPferd = null, pferdVorschlaege = emptyList()) }
}
// --- Reiter-Suche ---
fun onReiterSucheChanged(query: String) {
val vorschlaege = if (query.length >= 2) {
NennungMockData.reiter.filter {
it.kopfNr.startsWith(query) || it.vollname.contains(query, ignoreCase = true)
}
} else emptyList()
_uiState.update { it.copy(reiterSuche = query, reiterVorschlaege = vorschlaege, selectedReiter = null) }
}
fun onReiterSelected(reiter: Reiter) {
_uiState.update {
it.copy(
selectedReiter = reiter,
reiterSuche = "${reiter.kopfNr} ${reiter.vollname}",
reiterVorschlaege = emptyList(),
)
}
}
fun onReiterLeeren() {
_uiState.update { it.copy(reiterSuche = "", selectedReiter = null, reiterVorschlaege = emptyList()) }
}
// --- Nennung durchführen ---
fun nennungDurchfuehren(bewerb: Bewerb, startwunsch: Startwunsch = Startwunsch.KEINE_PRAEFERENZ) {
val pferd = _uiState.value.selectedPferd ?: return
val reiter = _uiState.value.selectedReiter ?: return
val bereitsGenannt = _uiState.value.nennungen.any {
it.bewerbNr == bewerb.nr && it.pferdName == pferd.name && it.reiterName == reiter.vollname
}
if (bereitsGenannt) {
_uiState.update { it.copy(statusMeldung = "⚠️ ${reiter.vollname} mit ${pferd.name} ist bereits in Bewerb ${bewerb.nr} gemeldet!") }
return
}
val neueNennung = Nennung(
tag = bewerb.tag,
platz = bewerb.platz,
bewerbNr = bewerb.nr,
bewerbName = bewerb.name,
pferdName = pferd.name,
reiterName = reiter.vollname,
startwunsch = startwunsch,
)
_uiState.update { state ->
state.copy(
nennungen = state.nennungen + neueNennung,
statusMeldung = "✅ Nennung erfasst: ${reiter.vollname} / ${pferd.name} → Bewerb ${bewerb.nr}",
)
}
}
// --- Nennung stornieren ---
fun nennungStornieren(nennung: Nennung) {
_uiState.update { state ->
state.copy(
nennungen = state.nennungen - nennung,
statusMeldung = "🗑️ Nennung storniert: ${nennung.reiterName} / ${nennung.pferdName} → Bewerb ${nennung.bewerbNr}",
)
}
}
// --- Verkauf ---
fun onVerkaufMengeChanged(artikel: VerkaufArtikel, delta: Int) {
_uiState.update { state ->
state.copy(
verkaufArtikel = state.verkaufArtikel.map {
if (it.buchungstext == artikel.buchungstext)
it.copy(menge = maxOf(0, it.menge + delta))
else it
}
)
}
}
// --- Filter & Tabs ---
fun onSpartFilterChanged(sparte: Sparte?) {
_uiState.update { it.copy(spartFilter = sparte) }
}
fun onNennungTabChanged(tab: NennungTab) {
_uiState.update { it.copy(activeNennungTab = tab) }
}
fun onVerkaufTabChanged(tab: VerkaufTab) {
_uiState.update { it.copy(activeVerkaufTab = tab) }
}
fun statusMeldungDismiss() {
_uiState.update { it.copy(statusMeldung = null) }
}
// --- Gefilterte Bewerbsliste ---
fun gefilterteBewerbe(): List<Bewerb> {
val filter = _uiState.value.spartFilter
return if (filter == null) _uiState.value.bewerbe
else _uiState.value.bewerbe.filter { it.sparte == filter }
}
// --- Nennungen für aktuellen Reiter/Pferd ---
fun nennungenFuerAktuellen(): List<Nennung> {
val state = _uiState.value
return state.nennungen.filter {
(state.selectedReiter == null || it.reiterName == state.selectedReiter.vollname) &&
(state.selectedPferd == null || it.pferdName == state.selectedPferd.name)
}
}
}
@@ -0,0 +1,812 @@
package at.mocode.nennung.feature.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.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
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.nennung.feature.domain.*
// 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)
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,
)
}
Divider(modifier = Modifier.fillMaxHeight().width(1.dp))
// Rechte Hälfte: Verkauf/Buchungen (40%)
Column(
modifier = Modifier
.weight(0.4f)
.fillMaxHeight()
) {
VerkaufBuchungenPanel(
state = state,
onTabChanged = viewModel::onVerkaufTabChanged,
onMengeChanged = viewModel::onVerkaufMengeChanged,
)
}
}
Divider()
// --- Zeile 2: Aktions-Buttons (fix) ---
AktionsButtonLeiste(
canNennen = state.selectedPferd != null && state.selectedReiter != null,
onStartlisteOeffnen = onStartlisteOeffnen,
onErgebnisseOeffnen = onErgebnisseOeffnen,
onAbrechnungOeffnen = onAbrechnungOeffnen,
)
Divider()
// --- 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,
)
}
Divider(modifier = Modifier.fillMaxHeight().width(1.dp))
// 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)
}
}
}
Divider(modifier = Modifier.fillMaxHeight().width(1.dp))
// --- 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 = "%.2f €".format(reiter.kontoSaldo),
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) Divider()
}
}
}
}
}
}
@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.Default.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
TabRow(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)
}
}
Divider()
// Tabellen-Header
TabellenHeader(
listOf("Tag", "Pl.", "Bewerb", "Bewerbsname", "Startwunsch", "Pferd"),
listOf(30f, 25f, 45f, 1f, 70f, 80f)
)
Divider()
// 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
)
}
Divider(thickness = 0.5.dp)
}
}
}
}
}
// ---------------------------------------------------------------------------
// 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)
}
Divider()
// Tabellen-Header
TabellenHeader(
listOf("Tag", "Pl.", "Bewerb", "Beginn", "Nenn.", "Bewerbsname"),
listOf(28f, 22f, 45f, 45f, 35f, 1f)
)
Divider()
// 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) {
val now = System.currentTimeMillis()
if (lastClickedBewerb == bewerb.nr && now - lastClickTime < 400) {
onNennung(bewerb)
lastClickedBewerb = null
} else {
lastClickedBewerb = bewerb.nr
lastClickTime = now
}
}
.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
)
}
Divider(thickness = 0.5.dp)
}
}
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()) {
TabRow(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)
}
}
Divider()
TabellenHeader(
listOf("KNr", "+", "Menge", "", "Buchungstext", "Betrag", "Gebucht"),
listOf(30f, 20f, 45f, 20f, 1f, 55f, 55f)
)
Divider()
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("%.2f".format(art.betrag), fontSize = 10.sp, modifier = Modifier.width(55.dp))
Text("%.2f".format(art.gebucht), fontSize = 10.sp, modifier = Modifier.width(55.dp))
}
Divider(thickness = 0.5.dp)
}
}
}
}
@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)
}
}
Divider()
TabellenHeader(listOf("Kopfnr", "Menge", "Buchungstext", "Soll", "Haben"), listOf(55f, 45f, 1f, 55f, 55f))
Divider()
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))
}
}
}
}
@@ -93,6 +93,7 @@ kotlin {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.koin.core)
implementation(project(":frontend:features:nennung-feature"))
}
jsMain.dependencies {
@@ -74,6 +74,7 @@ fun MainApp() {
is AppScreen.Dashboard -> DashboardScreen(
authTokenManager = authTokenManager,
onNennungOeffnen = { navigationPort.navigateToScreen(AppScreen.Nennung) },
onLogout = {
authTokenManager.clearToken()
if (currentPlatform() == PlatformType.DESKTOP) {
@@ -145,6 +146,7 @@ fun MainApp() {
}
}
is AppScreen.Nennung -> NennungScreenContent()
is AppScreen.Profile -> AuthStatusScreen(
authTokenManager = authTokenManager,
onNavigateToLogin = {
@@ -412,7 +414,8 @@ private fun FeatureCard(number: String, title: String, body: String) {
private fun DashboardScreen(
authTokenManager: AuthTokenManager,
onLogout: () -> Unit,
onCreateTournament: () -> Unit
onCreateTournament: () -> Unit,
onNennungOeffnen: () -> Unit = {},
) {
val authState by authTokenManager.authState.collectAsState()
val scrollState = rememberScrollState()
@@ -474,6 +477,15 @@ private fun DashboardScreen(
if (isDesktop && isAdmin) {
// DESKTOP VIEW - STEUERUNGSZENTRALE FÜR DEN ADMIN (DICH)
// Neues Turnier anlegen Button
Button(
onClick = onNennungOeffnen,
modifier = Modifier.fillMaxWidth().height(64.dp)
) {
Text(
text = "📋 Nennungs-Maske öffnen",
style = MaterialTheme.typography.titleMedium
)
}
OutlinedButton(
onClick = onCreateTournament,
modifier = Modifier.fillMaxWidth().height(64.dp)
@@ -0,0 +1,4 @@
import androidx.compose.runtime.Composable
@Composable
expect fun NennungScreenContent()
@@ -0,0 +1,8 @@
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
actual fun NennungScreenContent() {
// Nennungs-Maske ist nur für Desktop (JVM) verfügbar
Text("Nennungs-Maske ist nur in der Desktop-App verfügbar.")
}
@@ -0,0 +1,10 @@
import androidx.compose.runtime.Composable
import at.mocode.nennung.feature.presentation.NennungsMaske
import at.mocode.nennung.feature.presentation.NennungViewModel
import org.koin.compose.viewmodel.koinViewModel
@Composable
actual fun NennungScreenContent() {
val viewModel: NennungViewModel = koinViewModel()
NennungsMaske(viewModel = viewModel)
}
@@ -9,6 +9,7 @@ import at.mocode.frontend.core.localdb.localDbModule
import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.ping.feature.di.pingFeatureModule
import at.mocode.nennung.feature.di.nennungFeatureModule
import kotlinx.coroutines.runBlocking
import navigation.navigationModule
import org.koin.core.context.loadKoinModules
@@ -19,7 +20,17 @@ fun main() = application {
// Initialize DI (Koin) with shared modules + network module
try {
// Updated: Only load the consolidated pingFeatureModule from at.mocode.ping.feature.di
startKoin { modules(networkModule, syncModule, pingFeatureModule, authModule, navigationModule, localDbModule) }
startKoin {
modules(
networkModule,
syncModule,
pingFeatureModule,
nennungFeatureModule,
authModule,
navigationModule,
localDbModule
)
}
println("[DesktopApp] Koin initialized with networkModule + authModule + navigationModule + pingFeatureModule + localDbModule")
} catch (e: Exception) {
println("[DesktopApp] Koin initialization warning: ${e.message}")