(vision) SCS/DDD

This commit is contained in:
2025-07-18 23:21:03 +02:00
parent 611e31e196
commit e1125a3fc0
13 changed files with 1419 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
}
kotlin {
js(IR) {
browser {
commonWebpackConfig {
outputFileName = "composeApp.js"
}
}
binaries.executable()
}
jvm("desktop")
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
// Project dependencies
implementation(project(":shared-kernel"))
implementation(project(":member-management"))
implementation(project(":master-data"))
implementation(project(":horse-registry"))
implementation(project(":event-management"))
// Kotlinx dependencies
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("com.benasher44:uuid:0.8.4")
// Navigation
implementation("org.jetbrains.androidx.navigation:navigation-compose:2.7.0-alpha07")
// ViewModel
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
}
jsMain.dependencies {
implementation(compose.html.core)
}
val desktopMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
}
}
}
}
compose.experimental {
web.application {}
}
+54
View File
@@ -0,0 +1,54 @@
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import at.mocode.ui.screens.PersonListScreen
import at.mocode.ui.screens.CreatePersonScreen
import at.mocode.ui.theme.MeldestelleTheme
import at.mocode.di.AppDependencies
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun App() {
MeldestelleTheme {
val navController = rememberNavController()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Meldestelle - Reitersport Management") }
)
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = "person_list",
modifier = Modifier.padding(paddingValues)
) {
composable("person_list") {
val viewModel = remember { AppDependencies.personListViewModel() }
PersonListScreen(
viewModel = viewModel,
onNavigateToCreatePerson = {
navController.navigate("create_person")
}
)
}
composable("create_person") {
val viewModel = remember { AppDependencies.createPersonViewModel() }
CreatePersonScreen(
viewModel = viewModel,
onNavigateBack = {
navController.popBackStack()
}
)
}
}
}
}
}
@@ -0,0 +1,80 @@
package at.mocode.di
import at.mocode.members.application.usecase.CreatePersonUseCase
import at.mocode.members.domain.repository.PersonRepository
import at.mocode.members.domain.repository.VereinRepository
import at.mocode.members.domain.service.MasterDataService
import at.mocode.ui.viewmodel.CreatePersonViewModel
import at.mocode.ui.viewmodel.PersonListViewModel
/**
* Simple dependency injection container for the application.
* In a real application, you might want to use a proper DI framework like Koin.
*/
object AppDependencies {
// Mock implementations for demonstration
// In a real application, these would be proper implementations
private val mockPersonRepository = object : PersonRepository {
override suspend fun save(person: at.mocode.members.domain.model.DomPerson): at.mocode.members.domain.model.DomPerson {
// Mock implementation - just return the person with an ID
return person.copy(id = com.benasher44.uuid.uuid4())
}
override suspend fun findById(id: com.benasher44.uuid.Uuid): at.mocode.members.domain.model.DomPerson? {
return null // Mock implementation
}
override suspend fun findByOepsSatzNr(oepsSatzNr: String): at.mocode.members.domain.model.DomPerson? {
return null // Mock implementation
}
override suspend fun existsByOepsSatzNr(oepsSatzNr: String): Boolean {
return false // Mock implementation - no duplicates for demo
}
override suspend fun findAll(): List<at.mocode.members.domain.model.DomPerson> {
return emptyList() // Mock implementation
}
override suspend fun delete(id: com.benasher44.uuid.Uuid) {
// Mock implementation
}
}
private val mockVereinRepository = object : VereinRepository {
override suspend fun findById(id: com.benasher44.uuid.Uuid): at.mocode.members.domain.model.DomVerein? {
return null // Mock implementation
}
override suspend fun existsById(id: com.benasher44.uuid.Uuid): Boolean {
return true // Mock implementation - assume all clubs exist
}
override suspend fun findAll(): List<at.mocode.members.domain.model.DomVerein> {
return emptyList() // Mock implementation
}
}
private val mockMasterDataService = object : MasterDataService {
override suspend fun countryExists(countryId: com.benasher44.uuid.Uuid): Boolean {
return true // Mock implementation - assume all countries exist
}
}
// Use case instances
private val createPersonUseCase = CreatePersonUseCase(
personRepository = mockPersonRepository,
vereinRepository = mockVereinRepository,
masterDataService = mockMasterDataService
)
// ViewModel factory methods
fun createPersonViewModel(): CreatePersonViewModel {
return CreatePersonViewModel(createPersonUseCase)
}
fun personListViewModel(): PersonListViewModel {
return PersonListViewModel(mockPersonRepository)
}
}
@@ -0,0 +1,319 @@
package at.mocode.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import at.mocode.enums.GeschlechtE
import at.mocode.ui.viewmodel.CreatePersonViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreatePersonScreen(
viewModel: CreatePersonViewModel,
onNavigateBack: () -> Unit
) {
var showGeschlechtDropdown by remember { mutableStateOf(false) }
// Handle success navigation
LaunchedEffect(viewModel.isSuccess) {
if (viewModel.isSuccess) {
onNavigateBack()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Person erstellen") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Error message
viewModel.errorMessage?.let { error ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = error,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
// Basic Information Section
Text(
text = "Grunddaten",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = viewModel.nachname,
onValueChange = viewModel::updateNachname,
label = { Text("Nachname *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
OutlinedTextField(
value = viewModel.vorname,
onValueChange = viewModel::updateVorname,
label = { Text("Vorname *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
OutlinedTextField(
value = viewModel.titel,
onValueChange = viewModel::updateTitel,
label = { Text("Titel") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("z.B. Dr., Ing.") }
)
OutlinedTextField(
value = viewModel.oepsSatzNr,
onValueChange = viewModel::updateOepsSatzNr,
label = { Text("OEPS Satznummer") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("6-stellige Nummer") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
OutlinedTextField(
value = viewModel.geburtsdatum,
onValueChange = viewModel::updateGeburtsdatum,
label = { Text("Geburtsdatum") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("YYYY-MM-DD") }
)
// Gender Dropdown
ExposedDropdownMenuBox(
expanded = showGeschlechtDropdown,
onExpandedChange = { showGeschlechtDropdown = !showGeschlechtDropdown }
) {
OutlinedTextField(
value = viewModel.geschlecht?.let {
when(it) {
GeschlechtE.M -> "Männlich"
GeschlechtE.W -> "Weiblich"
GeschlechtE.D -> "Divers"
GeschlechtE.UNBEKANNT -> "Unbekannt"
}
} ?: "",
onValueChange = { },
readOnly = true,
label = { Text("Geschlecht") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showGeschlechtDropdown) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = showGeschlechtDropdown,
onDismissRequest = { showGeschlechtDropdown = false }
) {
GeschlechtE.entries.forEach { option ->
DropdownMenuItem(
text = {
Text(when(option) {
GeschlechtE.M -> "Männlich"
GeschlechtE.W -> "Weiblich"
GeschlechtE.D -> "Divers"
GeschlechtE.UNBEKANNT -> "Unbekannt"
})
},
onClick = {
viewModel.updateGeschlecht(option)
showGeschlechtDropdown = false
}
)
}
}
}
// Contact Information Section
Text(
text = "Kontaktdaten",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = viewModel.telefon,
onValueChange = viewModel::updateTelefon,
label = { Text("Telefon") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone)
)
OutlinedTextField(
value = viewModel.email,
onValueChange = viewModel::updateEmail,
label = { Text("E-Mail") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
// Address Section
Text(
text = "Adresse",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = viewModel.strasse,
onValueChange = viewModel::updateStrasse,
label = { Text("Straße und Hausnummer") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = viewModel.plz,
onValueChange = viewModel::updatePlz,
label = { Text("PLZ") },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
OutlinedTextField(
value = viewModel.ort,
onValueChange = viewModel::updateOrt,
label = { Text("Ort") },
modifier = Modifier.weight(2f),
singleLine = true
)
}
OutlinedTextField(
value = viewModel.adresszusatz,
onValueChange = viewModel::updateAdresszusatz,
label = { Text("Adresszusatz") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
// Additional Information Section
Text(
text = "Weitere Informationen",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = viewModel.feiId,
onValueChange = viewModel::updateFeiId,
label = { Text("FEI ID") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
OutlinedTextField(
value = viewModel.mitgliedsNummer,
onValueChange = viewModel::updateMitgliedsNummer,
label = { Text("Mitgliedsnummer beim Stammverein") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = viewModel.istGesperrt,
onCheckedChange = viewModel::updateIstGesperrt
)
Spacer(modifier = Modifier.width(8.dp))
Text("Person ist gesperrt")
}
if (viewModel.istGesperrt) {
OutlinedTextField(
value = viewModel.sperrGrund,
onValueChange = viewModel::updateSperrGrund,
label = { Text("Sperrgrund") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3
)
}
OutlinedTextField(
value = viewModel.notizen,
onValueChange = viewModel::updateNotizen,
label = { Text("Interne Notizen") },
modifier = Modifier.fillMaxWidth(),
maxLines = 4
)
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onNavigateBack,
modifier = Modifier.weight(1f),
enabled = !viewModel.isLoading
) {
Text("Abbrechen")
}
Button(
onClick = {
viewModel.createPerson()
},
modifier = Modifier.weight(1f),
enabled = !viewModel.isLoading
) {
if (viewModel.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Erstellen")
}
}
}
}
}
}
@@ -0,0 +1,212 @@
package at.mocode.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.members.domain.model.DomPerson
import at.mocode.enums.GeschlechtE
import at.mocode.enums.DatenQuelleE
import at.mocode.ui.viewmodel.PersonListViewModel
import kotlinx.datetime.LocalDate
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PersonListScreen(
viewModel: PersonListViewModel,
onNavigateToCreatePerson: () -> Unit
) {
Scaffold(
floatingActionButton = {
FloatingActionButton(
onClick = onNavigateToCreatePerson
) {
Icon(Icons.Default.Add, contentDescription = "Person hinzufügen")
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
Text(
text = "Personen",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
// Error handling
viewModel.errorMessage?.let { error ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
TextButton(
onClick = { viewModel.clearError() }
) {
Text("OK")
}
}
}
}
// Loading indicator
if (viewModel.isLoading) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
if (!viewModel.isLoading && viewModel.persons.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Keine Personen vorhanden",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(viewModel.persons) { person ->
PersonCard(person = person)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PersonCard(person: DomPerson) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "${person.titel?.let { "$it " } ?: ""}${person.vorname} ${person.nachname}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
person.oepsSatzNr?.let { oepsNr ->
Text(
text = "OEPS: $oepsNr",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
person.geburtsdatum?.let { birthDate ->
Text(
text = "Geboren: $birthDate",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Surface(
color = when (person.datenQuelle) {
DatenQuelleE.OEPS_ZNS -> MaterialTheme.colorScheme.primaryContainer
DatenQuelleE.MANUELL -> MaterialTheme.colorScheme.secondaryContainer
},
shape = MaterialTheme.shapes.small
) {
Text(
text = when (person.datenQuelle) {
DatenQuelleE.OEPS_ZNS -> "OEPS"
DatenQuelleE.MANUELL -> "Manuell"
},
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = when (person.datenQuelle) {
DatenQuelleE.OEPS_ZNS -> MaterialTheme.colorScheme.onPrimaryContainer
DatenQuelleE.MANUELL -> MaterialTheme.colorScheme.onSecondaryContainer
}
)
}
}
Spacer(modifier = Modifier.height(8.dp))
person.email?.let { email ->
Text(
text = "📧 $email",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
person.telefon?.let { phone ->
Text(
text = "📞 $phone",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (person.strasse != null && person.plz != null && person.ort != null) {
Text(
text = "📍 ${person.strasse}, ${person.plz} ${person.ort}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -0,0 +1,49 @@
package at.mocode.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF6750A4),
secondary = Color(0xFF625B71),
tertiary = Color(0xFF7D5260),
background = Color(0xFF1C1B1F),
surface = Color(0xFF1C1B1F),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFFFEFBFF),
onSurface = Color(0xFFFEFBFF),
)
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4),
secondary = Color(0xFF625B71),
tertiary = Color(0xFF7D5260),
background = Color(0xFFFEFBFF),
surface = Color(0xFFFEFBFF),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
)
@Composable
fun MeldestelleTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when {
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography(),
content = content
)
}
@@ -0,0 +1,176 @@
package at.mocode.ui.viewmodel
import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.members.application.usecase.CreatePersonUseCase
import at.mocode.members.domain.repository.PersonRepository
import at.mocode.members.domain.repository.VereinRepository
import at.mocode.members.domain.service.MasterDataService
import at.mocode.enums.GeschlechtE
import at.mocode.enums.DatenQuelleE
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
class CreatePersonViewModel(
private val createPersonUseCase: CreatePersonUseCase
) : ViewModel() {
// Form state
var nachname by mutableStateOf("")
private set
var vorname by mutableStateOf("")
private set
var titel by mutableStateOf("")
private set
var oepsSatzNr by mutableStateOf("")
private set
var geburtsdatum by mutableStateOf("")
private set
var geschlecht by mutableStateOf<GeschlechtE?>(null)
private set
var telefon by mutableStateOf("")
private set
var email by mutableStateOf("")
private set
var strasse by mutableStateOf("")
private set
var plz by mutableStateOf("")
private set
var ort by mutableStateOf("")
private set
var adresszusatz by mutableStateOf("")
private set
var feiId by mutableStateOf("")
private set
var mitgliedsNummer by mutableStateOf("")
private set
var notizen by mutableStateOf("")
private set
var istGesperrt by mutableStateOf(false)
private set
var sperrGrund by mutableStateOf("")
private set
// UI state
var isLoading by mutableStateOf(false)
private set
var errorMessage by mutableStateOf<String?>(null)
private set
var isSuccess by mutableStateOf(false)
private set
// Update methods
fun updateNachname(value: String) { nachname = value }
fun updateVorname(value: String) { vorname = value }
fun updateTitel(value: String) { titel = value }
fun updateOepsSatzNr(value: String) { oepsSatzNr = value }
fun updateGeburtsdatum(value: String) { geburtsdatum = value }
fun updateGeschlecht(value: GeschlechtE?) { geschlecht = value }
fun updateTelefon(value: String) { telefon = value }
fun updateEmail(value: String) { email = value }
fun updateStrasse(value: String) { strasse = value }
fun updatePlz(value: String) { plz = value }
fun updateOrt(value: String) { ort = value }
fun updateAdresszusatz(value: String) { adresszusatz = value }
fun updateFeiId(value: String) { feiId = value }
fun updateMitgliedsNummer(value: String) { mitgliedsNummer = value }
fun updateNotizen(value: String) { notizen = value }
fun updateIstGesperrt(value: Boolean) { istGesperrt = value }
fun updateSperrGrund(value: String) { sperrGrund = value }
fun clearError() {
errorMessage = null
}
fun createPerson() {
// Basic validation
when {
nachname.isBlank() -> {
errorMessage = "Nachname ist erforderlich"
return
}
vorname.isBlank() -> {
errorMessage = "Vorname ist erforderlich"
return
}
}
viewModelScope.launch {
isLoading = true
errorMessage = null
try {
// Parse birth date if provided
val parsedGeburtsdatum = if (geburtsdatum.isNotBlank()) {
try {
val parts = geburtsdatum.split("-")
if (parts.size == 3) {
LocalDate(parts[0].toInt(), parts[1].toInt(), parts[2].toInt())
} else null
} catch (e: Exception) {
errorMessage = "Ungültiges Datumsformat. Verwenden Sie YYYY-MM-DD"
isLoading = false
return@launch
}
} else null
val request = CreatePersonUseCase.CreatePersonRequest(
oepsSatzNr = oepsSatzNr.takeIf { it.isNotBlank() },
nachname = nachname,
vorname = vorname,
titel = titel.takeIf { it.isNotBlank() },
geburtsdatum = parsedGeburtsdatum,
geschlechtE = geschlecht,
telefon = telefon.takeIf { it.isNotBlank() },
email = email.takeIf { it.isNotBlank() },
strasse = strasse.takeIf { it.isNotBlank() },
plz = plz.takeIf { it.isNotBlank() },
ort = ort.takeIf { it.isNotBlank() },
adresszusatzZusatzinfo = adresszusatz.takeIf { it.isNotBlank() },
feiId = feiId.takeIf { it.isNotBlank() },
mitgliedsNummerBeiStammVerein = mitgliedsNummer.takeIf { it.isNotBlank() },
istGesperrt = istGesperrt,
sperrGrund = sperrGrund.takeIf { it.isNotBlank() },
datenQuelle = DatenQuelleE.MANUELL,
notizenIntern = notizen.takeIf { it.isNotBlank() }
)
val response = createPersonUseCase.execute(request)
if (response.success) {
isSuccess = true
} else {
errorMessage = response.error?.message ?: "Unbekannter Fehler beim Erstellen der Person"
}
} catch (e: Exception) {
errorMessage = "Fehler beim Erstellen der Person: ${e.message}"
} finally {
isLoading = false
}
}
}
fun resetForm() {
nachname = ""
vorname = ""
titel = ""
oepsSatzNr = ""
geburtsdatum = ""
geschlecht = null
telefon = ""
email = ""
strasse = ""
plz = ""
ort = ""
adresszusatz = ""
feiId = ""
mitgliedsNummer = ""
notizen = ""
istGesperrt = false
sperrGrund = ""
isLoading = false
errorMessage = null
isSuccess = false
}
}
@@ -0,0 +1,48 @@
package at.mocode.ui.viewmodel
import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.members.domain.model.DomPerson
import at.mocode.members.domain.repository.PersonRepository
import kotlinx.coroutines.launch
class PersonListViewModel(
private val personRepository: PersonRepository
) : ViewModel() {
// UI state
var persons by mutableStateOf<List<DomPerson>>(emptyList())
private set
var isLoading by mutableStateOf(false)
private set
var errorMessage by mutableStateOf<String?>(null)
private set
init {
loadPersons()
}
fun loadPersons() {
viewModelScope.launch {
isLoading = true
errorMessage = null
try {
persons = personRepository.findAll()
} catch (e: Exception) {
errorMessage = "Fehler beim Laden der Personen: ${e.message}"
} finally {
isLoading = false
}
}
}
fun clearError() {
errorMessage = null
}
fun refreshPersons() {
loadPersons()
}
}
@@ -0,0 +1,259 @@
package at.mocode.ui.viewmodel
import at.mocode.members.application.usecase.CreatePersonUseCase
import at.mocode.members.domain.model.DomPerson
import at.mocode.members.domain.repository.PersonRepository
import at.mocode.members.domain.repository.VereinRepository
import at.mocode.members.domain.service.MasterDataService
import at.mocode.enums.GeschlechtE
import at.mocode.enums.DatenQuelleE
import at.mocode.validation.ValidationResult
import com.benasher44.uuid.uuid4
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import kotlinx.datetime.LocalDate
import kotlin.test.*
@OptIn(ExperimentalCoroutinesApi::class)
class CreatePersonViewModelTest {
private lateinit var mockPersonRepository: PersonRepository
private lateinit var mockVereinRepository: VereinRepository
private lateinit var mockMasterDataService: MasterDataService
private lateinit var createPersonUseCase: CreatePersonUseCase
private lateinit var viewModel: CreatePersonViewModel
private val testDispatcher = StandardTestDispatcher()
@BeforeTest
fun setup() {
Dispatchers.setMain(testDispatcher)
mockPersonRepository = object : PersonRepository {
private val persons = mutableListOf<DomPerson>()
override suspend fun save(person: DomPerson): DomPerson {
val savedPerson = person.copy(id = uuid4())
persons.add(savedPerson)
return savedPerson
}
override suspend fun findById(id: com.benasher44.uuid.Uuid): DomPerson? {
return persons.find { it.id == id }
}
override suspend fun findByOepsSatzNr(oepsSatzNr: String): DomPerson? {
return persons.find { it.oepsSatzNr == oepsSatzNr }
}
override suspend fun existsByOepsSatzNr(oepsSatzNr: String): Boolean {
return persons.any { it.oepsSatzNr == oepsSatzNr }
}
override suspend fun findAll(): List<DomPerson> {
return persons.toList()
}
override suspend fun delete(id: com.benasher44.uuid.Uuid) {
persons.removeAll { it.id == id }
}
}
mockVereinRepository = object : VereinRepository {
override suspend fun findById(id: com.benasher44.uuid.Uuid): at.mocode.members.domain.model.DomVerein? {
return null
}
override suspend fun existsById(id: com.benasher44.uuid.Uuid): Boolean {
return true
}
override suspend fun findAll(): List<at.mocode.members.domain.model.DomVerein> {
return emptyList()
}
}
mockMasterDataService = object : MasterDataService {
override suspend fun countryExists(countryId: com.benasher44.uuid.Uuid): Boolean {
return true
}
}
createPersonUseCase = CreatePersonUseCase(
personRepository = mockPersonRepository,
vereinRepository = mockVereinRepository,
masterDataService = mockMasterDataService
)
viewModel = CreatePersonViewModel(createPersonUseCase)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `initial state should be correct`() {
assertEquals("", viewModel.nachname)
assertEquals("", viewModel.vorname)
assertEquals("", viewModel.titel)
assertEquals("", viewModel.oepsSatzNr)
assertEquals("", viewModel.geburtsdatum)
assertNull(viewModel.geschlecht)
assertEquals("", viewModel.telefon)
assertEquals("", viewModel.email)
assertEquals("", viewModel.strasse)
assertEquals("", viewModel.plz)
assertEquals("", viewModel.ort)
assertEquals("", viewModel.adresszusatz)
assertEquals("", viewModel.feiId)
assertEquals("", viewModel.mitgliedsNummer)
assertEquals("", viewModel.notizen)
assertFalse(viewModel.istGesperrt)
assertEquals("", viewModel.sperrGrund)
assertFalse(viewModel.isLoading)
assertNull(viewModel.errorMessage)
assertFalse(viewModel.isSuccess)
}
@Test
fun `update methods should change state correctly`() {
viewModel.updateNachname("Mustermann")
viewModel.updateVorname("Max")
viewModel.updateTitel("Dr.")
viewModel.updateGeschlecht(GeschlechtE.M)
viewModel.updateEmail("max@example.com")
viewModel.updateIstGesperrt(true)
assertEquals("Mustermann", viewModel.nachname)
assertEquals("Max", viewModel.vorname)
assertEquals("Dr.", viewModel.titel)
assertEquals(GeschlechtE.M, viewModel.geschlecht)
assertEquals("max@example.com", viewModel.email)
assertTrue(viewModel.istGesperrt)
}
@Test
fun `createPerson should fail with empty nachname`() = runTest {
// Given - empty nachname
viewModel.updateVorname("Max")
// When
viewModel.createPerson()
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertEquals("Nachname ist erforderlich", viewModel.errorMessage)
assertFalse(viewModel.isSuccess)
assertFalse(viewModel.isLoading)
}
@Test
fun `createPerson should fail with empty vorname`() = runTest {
// Given - empty vorname
viewModel.updateNachname("Mustermann")
// When
viewModel.createPerson()
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertEquals("Vorname ist erforderlich", viewModel.errorMessage)
assertFalse(viewModel.isSuccess)
assertFalse(viewModel.isLoading)
}
@Test
fun `createPerson should succeed with valid data`() = runTest {
// Given
viewModel.updateNachname("Mustermann")
viewModel.updateVorname("Max")
viewModel.updateGeschlecht(GeschlechtE.M)
viewModel.updateEmail("max@example.com")
// When
viewModel.createPerson()
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertTrue(viewModel.isSuccess)
assertNull(viewModel.errorMessage)
assertFalse(viewModel.isLoading)
}
@Test
fun `createPerson should handle invalid date format`() = runTest {
// Given
viewModel.updateNachname("Mustermann")
viewModel.updateVorname("Max")
viewModel.updateGeburtsdatum("invalid-date")
// When
viewModel.createPerson()
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertEquals("Ungültiges Datumsformat. Verwenden Sie YYYY-MM-DD", viewModel.errorMessage)
assertFalse(viewModel.isSuccess)
assertFalse(viewModel.isLoading)
}
@Test
fun `createPerson should handle valid date format`() = runTest {
// Given
viewModel.updateNachname("Mustermann")
viewModel.updateVorname("Max")
viewModel.updateGeburtsdatum("1990-05-15")
// When
viewModel.createPerson()
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertTrue(viewModel.isSuccess)
assertNull(viewModel.errorMessage)
assertFalse(viewModel.isLoading)
}
@Test
fun `resetForm should clear all fields`() {
// Given - set some values
viewModel.updateNachname("Mustermann")
viewModel.updateVorname("Max")
viewModel.updateEmail("max@example.com")
viewModel.updateIstGesperrt(true)
// When
viewModel.resetForm()
// Then
assertEquals("", viewModel.nachname)
assertEquals("", viewModel.vorname)
assertEquals("", viewModel.email)
assertFalse(viewModel.istGesperrt)
assertFalse(viewModel.isLoading)
assertNull(viewModel.errorMessage)
assertFalse(viewModel.isSuccess)
}
@Test
fun `clearError should reset error message`() {
// Given - simulate an error
viewModel.updateNachname("") // This will cause validation error
viewModel.updateVorname("Max")
runTest {
viewModel.createPerson()
testDispatcher.scheduler.advanceUntilIdle()
}
assertNotNull(viewModel.errorMessage)
// When
viewModel.clearError()
// Then
assertNull(viewModel.errorMessage)
}
}
@@ -0,0 +1,130 @@
package at.mocode.ui.viewmodel
import at.mocode.members.domain.model.DomPerson
import at.mocode.members.domain.repository.PersonRepository
import at.mocode.enums.GeschlechtE
import at.mocode.enums.DatenQuelleE
import com.benasher44.uuid.uuid4
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import kotlinx.datetime.LocalDate
import kotlin.test.*
@OptIn(ExperimentalCoroutinesApi::class)
class PersonListViewModelTest {
private lateinit var mockPersonRepository: PersonRepository
private lateinit var viewModel: PersonListViewModel
private val testDispatcher = StandardTestDispatcher()
@BeforeTest
fun setup() {
Dispatchers.setMain(testDispatcher)
mockPersonRepository = object : PersonRepository {
private val persons = mutableListOf<DomPerson>()
override suspend fun save(person: DomPerson): DomPerson {
val savedPerson = person.copy(id = uuid4())
persons.add(savedPerson)
return savedPerson
}
override suspend fun findById(id: com.benasher44.uuid.Uuid): DomPerson? {
return persons.find { it.id == id }
}
override suspend fun findByOepsSatzNr(oepsSatzNr: String): DomPerson? {
return persons.find { it.oepsSatzNr == oepsSatzNr }
}
override suspend fun existsByOepsSatzNr(oepsSatzNr: String): Boolean {
return persons.any { it.oepsSatzNr == oepsSatzNr }
}
override suspend fun findAll(): List<DomPerson> {
return persons.toList()
}
override suspend fun delete(id: com.benasher44.uuid.Uuid) {
persons.removeAll { it.id == id }
}
}
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `initial state should be correct`() {
viewModel = PersonListViewModel(mockPersonRepository)
assertTrue(viewModel.persons.isEmpty())
assertFalse(viewModel.isLoading)
assertNull(viewModel.errorMessage)
}
@Test
fun `loadPersons should update persons list`() = runTest {
// Given
val testPerson = DomPerson(
nachname = "Test",
vorname = "User",
geschlechtE = GeschlechtE.M,
datenQuelle = DatenQuelleE.MANUELL
)
mockPersonRepository.save(testPerson)
// When
viewModel = PersonListViewModel(mockPersonRepository)
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertEquals(1, viewModel.persons.size)
assertEquals("Test", viewModel.persons.first().nachname)
assertEquals("User", viewModel.persons.first().vorname)
assertFalse(viewModel.isLoading)
assertNull(viewModel.errorMessage)
}
@Test
fun `refreshPersons should reload data`() = runTest {
// Given
viewModel = PersonListViewModel(mockPersonRepository)
testDispatcher.scheduler.advanceUntilIdle()
val initialCount = viewModel.persons.size
// Add a new person to repository
val newPerson = DomPerson(
nachname = "New",
vorname = "Person",
geschlechtE = GeschlechtE.W,
datenQuelle = DatenQuelleE.MANUELL
)
mockPersonRepository.save(newPerson)
// When
viewModel.refreshPersons()
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertEquals(initialCount + 1, viewModel.persons.size)
assertTrue(viewModel.persons.any { it.nachname == "New" })
}
@Test
fun `clearError should reset error message`() {
viewModel = PersonListViewModel(mockPersonRepository)
// Simulate an error (this would normally happen in a real error scenario)
// For testing, we can't easily simulate repository errors with our mock
// but we can test the clearError functionality
viewModel.clearError()
assertNull(viewModel.errorMessage)
}
}
+11
View File
@@ -0,0 +1,11 @@
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Meldestelle - Reitersport Management"
) {
App()
}
}
+12
View File
@@ -0,0 +1,12 @@
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.CanvasBasedWindow
import org.jetbrains.skiko.wasm.onWasmReady
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
onWasmReady {
CanvasBasedWindow("Meldestelle - Reitersport Management") {
App()
}
}
}
+3
View File
@@ -35,3 +35,6 @@ include(":member-management")
include(":horse-registry")
include(":event-management")
include(":api-gateway")
// Frontend module
include(":composeApp")