Integrate series-service microservice with API gateway routing, implement Series domain and point aggregation logic, and update frontend with SeriesViewModel, SeriesScreen, and dynamic state handling.

This commit is contained in:
2026-04-12 16:58:22 +02:00
parent 4ad9b274e8
commit 6e99bc97fd
20 changed files with 711 additions and 25 deletions
@@ -36,4 +36,9 @@ object ApiRoutes {
const val ROOT = "/api/v1/results"
fun bewerb(bewerbId: String) = "$ROOT/bewerb/$bewerbId"
}
object Series {
const val ROOT = "/api/v1/series"
fun stand(serieId: String) = "$ROOT/$serieId/stand"
}
}
@@ -0,0 +1,30 @@
package at.mocode.turnier.feature.domain
import kotlinx.serialization.Serializable
@Serializable
data class Serie(
val id: String? = null,
val name: String,
val beschreibung: String? = null,
val reglementTyp: String = "STREICHER_NORMAL",
val bewerbIds: Set<String> = emptySet()
)
@Serializable
data class SeriePunkt(
val id: String? = null,
val serieId: String,
val reiterId: String,
val pferdId: String,
val bewerbId: String,
val punkte: Double,
val platzierung: Int
)
interface SeriesRepository {
suspend fun getAll(): Result<List<Serie>>
suspend fun getById(id: String): Result<Serie>
suspend fun save(serie: Serie): Result<Serie>
suspend fun getStand(serieId: String): Result<Map<String, Double>> // Einfache Map Reiter+Pferd ID zu Punkten
}
@@ -0,0 +1,59 @@
package at.mocode.turnier.feature.presentation
import at.mocode.turnier.feature.domain.Serie
import at.mocode.turnier.feature.domain.SeriesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
data class SeriesState(
val series: List<Serie> = emptyList(),
val isLoading: Boolean = false,
val selectedSerieStand: Map<String, Double> = emptyMap(),
val error: String? = null
)
class SeriesViewModel(
private val repository: SeriesRepository
) : ViewModel() {
private val _state = MutableStateFlow(SeriesState())
val state = _state.asStateFlow()
init {
loadSeries()
}
fun loadSeries() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
repository.getAll()
.onSuccess { series ->
_state.value = _state.value.copy(series = series, isLoading = false)
}
.onFailure {
_state.value = _state.value.copy(error = it.message, isLoading = false)
}
}
}
fun selectSerie(id: String) {
viewModelScope.launch {
repository.getStand(id)
.onSuccess { stand ->
_state.value = _state.value.copy(selectedSerieStand = stand)
}
}
}
fun createSerie(name: String) {
viewModelScope.launch {
repository.save(Serie(name = name))
.onSuccess {
loadSeries()
}
}
}
}
@@ -0,0 +1,40 @@
package at.mocode.turnier.feature.data.remote
import at.mocode.frontend.core.network.ApiRoutes
import at.mocode.turnier.feature.domain.Serie
import at.mocode.turnier.feature.domain.SeriesRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class DefaultSeriesRepository(
private val client: HttpClient
) : SeriesRepository {
override suspend fun getAll(): Result<List<Serie>> = runCatching {
client.get(ApiRoutes.Series.ROOT).body()
}
override suspend fun getById(id: String): Result<Serie> = runCatching {
client.get("${ApiRoutes.Series.ROOT}/$id").body()
}
override suspend fun save(serie: Serie): Result<Serie> = runCatching {
if (serie.id == null) {
client.post(ApiRoutes.Series.ROOT) {
contentType(ContentType.Application.Json)
setBody(serie)
}.body()
} else {
client.put("${ApiRoutes.Series.ROOT}/${serie.id}") {
contentType(ContentType.Application.Json)
setBody(serie)
}.body()
}
}
override suspend fun getStand(serieId: String): Result<Map<String, Double>> = runCatching {
client.get(ApiRoutes.Series.stand(serieId)).body()
}
}
@@ -19,6 +19,7 @@ val turnierFeatureModule = module {
single<at.mocode.turnier.feature.domain.NennungRepository> { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.turnier.feature.domain.MasterdataRepository> { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.turnier.feature.domain.ErgebnisRepository> { DefaultErgebnisRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.turnier.feature.domain.SeriesRepository> { DefaultSeriesRepository(client = get(qualifier = named("apiClient"))) }
// ViewModels
factory { TurnierViewModel(repo = get()) }
@@ -46,4 +47,5 @@ val turnierFeatureModule = module {
turnierId = turnierId
)
}
factory { SeriesViewModel(repository = get()) }
}
@@ -1,27 +1,31 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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.unit.dp
import androidx.compose.ui.unit.sp
import org.koin.compose.viewmodel.koinViewModel
private val SeriesBlue = Color(0xFF1E3A8A)
/**
* SERIES-Screen gemäß Vision_03 & Phase 10.
*
* Zeigt Cups, Serien und Meisterschaften mit konfigurierbaren Reglements.
*/
@Composable
fun SeriesScreen(
title: String,
onBack: () -> Unit
onBack: () -> Unit,
viewModel: SeriesViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar
Row(
@@ -34,7 +38,7 @@ fun SeriesScreen(
Text("Konfiguration & Auswertung (Phase 10)", fontSize = 13.sp, color = Color.Gray)
}
Button(
onClick = { /* Neu anlegen Dialog */ },
onClick = { viewModel.createSerie("Neuer Cup") },
colors = ButtonDefaults.buttonColors(containerColor = SeriesBlue)
) {
Text("Neue Serie anlegen")
@@ -43,23 +47,61 @@ fun SeriesScreen(
HorizontalDivider()
// Leere Liste (Placeholder)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine $title konfiguriert", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Text(
"Verknüpfe Bewerbe zu einer Serie, um Punktestände automatisch zu berechnen.",
fontSize = 13.sp,
color = Color.Gray,
modifier = Modifier.padding(horizontal = 32.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onBack) {
Text("Zurück zur Verwaltung")
if (state.series.isEmpty()) {
EmptyState(title, onBack)
} else {
SeriesList(state, onSelect = { viewModel.selectSerie(it) })
}
}
}
@Composable
private fun SeriesList(state: SeriesState, onSelect: (String) -> Unit) {
Row(Modifier.fillMaxSize()) {
LazyColumn(Modifier.weight(0.4f).padding(16.dp)) {
items(state.series) { serie ->
Card(
onClick = { serie.id?.let { onSelect(it) } },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
) {
Column(Modifier.padding(12.dp)) {
Text(serie.name, fontWeight = FontWeight.Bold)
Text(serie.reglementTyp, fontSize = 12.sp)
}
}
}
}
VerticalDivider()
Column(Modifier.weight(0.6f).padding(16.dp)) {
Text("Zwischenstand", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
state.selectedSerieStand.forEach { (paar, punkte) ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(paar)
Text("$punkte Pkt", fontWeight = FontWeight.Bold)
}
}
}
}
}
@Composable
private fun EmptyState(title: String, onBack: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine $title konfiguriert", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Text(
"Verknüpfe Bewerbe zu einer Serie, um Punktestände automatisch zu berechnen.",
fontSize = 13.sp,
color = Color.Gray,
modifier = Modifier.padding(horizontal = 32.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onBack) {
Text("Zurück zur Verwaltung")
}
}
}
}
@@ -12,7 +12,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.Ergebnis
import org.koin.compose.koinInject