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:
2026-04-10 10:10:42 +02:00
parent fbed4d34cc
commit c06eb79cba
11 changed files with 145 additions and 43 deletions
+1 -1
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] **Konzept/ADR:** LANSync (ADR0022) und OfflineFirst Desktop↔Backend Konzept definiert und verlinkt.
* [x] **Bewerbe-Import:** Implementierung der Merge-Logik (ZNS-XML -> BewerbUiModel). ✓ * [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). * [ ] **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). * [ ] **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`. * [ ] **Offline-First Desktop↔Backend:** Umsetzung gemäß Konzept „Offline-First Synchronisation (Desktop ↔ Backend)“ → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md`.
@@ -10,8 +10,27 @@ import at.mocode.turnier.feature.domain.Turnier
fun TurnierDto.toDomain(): Turnier = Turnier(id = id, name = name) fun TurnierDto.toDomain(): Turnier = Turnier(id = id, name = name)
fun Turnier.toDto(): TurnierDto = TurnierDto(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 BewerbDto.toDomain(): Bewerb = Bewerb(
fun Bewerb.toDto(): BewerbDto = BewerbDto(id = id, turnierId = turnierId, name = name) 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 AbteilungDto.toDomain(): Abteilung = Abteilung(id = id, bewerbId = bewerbId, name = name)
fun Abteilung.toDto(): AbteilungDto = AbteilungDto(id = id, bewerbId = bewerbId, name = name) fun Abteilung.toDto(): AbteilungDto = AbteilungDto(id = id, bewerbId = bewerbId, name = name)
@@ -12,7 +12,12 @@ data class TurnierDto(
data class BewerbDto( data class BewerbDto(
val id: Long, val id: Long,
val turnierId: Long, val turnierId: Long,
val tag: String,
val platz: Int,
val name: String, val name: String,
val sparte: String,
val klasse: String,
val nennungen: Int,
) )
@Serializable @Serializable
@@ -3,7 +3,12 @@ package at.mocode.turnier.feature.domain
data class Bewerb( data class Bewerb(
val id: Long, val id: Long,
val turnierId: Long, val turnierId: Long,
val tag: String,
val platz: Int,
val name: String, val name: String,
val sparte: String,
val klasse: String,
val nennungen: Int,
) )
interface BewerbRepository { interface BewerbRepository {
@@ -12,4 +17,5 @@ interface BewerbRepository {
suspend fun create(model: Bewerb): Result<Bewerb> suspend fun create(model: Bewerb): Result<Bewerb>
suspend fun update(id: Long, model: Bewerb): Result<Bewerb> suspend fun update(id: Long, model: Bewerb): Result<Bewerb>
suspend fun delete(id: Long): Result<Unit> suspend fun delete(id: Long): Result<Unit>
suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit>
} }
@@ -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>>
}
@@ -1,5 +1,8 @@
package at.mocode.turnier.feature.presentation 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.ZnsBewerb
import at.mocode.zns.parser.ZnsBewerbParser import at.mocode.zns.parser.ZnsBewerbParser
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -8,18 +11,12 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
data class BewerbListItem( typealias BewerbListItem = Bewerb
val id: Long,
val tag: String,
val platz: Int,
val name: String,
val sparte: String,
val klasse: String,
val nennungen: Int,
)
@Serializable
data class StartlistenZeile( data class StartlistenZeile(
val nr: Int, val nr: Int,
val zeit: String, val zeit: String,
@@ -64,13 +61,10 @@ sealed interface BewerbIntent {
data object CloseStartlistePreview : 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( class BewerbViewModel(
private val repo: BewerbRepository, private val repo: BewerbRepository,
private val startlistenRepo: StartlistenRepository,
private val turnierId: Long, private val turnierId: Long,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -127,21 +121,17 @@ class BewerbViewModel(
val selectedId = _state.value.selectedId ?: return val selectedId = _state.value.selectedId ?: return
reduce { it.copy(isLoading = true) } 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 { scope.launch {
kotlinx.coroutines.delay(800.milliseconds) // Simulation startlistenRepo.generate(selectedId).onSuccess { list ->
val mockStartliste = listOf( reduce {
StartlistenZeile(1, "08:00", "Max Mustermann", "Ares", "VORNE"), it.copy(
StartlistenZeile(2, "08:05", "Susi Sonnenschein", "Bibi", "KEIN_WUNSCH"), isLoading = false,
StartlistenZeile(3, "08:10", "Tom Turbo", "Flash", "HINTEN") showStartlistePreview = true,
) currentStartliste = list
reduce { )
it.copy( }
isLoading = false, }.onFailure { t ->
showStartlistePreview = true, reduce { it.copy(isLoading = false, errorMessage = "Startlisten-Generierung fehlgeschlagen: ${t.message}") }
currentStartliste = mockStartliste
)
} }
} }
} }
@@ -168,13 +158,12 @@ class BewerbViewModel(
private fun load() { private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) } reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch { scope.launch {
try { repo.list(turnierId).onSuccess { items ->
val items = repo.listByTurnier(turnierId)
reduce { cur -> reduce { cur ->
val filtered = filterList(items, cur.searchQuery) val filtered = filterList(items, cur.searchQuery)
cur.copy(isLoading = false, list = items, filtered = filtered) 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") } reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Unbekannter Fehler beim Laden") }
} }
} }
@@ -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 { override suspend fun getById(id: Long): Result<Bewerb> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id") val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id")
when { when {
@@ -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)
}
}
}
@@ -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.DefaultAbteilungRepository
import at.mocode.turnier.feature.data.remote.DefaultBewerbRepository 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.data.remote.DefaultTurnierRepository
import at.mocode.turnier.feature.domain.AbteilungRepository import at.mocode.turnier.feature.domain.AbteilungRepository
import at.mocode.turnier.feature.domain.BewerbRepository 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.domain.TurnierRepository
import at.mocode.turnier.feature.presentation.AbteilungViewModel import at.mocode.turnier.feature.presentation.AbteilungViewModel
import at.mocode.turnier.feature.presentation.BewerbAnlegenViewModel import at.mocode.turnier.feature.presentation.BewerbAnlegenViewModel
@@ -18,11 +20,12 @@ val turnierFeatureModule = module {
single<TurnierRepository> { DefaultTurnierRepository(client = get(qualifier = named("apiClient"))) } single<TurnierRepository> { DefaultTurnierRepository(client = get(qualifier = named("apiClient"))) }
single<BewerbRepository> { DefaultBewerbRepository(client = get(qualifier = named("apiClient"))) } single<BewerbRepository> { DefaultBewerbRepository(client = get(qualifier = named("apiClient"))) }
single<AbteilungRepository> { DefaultAbteilungRepository(client = get(qualifier = named("apiClient"))) } single<AbteilungRepository> { DefaultAbteilungRepository(client = get(qualifier = named("apiClient"))) }
single<StartlistenRepository> { DefaultStartlistenRepository(client = get(qualifier = named("apiClient"))) }
// ViewModels // ViewModels
factory { TurnierViewModel(repo = get()) } factory { TurnierViewModel(repo = get()) }
// BewerbViewModel: repo + turnierId — turnierId wird per parametersOf übergeben // BewerbViewModel: repos + turnierId — turnierId wird per parametersOf übergeben
factory { (turnierId: Long) -> BewerbViewModel(repo = get(), turnierId = turnierId) } factory { (turnierId: Long) -> BewerbViewModel(repo = get(), startlistenRepo = get(), turnierId = turnierId) }
// BewerbAnlegenViewModel hat keinen Repository-Parameter (nutzt StoreV2 intern) // BewerbAnlegenViewModel hat keinen Repository-Parameter (nutzt StoreV2 intern)
factory { BewerbAnlegenViewModel() } factory { BewerbAnlegenViewModel() }
// AbteilungViewModel: repo + bewerbId + abteilungsNr — per parametersOf übergeben // AbteilungViewModel: repo + bewerbId + abteilungsNr — per parametersOf übergeben
@@ -1,6 +1,7 @@
package at.mocode.turnier.feature.presentation package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -178,37 +179,50 @@ private fun StartlistePreviewDialog(
Surface( Surface(
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface, 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)) { Column(modifier = Modifier.padding(16.dp)) {
Text("Startliste Vorschau", style = MaterialTheme.typography.titleLarge) Text("Startliste Vorschau", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(12.dp)) 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()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
item { 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("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("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("Reiter", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Pferd", 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 -> 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.nr.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp)
Text(e.zeit, modifier = Modifier.width(60.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.reiter, modifier = Modifier.weight(1f), fontSize = 12.sp)
Text(e.pferd, 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)) Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { 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") }
} }
} }
} }
@@ -2,6 +2,9 @@ package at.mocode.desktop.screens.preview
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.turnier.feature.presentation.*
import at.mocode.zns.parser.ZnsBewerb import at.mocode.zns.parser.ZnsBewerb
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen
@@ -114,10 +117,18 @@ fun PreviewTurnierOrganisationTab() {
@Composable @Composable
fun PreviewTurnierBewerbeTab() { fun PreviewTurnierBewerbeTab() {
val mockRepo = object : BewerbRepository { 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) 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 { MaterialTheme {
BewerbeTabContent(viewModel = vm, turnierId = 1L) BewerbeTabContent(viewModel = vm, turnierId = 1L)
} }