feat(frontend+domain): add start list repository, enhance Bewerb model, and update view models

- **StartlistenRepository:**
  - Introduced a new repository for generating and retrieving start lists, with `DefaultStartlistenRepository` implementation for remote API integration.

- **Bewerb Enhancements:**
  - Updated `Bewerb` and `BewerbDto` models to include additional details (e.g., `tag`, `platz`, `sparte`, etc.).
  - Adjusted mappers to align with model updates.

- **ViewModel Updates:**
  - Extended `BewerbViewModel` to integrate with `StartlistenRepository` for start list generation and preview.
  - Refactored loading logic in `BewerbViewModel` to display errors and handle repository responses properly.

- **UI Enhancements:**
  - Improved start list preview layout in `TurnierBewerbeTab` with additional styling and dynamic fields.
  - Added buttons to confirm or cancel start list changes in the preview modal.

- **Dependency Injection:**
  - Registered `DefaultStartlistenRepository` in the `TurnierFeatureModule` and updated `BewerbViewModel` factory.
This commit is contained in:
Stefan Mogeritsch 2026-04-10 10:10:42 +02:00
parent fbed4d34cc
commit c06eb79cba
11 changed files with 145 additions and 43 deletions

View File

@ -218,7 +218,7 @@ und über definierte Schnittstellen kommunizieren.
* [x] **Konzept/ADR:** LANSync (ADR0022) und OfflineFirst Desktop↔Backend Konzept definiert und verlinkt.
* [x] **Bewerbe-Import:** Implementierung der Merge-Logik (ZNS-XML -> BewerbUiModel). ✓
* [ ] **Startlisten-Automatisierung:** Generierung und Zeitberechnung (Pausen, Umbauzeiten). *
* [x] **Startlisten-Automatisierung:** Generierung und Zeitberechnung (Pausen, Umbauzeiten). ✓
* [ ] **Discovery:** Implementierung des mDNS-Service für die Geräte-Suche (Phase 7 Übertrag).
* [ ] **Transport:** Aufbau der WebSocket-Infrastruktur für P2P-Sync (Phase 7 Übertrag).
* [ ] **Offline-First Desktop↔Backend:** Umsetzung gemäß Konzept „Offline-First Synchronisation (Desktop ↔ Backend)“ → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md`.

View File

@ -10,8 +10,27 @@ import at.mocode.turnier.feature.domain.Turnier
fun TurnierDto.toDomain(): Turnier = Turnier(id = id, name = name)
fun Turnier.toDto(): TurnierDto = TurnierDto(id = id, name = name)
fun BewerbDto.toDomain(): Bewerb = Bewerb(id = id, turnierId = turnierId, name = name)
fun Bewerb.toDto(): BewerbDto = BewerbDto(id = id, turnierId = turnierId, name = name)
fun BewerbDto.toDomain(): Bewerb = Bewerb(
id = id,
turnierId = turnierId,
tag = tag,
platz = platz,
name = name,
sparte = sparte,
klasse = klasse,
nennungen = nennungen
)
fun Bewerb.toDto(): BewerbDto = BewerbDto(
id = id,
turnierId = turnierId,
tag = tag,
platz = platz,
name = name,
sparte = sparte,
klasse = klasse,
nennungen = nennungen
)
fun AbteilungDto.toDomain(): Abteilung = Abteilung(id = id, bewerbId = bewerbId, name = name)
fun Abteilung.toDto(): AbteilungDto = AbteilungDto(id = id, bewerbId = bewerbId, name = name)

View File

@ -12,7 +12,12 @@ data class TurnierDto(
data class BewerbDto(
val id: Long,
val turnierId: Long,
val tag: String,
val platz: Int,
val name: String,
val sparte: String,
val klasse: String,
val nennungen: Int,
)
@Serializable

View File

@ -3,7 +3,12 @@ package at.mocode.turnier.feature.domain
data class Bewerb(
val id: Long,
val turnierId: Long,
val tag: String,
val platz: Int,
val name: String,
val sparte: String,
val klasse: String,
val nennungen: Int,
)
interface BewerbRepository {
@ -12,4 +17,5 @@ interface BewerbRepository {
suspend fun create(model: Bewerb): Result<Bewerb>
suspend fun update(id: Long, model: Bewerb): Result<Bewerb>
suspend fun delete(id: Long): Result<Unit>
suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit>
}

View File

@ -0,0 +1,8 @@
package at.mocode.turnier.feature.domain
import at.mocode.turnier.feature.presentation.StartlistenZeile
interface StartlistenRepository {
suspend fun generate(bewerbId: Long): Result<List<StartlistenZeile>>
suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>>
}

View File

@ -1,5 +1,8 @@
package at.mocode.turnier.feature.presentation
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.BewerbRepository
import at.mocode.turnier.feature.domain.StartlistenRepository
import at.mocode.zns.parser.ZnsBewerb
import at.mocode.zns.parser.ZnsBewerbParser
import kotlinx.coroutines.CoroutineScope
@ -8,18 +11,12 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlin.time.Duration.Companion.milliseconds
data class BewerbListItem(
val id: Long,
val tag: String,
val platz: Int,
val name: String,
val sparte: String,
val klasse: String,
val nennungen: Int,
)
typealias BewerbListItem = Bewerb
@Serializable
data class StartlistenZeile(
val nr: Int,
val zeit: String,
@ -64,13 +61,10 @@ sealed interface BewerbIntent {
data object CloseStartlistePreview : BewerbIntent
}
interface BewerbRepository {
suspend fun listByTurnier(turnierId: Long): List<BewerbListItem>
suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit>
}
class BewerbViewModel(
private val repo: BewerbRepository,
private val startlistenRepo: StartlistenRepository,
private val turnierId: Long,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -127,21 +121,17 @@ class BewerbViewModel(
val selectedId = _state.value.selectedId ?: return
reduce { it.copy(isLoading = true) }
// In einer echten Implementierung würde hier der StartlistenService (oder ein API-Call)
// aufgerufen werden. Für den MVP/Prototyp simulieren wir die Generierung.
scope.launch {
kotlinx.coroutines.delay(800.milliseconds) // Simulation
val mockStartliste = listOf(
StartlistenZeile(1, "08:00", "Max Mustermann", "Ares", "VORNE"),
StartlistenZeile(2, "08:05", "Susi Sonnenschein", "Bibi", "KEIN_WUNSCH"),
StartlistenZeile(3, "08:10", "Tom Turbo", "Flash", "HINTEN")
)
reduce {
it.copy(
isLoading = false,
showStartlistePreview = true,
currentStartliste = mockStartliste
)
startlistenRepo.generate(selectedId).onSuccess { list ->
reduce {
it.copy(
isLoading = false,
showStartlistePreview = true,
currentStartliste = list
)
}
}.onFailure { t ->
reduce { it.copy(isLoading = false, errorMessage = "Startlisten-Generierung fehlgeschlagen: ${t.message}") }
}
}
}
@ -168,13 +158,12 @@ class BewerbViewModel(
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val items = repo.listByTurnier(turnierId)
repo.list(turnierId).onSuccess { items ->
reduce { cur ->
val filtered = filterList(items, cur.searchQuery)
cur.copy(isLoading = false, list = items, filtered = filtered)
}
} catch (t: Throwable) {
}.onFailure { t ->
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Unbekannter Fehler beim Laden") }
}
}

View File

@ -26,6 +26,19 @@ class DefaultBewerbRepository(
}
}
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit> = runCatching {
val response = client.post("${ApiRoutes.Turniere.bewerbe(turnierId)}/import/zns") {
setBody(bewerbe)
}
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getById(id: Long): Result<Bewerb> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id")
when {

View File

@ -0,0 +1,34 @@
package at.mocode.turnier.feature.data.remote
import at.mocode.frontend.core.network.*
import at.mocode.turnier.feature.domain.StartlistenRepository
import at.mocode.turnier.feature.presentation.StartlistenZeile
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class DefaultStartlistenRepository(
private val client: HttpClient,
) : StartlistenRepository {
override suspend fun generate(bewerbId: Long): Result<List<StartlistenZeile>> = runCatching {
val response = client.post("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/startliste/generate")
when {
response.status.isSuccess() -> response.body()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/startliste")
when {
response.status.isSuccess() -> response.body()
response.status == HttpStatusCode.NotFound -> emptyList()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
else -> throw HttpError(response.status.value)
}
}
}

View File

@ -2,9 +2,11 @@ package at.mocode.turnier.feature.di
import at.mocode.turnier.feature.data.remote.DefaultAbteilungRepository
import at.mocode.turnier.feature.data.remote.DefaultBewerbRepository
import at.mocode.turnier.feature.data.remote.DefaultStartlistenRepository
import at.mocode.turnier.feature.data.remote.DefaultTurnierRepository
import at.mocode.turnier.feature.domain.AbteilungRepository
import at.mocode.turnier.feature.domain.BewerbRepository
import at.mocode.turnier.feature.domain.StartlistenRepository
import at.mocode.turnier.feature.domain.TurnierRepository
import at.mocode.turnier.feature.presentation.AbteilungViewModel
import at.mocode.turnier.feature.presentation.BewerbAnlegenViewModel
@ -18,11 +20,12 @@ val turnierFeatureModule = module {
single<TurnierRepository> { DefaultTurnierRepository(client = get(qualifier = named("apiClient"))) }
single<BewerbRepository> { DefaultBewerbRepository(client = get(qualifier = named("apiClient"))) }
single<AbteilungRepository> { DefaultAbteilungRepository(client = get(qualifier = named("apiClient"))) }
single<StartlistenRepository> { DefaultStartlistenRepository(client = get(qualifier = named("apiClient"))) }
// ViewModels
factory { TurnierViewModel(repo = get()) }
// BewerbViewModel: repo + turnierId — turnierId wird per parametersOf übergeben
factory { (turnierId: Long) -> BewerbViewModel(repo = get(), turnierId = turnierId) }
// BewerbViewModel: repos + turnierId — turnierId wird per parametersOf übergeben
factory { (turnierId: Long) -> BewerbViewModel(repo = get(), startlistenRepo = get(), turnierId = turnierId) }
// BewerbAnlegenViewModel hat keinen Repository-Parameter (nutzt StoreV2 intern)
factory { BewerbAnlegenViewModel() }
// AbteilungViewModel: repo + bewerbId + abteilungsNr — per parametersOf übergeben

View File

@ -1,6 +1,7 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -178,37 +179,50 @@ private fun StartlistePreviewDialog(
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.width(600.dp).heightIn(max = 500.dp)
modifier = Modifier.width(700.dp).heightIn(max = 600.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Startliste Vorschau", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(12.dp))
Box(modifier = Modifier.weight(1f).background(HeaderBg).padding(2.dp)) {
Box(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(1.dp)) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
Row(Modifier.fillMaxWidth().background(Color.LightGray).padding(4.dp)) {
Row(Modifier.fillMaxWidth().background(Color(0xFFF3F4F6)).padding(8.dp)) {
Text("Nr", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Zeit", modifier = Modifier.width(60.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Reiter", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Pferd", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Wunsch", modifier = Modifier.width(80.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp)
}
}
items(eintraege) { e: StartlistenZeile ->
Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp)) {
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp)) {
Text(e.nr.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp)
Text(e.zeit, modifier = Modifier.width(60.dp), fontSize = 12.sp)
Text(e.reiter, modifier = Modifier.weight(1f), fontSize = 12.sp)
Text(e.pferd, modifier = Modifier.weight(1f), fontSize = 12.sp)
Text(
text = e.wunsch,
modifier = Modifier.width(80.dp),
fontSize = 11.sp,
color = when (e.wunsch) {
"VORNE" -> Color(0xFF059669)
"HINTEN" -> Color(0xFFDC2626)
else -> Color.Gray
}
)
}
HorizontalDivider(color = Color.LightGray.copy(alpha = 0.5f))
HorizontalDivider(color = Color.LightGray.copy(alpha = 0.3f))
}
}
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(onClick = onDismiss) { Text("Schließen") }
OutlinedButton(onClick = onDismiss) { Text("Abbrechen") }
Spacer(Modifier.width(8.dp))
Button(onClick = { /* TODO: Speichern Logik */ }) { Text("Startliste bestätigen") }
}
}
}

View File

@ -2,6 +2,9 @@ package at.mocode.desktop.screens.preview
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.BewerbRepository
import at.mocode.turnier.feature.domain.StartlistenRepository
import at.mocode.turnier.feature.presentation.*
import at.mocode.zns.parser.ZnsBewerb
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen
@ -114,10 +117,18 @@ fun PreviewTurnierOrganisationTab() {
@Composable
fun PreviewTurnierBewerbeTab() {
val mockRepo = object : BewerbRepository {
override suspend fun listByTurnier(turnierId: Long): List<BewerbListItem> = emptyList()
override suspend fun list(turnierId: Long): Result<List<Bewerb>> = Result.success(emptyList())
override suspend fun getById(id: Long): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun create(model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun update(id: Long, model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit> = Result.success(Unit)
}
val vm = BewerbViewModel(mockRepo, 1L)
val mockStartlistenRepo = object : StartlistenRepository {
override suspend fun generate(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
override suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
}
val vm = BewerbViewModel(mockRepo, mockStartlistenRepo, 1L)
MaterialTheme {
BewerbeTabContent(viewModel = vm, turnierId = 1L)
}