(vision) SCS/DDD
This commit is contained in:
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,3 +35,6 @@ include(":member-management")
|
|||||||
include(":horse-registry")
|
include(":horse-registry")
|
||||||
include(":event-management")
|
include(":event-management")
|
||||||
include(":api-gateway")
|
include(":api-gateway")
|
||||||
|
|
||||||
|
// Frontend module
|
||||||
|
include(":composeApp")
|
||||||
|
|||||||
Reference in New Issue
Block a user