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:
parent
fbed4d34cc
commit
c06eb79cba
|
|
@ -218,7 +218,7 @@ und über definierte Schnittstellen kommunizieren.
|
|||
|
||||
* [x] **Konzept/ADR:** LAN‑Sync (ADR‑0022) und Offline‑First 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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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") }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.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
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user