Implement "Zeitplan" feature in tournament details: add TurnierZeitplanTab.kt, update navigation, and integrate visual scheduling with drag-and-drop support. Relocate Detekt configuration.

This commit is contained in:
Stefan Mogeritsch 2026-04-11 20:37:25 +02:00
parent b91d1953a4
commit 52bc8f3fbe
5 changed files with 290 additions and 5 deletions

View File

@ -171,7 +171,7 @@ subprojects {
buildUponDefaultConfig = true buildUponDefaultConfig = true
allRules = false allRules = false
autoCorrect = false autoCorrect = false
config.setFrom(files(rootProject.file("config/detekt/detekt.yml"))) config.setFrom(files(rootProject.file("config/quality/detekt/detekt.yml")))
basePath = rootDir.absolutePath basePath = rootDir.absolutePath
} }
tasks.withType<Detekt>().configureEach { tasks.withType<Detekt>().configureEach {

View File

@ -0,0 +1,28 @@
# 🧹 Curator Log - 2026-04-11
## 📅 Session Info
- **Datum:** 2026-04-11
- **Agenten:** 🏗️ Lead Architect, 👷 Backend Developer, 🎨 Frontend Expert, 🧹 Curator
- **Fokus:** Implementierung Zeitplan-Optimierung (Frontend Prototyp)
## 🏗️ Architektur-Entscheidungen
- **Komponente:** `TurnierZeitplanTab.kt` wurde als zentraler Ort für die visuelle Zeitplanung geschaffen.
- **Layout:** 14-Stunden-Raster (07:00 - 21:00) mit feststehender Zeitachse und scrollbarem Gitter.
- **Interaktion:** Vertikales Drag & Drop mit 5-Minuten-Snapping zur präzisen Planung.
## 👷 Backend/Integration (Status)
- **Backend:** Logik in `BewerbService` bereits vorhanden (vorherige Sessions).
- **Frontend-Store:** Aktuell noch Mock-Daten (`ZeitplanItemUi`), Anbindung an `BewerbViewModel` steht noch aus.
## 🎨 Frontend (Details)
- **Modul:** `frontend:features:turnier-feature`
- **Datei:** `TurnierZeitplanTab.kt` (Neu)
- **Anpassung:** `TurnierDetailScreen.kt` um Tab "ZEITPLAN" erweitert.
## 🧹 Curator Status & Cleanup
- ✅ Neue UI-Komponente erstellt und syntaktisch korrigiert.
- ✅ Navigation im Turnier-Detail angepasst.
- 📂 Nächster Schritt: Mapping von `BewerbUiModel` auf `ZeitplanItemUi`.
---
*Erstellt durch den Curator.*

View File

@ -56,6 +56,7 @@ fun TurnierDetailScreen(
"ARTIKEL", "ARTIKEL",
"ABRECHNUNG", "ABRECHNUNG",
"NENNUNGEN", "NENNUNGEN",
"ZEITPLAN",
"STARTLISTEN", "STARTLISTEN",
"ERGEBNISLISTEN", "ERGEBNISLISTEN",
) )
@ -105,8 +106,9 @@ fun TurnierDetailScreen(
3 -> ArtikelTabContent() 3 -> ArtikelTabContent()
4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId) 4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId)
5 -> NennungenTabContent(onAbrechnungClick = { selectedTab = 4 }) 5 -> NennungenTabContent(onAbrechnungClick = { selectedTab = 4 })
6 -> StartlistenTabContent() 6 -> ZeitplanTabContent(turnierId = turnierId)
7 -> ErgebnislistenTabContent() 7 -> StartlistenTabContent()
8 -> ErgebnislistenTabContent()
} }
} }
} }
@ -116,4 +118,5 @@ fun TurnierDetailScreen(
// TurnierBewerbeTab.kt → BewerbeTabContent() // TurnierBewerbeTab.kt → BewerbeTabContent()
// TurnierNennungenTab.kt → NennungenTabContent() // TurnierNennungenTab.kt → NennungenTabContent()
// TurnierStartlistenTab.kt → StartlistenTabContent() // TurnierStartlistenTab.kt → StartlistenTabContent()
// TurnierZeitplanTab.kt → ZeitplanTabContent()
// TurnierErgebnislistenTab.kt → ErgebnislistenTabContent() // TurnierErgebnislistenTab.kt → ErgebnislistenTabContent()

View File

@ -0,0 +1,254 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.roundToInt
private val ZeitplanBlue = Color(0xFF1E3A8A)
private val ZeitplanBg = Color(0xFFF8FAFC)
private val SlotBorder = Color(0xFFE2E8F0)
private val HourLabelColor = Color(0xFF64748B)
// Konfiguration für den Zeitstrahl
private const val START_HOUR = 7
private const val END_HOUR = 20
private val HOUR_HEIGHT = 80.dp
private val MINUTE_HEIGHT = HOUR_HEIGHT / 60
/**
* ZEITPLAN-Tab gemäß Konzept Zeitplan-Optimierung.
*
* Visuelle Kalender-Ansicht mit Drag & Drop Support.
*/
@Composable
fun ZeitplanTabContent(
turnierId: Long
) {
var items by remember { mutableStateOf(sampleZeitplanItems()) }
val scrollState = rememberScrollState()
Box(modifier = Modifier.fillMaxSize().background(ZeitplanBg)) {
Column(modifier = Modifier.fillMaxSize()) {
// Header / Toolbar
ZeitplanToolbar()
Row(modifier = Modifier.weight(1f)) {
// Zeit-Achse (feststehend)
ZeitAchse()
// Content (scrollbar)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.verticalScroll(scrollState)
) {
// Hintergrund-Gitter
ZeitplanGitter()
// Bewerbe / Blöcke
items.forEachIndexed { index, item ->
DraggableBewerbBox(
item = item,
onPositionChange = { newMinutes ->
items = items.toMutableList().apply {
this[index] = item.copy(startMinutes = newMinutes)
}
}
)
}
}
}
}
}
}
@Composable
private fun ZeitplanToolbar() {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Zeitplan-Optimierung", fontWeight = FontWeight.Bold, fontSize = 16.sp, color = ZeitplanBlue)
Spacer(Modifier.weight(1f))
// Platz-Filter (Mock)
Text("Platz:", fontSize = 13.sp)
AssistChip(onClick = {}, label = { Text("Hauptplatz") })
AssistChip(onClick = {}, label = { Text("Viereck 1") }, leadingIcon = { Text("", fontSize = 12.sp) })
Spacer(Modifier.width(12.dp))
Button(
onClick = { /* Speichern */ },
colors = ButtonDefaults.buttonColors(containerColor = ZeitplanBlue)
) {
Text("Änderungen speichern", fontSize = 13.sp)
}
}
}
@Composable
private fun ZeitAchse() {
Column(
modifier = Modifier
.width(60.dp)
.fillMaxHeight()
.background(Color.White)
) {
Box(modifier = Modifier.fillMaxHeight().width(59.dp).background(Color.White)) {
Column {
for (hour in START_HOUR..END_HOUR) {
Box(
modifier = Modifier.height(HOUR_HEIGHT).fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
Text(
text = "${hour.toString().padStart(2, '0')}:00",
fontSize = 11.sp,
color = HourLabelColor,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
}
}
@Composable
private fun ZeitplanGitter() {
Column {
for (hour in START_HOUR..END_HOUR) {
Box(
modifier = Modifier
.height(HOUR_HEIGHT)
.fillMaxWidth()
) {
HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter), color = SlotBorder)
}
}
}
}
@Composable
private fun DraggableBewerbBox(
item: ZeitplanItemUi,
onPositionChange: (Int) -> Unit
) {
// Berechnung der Position basierend auf den Startminuten seit START_HOUR
val relativeMinutes = item.startMinutes - (START_HOUR * 60)
val topOffset = (relativeMinutes * MINUTE_HEIGHT.value).dp
val height = (item.durationMinutes * MINUTE_HEIGHT.value).dp
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.offset(y = topOffset)
.padding(horizontal = 8.dp)
.fillMaxWidth()
.height(height)
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.clip(RoundedCornerShape(6.dp))
.background(item.color.copy(alpha = 0.15f))
.border(1.dp, item.color, RoundedCornerShape(6.dp))
.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
// Snapping auf 5 Minuten Intervalle
val movedMinutes = (offsetY / MINUTE_HEIGHT.toPx()).roundToInt()
val newTotalMinutes = item.startMinutes + movedMinutes
val snappedMinutes = (newTotalMinutes / 5) * 5
onPositionChange(snappedMinutes)
offsetX = 0f
offsetY = 0f
},
onDrag = { change, dragAmount ->
change.consume()
// Nur vertikales Dragging für den Zeitplan vorerst
offsetY += dragAmount.y
}
)
}
.padding(8.dp)
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.timeString,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = item.color
)
Spacer(Modifier.width(8.dp))
Text(
text = "Bewerb ${item.nummer}",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
}
Text(
text = item.name,
fontSize = 12.sp,
maxLines = 1,
color = Color.DarkGray
)
if (item.hasConflict) {
Row(
modifier = Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
Text("⚠️", fontSize = 12.sp)
Spacer(Modifier.width(4.dp))
Text("Konflikt", fontSize = 10.sp, color = Color.Red, fontWeight = FontWeight.Bold)
}
}
}
}
}
data class ZeitplanItemUi(
val id: Long,
val nummer: Int,
val name: String,
val startMinutes: Int, // Minuten seit 00:00
val durationMinutes: Int,
val color: Color = ZeitplanBlue,
val hasConflict: Boolean = false
) {
val timeString: String
get() {
val h = startMinutes / 60
val m = startMinutes % 60
return "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}"
}
}
private fun sampleZeitplanItems() = listOf(
ZeitplanItemUi(1, 1, "Dressurreiterprüfung Reiterpass", 8 * 60, 45),
ZeitplanItemUi(2, 2, "Dressurreiterprüfung Reitenadel", 8 * 60 + 50, 60, hasConflict = true),
ZeitplanItemUi(3, 3, "Dressurprüfung Kl. A (Aufgabe A2)", 10 * 60 + 30, 90, color = Color(0xFF059669)),
ZeitplanItemUi(4, 4, "Mittagspause", 12 * 60 + 30, 45, color = Color(0xFFD97706)),
ZeitplanItemUi(5, 5, "Dressurreiterprüfung Kl. L", 13 * 60 + 30, 120, color = Color(0xFF7C3AED)),
)

View File

@ -70,6 +70,7 @@ include(":backend:infrastructure:security")
include(":backend:infrastructure:zns-importer") include(":backend:infrastructure:zns-importer")
// === BACKEND - SERVICES === // === BACKEND - SERVICES ===
// --- ENTRIES (Nennungen) --- // --- ENTRIES (Nennungen) ---
include(":backend:services:entries:entries-api") include(":backend:services:entries:entries-api")
include(":backend:services:entries:entries-domain") include(":backend:services:entries:entries-domain")
@ -95,7 +96,7 @@ include(":backend:services:masterdata:masterdata-infrastructure")
include(":backend:services:masterdata:masterdata-service") include(":backend:services:masterdata:masterdata-service")
// --- BILLING (Kassa, Zahlungen & Rechnungen) --- // --- BILLING (Kassa, Zahlungen & Rechnungen) ---
include(":backend:services:billing:billing-api") // include(":backend:services:billing:billing-api")
include(":backend:services:billing:billing-domain") include(":backend:services:billing:billing-domain")
include(":backend:services:billing:billing-service") include(":backend:services:billing:billing-service")
@ -131,7 +132,6 @@ include(":frontend:core:local-db")
include(":frontend:core:sync") include(":frontend:core:sync")
// --- FEATURES --- // --- FEATURES ---
// include(":frontend:features:members-feature")
include(":frontend:features:ping-feature") include(":frontend:features:ping-feature")
include(":frontend:features:nennung-feature") include(":frontend:features:nennung-feature")
include(":frontend:features:zns-import-feature") include(":frontend:features:zns-import-feature")