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 {
// 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.compiler)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
// Wir definieren die Zielplattformen, für die dieses Modul Code bereitstellt.
jvm("desktop") // Ein JVM-Target für unsere Desktop-App
js(IR) { // Ein JavaScript-Target für unsere Web-App
jvm("desktop")
js(IR) {
browser()
binaries.executable()
}
// Hier definieren wir die Abhängigkeiten für die jeweiligen Source Sets.
sourceSets {
val commonMain by getting {
dependencies {
// --- Interne Module (für alle Plattformen verfügbar) ---
api(projects.core.coreDomain)
api(projects.core.coreUtils)
// --- Jetpack Compose UI (für alle Plattformen verfügbar) ---
// Compose UI
api(compose.runtime)
api(compose.foundation)
api(compose.material3)
api(compose.ui)
api(compose.components.resources)
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.contentNegotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
// --- Coroutines (für alle Plattformen verfügbar) ---
// Coroutines for background tasks
implementation(libs.kotlinx.coroutines.core)
}
}
val desktopMain by getting {
dependencies {
// Ktor-Engine, die nur für die Desktop (JVM) Version benötigt wird
// Ktor engine for Desktop
implementation(libs.ktor.client.cio)
}
}
val jsMain by getting {
dependencies {
// Ktor-Engine, die nur für die Web (JS) Version benötigt wird
// Ktor engine for Browser
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
)
}