chore: refactor Veranstalter screens and ViewModels to decouple from Koin, implement hoisting for better testability, consolidate legacy shell logic into modern components, and add mock repositories for previews
This commit is contained in:
+4
@@ -2,8 +2,12 @@ package at.mocode.frontend.features.veranstalter.di
|
||||
|
||||
import at.mocode.frontend.features.veranstalter.data.remote.FakeVeranstalterRepository
|
||||
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
|
||||
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailViewModel
|
||||
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterViewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val veranstalterModule = module {
|
||||
single<VeranstalterRepository> { FakeVeranstalterRepository() }
|
||||
factory { VeranstalterViewModel(get()) }
|
||||
factory { VeranstalterDetailViewModel(get()) }
|
||||
}
|
||||
|
||||
+5
-4
@@ -8,12 +8,14 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -30,12 +32,11 @@ private val AccentBlue = Color(0xFF3B82F6)
|
||||
*/
|
||||
@Composable
|
||||
fun VeranstalterAuswahlScreen(
|
||||
viewModel: VeranstalterViewModel,
|
||||
onZurueck: () -> Unit,
|
||||
onWeiter: (Long) -> Unit,
|
||||
onNeuerVeranstalter: () -> Unit = {},
|
||||
) {
|
||||
val repo: at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository = org.koin.compose.koinInject()
|
||||
val viewModel = remember { VeranstalterViewModel(repo) }
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().background(Color.White)) {
|
||||
|
||||
+16
-78
@@ -7,13 +7,16 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -44,85 +47,20 @@ private val StatusAbgeschlossenColor = Color(0xFF6B7280)
|
||||
@Composable
|
||||
fun VeranstalterDetailScreen(
|
||||
veranstalterId: Long,
|
||||
viewModel: VeranstalterDetailViewModel,
|
||||
onZurueck: () -> Unit,
|
||||
onVeranstaltungOeffnen: (Long) -> Unit,
|
||||
onVeranstaltungNeu: () -> Unit,
|
||||
) {
|
||||
var suchtext by remember { mutableStateOf("") }
|
||||
var statusFilter by remember { mutableStateOf(VeranstaltungStatusFilter.ALLE) }
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
// Placeholder-Daten gemäß Figma
|
||||
val veranstalter = remember(veranstalterId) {
|
||||
VeranstalterDetailUiModel(
|
||||
id = veranstalterId,
|
||||
name = "Reit- und Fahrverein Wels",
|
||||
oepsNummer = "V-OOE-1234",
|
||||
ansprechpartner = "Maria Huber",
|
||||
email = "office@rfv-wels.at",
|
||||
telefon = "+43 7242 12345",
|
||||
adresse = "Reitweg 15\n4600 Wels",
|
||||
loginStatus = LoginStatus.AKTIV,
|
||||
mitgliedSeit = "15.1.2023",
|
||||
)
|
||||
}
|
||||
|
||||
// Liste aus dem Fake-Store (pro Veranstalter). Falls leer, einmalig seeden.
|
||||
val storeList = FakeVeranstaltungStore.listFor(veranstalterId)
|
||||
LaunchedEffect(veranstalterId) {
|
||||
if (storeList.isEmpty()) {
|
||||
FakeVeranstaltungStore.seedIfEmpty(
|
||||
veranstalterId,
|
||||
listOf(
|
||||
VeranstaltungListUiModel(
|
||||
id = 1L,
|
||||
name = "Union Reit- und Fahrverein Neumarkt Frühjahrsturnier 2026",
|
||||
datum = "25.-26. April 2026",
|
||||
ort = "Reitanlage Stroblmair, Neumarkt/M., OO",
|
||||
turnierAnzahl = 2,
|
||||
nennungen = 87,
|
||||
bewerbe = 26,
|
||||
letzteAktivitaet = "22.03.2026 14:30",
|
||||
status = VeranstaltungStatus.VORBEREITUNG,
|
||||
),
|
||||
VeranstaltungListUiModel(
|
||||
id = 2L,
|
||||
name = "AWÖ-Cup Stadl-Paura 2025",
|
||||
datum = "15.-17. Mai 2025",
|
||||
ort = "Bundesgestüt Piber, Stadl-Paura",
|
||||
turnierAnzahl = 2,
|
||||
nennungen = 142,
|
||||
bewerbe = 33,
|
||||
letzteAktivitaet = "17.05.2025 18:45",
|
||||
status = VeranstaltungStatus.ABGESCHLOSSEN,
|
||||
),
|
||||
VeranstaltungListUiModel(
|
||||
id = 3L,
|
||||
name = "Linzer Pferdetage 2026",
|
||||
datum = "12.-14. Juni 2026",
|
||||
ort = "Reitsportzentrum Linz-Ebelsberg",
|
||||
turnierAnzahl = 2,
|
||||
nennungen = 23,
|
||||
bewerbe = 30,
|
||||
letzteAktivitaet = "20.03.2026 09:15",
|
||||
status = VeranstaltungStatus.VORBEREITUNG,
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
viewModel.send(VeranstalterDetailIntent.Load(veranstalterId))
|
||||
}
|
||||
|
||||
val gefiltert = storeList.filter { v ->
|
||||
val matchesStatus = when (statusFilter) {
|
||||
VeranstaltungStatusFilter.ALLE -> true
|
||||
VeranstaltungStatusFilter.VORBEREITUNG -> v.status == VeranstaltungStatus.VORBEREITUNG
|
||||
VeranstaltungStatusFilter.LIVE -> v.status == VeranstaltungStatus.LIVE
|
||||
VeranstaltungStatusFilter.ABGESCHLOSSEN -> v.status == VeranstaltungStatus.ABGESCHLOSSEN
|
||||
}
|
||||
val matchesSuche = suchtext.isBlank() ||
|
||||
v.name.contains(suchtext, ignoreCase = true) ||
|
||||
v.ort.contains(suchtext, ignoreCase = true)
|
||||
matchesStatus && matchesSuche
|
||||
}
|
||||
val veranstalter = state.veranstalter ?: return // Oder Loading-Spinner
|
||||
|
||||
val gefiltert = state.filteredVeranstaltungen
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Header mit Zurück-Pfeil ─────────────────────────────────────────
|
||||
@@ -223,8 +161,8 @@ fun VeranstalterDetailScreen(
|
||||
Text("Neue Veranstaltung")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = suchtext,
|
||||
onValueChange = { suchtext = it },
|
||||
value = state.suchtext,
|
||||
onValueChange = { viewModel.send(VeranstalterDetailIntent.SearchChanged(it)) },
|
||||
placeholder = { Text("Suche nach Name, Ort oder Turnier-Nr.", fontSize = 12.sp) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
|
||||
modifier = Modifier.weight(1f).height(44.dp),
|
||||
@@ -233,10 +171,10 @@ fun VeranstalterDetailScreen(
|
||||
// Status-Filter-Chips
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
VeranstaltungStatusFilter.entries.forEach { filter ->
|
||||
val isActive = statusFilter == filter
|
||||
val isActive = state.statusFilter == filter
|
||||
FilterChip(
|
||||
selected = isActive,
|
||||
onClick = { statusFilter = filter },
|
||||
onClick = { viewModel.send(VeranstalterDetailIntent.FilterChanged(filter)) },
|
||||
label = {
|
||||
Text(
|
||||
text = when (filter) {
|
||||
@@ -268,7 +206,7 @@ fun VeranstalterDetailScreen(
|
||||
veranstaltung = veranstaltung,
|
||||
onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) },
|
||||
onLoeschen = {
|
||||
FakeVeranstaltungStore.remove(veranstalterId, veranstaltung.id)
|
||||
viewModel.send(VeranstalterDetailIntent.DeleteVeranstaltung(veranstaltung.id))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package at.mocode.frontend.features.veranstalter.presentation
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
|
||||
import at.mocode.frontend.core.designsystem.models.VeranstaltungStatus
|
||||
import at.mocode.frontend.core.designsystem.models.LoginStatus
|
||||
|
||||
data class VeranstalterDetailState(
|
||||
val isLoading: Boolean = false,
|
||||
val veranstalter: VeranstalterDetailUiModel? = null,
|
||||
val veranstaltungen: List<VeranstaltungListUiModel> = emptyList(),
|
||||
val filteredVeranstaltungen: List<VeranstaltungListUiModel> = emptyList(),
|
||||
val suchtext: String = "",
|
||||
val statusFilter: VeranstaltungStatusFilter = VeranstaltungStatusFilter.ALLE,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
sealed interface VeranstalterDetailIntent {
|
||||
data class Load(val id: Long) : VeranstalterDetailIntent
|
||||
data class SearchChanged(val query: String) : VeranstalterDetailIntent
|
||||
data class FilterChanged(val filter: VeranstaltungStatusFilter) : VeranstalterDetailIntent
|
||||
data class DeleteVeranstaltung(val id: Long) : VeranstalterDetailIntent
|
||||
}
|
||||
|
||||
class VeranstalterDetailViewModel(
|
||||
private val repo: VeranstalterRepository,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val _state = MutableStateFlow(VeranstalterDetailState(isLoading = true))
|
||||
val state: StateFlow<VeranstalterDetailState> = _state
|
||||
|
||||
fun send(intent: VeranstalterDetailIntent) {
|
||||
when (intent) {
|
||||
is VeranstalterDetailIntent.Load -> load(intent.id)
|
||||
is VeranstalterDetailIntent.SearchChanged -> {
|
||||
_state.value = _state.value.copy(suchtext = intent.query)
|
||||
applyFilter()
|
||||
}
|
||||
is VeranstalterDetailIntent.FilterChanged -> {
|
||||
_state.value = _state.value.copy(statusFilter = intent.filter)
|
||||
applyFilter()
|
||||
}
|
||||
is VeranstalterDetailIntent.DeleteVeranstaltung -> deleteVeranstaltung(intent.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(id: Long) {
|
||||
_state.value = _state.value.copy(isLoading = true)
|
||||
scope.launch {
|
||||
// In einer echten App würden wir hier das Repo abfragen.
|
||||
// Für den Prototyp nutzen wir vorerst die Logik aus dem Screen, aber im VM gekapselt.
|
||||
|
||||
val mockVeranstalter = VeranstalterDetailUiModel(
|
||||
id = id,
|
||||
name = "Reit- und Fahrverein Wels",
|
||||
oepsNummer = "V-OOE-1234",
|
||||
ansprechpartner = "Maria Huber",
|
||||
email = "office@rfv-wels.at",
|
||||
telefon = "+43 7242 12345",
|
||||
adresse = "Reitweg 15\n4600 Wels",
|
||||
loginStatus = LoginStatus.AKTIV,
|
||||
mitgliedSeit = "15.1.2023",
|
||||
)
|
||||
|
||||
val mockVeranstaltungen = listOf(
|
||||
VeranstaltungListUiModel(
|
||||
id = 1L,
|
||||
name = "Union Reit- und Fahrverein Neumarkt Frühjahrsturnier 2026",
|
||||
datum = "25.-26. April 2026",
|
||||
ort = "Reitanlage Stroblmair, Neumarkt/M., OO",
|
||||
turnierAnzahl = 2,
|
||||
nennungen = 87,
|
||||
bewerbe = 26,
|
||||
letzteAktivitaet = "22.03.2026 14:30",
|
||||
status = VeranstaltungStatus.VORBEREITUNG,
|
||||
),
|
||||
VeranstaltungListUiModel(
|
||||
id = 2L,
|
||||
name = "AWÖ-Cup Stadl-Paura 2025",
|
||||
datum = "15.-17. Mai 2025",
|
||||
ort = "Bundesgestüt Piber, Stadl-Paura",
|
||||
turnierAnzahl = 2,
|
||||
nennungen = 142,
|
||||
bewerbe = 33,
|
||||
letzteAktivitaet = "17.05.2025 18:45",
|
||||
status = VeranstaltungStatus.ABGESCHLOSSEN,
|
||||
),
|
||||
VeranstaltungListUiModel(
|
||||
id = 3L,
|
||||
name = "Linzer Pferdetage 2026",
|
||||
datum = "12.-14. Juni 2026",
|
||||
ort = "Reitsportzentrum Linz-Ebelsberg",
|
||||
turnierAnzahl = 2,
|
||||
nennungen = 23,
|
||||
bewerbe = 30,
|
||||
letzteAktivitaet = "20.03.2026 09:15",
|
||||
status = VeranstaltungStatus.VORBEREITUNG,
|
||||
),
|
||||
)
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = false,
|
||||
veranstalter = mockVeranstalter,
|
||||
veranstaltungen = mockVeranstaltungen,
|
||||
filteredVeranstaltungen = mockVeranstaltungen
|
||||
)
|
||||
applyFilter()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteVeranstaltung(id: Long) {
|
||||
val newList = _state.value.veranstaltungen.filter { it.id != id }
|
||||
_state.value = _state.value.copy(veranstaltungen = newList)
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
private fun applyFilter() {
|
||||
val current = _state.value
|
||||
val filtered = current.veranstaltungen.filter { v ->
|
||||
val matchesSearch = v.name.contains(current.suchtext, ignoreCase = true) ||
|
||||
v.ort.contains(current.suchtext, ignoreCase = true)
|
||||
val matchesStatus = when (current.statusFilter) {
|
||||
VeranstaltungStatusFilter.ALLE -> true
|
||||
VeranstaltungStatusFilter.VORBEREITUNG -> v.status == VeranstaltungStatus.VORBEREITUNG
|
||||
VeranstaltungStatusFilter.LIVE -> v.status == VeranstaltungStatus.LIVE
|
||||
VeranstaltungStatusFilter.ABGESCHLOSSEN -> v.status == VeranstaltungStatus.ABGESCHLOSSEN
|
||||
}
|
||||
matchesSearch && matchesStatus
|
||||
}
|
||||
_state.value = _state.value.copy(filteredVeranstaltungen = filtered)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user