fixing(client-module)

This commit is contained in:
stefan
2025-08-12 17:36:55 +02:00
parent a50b1b3822
commit 23b6708197
42 changed files with 198 additions and 3779 deletions
+9 -27
View File
@@ -1,63 +1,45 @@
plugins { plugins {
// KORREKTUR: Wir deklarieren dieses Modul als Kotlin Multiplatform Modul.
alias(libs.plugins.kotlin.multiplatform)
// KORREKTUR: Wir deklarieren, dass wir Jetpack Compose verwenden.
alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.multiplatform)
} }
kotlin { kotlin {
// Wir definieren die Zielplattformen, für die dieses Modul Code bereitstellt. jvm("desktop")
jvm("desktop") // Ein JVM-Target für unsere Desktop-App js(IR) {
js(IR) { // Ein JavaScript-Target für unsere Web-App
browser() browser()
binaries.executable()
} }
// Hier definieren wir die Abhängigkeiten für die jeweiligen Source Sets.
sourceSets { sourceSets {
val commonMain by getting { val commonMain by getting {
dependencies { dependencies {
// --- Interne Module (für alle Plattformen verfügbar) --- // Compose UI
api(projects.core.coreDomain)
api(projects.core.coreUtils)
// --- Jetpack Compose UI (für alle Plattformen verfügbar) ---
api(compose.runtime) api(compose.runtime)
api(compose.foundation) api(compose.foundation)
api(compose.material3) api(compose.material3)
api(compose.ui)
api(compose.components.resources)
api(compose.materialIconsExtended) api(compose.materialIconsExtended)
// --- Ktor Client für API-Kommunikation (Kernmodul für alle) --- // Ktor Client for API calls
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentNegotiation) implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.serialization.kotlinx.json)
// --- Coroutines (für alle Plattformen verfügbar) --- // Coroutines for background tasks
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
} }
} }
val desktopMain by getting { val desktopMain by getting {
dependencies { dependencies {
// Ktor-Engine, die nur für die Desktop (JVM) Version benötigt wird // Ktor engine for Desktop
implementation(libs.ktor.client.cio) implementation(libs.ktor.client.cio)
} }
} }
val jsMain by getting { val jsMain by getting {
dependencies { dependencies {
// Ktor-Engine, die nur für die Web (JS) Version benötigt wird // Ktor engine for Browser
implementation(libs.ktor.client.js) implementation(libs.ktor.client.js)
} }
} }
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
} }
} }
@@ -0,0 +1,29 @@
package at.mocode.client.data.api
import at.mocode.client.data.model.PingResponse
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
class PingApiClient {
private val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
})
}
}
suspend fun ping(): String {
return try {
// HINWEIS: Wir rufen hier Port 8081 an, den Port unseres Gateways.
val response = httpClient.get("http://localhost:8081/ping").body<PingResponse>()
response.status
} catch (e: Exception) {
"Fehler: ${e.message}"
}
}
}
@@ -0,0 +1,8 @@
package at.mocode.client.data.model
import kotlinx.serialization.Serializable
@Serializable
data class PingResponse(
val status: String
)
@@ -0,0 +1,12 @@
package at.mocode.client.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import at.mocode.client.ui.features.ping.PingScreen
@Composable
fun App() {
MaterialTheme {
PingScreen()
}
}
@@ -0,0 +1,35 @@
package at.mocode.client.ui.features.ping
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
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.unit.dp
@Composable
fun PingScreen() {
val viewModel = remember { PingViewModel() }
val responseText by viewModel.responseText.collectAsState()
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(onClick = { viewModel.onPingClicked() }) {
Text("Ping Backend")
}
Text(
text = responseText,
modifier = Modifier.padding(top = 16.dp)
)
}
}
@@ -0,0 +1,24 @@
package at.mocode.client.ui.features.ping
import at.mocode.client.data.api.PingApiClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class PingViewModel {
private val apiClient = PingApiClient()
private val viewModelScope = CoroutineScope(Dispatchers.Default)
private val _responseText = MutableStateFlow("Klicke auf den Button, um das Backend zu pingen.")
val responseText = _responseText.asStateFlow()
fun onPingClicked() {
_responseText.value = "Pinge Backend..."
viewModelScope.launch {
val response = apiClient.ping()
_responseText.value = "Antwort vom Backend: $response"
}
}
}
@@ -1,25 +0,0 @@
package at.mocode.client.common
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import at.mocode.client.common.theme.MeldestelleTheme
/**
* Base application theme wrapper for consistent UI across all applications.
* This is a simplified version that just applies the theme.
* Specific applications should implement their own App composable with navigation.
*/
@Composable
fun BaseApp(content: @Composable () -> Unit) {
MeldestelleTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
content()
}
}
}
@@ -1,232 +0,0 @@
package at.mocode.client.common.api
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import java.util.concurrent.ConcurrentHashMap
/**
* Shared API client for making HTTP requests to the backend API.
* Provides methods for common HTTP operations and handles response deserialization.
* Includes a simple caching mechanism for GET requests.
*/
object ApiClient {
// Public properties to avoid inline function issues
val BASE_URL = "http://localhost:8080"
val json = Json { ignoreUnknownKeys = true; isLenient = true }
val httpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(json)
}
// Add error handling, timeouts, etc.
engine {
requestTimeout = 30_000 // 30 seconds
}
}
// Cache implementation
val cache = ConcurrentHashMap<String, Pair<Any, Long>>()
val CACHE_TTL = 30_000L // 30 seconds
/**
* Generic GET method with ApiResponse handling and caching
*
* @param endpoint The API endpoint to call (without base URL)
* @param cacheable Whether to cache the response
* @return The deserialized data of type T
* @throws ApiException if the request fails or returns an error
*/
suspend inline fun <reified T> get(endpoint: String, cacheable: Boolean = true): T? {
try {
// Check cache if cacheable
if (cacheable) {
val cacheKey = endpoint
val cachedValue = cache[cacheKey]
if (cachedValue != null && System.currentTimeMillis() - cachedValue.second < CACHE_TTL) {
@Suppress("UNCHECKED_CAST")
return cachedValue.first as T
}
}
// Make HTTP request
val response = httpClient.get("$BASE_URL$endpoint")
val responseText = response.bodyAsText()
val apiResponse = json.decodeFromString<ApiResponse<T>>(responseText)
// Handle success/error
if (apiResponse.success) {
val data = apiResponse.data
// Update cache if cacheable
if (cacheable && data != null) {
val cacheKey = endpoint
cache[cacheKey] = Pair(data, System.currentTimeMillis())
}
return data
} else {
throw ApiException(
message = apiResponse.error?.message ?: "Unknown API error",
code = apiResponse.error?.code ?: "ERROR",
details = apiResponse.error?.details
)
}
} catch (e: Exception) {
if (e is ApiException) throw e
throw ApiException(
message = "Error executing GET request: ${e.message}",
code = "ERROR",
details = null
)
}
}
/**
* Generic POST method with ApiResponse handling
*
* @param endpoint The API endpoint to call (without base URL)
* @param body The request body to send
* @return The deserialized data of type T
* @throws ApiException if the request fails or returns an error
*/
suspend inline fun <reified T> post(endpoint: String, body: Any): T {
try {
// Make HTTP request
val response = httpClient.post("$BASE_URL$endpoint") {
contentType(ContentType.Application.Json)
setBody(body)
}
val responseText = response.bodyAsText()
val apiResponse = json.decodeFromString<ApiResponse<T>>(responseText)
// Handle success/error
if (apiResponse.success) {
return apiResponse.data
?: throw IllegalStateException("API response success but data is null")
} else {
throw ApiException(
message = apiResponse.error?.message ?: "Unknown API error",
code = apiResponse.error?.code ?: "ERROR",
details = apiResponse.error?.details
)
}
} catch (e: Exception) {
if (e is ApiException) throw e
throw ApiException(
message = "Error executing POST request: ${e.message}",
code = "ERROR",
details = null
)
}
}
/**
* Generic PUT method with ApiResponse handling
*
* @param endpoint The API endpoint to call (without base URL)
* @param body The request body to send
* @return The deserialized data of type T
* @throws ApiException if the request fails or returns an error
*/
suspend inline fun <reified T> put(endpoint: String, body: Any): T {
try {
// Make HTTP request
val response = httpClient.put("$BASE_URL$endpoint") {
contentType(ContentType.Application.Json)
setBody(body)
}
val responseText = response.bodyAsText()
val apiResponse = json.decodeFromString<ApiResponse<T>>(responseText)
// Handle success/error
if (apiResponse.success) {
return apiResponse.data
?: throw IllegalStateException("API response success but data is null")
} else {
throw ApiException(
message = apiResponse.error?.message ?: "Unknown API error",
code = apiResponse.error?.code ?: "ERROR",
details = apiResponse.error?.details
)
}
} catch (e: Exception) {
if (e is ApiException) throw e
throw ApiException(
message = "Error executing PUT request: ${e.message}",
code = "ERROR",
details = null
)
}
}
/**
* Generic DELETE method with ApiResponse handling
*
* @param endpoint The API endpoint to call (without base URL)
* @return The deserialized data of type T
* @throws ApiException if the request fails or returns an error
*/
suspend inline fun <reified T> delete(endpoint: String): T {
try {
// Make HTTP request
val response = httpClient.delete("$BASE_URL$endpoint")
val responseText = response.bodyAsText()
val apiResponse = json.decodeFromString<ApiResponse<T>>(responseText)
// Handle success/error
if (apiResponse.success) {
return apiResponse.data
?: throw IllegalStateException("API response success but data is null")
} else {
throw ApiException(
message = apiResponse.error?.message ?: "Unknown API error",
code = apiResponse.error?.code ?: "ERROR",
details = apiResponse.error?.details
)
}
} catch (e: Exception) {
if (e is ApiException) throw e
throw ApiException(
message = "Error executing DELETE request: ${e.message}",
code = "ERROR",
details = null
)
}
}
/**
* Clears the cache
*/
fun clearCache() {
cache.clear()
}
/**
* Removes a specific item from the cache
*/
fun invalidateCache(endpoint: String) {
cache.remove(endpoint)
}
}
/**
* Exception thrown when an API request fails
*/
class ApiException(
message: String,
val code: String,
val details: Map<String, String>?
) : Exception(message)
@@ -1,147 +0,0 @@
package at.mocode.client.common.cache
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
/**
* Thread-safe LRU cache implementation for API responses.
* Provides TTL-based expiration and size-based eviction.
*/
class ApiCache(
private val maxSize: Int,
private val ttlMs: Long
) {
private val cache = ConcurrentHashMap<String, CacheEntry>()
private val accessOrder = ConcurrentLinkedQueue<String>()
private val lock = ReentrantReadWriteLock()
data class CacheEntry(
val data: Any,
val timestamp: Long
)
/**
* Retrieves a cached value if it exists and hasn't expired.
*/
@Suppress("UNCHECKED_CAST")
fun <T> get(key: String): T? {
return lock.read {
val entry = cache[key] ?: return null
// Check if expired
if (System.currentTimeMillis() - entry.timestamp > ttlMs) {
// Remove expired entry
lock.write {
cache.remove(key)
accessOrder.remove(key)
}
return null
}
// Update access order
lock.write {
accessOrder.remove(key)
accessOrder.offer(key)
}
entry.data as T
}
}
/**
* Stores a value in the cache with current timestamp.
*/
fun put(key: String, value: Any) {
lock.write {
// Remove if already exists
if (cache.containsKey(key)) {
accessOrder.remove(key)
}
// Add new entry
cache[key] = CacheEntry(value, System.currentTimeMillis())
accessOrder.offer(key)
// Evict oldest entries if over capacity
while (cache.size > maxSize) {
val oldestKey = accessOrder.poll()
if (oldestKey != null) {
cache.remove(oldestKey)
}
}
}
}
/**
* Removes a specific entry from the cache.
*/
fun remove(key: String) {
lock.write {
cache.remove(key)
accessOrder.remove(key)
}
}
/**
* Removes entries matching the given pattern.
* Useful for invalidating related cache entries.
*/
fun removePattern(pattern: String) {
lock.write {
val keysToRemove = cache.keys.filter { it.contains(pattern) }
keysToRemove.forEach { key ->
cache.remove(key)
accessOrder.remove(key)
}
}
}
/**
* Clears all cached entries.
*/
fun clear() {
lock.write {
cache.clear()
accessOrder.clear()
}
}
/**
* Removes all expired entries from the cache.
*/
fun cleanupExpired() {
val currentTime = System.currentTimeMillis()
lock.write {
val expiredKeys = cache.entries
.filter { currentTime - it.value.timestamp > ttlMs }
.map { it.key }
expiredKeys.forEach { key ->
cache.remove(key)
accessOrder.remove(key)
}
}
}
/**
* Returns current cache statistics.
*/
fun getStats(): CacheStats {
return lock.read {
CacheStats(
size = cache.size,
maxSize = maxSize,
ttlMs = ttlMs
)
}
}
data class CacheStats(
val size: Int,
val maxSize: Int,
val ttlMs: Long
)
}
@@ -1,142 +0,0 @@
package at.mocode.client.common.components.events
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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.events.domain.model.Veranstaltung
/**
* Utility functions for event display in Compose UI
* This is a Compose-based replacement for the JS-specific EventUIUtils
*/
object EventComposeUtils {
/**
* Formats an event as a summary string
*/
fun formatEventSummary(event: Veranstaltung): String {
return buildString {
append("${event.name}")
append(" | ${event.ort}")
append(" | ${event.startDatum}")
if (event.isMultiDay()) {
append(" - ${event.endDatum}")
}
}
}
/**
* Returns a formatted date range string for an event
*/
fun formatEventDateRange(event: Veranstaltung): String {
return if (event.isMultiDay()) {
"${event.startDatum} - ${event.endDatum} (${event.getDurationInDays()} Tage)"
} else {
"${event.startDatum} (Eintägige Veranstaltung)"
}
}
/**
* Returns a list of status indicators for an event
*/
fun getEventStatusList(event: Veranstaltung): List<String> {
val statusList = mutableListOf<String>()
if (event.istAktiv) statusList.add("Aktiv")
if (event.istOeffentlich) statusList.add("Öffentlich")
if (event.isRegistrationOpen()) statusList.add("Anmeldung offen")
return statusList
}
}
/**
* A compact event card for displaying basic event information
*/
@Composable
fun CompactEventCard(
event: Veranstaltung,
onClick: () -> Unit = {}
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
onClick = onClick
) {
Column(
modifier = Modifier.padding(12.dp)
) {
Text(
text = event.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📍",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = event.ort,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📅",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = EventComposeUtils.formatEventDateRange(event),
style = MaterialTheme.typography.bodyMedium
)
}
// Status indicators
val statusList = EventComposeUtils.getEventStatusList(event)
if (statusList.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Status: ${statusList.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/**
* A badge that displays the event status
*/
@Composable
fun EventStatusBadge(event: Veranstaltung) {
val statusList = EventComposeUtils.getEventStatusList(event)
if (statusList.isNotEmpty()) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = statusList.first(),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
@@ -1,233 +0,0 @@
package at.mocode.client.common.components.events
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.events.domain.model.Veranstaltung
/**
* Compose component that displays a list of events (Veranstaltungen).
* This is a Compose-based replacement for the React-based VeranstaltungsListe component.
*/
@Composable
fun VeranstaltungsListe(
events: List<Veranstaltung> = emptyList(),
isLoading: Boolean = false,
errorMessage: String? = null
) {
// UI rendering with Compose
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Veranstaltungen",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
when {
isLoading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
errorMessage != null -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = errorMessage,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
events.isEmpty() -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Text(
text = "Keine Veranstaltungen verfügbar",
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(events) { event ->
EventCard(event = event)
}
}
}
}
}
}
@Composable
private fun EventCard(event: Veranstaltung) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = event.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📍",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = event.ort,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📅",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (event.isMultiDay()) {
"${event.startDatum} - ${event.endDatum} (${event.getDurationInDays()} Tage)"
} else {
"${event.startDatum} (Eintägige Veranstaltung)"
},
style = MaterialTheme.typography.bodyMedium
)
}
// Status indicators
val statusList = mutableListOf<String>()
if (event.istAktiv) statusList.add("Aktiv")
if (event.istOeffentlich) statusList.add("Öffentlich")
if (event.isRegistrationOpen()) statusList.add("Anmeldung offen")
if (statusList.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Status: ${statusList.joinToString(", ")}",
style = MaterialTheme.typography.bodyMedium
)
}
}
// Description
if (!event.beschreibung.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📝",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = event.beschreibung!!,
style = MaterialTheme.typography.bodyMedium
)
}
}
// Sports/Sparten
if (event.sparten.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🏆",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Sparten: ${event.sparten.joinToString(", ") { it.name }}",
style = MaterialTheme.typography.bodyMedium
)
}
}
// Additional info
event.maxTeilnehmer?.let { max ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "👥",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Max. Teilnehmer: $max",
style = MaterialTheme.typography.bodyMedium
)
}
}
event.anmeldeschluss?.let { deadline ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Anmeldeschluss: $deadline",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
@@ -1,237 +0,0 @@
package at.mocode.client.common.components.horses
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.horses.domain.model.DomPferd
/**
* Compose component that displays a list of horses (Pferde).
* This is a Compose-based replacement for the React-based PferdeListe component.
*/
@Composable
fun PferdeListe(
horses: List<DomPferd> = emptyList(),
isLoading: Boolean = false,
errorMessage: String? = null,
onHorseClick: (DomPferd) -> Unit = {}
) {
// UI rendering with Compose
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Pferde-Register",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
when {
isLoading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
errorMessage != null -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = errorMessage,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
horses.isEmpty() -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Text(
text = "Keine Pferde verfügbar",
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(horses) { horse ->
HorseCard(horse = horse, onClick = { onHorseClick(horse) })
}
}
}
}
}
}
@Composable
private fun HorseCard(
horse: DomPferd,
onClick: () -> Unit = {}
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
onClick = onClick
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = horse.getDisplayName(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
// Basic information
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🐎",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Geschlecht: ${horse.geschlecht.name}",
style = MaterialTheme.typography.bodyMedium
)
}
horse.geburtsdatum?.let { birthDate ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📅",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = buildString {
append("Geburtsdatum: $birthDate")
horse.getAge()?.let { age ->
append(" (${age} Jahre alt)")
}
},
style = MaterialTheme.typography.bodyMedium
)
}
}
// Breed and color
val breedAndColor = mutableListOf<String>()
horse.rasse?.let { breedAndColor.add("Rasse: $it") }
horse.farbe?.let { breedAndColor.add("Farbe: $it") }
if (breedAndColor.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🏇",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = breedAndColor.joinToString(" | "),
style = MaterialTheme.typography.bodyMedium
)
}
}
// Identification numbers (show only the most important ones in the card)
val identificationNumbers = mutableListOf<String>()
horse.lebensnummer?.let { identificationNumbers.add("Lebensnummer: $it") }
horse.oepsNummer?.let { identificationNumbers.add("OEPS: $it") }
if (identificationNumbers.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🆔",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = identificationNumbers.joinToString(" | "),
style = MaterialTheme.typography.bodyMedium
)
}
}
// Status indicators
val statusList = mutableListOf<String>()
if (horse.istAktiv) statusList.add("Aktiv") else statusList.add("Inaktiv")
if (horse.isOepsRegistered()) statusList.add("OEPS registriert")
if (horse.isFeiRegistered()) statusList.add("FEI registriert")
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Status: ${statusList.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Data source
Text(
text = "Datenquelle: ${horse.datenQuelle.name}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
/**
* A badge that displays the horse's registration status
*/
@Composable
fun HorseStatusBadge(horse: DomPferd) {
val status = when {
horse.isFeiRegistered() -> "FEI"
horse.isOepsRegistered() -> "OEPS"
else -> null
}
status?.let {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = it,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
@@ -1,266 +0,0 @@
package at.mocode.client.common.components.masterdata
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
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.masterdata.domain.model.LandDefinition
/**
* Compose component that displays master data (Stammdaten).
* This is a Compose-based replacement for the React-based StammdatenListe component.
* Currently focuses on countries (LandDefinition) but can be extended for other master data types.
*/
@Composable
fun StammdatenListe(
countries: List<LandDefinition> = emptyList(),
isLoading: Boolean = false,
errorMessage: String? = null,
onCountryClick: (LandDefinition) -> Unit = {}
) {
// UI rendering with Compose
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Stammdaten",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "Länder",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 16.dp)
)
when {
isLoading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
errorMessage != null -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = errorMessage,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
countries.isEmpty() -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Text(
text = "Keine Länder verfügbar",
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 300.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(countries) { country ->
CountryCard(country = country, onClick = { onCountryClick(country) })
}
}
}
}
}
}
@Composable
private fun CountryCard(
country: LandDefinition,
onClick: () -> Unit = {}
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
onClick = onClick
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = country.nameDeutsch,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
// ISO codes
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🌍",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "ISO-Codes: ${country.isoAlpha2Code} / ${country.isoAlpha3Code}",
style = MaterialTheme.typography.bodyMedium
)
country.isoNumerischerCode?.let { numCode ->
Text(
text = " / $numCode",
style = MaterialTheme.typography.bodyMedium
)
}
}
// English name if available
country.nameEnglisch?.let { englishName ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🇬🇧",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Englischer Name: $englishName",
style = MaterialTheme.typography.bodyMedium
)
}
}
// EU/EWR membership
val membershipInfo = mutableListOf<String>()
country.istEuMitglied?.let { isEuMember ->
if (isEuMember) membershipInfo.add("EU-Mitglied")
}
country.istEwrMitglied?.let { isEwrMember ->
if (isEwrMember) membershipInfo.add("EWR-Mitglied")
}
if (membershipInfo.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🇪🇺",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Mitgliedschaft: ${membershipInfo.joinToString(", ")}",
style = MaterialTheme.typography.bodyMedium
)
}
}
// Status
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Status: ${if (country.istAktiv) "Aktiv" else "Inaktiv"}",
style = MaterialTheme.typography.bodyMedium
)
}
// Sort order if available
country.sortierReihenfolge?.let { sortOrder ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🔢",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Sortierreihenfolge: $sortOrder",
style = MaterialTheme.typography.bodyMedium
)
}
}
// Coat of arms/flag URL if available
country.wappenUrl?.let { flagUrl ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🏴",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Wappen/Flagge: $flagUrl",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
/**
* A badge that displays the country's EU/EWR membership status
*/
@Composable
fun CountryMembershipBadge(country: LandDefinition) {
val membership = when {
country.istEuMitglied == true -> "EU"
country.istEwrMitglied == true -> "EWR"
else -> null
}
membership?.let {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = it,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
@@ -1,13 +0,0 @@
package at.mocode.client.common.config
/**
* Configuration class for API client settings.
* Allows for environment-specific configuration.
*/
data class ApiConfig(
val baseUrl: String = System.getProperty("api.base.url") ?: System.getenv("API_BASE_URL") ?: "http://localhost:8080",
val requestTimeoutMs: Long = System.getProperty("api.timeout")?.toLongOrNull() ?: 30_000L,
val cacheTtlMs: Long = System.getProperty("api.cache.ttl")?.toLongOrNull() ?: 30_000L,
val maxCacheSize: Int = System.getProperty("api.cache.max.size")?.toIntOrNull() ?: 1000,
val enableLogging: Boolean = System.getProperty("api.logging.enabled")?.toBoolean() ?: false
)
@@ -1,175 +0,0 @@
package at.mocode.client.common.repository
import at.mocode.client.common.api.ApiClient
import at.mocode.client.common.api.ApiException
/**
* Base repository class that provides common CRUD operations for client-side repositories.
* Eliminates code duplication and provides consistent error handling across all repositories.
*/
abstract class BaseClientRepository<T : Any>(
protected val baseEndpoint: String
) {
/**
* Finds an entity by its ID.
*/
protected suspend fun <T> findEntityById(id: String, entityClass: Class<T>): T? {
return try {
// Note: This is a simplified version - in practice you'd use reflection or other means
// to handle the generic type properly
@Suppress("UNCHECKED_CAST")
ApiClient.get<Any>("$baseEndpoint/$id") as? T
} catch (e: Exception) {
logError("Failed to fetch entity with ID $id", e)
null
}
}
/**
* Finds all active entities with pagination.
*/
protected suspend fun <T> findAllActiveEntities(limit: Int, offset: Int, entityClass: Class<T>): List<T> {
return try {
@Suppress("UNCHECKED_CAST")
(ApiClient.get<Any>("$baseEndpoint?limit=$limit&offset=$offset") as? List<T>) ?: emptyList()
} catch (e: Exception) {
logError("Failed to fetch active entities", e)
emptyList()
}
}
/**
* Searches entities by name/search term.
*/
protected suspend fun <T> searchEntities(searchTerm: String, limit: Int, entityClass: Class<T>): List<T> {
return try {
@Suppress("UNCHECKED_CAST")
(ApiClient.get<Any>("$baseEndpoint?search=$searchTerm&limit=$limit") as? List<T>) ?: emptyList()
} catch (e: Exception) {
logError("Failed to search entities by term: $searchTerm", e)
emptyList()
}
}
/**
* Searches entities by a specific field.
*/
protected suspend fun <T> searchEntitiesByField(
fieldName: String,
fieldValue: String,
limit: Int,
entityClass: Class<T>
): List<T> {
return try {
@Suppress("UNCHECKED_CAST")
(ApiClient.get<Any>("$baseEndpoint?$fieldName=$fieldValue&limit=$limit") as? List<T>) ?: emptyList()
} catch (e: Exception) {
logError("Failed to search entities by $fieldName: $fieldValue", e)
emptyList()
}
}
/**
* Searches entities by date range.
*/
protected suspend fun <T> searchEntitiesByDateRange(
startDate: String,
endDate: String,
limit: Int,
entityClass: Class<T>
): List<T> {
return try {
@Suppress("UNCHECKED_CAST")
(ApiClient.get<Any>("$baseEndpoint?startDate=$startDate&endDate=$endDate&limit=$limit") as? List<T>) ?: emptyList()
} catch (e: Exception) {
logError("Failed to search entities by date range: $startDate to $endDate", e)
emptyList()
}
}
/**
* Saves an entity (create or update based on ID).
*/
protected suspend fun <T> saveEntity(entity: T, getId: (T) -> String, entityClass: Class<T>): T {
return try {
val id = getId(entity)
if (id.isBlank()) {
// Create new entity
@Suppress("UNCHECKED_CAST")
ApiClient.post<Any>(baseEndpoint, entity as Any) as T
} else {
// Update existing entity
@Suppress("UNCHECKED_CAST")
ApiClient.put<Any>("$baseEndpoint/$id", entity as Any) as T
}
} catch (e: ApiException) {
logError("Failed to save entity", e)
throw e
} catch (e: Exception) {
logError("Unexpected error while saving entity", e)
throw ApiException(
message = "Failed to save entity: ${e.message}",
code = "SAVE_ERROR",
details = null
)
}
}
/**
* Deletes an entity by ID.
*/
protected suspend fun deleteEntity(id: String): Boolean {
return try {
ApiClient.delete<Boolean>("$baseEndpoint/$id")
true
} catch (e: Exception) {
logError("Failed to delete entity with ID $id", e)
false
}
}
/**
* Counts active entities.
*/
protected suspend fun countActiveEntities(): Long {
return try {
ApiClient.get<Long>("$baseEndpoint/count") ?: 0L
} catch (e: Exception) {
logError("Failed to count active entities", e)
0L
}
}
/**
* Gets entities from a specific sub-endpoint.
*/
protected suspend fun <T> getFromSubEndpoint(subEndpoint: String, limit: Int, entityClass: Class<T>): List<T> {
return try {
@Suppress("UNCHECKED_CAST")
(ApiClient.get<Any>("$baseEndpoint/$subEndpoint?limit=$limit") as? List<T>) ?: emptyList()
} catch (e: Exception) {
logError("Failed to fetch from sub-endpoint: $subEndpoint", e)
emptyList()
}
}
/**
* Logs errors in a consistent format.
* In a real application, this should use a proper logging framework.
*/
internal fun logError(message: String, exception: Exception) {
println("[ERROR] ${this::class.simpleName}: $message - ${exception.message}")
// TODO: Replace with proper logging framework (e.g., SLF4J)
}
/**
* Invalidates cache entries related to this repository's endpoint.
*/
protected fun invalidateCache() {
// Extract base path for cache invalidation
val basePath = baseEndpoint.split("/").take(3).joinToString("/")
// For now, clear all cache - in a real implementation, we'd use pattern matching
ApiClient.clearCache()
}
}
@@ -1,109 +0,0 @@
package at.mocode.client.common.repository
import at.mocode.client.common.api.ApiClient
import at.mocode.client.common.api.ApiException
import kotlinx.datetime.LocalDate
/**
* Client-side implementation of the EventRepository interface.
* Uses the ApiClient to make HTTP requests to the backend API.
*/
class ClientEventRepository : EventRepository {
private val baseEndpoint = "/api/events"
override suspend fun findById(id: String): Event? {
return try {
ApiClient.get<Event>("$baseEndpoint/$id")
} catch (e: Exception) {
println("[ERROR] Failed to fetch event with ID $id: ${e.message}")
null
}
}
override suspend fun findAllActive(limit: Int, offset: Int): List<Event> {
return try {
ApiClient.get<List<Event>>("$baseEndpoint?limit=$limit&offset=$offset") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to fetch active events: ${e.message}")
emptyList()
}
}
override suspend fun findByName(searchTerm: String, limit: Int): List<Event> {
return try {
ApiClient.get<List<Event>>("$baseEndpoint?search=$searchTerm&limit=$limit") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to search events by name: ${e.message}")
emptyList()
}
}
override suspend fun findByLocation(location: String, limit: Int): List<Event> {
return try {
ApiClient.get<List<Event>>("$baseEndpoint?location=$location&limit=$limit") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to search events by location: ${e.message}")
emptyList()
}
}
override suspend fun findByDateRange(startDate: LocalDate, endDate: LocalDate, limit: Int): List<Event> {
return try {
ApiClient.get<List<Event>>("$baseEndpoint?startDate=$startDate&endDate=$endDate&limit=$limit") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to search events by date range: ${e.message}")
emptyList()
}
}
override suspend fun findUpcoming(limit: Int): List<Event> {
return try {
ApiClient.get<List<Event>>("$baseEndpoint/upcoming?limit=$limit") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to fetch upcoming events: ${e.message}")
emptyList()
}
}
override suspend fun save(event: Event): Event {
return try {
if (event.id.isBlank()) {
// Create new event
ApiClient.post<Event>(baseEndpoint, event)
} else {
// Update existing event
ApiClient.put<Event>("$baseEndpoint/${event.id}", event)
}
} catch (e: ApiException) {
println("[ERROR] Failed to save event: ${e.message}")
throw e
} catch (e: Exception) {
println("[ERROR] Unexpected error while saving event: ${e.message}")
throw ApiException(
message = "Failed to save event: ${e.message}",
code = "SAVE_ERROR",
details = null
)
}
}
override suspend fun delete(id: String): Boolean {
return try {
ApiClient.delete<Boolean>("$baseEndpoint/$id")
true
} catch (e: Exception) {
println("[ERROR] Failed to delete event with ID $id: ${e.message}")
false
}
}
override suspend fun countActive(): Long {
return try {
ApiClient.get<Long>("$baseEndpoint/count") ?: 0L
} catch (e: Exception) {
println("[ERROR] Failed to count active events: ${e.message}")
0L
}
}
}
@@ -1,81 +0,0 @@
package at.mocode.client.common.repository
import at.mocode.client.common.api.ApiClient
import at.mocode.client.common.api.ApiException
/**
* Client-side implementation of the PersonRepository interface.
* Uses the ApiClient to make HTTP requests to the backend API.
*/
class ClientPersonRepository : PersonRepository {
private val baseEndpoint = "/api/persons"
override suspend fun findById(id: String): Person? {
return try {
ApiClient.get<Person>("$baseEndpoint/$id")
} catch (e: Exception) {
println("[ERROR] Failed to fetch person with ID $id: ${e.message}")
null
}
}
override suspend fun findAllActive(limit: Int, offset: Int): List<Person> {
return try {
ApiClient.get<List<Person>>("$baseEndpoint?limit=$limit&offset=$offset") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to fetch active persons: ${e.message}")
emptyList()
}
}
override suspend fun findByName(searchTerm: String, limit: Int): List<Person> {
return try {
ApiClient.get<List<Person>>("$baseEndpoint?search=$searchTerm&limit=$limit") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to search persons by name: ${e.message}")
emptyList()
}
}
override suspend fun save(person: Person): Person {
return try {
if (person.id.isBlank()) {
// Create new person
ApiClient.post<Person>(baseEndpoint, person)
} else {
// Update existing person
ApiClient.put<Person>("$baseEndpoint/${person.id}", person)
}
} catch (e: ApiException) {
println("[ERROR] Failed to save person: ${e.message}")
throw e
} catch (e: Exception) {
println("[ERROR] Unexpected error while saving person: ${e.message}")
throw ApiException(
message = "Failed to save person: ${e.message}",
code = "SAVE_ERROR",
details = null
)
}
}
override suspend fun delete(id: String): Boolean {
return try {
ApiClient.delete<Boolean>("$baseEndpoint/$id")
true
} catch (e: Exception) {
println("[ERROR] Failed to delete person with ID $id: ${e.message}")
false
}
}
override suspend fun countActive(): Long {
return try {
ApiClient.get<Long>("$baseEndpoint/count") ?: 0L
} catch (e: Exception) {
println("[ERROR] Failed to count active persons: ${e.message}")
0L
}
}
}
@@ -1,48 +0,0 @@
package at.mocode.client.common.repository
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
/**
* Simplified Event data class for client-side use.
* This is a client-side representation of the Veranstaltung entity from the domain model.
*/
@Serializable
data class Event(
val id: String = "",
val name: String,
val beschreibung: String? = null,
val startDatum: LocalDate,
val endDatum: LocalDate,
val ort: String,
val veranstalterVereinId: String? = null,
val sparten: List<String> = emptyList(),
val istAktiv: Boolean = true,
val istOeffentlich: Boolean = true,
val maxTeilnehmer: Int? = null,
val anmeldeschluss: LocalDate? = null,
val createdAt: String? = null,
val updatedAt: String? = null
) {
/**
* Checks if the event is currently accepting registrations.
*/
fun isRegistrationOpen(): Boolean {
// Simplified implementation - can be enhanced with proper date comparison
return istAktiv && anmeldeschluss != null
}
/**
* Returns the duration of the event in days.
*/
fun getDurationInDays(): Int {
return (endDatum.toEpochDays() - startDatum.toEpochDays()).toInt() + 1
}
/**
* Checks if the event spans multiple days.
*/
fun isMultiDay(): Boolean {
return startDatum != endDatum
}
}
@@ -1,85 +0,0 @@
package at.mocode.client.common.repository
import kotlinx.datetime.LocalDate
/**
* Client-side repository interface for Event entities.
* This is a simplified version of the domain repository interface.
*/
interface EventRepository {
/**
* Finds an event by its ID.
*
* @param id The unique identifier of the event
* @return The event if found, null otherwise
*/
suspend fun findById(id: String): Event?
/**
* Finds all active events with pagination.
*
* @param limit Maximum number of results to return
* @param offset Number of results to skip
* @return List of active events
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<Event>
/**
* Finds events by name (partial match).
*
* @param searchTerm The search term to match against event names
* @param limit Maximum number of results to return
* @return List of matching events
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<Event>
/**
* Finds events by location (partial match).
*
* @param location The location to match against event locations
* @param limit Maximum number of results to return
* @return List of matching events
*/
suspend fun findByLocation(location: String, limit: Int = 50): List<Event>
/**
* Finds events by date range.
*
* @param startDate The start date of the range
* @param endDate The end date of the range
* @param limit Maximum number of results to return
* @return List of events within the date range
*/
suspend fun findByDateRange(startDate: LocalDate, endDate: LocalDate, limit: Int = 100): List<Event>
/**
* Finds upcoming events.
*
* @param limit Maximum number of results to return
* @return List of upcoming events
*/
suspend fun findUpcoming(limit: Int = 50): List<Event>
/**
* Saves an event (create or update).
*
* @param event The event to save
* @return The saved event with updated information
*/
suspend fun save(event: Event): Event
/**
* Deletes an event by ID.
*
* @param id The unique identifier of the event to delete
* @return true if the event was deleted, false if not found
*/
suspend fun delete(id: String): Boolean
/**
* Counts the total number of active events.
*
* @return The total count of active events
*/
suspend fun countActive(): Long
}
@@ -1,75 +0,0 @@
package at.mocode.client.common.repository
import at.mocode.client.common.api.ApiClient
/**
* Optimized client-side implementation of the PersonRepository interface.
* Uses BaseClientRepository to eliminate code duplication and provide consistent error handling.
*/
class OptimizedClientPersonRepository : BaseClientRepository<Person>("/api/persons"), PersonRepository {
override suspend fun findById(id: String): Person? {
return try {
ApiClient.get<Person>("$baseEndpoint/$id")
} catch (e: Exception) {
logError("Failed to fetch person with ID $id", e)
null
}
}
override suspend fun findAllActive(limit: Int, offset: Int): List<Person> {
return try {
ApiClient.get<List<Person>>("$baseEndpoint?limit=$limit&offset=$offset") ?: emptyList()
} catch (e: Exception) {
logError("Failed to fetch active persons", e)
emptyList()
}
}
override suspend fun findByName(searchTerm: String, limit: Int): List<Person> {
return try {
ApiClient.get<List<Person>>("$baseEndpoint?search=$searchTerm&limit=$limit") ?: emptyList()
} catch (e: Exception) {
logError("Failed to search persons by name", e)
emptyList()
}
}
override suspend fun save(person: Person): Person {
return try {
val result = if (person.id.isBlank()) {
// Create new person
ApiClient.post<Person>(baseEndpoint, person)
} else {
// Update existing person
ApiClient.put<Person>("$baseEndpoint/${person.id}", person)
}
// Invalidate related cache entries after successful save
invalidateCache()
result
} catch (e: Exception) {
logError("Failed to save person", e)
throw e
}
}
override suspend fun delete(id: String): Boolean {
return try {
ApiClient.delete<Boolean>("$baseEndpoint/$id")
// Invalidate related cache entries after successful delete
invalidateCache()
true
} catch (e: Exception) {
logError("Failed to delete person with ID $id", e)
false
}
}
override suspend fun countActive(): Long {
return countActiveEntities()
}
}
@@ -1,56 +0,0 @@
package at.mocode.client.common.repository
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
/**
* Simplified Person data class for client-side use.
* This is a client-side representation of the DomPerson entity from the domain model.
*/
@Serializable
data class Person(
val id: String = "",
val nachname: String,
val vorname: String,
val titel: String? = null,
val oepsSatzNr: String? = null,
val geburtsdatum: LocalDate? = null,
val geschlecht: String? = null,
val telefon: String? = null,
val email: String? = null,
val strasse: String? = null,
val plz: String? = null,
val ort: String? = null,
val adresszusatz: String? = null,
val feiId: String? = null,
val mitgliedsNummer: String? = null,
val istGesperrt: Boolean = false,
val sperrGrund: String? = null,
val notizen: String? = null,
val datenQuelle: String = "MANUELL",
val createdAt: String? = null,
val updatedAt: String? = null
) {
/**
* Returns the full name of the person, including title if available.
*/
fun getFullName(): String {
return buildString {
titel?.let { append("$it ") }
append("$vorname $nachname")
}
}
/**
* Returns a display-friendly representation of the address.
*/
fun getFormattedAddress(): String? {
if (strasse == null || plz == null || ort == null) return null
return buildString {
append(strasse)
adresszusatz?.let { append(", $it") }
append(", $plz $ort")
}
}
}
@@ -1,56 +0,0 @@
package at.mocode.client.common.repository
/**
* Client-side repository interface for Person entities.
* This is a simplified version of the domain repository interface.
*/
interface PersonRepository {
/**
* Finds a person by their ID.
*
* @param id The unique identifier of the person
* @return The person if found, null otherwise
*/
suspend fun findById(id: String): Person?
/**
* Finds all active persons with pagination.
*
* @param limit Maximum number of results to return
* @param offset Number of results to skip
* @return List of active persons
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<Person>
/**
* Finds persons by name (partial match).
*
* @param searchTerm The search term to match against person names
* @param limit Maximum number of results to return
* @return List of matching persons
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<Person>
/**
* Saves a person (create or update).
*
* @param person The person to save
* @return The saved person with updated information
*/
suspend fun save(person: Person): Person
/**
* Deletes a person by ID.
*
* @param id The unique identifier of the person to delete
* @return true if the person was deleted, false if not found
*/
suspend fun delete(id: String): Boolean
/**
* Counts the total number of active persons.
*
* @return The total count of active persons
*/
suspend fun countActive(): Long
}
@@ -1,49 +0,0 @@
package at.mocode.client.common.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
)
}
+19 -13
View File
@@ -1,25 +1,31 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins { plugins {
kotlin("jvm")
alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.multiplatform)
} }
dependencies { kotlin {
// Greift explizit auf den "desktop" (JVM) Teil unseres KMP-Moduls zu. jvm("desktop")
implementation(projects.client.commonUi)
// Stellt die Desktop-spezifischen Teile von Jetpack Compose bereit. sourceSets {
implementation(compose.desktop.currentOs) val desktopMain by getting {
dependencies {
// Stellt die Coroutine-Integration für die Swing-UI-Bibliothek bereit. implementation(libs.compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing) implementation(project(":client:common-ui"))
}
// --- Testing --- }
testImplementation(projects.platform.platformTesting) }
} }
compose.desktop { compose.desktop {
application { application {
mainClass = "at.mocode.client.desktop.MainKt" mainClass = "MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "MeldestellePro"
packageVersion = "1.0.0"
}
} }
} }
@@ -0,0 +1,10 @@
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import at.mocode.client.ui.App
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "MeldestellePro") {
// Wir rufen hier exakt dieselbe geteilte App() Composable-Funktion auf.
App()
}
}
@@ -1,469 +0,0 @@
package at.mocode.client.desktop
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import at.mocode.client.common.BaseApp
import at.mocode.client.common.components.events.VeranstaltungsListe
import at.mocode.client.common.components.horses.PferdeListe
import at.mocode.client.common.components.masterdata.StammdatenListe
import at.mocode.client.web.screens.CreatePersonScreen
import at.mocode.client.web.screens.PersonListScreen
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.events.domain.model.Veranstaltung
import at.mocode.horses.domain.model.DomPferd
import at.mocode.masterdata.domain.model.LandDefinition
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.serialization.json.Json
/**
* Main application composable for the desktop application.
* Implements a simple tab-based navigation between different screens.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun App() {
// State for navigation
var selectedTabIndex by remember { mutableStateOf(0) }
// Define tabs
val tabs = listOf(
TabItem("Dashboard", Icons.Default.Home),
TabItem("Veranstaltungen", Icons.Default.Event),
TabItem("Pferde", Icons.Default.Pets),
TabItem("Personen", Icons.Default.Person),
TabItem("Stammdaten", Icons.Default.Settings)
)
BaseApp {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Meldestelle - Reitersport Management") }
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Tab row for navigation
TabRow(
selectedTabIndex = selectedTabIndex
) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index },
text = { Text(tab.title) },
icon = { Icon(tab.icon, contentDescription = tab.title) }
)
}
}
// Content based on selected tab
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
when (selectedTabIndex) {
0 -> DashboardScreen()
1 -> EventsScreen()
2 -> HorsesScreen()
3 -> PersonsScreen()
4 -> MasterDataScreen()
}
}
}
}
}
}
/**
* Data class representing a tab item
*/
data class TabItem(
val title: String,
val icon: androidx.compose.ui.graphics.vector.ImageVector
)
/**
* Dashboard screen showing an overview of the application
*/
@Composable
fun DashboardScreen() {
val coroutineScope = rememberCoroutineScope()
var pingResult by remember { mutableStateOf<String?>(null) }
var pingLoading by remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Willkommen bei Meldestelle",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = "Reitersport Management System",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 32.dp)
)
// Display ping result if available
pingResult?.let { result ->
Text(
text = "Ping Result: $result",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Quick access buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(
onClick = { /* TODO: Implement quick action */ }
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Add, contentDescription = "Neue Veranstaltung")
Spacer(modifier = Modifier.height(4.dp))
Text("Neue Veranstaltung")
}
}
Button(
onClick = { /* TODO: Implement quick action */ }
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Search, contentDescription = "Suche")
Spacer(modifier = Modifier.height(4.dp))
Text("Suche")
}
}
Button(
onClick = {
coroutineScope.launch {
pingLoading = true
try {
val pingClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Auth) {
basic {
credentials {
BasicAuthCredentials(username = "admin", password = "admin")
}
}
}
}
val response: Map<String, String> = pingClient.get("http://localhost:8080/api/ping").body()
pingResult = response["status"] ?: "No status in response"
pingClient.close()
} catch (e: Exception) {
pingResult = "Error: ${e.message}"
} finally {
pingLoading = false
}
}
},
enabled = !pingLoading
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
if (pingLoading) Icons.Default.Refresh else Icons.Default.NetworkCheck,
contentDescription = "Ping Test"
)
Spacer(modifier = Modifier.height(4.dp))
Text(if (pingLoading) "Pinging..." else "Ping Test")
}
}
}
}
}
/**
* Events screen showing a list of events
*/
@Composable
fun EventsScreen() {
// Create some dummy event data for testing
val dummyEvents = remember {
listOf(
Veranstaltung(
name = "Reitturnier Wien",
ort = "Wien",
startDatum = LocalDate(2025, 8, 15),
endDatum = LocalDate(2025, 8, 17),
veranstalterVereinId = com.benasher44.uuid.uuid4(),
beschreibung = "Internationales Reitturnier mit Springprüfungen",
istAktiv = true,
istOeffentlich = true,
anmeldeschluss = LocalDate(2025, 8, 1),
maxTeilnehmer = 100
),
Veranstaltung(
name = "Dressurturnier Salzburg",
ort = "Salzburg",
startDatum = LocalDate(2025, 9, 5),
endDatum = LocalDate(2025, 9, 5),
veranstalterVereinId = com.benasher44.uuid.uuid4(),
beschreibung = "Dressurturnier für alle Altersklassen",
istAktiv = true,
istOeffentlich = true,
anmeldeschluss = LocalDate(2025, 8, 25),
maxTeilnehmer = 50
)
)
}
// Use the VeranstaltungsListe component to display the events
VeranstaltungsListe(
events = dummyEvents,
isLoading = false,
errorMessage = null
)
}
/**
* Horses screen showing a list of horses
*/
@Composable
fun HorsesScreen() {
// Create some dummy horse data for testing
val dummyHorses = remember {
listOf(
DomPferd(
pferdeName = "Maestoso Bella",
geschlecht = PferdeGeschlechtE.STUTE,
geburtsdatum = LocalDate(2018, 5, 12),
rasse = "Lipizzaner",
farbe = "Schimmel",
lebensnummer = "AT2018123456",
chipNummer = "276098100123456",
oepsNummer = "AT12345",
stockmass = 165,
istAktiv = true,
datenQuelle = DatenQuelleE.MANUELL
),
DomPferd(
pferdeName = "Donnerhall",
geschlecht = PferdeGeschlechtE.HENGST,
geburtsdatum = LocalDate(2020, 3, 24),
rasse = "Hannoveraner",
farbe = "Rappe",
lebensnummer = "DE2020654321",
passNummer = "DE98765",
feiNummer = "FEI10293847",
vaterName = "Dressage King",
mutterName = "Hannelore",
stockmass = 172,
istAktiv = true,
datenQuelle = DatenQuelleE.MANUELL
),
DomPferd(
pferdeName = "Lucky Star",
geschlecht = PferdeGeschlechtE.WALLACH,
geburtsdatum = LocalDate(2015, 7, 8),
rasse = "Haflinger",
farbe = "Fuchs",
chipNummer = "276098100654321",
istAktiv = true,
datenQuelle = DatenQuelleE.MANUELL
)
)
}
// Use the PferdeListe component to display the horses
PferdeListe(
horses = dummyHorses,
isLoading = false,
errorMessage = null,
onHorseClick = { /* Handle horse click */ }
)
}
/**
* Persons screen showing a list of persons
*/
@Composable
fun PersonsScreen() {
// State for navigation
var showCreatePerson by remember { mutableStateOf(false) }
// Create view models using AppDependencies
val personListViewModel = remember { at.mocode.client.web.di.AppDependencies.personListViewModel() }
val createPersonViewModel = remember { at.mocode.client.web.di.AppDependencies.createPersonViewModel() }
if (showCreatePerson) {
// Show create person screen
CreatePersonScreen(
viewModel = createPersonViewModel,
onNavigateBack = {
// When navigating back, refresh the person list if a person was created
if (createPersonViewModel.isSuccess) {
personListViewModel.refreshPersons()
}
showCreatePerson = false
}
)
} else {
// Show person list screen
PersonListScreen(
viewModel = personListViewModel,
onNavigateToCreatePerson = { showCreatePerson = true }
)
}
}
/**
* Master data screen showing master data like countries
*/
@Composable
fun MasterDataScreen() {
// Create some dummy country data for testing
val dummyCountries = remember {
listOf(
LandDefinition(
isoAlpha2Code = "AT",
isoAlpha3Code = "AUT",
isoNumerischerCode = "040",
nameDeutsch = "Österreich",
nameEnglisch = "Austria",
istEuMitglied = true,
istEwrMitglied = true,
istAktiv = true,
sortierReihenfolge = 1
),
LandDefinition(
isoAlpha2Code = "DE",
isoAlpha3Code = "DEU",
isoNumerischerCode = "276",
nameDeutsch = "Deutschland",
nameEnglisch = "Germany",
istEuMitglied = true,
istEwrMitglied = true,
istAktiv = true,
sortierReihenfolge = 2
),
LandDefinition(
isoAlpha2Code = "CH",
isoAlpha3Code = "CHE",
isoNumerischerCode = "756",
nameDeutsch = "Schweiz",
nameEnglisch = "Switzerland",
istEuMitglied = false,
istEwrMitglied = false,
istAktiv = true,
sortierReihenfolge = 3
),
LandDefinition(
isoAlpha2Code = "IT",
isoAlpha3Code = "ITA",
isoNumerischerCode = "380",
nameDeutsch = "Italien",
nameEnglisch = "Italy",
istEuMitglied = true,
istEwrMitglied = true,
istAktiv = true,
sortierReihenfolge = 4
),
LandDefinition(
isoAlpha2Code = "FR",
isoAlpha3Code = "FRA",
isoNumerischerCode = "250",
nameDeutsch = "Frankreich",
nameEnglisch = "France",
istEuMitglied = true,
istEwrMitglied = true,
istAktiv = true,
sortierReihenfolge = 5
)
)
}
// Use the StammdatenListe component to display the countries
StammdatenListe(
countries = dummyCountries,
isLoading = false,
errorMessage = null,
onCountryClick = { /* Handle country click */ }
)
}
/**
* A generic placeholder screen
*/
@Composable
fun PlaceholderScreen(
title: String,
description: String,
icon: androidx.compose.ui.graphics.vector.ImageVector
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
icon,
contentDescription = title,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp)
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { /* TODO: Implement action */ }
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Add, contentDescription = "Hinzufügen")
Spacer(modifier = Modifier.width(8.dp))
Text("Hinzufügen")
}
}
}
}
@@ -1,13 +0,0 @@
package at.mocode.client.desktop
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Meldestelle - Reitersport Management"
) {
App()
}
}
+24 -18
View File
@@ -7,9 +7,29 @@ plugins {
kotlin { kotlin {
js(IR) { js(IR) {
browser { browser {
// Konfiguriert den Development-Server und die finalen Bundles.
commonWebpackConfig { commonWebpackConfig {
outputFileName = "MeldestelleWebApp.js" devServer = org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig.DevServer(
open = true,
port = 8081
)
}
webpackTask {
cssSupport {
enabled.set(true)
}
}
runTask {
cssSupport {
enabled.set(true)
}
}
testTask {
useKarma {
useChromeHeadless()
webpackConfig.cssSupport {
enabled.set(true)
}
}
} }
} }
binaries.executable() binaries.executable()
@@ -18,23 +38,9 @@ kotlin {
sourceSets { sourceSets {
val jsMain by getting { val jsMain by getting {
dependencies { dependencies {
// Greift explizit auf den JS-Teil unseres KMP-Moduls zu. implementation(project(":client:common-ui"))
implementation(projects.client.commonUi)
// Stellt die Web-spezifischen (HTML) Teile von Jetpack Compose bereit.
implementation(compose.html.core)
// HTTP client for making requests to the backend
implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.client.js)
implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
}
}
val jsTest by getting {
dependencies {
implementation(libs.kotlin.test)
} }
} }
} }
} }
+11
View File
@@ -0,0 +1,11 @@
import at.mocode.client.ui.App
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.CanvasBasedWindow
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
CanvasBasedWindow(canvasElementId = "root") {
App()
}
}
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Meldestelle Pro</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<canvas id="root"></canvas>
<script src="MeldestelleWebApp.js"></script>
</body>
</html>
@@ -1,174 +0,0 @@
package at.mocode.client.web
import androidx.compose.runtime.*
import org.jetbrains.compose.web.dom.*
import org.jetbrains.compose.web.css.*
import kotlinx.coroutines.launch
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class PingResponse(val status: String)
@Composable
fun App() {
var responseStatus by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val httpClient = remember {
HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
}
Div({
style {
fontFamily("Arial, sans-serif")
padding(20.px)
maxWidth(800.px)
margin("0 auto")
}
}) {
H1({
style {
color(Color.darkblue)
textAlign("center")
marginBottom(30.px)
}
}) {
Text("Meldestelle - Reitersport Management")
}
Div({
style {
textAlign("center")
marginBottom(20.px)
}
}) {
P { Text("Welcome to the Meldestelle Web Application") }
P { Text("Click the button below to test the backend connection") }
}
Div({
style {
textAlign("center")
marginBottom(20.px)
}
}) {
Button({
style {
backgroundColor(Color.lightblue)
color(Color.white)
border(0.px)
padding(10.px, 20.px)
fontSize(16.px)
cursor("pointer")
borderRadius(5.px)
}
onClick {
scope.launch {
try {
isLoading = true
errorMessage = null
responseStatus = null
// Try different potential gateway URLs with correct routing
val gatewayUrls = listOf(
"http://localhost:8080/api/ping/ping", // Correct gateway path
"http://localhost:8080/ping", // Direct service call (fallback)
"http://localhost:8081/api/ping/ping" // Alternative gateway port
)
var success = false
for (url in gatewayUrls) {
try {
val response: HttpResponse = httpClient.get(url)
val responseText = response.bodyAsText()
// Try to parse as JSON first
try {
val pingResponse = Json.decodeFromString<PingResponse>(responseText)
responseStatus = pingResponse.status
success = true
break
} catch (e: Exception) {
// If JSON parsing fails, use the raw response
responseStatus = responseText
success = true
break
}
} catch (e: Exception) {
// Continue to next URL
continue
}
}
if (!success) {
errorMessage = "Could not reach any backend service. Please ensure the backend is running."
}
} catch (e: Exception) {
errorMessage = "Error: ${e.message}"
} finally {
isLoading = false
}
}
}
disabled(isLoading)
}) {
Text(if (isLoading) "Loading..." else "Ping Backend")
}
}
// Response display area
Div({
style {
textAlign("center")
marginTop(20.px)
minHeight(100.px)
border(1.px, LineStyle.Solid, Color.lightgray)
borderRadius(5.px)
padding(20.px)
backgroundColor(Color.lightyellow)
}
}) {
when {
isLoading -> {
P { Text("Sending request to backend...") }
}
errorMessage != null -> {
P({
style {
color(Color.red)
fontWeight("bold")
}
}) {
Text(errorMessage!!)
}
}
responseStatus != null -> {
P({
style {
color(Color.green)
fontWeight("bold")
fontSize(18.px)
}
}) {
Text("Backend Response: $responseStatus")
}
}
else -> {
P { Text("Click the button above to test backend connection") }
}
}
}
}
}
@@ -1,35 +0,0 @@
package at.mocode.client.web.di
import at.mocode.client.common.api.ApiClient
import at.mocode.client.common.repository.ClientEventRepository
import at.mocode.client.common.repository.ClientPersonRepository
import at.mocode.client.common.repository.EventRepository
import at.mocode.client.common.repository.PersonRepository
import at.mocode.client.web.viewmodel.CreatePersonViewModel
import at.mocode.client.web.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 {
// Repository instances
private val personRepository: PersonRepository by lazy { ClientPersonRepository() }
private val eventRepository: EventRepository by lazy { ClientEventRepository() }
// ViewModel factory methods
fun createPersonViewModel(): CreatePersonViewModel {
return CreatePersonViewModel(personRepository)
}
fun personListViewModel(): PersonListViewModel {
return PersonListViewModel(personRepository)
}
// Helper method to initialize dependencies
fun initialize() {
// Initialize ApiClient if needed
println("AppDependencies initialized")
}
}
@@ -1,10 +0,0 @@
package at.mocode.client.web
import androidx.compose.runtime.Composable
import org.jetbrains.compose.web.renderComposable
fun main() {
renderComposable(rootElementId = "root") {
App()
}
}
@@ -1,275 +0,0 @@
package at.mocode.client.web.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.automirrored.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.client.web.viewmodel.CreatePersonViewModel
/**
* Screen for creating a new person.
* This is a simplified version that uses the simplified CreatePersonViewModel.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreatePersonScreen(
viewModel: CreatePersonViewModel,
onNavigateBack: () -> Unit
) {
// Handle success navigation
LaunchedEffect(viewModel.isSuccess) {
if (viewModel.isSuccess) {
onNavigateBack()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Person erstellen") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.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") }
)
// 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")
}
}
}
}
}
}
@@ -1,166 +0,0 @@
package at.mocode.client.web.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.client.web.viewmodel.PersonListViewModel
import at.mocode.client.web.viewmodel.PersonUiModel
/**
* Screen for displaying a list of persons.
* This is a simplified version that uses the simplified PersonListViewModel.
*/
@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)
}
}
}
}
}
}
@Composable
private fun PersonCard(person: PersonUiModel) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = person.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
person.email?.let { email ->
Text(
text = "📧 $email",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
person.phone?.let { phone ->
Text(
text = "📞 $phone",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
person.address?.let { address ->
Text(
text = "📍 $address",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -1,181 +0,0 @@
package at.mocode.client.web.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import at.mocode.client.common.repository.Person
import at.mocode.client.common.repository.PersonRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
/**
* ViewModel for creating a person.
* This is a simplified version that doesn't depend on androidx.lifecycle.
* It uses Compose for Desktop's own state management.
*/
class CreatePersonViewModel(
private val personRepository: PersonRepository
) {
// Coroutine scope for launching background tasks
private val coroutineScope = CoroutineScope(Dispatchers.Default)
// 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 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 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
}
}
coroutineScope.launch {
isLoading = true
errorMessage = null
try {
// Parse birthdate 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 {
errorMessage = "Ungültiges Datumsformat. Verwenden Sie YYYY-MM-DD"
isLoading = false
isSuccess = false
return@launch
}
} catch (_: Exception) {
errorMessage = "Ungültiges Datumsformat. Verwenden Sie YYYY-MM-DD"
isLoading = false
isSuccess = false
return@launch
}
} else null
// Create a Person object from form data
val person = Person(
nachname = nachname,
vorname = vorname,
titel = titel.takeIf { it.isNotBlank() },
oepsSatzNr = oepsSatzNr.takeIf { it.isNotBlank() },
geburtsdatum = parsedGeburtsdatum,
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() },
adresszusatz = adresszusatz.takeIf { it.isNotBlank() },
feiId = feiId.takeIf { it.isNotBlank() },
mitgliedsNummer = mitgliedsNummer.takeIf { it.isNotBlank() },
notizen = notizen.takeIf { it.isNotBlank() },
istGesperrt = istGesperrt,
sperrGrund = sperrGrund.takeIf { it.isNotBlank() },
datenQuelle = "MANUELL"
)
// Save the person using the repository
personRepository.save(person)
// Set success state
isSuccess = true
} catch (e: Exception) {
errorMessage = "Fehler beim Erstellen der Person: ${e.message}"
} finally {
isLoading = false
}
}
}
fun resetForm() {
nachname = ""
vorname = ""
titel = ""
oepsSatzNr = ""
geburtsdatum = ""
telefon = ""
email = ""
strasse = ""
plz = ""
ort = ""
adresszusatz = ""
feiId = ""
mitgliedsNummer = ""
notizen = ""
istGesperrt = false
sperrGrund = ""
isLoading = false
errorMessage = null
isSuccess = false
}
}
@@ -1,86 +0,0 @@
package at.mocode.client.web.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import at.mocode.client.common.repository.Person
import at.mocode.client.common.repository.PersonRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* ViewModel for displaying a list of persons.
* This is a simplified version that doesn't depend on androidx.lifecycle.
* It uses Compose for Desktop's own state management.
*/
class PersonListViewModel(
private val personRepository: PersonRepository
) {
// Coroutine scope for launching background tasks
private val coroutineScope = CoroutineScope(Dispatchers.Default)
// UI state
var persons by mutableStateOf<List<PersonUiModel>>(emptyList())
private set
var isLoading by mutableStateOf(false)
private set
var errorMessage by mutableStateOf<String?>(null)
private set
init {
loadPersons()
}
fun loadPersons() {
coroutineScope.launch {
isLoading = true
errorMessage = null
try {
// Load persons from the repository
val personList = personRepository.findAllActive(limit = 100, offset = 0)
// Map domain models to UI models
persons = personList.map { it.toUiModel() }
} catch (e: Exception) {
errorMessage = "Fehler beim Laden der Personen: ${e.message}"
} finally {
isLoading = false
}
}
}
fun clearError() {
errorMessage = null
}
fun refreshPersons() {
loadPersons()
}
/**
* Maps a domain Person to a UI PersonUiModel
*/
private fun Person.toUiModel(): PersonUiModel {
return PersonUiModel(
id = this.id,
name = this.getFullName(),
email = this.email,
phone = this.telefon,
address = this.getFormattedAddress()
)
}
}
/**
* UI model for a person.
* This is a simplified version that doesn't depend on domain models.
*/
data class PersonUiModel(
val id: String,
val name: String,
val email: String? = null,
val phone: String? = null,
val address: String? = null
)
@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meldestelle - Reitersport Management</title>
<style>
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
}
</style>
</head>
<body>
<div id="root"></div>
<script src="MeldestelleWebApp.js"></script>
</body>
</html>
@@ -1,258 +0,0 @@
package at.mocode.client.web.viewmodel
import at.mocode.client.common.repository.Person
import at.mocode.client.common.repository.PersonRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.*
/**
* Simplified test suite for client-side Person functionality.
*
* This test focuses on the client-layer PersonRepository without domain dependencies.
* Tests cover basic CRUD operations through the client repository interface.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CreatePersonViewModelTest {
private lateinit var mockPersonRepository: PersonRepository
private val testDispatcher = StandardTestDispatcher()
@BeforeTest
fun setup() {
Dispatchers.setMain(testDispatcher)
setupMockRepository()
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
/**
* Sets up mock repository for testing
*/
private fun setupMockRepository() {
mockPersonRepository = object : PersonRepository {
private val persons = mutableListOf<Person>()
override suspend fun save(person: Person): Person {
val savedPerson = if (person.id.isBlank()) {
person.copy(id = "test-id-${persons.size + 1}")
} else {
person
}
persons.removeIf { it.id == savedPerson.id }
persons.add(savedPerson)
return savedPerson
}
override suspend fun findById(id: String): Person? {
return persons.find { it.id == id }
}
override suspend fun findByName(searchTerm: String, limit: Int): List<Person> {
return persons.filter {
it.vorname.contains(searchTerm, ignoreCase = true) ||
it.nachname.contains(searchTerm, ignoreCase = true)
}.take(limit)
}
override suspend fun findAllActive(limit: Int, offset: Int): List<Person> {
return persons.filter { !it.istGesperrt }.drop(offset).take(limit)
}
override suspend fun delete(id: String): Boolean {
return persons.removeIf { it.id == id }
}
override suspend fun countActive(): Long {
return persons.filter { !it.istGesperrt }.size.toLong()
}
}
}
@Test
fun `test person repository save creates new person`() = runTest {
// Given
val newPerson = Person(
nachname = "Mustermann",
vorname = "Max",
email = "max@example.com"
)
// When
val savedPerson = mockPersonRepository.save(newPerson)
// Then
assertNotNull(savedPerson.id)
assertTrue(savedPerson.id.isNotBlank())
assertEquals("Mustermann", savedPerson.nachname)
assertEquals("Max", savedPerson.vorname)
assertEquals("max@example.com", savedPerson.email)
}
@Test
fun `test person repository save updates existing person`() = runTest {
// Given
val person = Person(
id = "existing-id",
nachname = "Mustermann",
vorname = "Max",
email = "max@example.com"
)
mockPersonRepository.save(person)
// When
val updatedPerson = person.copy(email = "max.updated@example.com")
val savedPerson = mockPersonRepository.save(updatedPerson)
// Then
assertEquals("existing-id", savedPerson.id)
assertEquals("max.updated@example.com", savedPerson.email)
}
@Test
fun `test person repository findById returns correct person`() = runTest {
// Given
val person = Person(
nachname = "Mustermann",
vorname = "Max",
email = "max@example.com"
)
val savedPerson = mockPersonRepository.save(person)
// When
val foundPerson = mockPersonRepository.findById(savedPerson.id)
// Then
assertNotNull(foundPerson)
assertEquals(savedPerson.id, foundPerson.id)
assertEquals("Mustermann", foundPerson.nachname)
assertEquals("Max", foundPerson.vorname)
}
@Test
fun `test person repository findById returns null for non-existent id`() = runTest {
// When
val foundPerson = mockPersonRepository.findById("non-existent-id")
// Then
assertNull(foundPerson)
}
@Test
fun `test person repository findByName returns matching persons`() = runTest {
// Given
val person1 = Person(nachname = "Mustermann", vorname = "Max")
val person2 = Person(nachname = "Schmidt", vorname = "Anna")
val person3 = Person(nachname = "Mueller", vorname = "Max")
mockPersonRepository.save(person1)
mockPersonRepository.save(person2)
mockPersonRepository.save(person3)
// When
val foundPersons = mockPersonRepository.findByName("Max", 10)
// Then
assertEquals(2, foundPersons.size)
assertTrue(foundPersons.any { it.vorname == "Max" && it.nachname == "Mustermann" })
assertTrue(foundPersons.any { it.vorname == "Max" && it.nachname == "Mueller" })
}
@Test
fun `test person repository findAllActive returns only active persons`() = runTest {
// Given
val activePerson = Person(nachname = "Active", vorname = "Person", istGesperrt = false)
val blockedPerson = Person(nachname = "Blocked", vorname = "Person", istGesperrt = true)
mockPersonRepository.save(activePerson)
mockPersonRepository.save(blockedPerson)
// When
val activePersons = mockPersonRepository.findAllActive(10, 0)
// Then
assertEquals(1, activePersons.size)
assertEquals("Active", activePersons.first().nachname)
assertFalse(activePersons.first().istGesperrt)
}
@Test
fun `test person repository delete removes person`() = runTest {
// Given
val person = Person(nachname = "ToDelete", vorname = "Person")
val savedPerson = mockPersonRepository.save(person)
// When
val deleted = mockPersonRepository.delete(savedPerson.id)
// Then
assertTrue(deleted)
assertNull(mockPersonRepository.findById(savedPerson.id))
}
@Test
fun `test person repository countActive returns correct count`() = runTest {
// Given
val activePerson1 = Person(nachname = "Active1", vorname = "Person", istGesperrt = false)
val activePerson2 = Person(nachname = "Active2", vorname = "Person", istGesperrt = false)
val blockedPerson = Person(nachname = "Blocked", vorname = "Person", istGesperrt = true)
mockPersonRepository.save(activePerson1)
mockPersonRepository.save(activePerson2)
mockPersonRepository.save(blockedPerson)
// When
val count = mockPersonRepository.countActive()
// Then
assertEquals(2L, count)
}
@Test
fun `test person getFullName method`() {
// Given
val personWithTitle = Person(
nachname = "Mustermann",
vorname = "Max",
titel = "Dr."
)
val personWithoutTitle = Person(
nachname = "Schmidt",
vorname = "Anna"
)
// When & Then
assertEquals("Dr. Max Mustermann", personWithTitle.getFullName())
assertEquals("Anna Schmidt", personWithoutTitle.getFullName())
}
@Test
fun `test person getFormattedAddress method`() {
// Given
val personWithCompleteAddress = Person(
nachname = "Mustermann",
vorname = "Max",
strasse = "Musterstraße 123",
plz = "12345",
ort = "Musterstadt",
adresszusatz = "2. Stock"
)
val personWithIncompleteAddress = Person(
nachname = "Schmidt",
vorname = "Anna",
strasse = "Teststraße 456"
// Missing PLZ and Ort
)
// When & Then
assertEquals("Musterstraße 123, 2. Stock, 12345 Musterstadt", personWithCompleteAddress.getFormattedAddress())
assertNull(personWithIncompleteAddress.getFormattedAddress())
}
}
+2
View File
@@ -56,6 +56,7 @@ reactorKafka = "1.3.22"
jackson = "2.17.0" jackson = "2.17.0"
jakartaAnnotation = "2.1.1" jakartaAnnotation = "2.1.1"
roomCommonJvm = "2.7.2" roomCommonJvm = "2.7.2"
uiDesktop = "1.7.0"
[libraries] [libraries]
# --- Platform BOMs (Bill of Materials) --- # --- Platform BOMs (Bill of Materials) ---
@@ -178,6 +179,7 @@ testcontainers-postgresql = { module = "org.testcontainers:postgresql", version.
testcontainers-kafka = { module = "org.testcontainers:kafka", version.ref = "testcontainers" } testcontainers-kafka = { module = "org.testcontainers:kafka", version.ref = "testcontainers" }
reactor-test = { module = "io.projectreactor:reactor-test" } # Version wird von der Spring BOM verwaltet reactor-test = { module = "io.projectreactor:reactor-test" } # Version wird von der Spring BOM verwaltet
room-common-jvm = { group = "androidx.room", name = "room-common-jvm", version.ref = "roomCommonJvm" } room-common-jvm = { group = "androidx.room", name = "room-common-jvm", version.ref = "roomCommonJvm" }
ui-desktop = { group = "androidx.compose.ui", name = "ui-desktop", version.ref = "uiDesktop" }
[bundles] [bundles]
# OPTIMIERUNG: Bündelt gängige Abhängigkeitsgruppen. # OPTIMIERUNG: Bündelt gängige Abhängigkeitsgruppen.
+3 -6
View File
@@ -14,6 +14,9 @@ pluginManagement {
} }
} }
} }
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
dependencyResolutionManagement { dependencyResolutionManagement {
@@ -85,11 +88,5 @@ include(":masterdata:masterdata-infrastructure")
include(":masterdata:masterdata-api") include(":masterdata:masterdata-api")
include(":masterdata:masterdata-service") include(":masterdata:masterdata-service")
// Client modules
include(":client:common-ui")
include(":client:web-app")
include(":client:desktop-app")
// Legacy modules have been removed after successful migration // Legacy modules have been removed after successful migration
*/ */