fixing(client)
This commit is contained in:
@@ -0,0 +1,123 @@
|
|||||||
|
# 🖥️ Client-Modul
|
||||||
|
|
||||||
|
Dieses Modul liefert die **grafische Benutzeroberfläche** für das Projekt
|
||||||
|
– einmal als **Desktop-App (JVM)** und einmal als **Web-App (JavaScript)**.
|
||||||
|
Dank **Kotlin Multiplatform + Compose Multiplatform** teilt sich beides eine
|
||||||
|
gemeinsame Code-Basis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Voraussetzungen
|
||||||
|
|
||||||
|
| Tool | Empfohlene Version | Bemerkung |
|
||||||
|
|-----------------|--------------------|-------------------------------------------------|
|
||||||
|
| JDK | 21 (Temurin) | Für Desktop‐Build und Gradle |
|
||||||
|
| Node.js & npm | ≥ 20 | Nur für den JS-/Browser-Build |
|
||||||
|
| Gradle Wrapper | Wird mitgeliefert | `./gradlew` ruft immer die projektinterne Version auf |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Build & Run
|
||||||
|
|
||||||
|
### 2.1 Desktop-App starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Im Projekt-Root
|
||||||
|
./gradlew :client:runJvm
|
||||||
|
```
|
||||||
|
Die App startet als eigenständiges JVM-Fenster auf Ihrem Desktop.
|
||||||
|
|
||||||
|
### 2.2 Web-App starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :client:jsBrowserDevelopmentRun
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Gradle kompiliert das JS-Artefakt.
|
||||||
|
2. Anschließend öffnet sich ein lokaler Dev-Server (Standard: <http://localhost:3000>).
|
||||||
|
|
||||||
|
Hot-Reload wird vom Compose-/Ktor-Dev-Server automatisch gehandhabt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Packaging
|
||||||
|
|
||||||
|
| Ziel | Task (Gradle) | Ergebnis |
|
||||||
|
|-----------------|----------------------------------------|----------------------------------------|
|
||||||
|
| **Desktop** | `:client:packageJvm` | Self-contained Verzeichnis mit Start-Skript |
|
||||||
|
| **Web (prod)** | `:client:jsBrowserProductionWebpack` | Optimiertes Bundle in `build/dist` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architekturüberblick
|
||||||
|
|
||||||
|
```client
|
||||||
|
commonMain ├─ UI: Compose Runtime/Foundation/Material³ ├─ Netzwerk: Ktor Client (+ JSON Serialisierung) └─ Geschäftslogik & Models
|
||||||
|
jvmMain └─ Ktor CIO Engine (Desktop)
|
||||||
|
jsMain └─ Ktor JS Engine (Browser)
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemeinsame Logik (UI-State, Repository-Klassen etc.) lebt in
|
||||||
|
`commonMain`. Plattform-spezifisch ist im Wesentlichen nur der
|
||||||
|
gewählte **Ktor-Engine**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API-Kommunikation
|
||||||
|
|
||||||
|
Alle Aufrufe an das Backend erfolgen **asynchron** via `Ktor Client`.
|
||||||
|
Das JSON-Serialisieren übernimmt `kotlinx.serialization`.
|
||||||
|
|
||||||
|
Beispiel (vereinfacht):
|
||||||
|
|
||||||
|
kotlin val client = HttpClient(CIO) { install(ContentNegotiation) { json() } }
|
||||||
|
suspend fun ping(): PingResponse = client.get("$BASE_URL/ping").body()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Konfiguration
|
||||||
|
|
||||||
|
| Schlüssel | Zweck | Standardwert |
|
||||||
|
|------------------------------|-----------------------------|-----------------------------|
|
||||||
|
| `BASE_URL` (env / props) | Root-URL des Gateways | `http://localhost:8080` |
|
||||||
|
| `LOG_LEVEL` (env / props) | Logging (DEBUG/INFO/…) | `INFO` |
|
||||||
|
|
||||||
|
Konfiguration kann via JVM-Args (`-D`) oder Umgebungsvariablen
|
||||||
|
überschrieben werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Tests
|
||||||
|
|
||||||
|
Noch keine UI-Tests enthalten.
|
||||||
|
Empfohlen: **Compose UI Testing** (Desktop) und **Kotlin/Wrappers
|
||||||
|
Testing** (JS).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Häufige Gradle-Tasks
|
||||||
|
|
||||||
|
| Zweck | Task |
|
||||||
|
|------------------------------------|---------------------------------------|
|
||||||
|
| Desktop-App starten (Dev) | `./gradlew :client:runJvm` |
|
||||||
|
| Web-App starten (Dev) | `./gradlew :client:jsBrowserDevelopmentRun` |
|
||||||
|
| Desktop-Artefakt packen | `./gradlew :client:packageJvm` |
|
||||||
|
| Web-Artefakt für Prod erstellen | `./gradlew :client:jsBrowserProductionWebpack` |
|
||||||
|
| Alle Tests ausführen | `./gradlew :client:test` |
|
||||||
|
| Abhängigkeits-Updates anzeigen | `./gradlew :client:dependencyUpdates` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Lösungsvorschlag |
|
||||||
|
|---------------------------------|------------------|
|
||||||
|
| Weißer Bildschirm im Browser | Dev-Konsole öffnen (`F12`) → Netzwerk-Fehler? CORS-Header prüfen |
|
||||||
|
| `java.lang.UnsatisfiedLinkError`| Prüfen, ob das korrekte JDK (21) verwendet wird |
|
||||||
|
| Gradle-Timeout beim NPM-Install | Proxy-/Firewall-Settings überprüfen; ggf. `--network=host` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Lizenz
|
||||||
|
|
||||||
|
`TODO: <Lizenzname>` – bitte anpassen.
|
||||||
@@ -1,892 +0,0 @@
|
|||||||
# Client Module
|
|
||||||
|
|
||||||
## Überblick
|
|
||||||
|
|
||||||
Das Client-Modul implementiert die Benutzeroberflächen für das Meldestelle-System und bietet sowohl eine Web-Anwendung als auch eine Desktop-Anwendung. Es folgt einer modernen, komponentenbasierten Architektur mit Jetpack Compose und implementiert das Repository-Pattern für saubere Datenschicht-Abstraktion.
|
|
||||||
|
|
||||||
## Architektur
|
|
||||||
|
|
||||||
Das Client-Modul ist in drei Hauptkomponenten unterteilt:
|
|
||||||
|
|
||||||
```
|
|
||||||
client/
|
|
||||||
├── common-ui/ # Gemeinsame UI-Komponenten
|
|
||||||
│ ├── api/ # API-Client-Schicht
|
|
||||||
│ │ └── ApiClient.kt # HTTP-Client für Backend-Kommunikation
|
|
||||||
│ ├── repository/ # Repository-Pattern
|
|
||||||
│ │ ├── Person.kt # Person-Domain-Model
|
|
||||||
│ │ ├── PersonRepository.kt # Person-Repository-Interface
|
|
||||||
│ │ ├── ClientPersonRepository.kt # Person-Repository-Implementierung
|
|
||||||
│ │ ├── Event.kt # Event-Domain-Model
|
|
||||||
│ │ ├── EventRepository.kt # Event-Repository-Interface
|
|
||||||
│ │ └── ClientEventRepository.kt # Event-Repository-Implementierung
|
|
||||||
│ ├── components/ # Wiederverwendbare UI-Komponenten
|
|
||||||
│ │ └── events/ # Event-spezifische Komponenten
|
|
||||||
│ │ ├── EventComponent.kt
|
|
||||||
│ │ └── VeranstaltungsListe.kt
|
|
||||||
│ ├── theme/ # Design System
|
|
||||||
│ │ └── Theme.kt # Compose Theme-Definition
|
|
||||||
│ └── App.kt # Gemeinsame App-Komponente
|
|
||||||
├── web-app/ # Web-Anwendung
|
|
||||||
│ ├── screens/ # Web-spezifische Screens
|
|
||||||
│ ├── viewmodel/ # ViewModels für Web-App
|
|
||||||
│ └── main.kt # Web-App Entry Point
|
|
||||||
└── desktop-app/ # Desktop-Anwendung
|
|
||||||
├── App.kt # Desktop-App-Komponente
|
|
||||||
└── main.kt # Desktop-App Entry Point
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common-UI Komponenten
|
|
||||||
|
|
||||||
### 1. API-Client (ApiClient.kt)
|
|
||||||
|
|
||||||
Zentrale HTTP-Client-Implementierung für Backend-Kommunikation.
|
|
||||||
|
|
||||||
#### Features
|
|
||||||
- **HTTP-Client**: Ktor-basierter HTTP-Client
|
|
||||||
- **JSON-Serialisierung**: Kotlinx Serialization Integration
|
|
||||||
- **Fehlerbehandlung**: Strukturierte Fehlerbehandlung mit ApiException
|
|
||||||
- **Caching**: Intelligentes Caching für GET-Requests
|
|
||||||
- **Request/Response Logging**: Debugging-Unterstützung
|
|
||||||
|
|
||||||
#### Implementierung
|
|
||||||
```kotlin
|
|
||||||
object ApiClient {
|
|
||||||
val BASE_URL = "http://localhost:8080"
|
|
||||||
|
|
||||||
val json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
isLenient = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val httpClient = HttpClient(CIO) {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(json)
|
|
||||||
}
|
|
||||||
install(Logging) {
|
|
||||||
logger = Logger.DEFAULT
|
|
||||||
level = LogLevel.INFO
|
|
||||||
}
|
|
||||||
install(HttpTimeout) {
|
|
||||||
requestTimeoutMillis = 30000
|
|
||||||
connectTimeoutMillis = 10000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache für GET-Requests
|
|
||||||
val cache = ConcurrentHashMap<String, Pair<Any, Long>>()
|
|
||||||
val CACHE_TTL = 30_000L // 30 Sekunden
|
|
||||||
|
|
||||||
suspend inline fun <reified T> get(
|
|
||||||
endpoint: String,
|
|
||||||
cacheable: Boolean = true
|
|
||||||
): T? {
|
|
||||||
// Caching-Logik
|
|
||||||
if (cacheable) {
|
|
||||||
val cached = cache[endpoint]
|
|
||||||
if (cached != null && System.currentTimeMillis() - cached.second < CACHE_TTL) {
|
|
||||||
return cached.first as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val response = httpClient.get("$BASE_URL$endpoint")
|
|
||||||
val result = response.body<T>()
|
|
||||||
|
|
||||||
if (cacheable && result != null) {
|
|
||||||
cache[endpoint] = Pair(result, System.currentTimeMillis())
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw ApiException("Failed to fetch data from $endpoint", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend inline fun <reified T> post(endpoint: String, body: Any): T {
|
|
||||||
return try {
|
|
||||||
httpClient.post("$BASE_URL$endpoint") {
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(body)
|
|
||||||
}.body()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw ApiException("Failed to post data to $endpoint", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend inline fun <reified T> put(endpoint: String, body: Any): T {
|
|
||||||
return try {
|
|
||||||
httpClient.put("$BASE_URL$endpoint") {
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(body)
|
|
||||||
}.body()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw ApiException("Failed to update data at $endpoint", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend inline fun <reified T> delete(endpoint: String): T {
|
|
||||||
return try {
|
|
||||||
httpClient.delete("$BASE_URL$endpoint").body()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw ApiException("Failed to delete data at $endpoint", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearCache() {
|
|
||||||
cache.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun invalidateCache(endpoint: String) {
|
|
||||||
cache.remove(endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Repository-Pattern
|
|
||||||
|
|
||||||
Saubere Abstraktion der Datenschicht mit Repository-Pattern.
|
|
||||||
|
|
||||||
#### Domain Models
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// Person.kt
|
|
||||||
@Serializable
|
|
||||||
data class Person(
|
|
||||||
val id: String,
|
|
||||||
val firstName: String,
|
|
||||||
val lastName: String,
|
|
||||||
val email: String,
|
|
||||||
val phone: String? = null,
|
|
||||||
val isActive: Boolean = true,
|
|
||||||
val createdAt: String,
|
|
||||||
val updatedAt: String
|
|
||||||
) {
|
|
||||||
fun getFullName(): String = "$firstName $lastName"
|
|
||||||
|
|
||||||
fun toUiModel(): PersonUiModel {
|
|
||||||
return PersonUiModel(
|
|
||||||
id = id,
|
|
||||||
fullName = getFullName(),
|
|
||||||
email = email,
|
|
||||||
phone = phone,
|
|
||||||
isActive = isActive
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event.kt
|
|
||||||
@Serializable
|
|
||||||
data class Event(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val description: String? = null,
|
|
||||||
val startDate: String,
|
|
||||||
val endDate: String,
|
|
||||||
val location: String,
|
|
||||||
val isPublic: Boolean = true,
|
|
||||||
val maxParticipants: Int? = null,
|
|
||||||
val createdAt: String,
|
|
||||||
val updatedAt: String
|
|
||||||
) {
|
|
||||||
fun toUiModel(): EventUiModel {
|
|
||||||
return EventUiModel(
|
|
||||||
id = id,
|
|
||||||
name = name,
|
|
||||||
description = description,
|
|
||||||
startDate = startDate,
|
|
||||||
endDate = endDate,
|
|
||||||
location = location,
|
|
||||||
isPublic = isPublic,
|
|
||||||
maxParticipants = maxParticipants
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Repository Interfaces
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// PersonRepository.kt
|
|
||||||
interface PersonRepository {
|
|
||||||
suspend fun findById(id: String): Person?
|
|
||||||
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<Person>
|
|
||||||
suspend fun findByName(searchTerm: String, limit: Int = 50): List<Person>
|
|
||||||
suspend fun save(person: Person): Person
|
|
||||||
suspend fun delete(id: String): Boolean
|
|
||||||
suspend fun countActive(): Long
|
|
||||||
}
|
|
||||||
|
|
||||||
// EventRepository.kt
|
|
||||||
interface EventRepository {
|
|
||||||
suspend fun findById(id: String): Event?
|
|
||||||
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<Event>
|
|
||||||
suspend fun findByName(searchTerm: String, limit: Int = 50): List<Event>
|
|
||||||
suspend fun findPublicEvents(): List<Event>
|
|
||||||
suspend fun save(event: Event): Event
|
|
||||||
suspend fun delete(id: String): Boolean
|
|
||||||
suspend fun countActive(): Long
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Repository Implementierungen
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// ClientPersonRepository.kt
|
|
||||||
class ClientPersonRepository : PersonRepository {
|
|
||||||
private val baseEndpoint = "/api/persons"
|
|
||||||
|
|
||||||
override suspend fun findById(id: String): Person? {
|
|
||||||
return try {
|
|
||||||
ApiClient.get<Person>("$baseEndpoint/$id")
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
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: ApiException) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun findByName(searchTerm: String, limit: Int): List<Person> {
|
|
||||||
return try {
|
|
||||||
ApiClient.get<List<Person>>("$baseEndpoint/search?name=$searchTerm&limit=$limit") ?: emptyList()
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun save(person: Person): Person {
|
|
||||||
return if (person.id.isEmpty()) {
|
|
||||||
ApiClient.post<Person>(baseEndpoint, person)
|
|
||||||
} else {
|
|
||||||
ApiClient.put<Person>("$baseEndpoint/${person.id}", person)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun delete(id: String): Boolean {
|
|
||||||
return try {
|
|
||||||
ApiClient.delete<Unit>("$baseEndpoint/$id")
|
|
||||||
true
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun countActive(): Long {
|
|
||||||
return try {
|
|
||||||
ApiClient.get<Map<String, Long>>("$baseEndpoint/count")?.get("count") ?: 0L
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. UI-Komponenten
|
|
||||||
|
|
||||||
Wiederverwendbare Compose-Komponenten für verschiedene Domänen.
|
|
||||||
|
|
||||||
#### Event-Komponenten
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// EventComponent.kt
|
|
||||||
@Composable
|
|
||||||
fun EventCard(
|
|
||||||
event: EventUiModel,
|
|
||||||
onClick: (String) -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onClick(event.id) },
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = event.name,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
event.description?.let { description ->
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = "Ort: ${event.location}",
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Start: ${event.startDate}",
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Ende: ${event.endDate}",
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
|
||||||
if (event.isPublic) {
|
|
||||||
Badge {
|
|
||||||
Text("Öffentlich")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
event.maxParticipants?.let { max ->
|
|
||||||
Text(
|
|
||||||
text = "Max: $max Teilnehmer",
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// VeranstaltungsListe.kt
|
|
||||||
@Composable
|
|
||||||
fun VeranstaltungsListe(
|
|
||||||
events: List<EventUiModel>,
|
|
||||||
isLoading: Boolean = false,
|
|
||||||
onEventClick: (String) -> Unit = {},
|
|
||||||
onRefresh: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Veranstaltungen",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(onClick = onRefresh) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Refresh,
|
|
||||||
contentDescription = "Aktualisieren"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
items(events) { event ->
|
|
||||||
EventCard(
|
|
||||||
event = event,
|
|
||||||
onClick = onEventClick
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Theme System (Theme.kt)
|
|
||||||
|
|
||||||
Konsistentes Design System mit Material Design 3.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// Theme.kt
|
|
||||||
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(),
|
|
||||||
dynamicColor: Boolean = true,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
val colorScheme = when {
|
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
|
||||||
val context = LocalContext.current
|
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
darkTheme -> DarkColorScheme
|
|
||||||
else -> LightColorScheme
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
typography = Typography,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val Typography = Typography(
|
|
||||||
bodyLarge = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 24.sp,
|
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
),
|
|
||||||
headlineMedium = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 28.sp,
|
|
||||||
lineHeight = 36.sp,
|
|
||||||
letterSpacing = 0.sp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Web-App Komponenten
|
|
||||||
|
|
||||||
### 1. Screens
|
|
||||||
|
|
||||||
Web-spezifische Bildschirme und Navigation.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// PersonListScreen.kt
|
|
||||||
@Composable
|
|
||||||
fun PersonListScreen(
|
|
||||||
viewModel: PersonListViewModel = remember { AppDependencies.personListViewModel() }
|
|
||||||
) {
|
|
||||||
val persons by viewModel.persons.collectAsState()
|
|
||||||
val isLoading by viewModel.isLoading.collectAsState()
|
|
||||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Personen",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
} else if (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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
items(persons) { person ->
|
|
||||||
PersonCard(person = person)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. ViewModels
|
|
||||||
|
|
||||||
State Management für Web-App Screens.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// PersonListViewModel.kt
|
|
||||||
class PersonListViewModel(
|
|
||||||
private val personRepository: PersonRepository
|
|
||||||
) {
|
|
||||||
private val _persons = MutableStateFlow<List<PersonUiModel>>(emptyList())
|
|
||||||
val persons: StateFlow<List<PersonUiModel>> = _persons.asStateFlow()
|
|
||||||
|
|
||||||
private val _isLoading = MutableStateFlow(false)
|
|
||||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
|
||||||
|
|
||||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
|
||||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
|
||||||
|
|
||||||
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
||||||
|
|
||||||
init {
|
|
||||||
loadPersons()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadPersons() {
|
|
||||||
coroutineScope.launch {
|
|
||||||
_isLoading.value = true
|
|
||||||
_errorMessage.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
val personList = personRepository.findAllActive(limit = 100, offset = 0)
|
|
||||||
_persons.value = personList.map { it.toUiModel() }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_errorMessage.value = "Fehler beim Laden der Personen: ${e.message}"
|
|
||||||
} finally {
|
|
||||||
_isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun searchPersons(searchTerm: String) {
|
|
||||||
if (searchTerm.isBlank()) {
|
|
||||||
loadPersons()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
coroutineScope.launch {
|
|
||||||
_isLoading.value = true
|
|
||||||
_errorMessage.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
val personList = personRepository.findByName(searchTerm, limit = 50)
|
|
||||||
_persons.value = personList.map { it.toUiModel() }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_errorMessage.value = "Fehler bei der Suche: ${e.message}"
|
|
||||||
} finally {
|
|
||||||
_isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
ApiClient.clearCache()
|
|
||||||
loadPersons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Dependency Injection
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// AppDependencies.kt
|
|
||||||
object AppDependencies {
|
|
||||||
private val personRepository: PersonRepository by lazy { ClientPersonRepository() }
|
|
||||||
private val eventRepository: EventRepository by lazy { ClientEventRepository() }
|
|
||||||
|
|
||||||
fun createPersonViewModel(): CreatePersonViewModel {
|
|
||||||
return CreatePersonViewModel(personRepository)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun personListViewModel(): PersonListViewModel {
|
|
||||||
return PersonListViewModel(personRepository)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun eventListViewModel(): EventListViewModel {
|
|
||||||
return EventListViewModel(eventRepository)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun initialize() {
|
|
||||||
// Initialize ApiClient if needed
|
|
||||||
println("AppDependencies initialized")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Desktop-App Komponenten
|
|
||||||
|
|
||||||
### 1. Desktop-spezifische Implementierung
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// desktop-app/main.kt
|
|
||||||
fun main() = application {
|
|
||||||
Window(
|
|
||||||
onCloseRequest = ::exitApplication,
|
|
||||||
title = "Meldestelle Desktop",
|
|
||||||
state = rememberWindowState(
|
|
||||||
width = 1200.dp,
|
|
||||||
height = 800.dp
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
MeldestelleTheme {
|
|
||||||
DesktopApp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// desktop-app/App.kt
|
|
||||||
@Composable
|
|
||||||
fun DesktopApp() {
|
|
||||||
var selectedTab by remember { mutableStateOf(0) }
|
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// Navigation Rail
|
|
||||||
NavigationRail(
|
|
||||||
modifier = Modifier.width(80.dp)
|
|
||||||
) {
|
|
||||||
NavigationRailItem(
|
|
||||||
icon = { Icon(Icons.Default.Person, contentDescription = null) },
|
|
||||||
label = { Text("Personen") },
|
|
||||||
selected = selectedTab == 0,
|
|
||||||
onClick = { selectedTab = 0 }
|
|
||||||
)
|
|
||||||
NavigationRailItem(
|
|
||||||
icon = { Icon(Icons.Default.Event, contentDescription = null) },
|
|
||||||
label = { Text("Events") },
|
|
||||||
selected = selectedTab == 1,
|
|
||||||
onClick = { selectedTab = 1 }
|
|
||||||
)
|
|
||||||
NavigationRailItem(
|
|
||||||
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
|
||||||
label = { Text("Settings") },
|
|
||||||
selected = selectedTab == 2,
|
|
||||||
onClick = { selectedTab = 2 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content Area
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
when (selectedTab) {
|
|
||||||
0 -> PersonListScreen()
|
|
||||||
1 -> EventListScreen()
|
|
||||||
2 -> SettingsScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
### Gradle Dependencies
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// common-ui/build.gradle.kts
|
|
||||||
dependencies {
|
|
||||||
api(compose.runtime)
|
|
||||||
api(compose.foundation)
|
|
||||||
api(compose.material3)
|
|
||||||
api(compose.ui)
|
|
||||||
api(compose.components.resources)
|
|
||||||
|
|
||||||
implementation("io.ktor:ktor-client-core:2.3.7")
|
|
||||||
implementation("io.ktor:ktor-client-cio:2.3.7")
|
|
||||||
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
|
|
||||||
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
|
|
||||||
implementation("io.ktor:ktor-client-logging:2.3.7")
|
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
|
||||||
|
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
|
||||||
}
|
|
||||||
|
|
||||||
// web-app/build.gradle.kts
|
|
||||||
dependencies {
|
|
||||||
implementation(project(":client:common-ui"))
|
|
||||||
implementation(compose.html.core)
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
|
||||||
}
|
|
||||||
|
|
||||||
// desktop-app/build.gradle.kts
|
|
||||||
dependencies {
|
|
||||||
implementation(project(":client:common-ui"))
|
|
||||||
implementation(compose.desktop.currentOs)
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.7.3")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
class ApiClientTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should cache GET requests`() = runTest {
|
|
||||||
// Test caching functionality
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should handle API errors gracefully`() = runTest {
|
|
||||||
// Test error handling
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PersonRepositoryTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should fetch persons from API`() = runTest {
|
|
||||||
// Test repository functionality
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should handle empty responses`() = runTest {
|
|
||||||
// Test edge cases
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PersonListViewModelTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should load persons on initialization`() = runTest {
|
|
||||||
// Test ViewModel behavior
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should handle loading states correctly`() = runTest {
|
|
||||||
// Test state management
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
### Web-App Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build für Produktion
|
|
||||||
./gradlew :client:web-app:jsBrowserDistribution
|
|
||||||
|
|
||||||
# Statische Dateien werden generiert in:
|
|
||||||
# client/web-app/build/dist/js/productionExecutable/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Desktop-App Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Desktop-App für aktuelles OS erstellen
|
|
||||||
./gradlew :client:desktop-app:createDistributable
|
|
||||||
|
|
||||||
# Plattform-spezifische Builds
|
|
||||||
./gradlew :client:desktop-app:packageDmg # macOS
|
|
||||||
./gradlew :client:desktop-app:packageMsi # Windows
|
|
||||||
./gradlew :client:desktop-app:packageDeb # Linux
|
|
||||||
```
|
|
||||||
|
|
||||||
## Entwicklung
|
|
||||||
|
|
||||||
### Lokale Entwicklung
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Web-App im Development-Modus starten
|
|
||||||
./gradlew :client:web-app:jsBrowserDevelopmentRun
|
|
||||||
|
|
||||||
# Desktop-App starten
|
|
||||||
./gradlew :client:desktop-app:run
|
|
||||||
|
|
||||||
# Tests ausführen
|
|
||||||
./gradlew :client:test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hot Reload
|
|
||||||
|
|
||||||
- **Web-App**: Automatisches Hot Reload bei Änderungen
|
|
||||||
- **Desktop-App**: Neustart erforderlich bei Änderungen
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### 1. State Management
|
|
||||||
|
|
||||||
- **StateFlow/MutableStateFlow** für reaktive State-Verwaltung
|
|
||||||
- **Compose State** für UI-spezifischen State
|
|
||||||
- **Repository Pattern** für Datenschicht-Abstraktion
|
|
||||||
|
|
||||||
### 2. Error Handling
|
|
||||||
|
|
||||||
- **Strukturierte Exceptions** mit ApiException
|
|
||||||
- **Loading States** für bessere UX
|
|
||||||
- **Retry-Mechanismen** für fehlgeschlagene Requests
|
|
||||||
|
|
||||||
### 3. Performance
|
|
||||||
|
|
||||||
- **Lazy Loading** für große Listen
|
|
||||||
- **Caching** für häufig abgerufene Daten
|
|
||||||
- **Coroutines** für asynchrone Operationen
|
|
||||||
|
|
||||||
### 4. Testing
|
|
||||||
|
|
||||||
- **Unit Tests** für ViewModels und Repositories
|
|
||||||
- **UI Tests** für Compose-Komponenten
|
|
||||||
- **Integration Tests** für API-Client
|
|
||||||
|
|
||||||
## Zukünftige Erweiterungen
|
|
||||||
|
|
||||||
1. **Offline-Unterstützung** - Lokale Datenspeicherung
|
|
||||||
2. **Push-Benachrichtigungen** - Real-time Updates
|
|
||||||
3. **Progressive Web App** - PWA-Features für Web-App
|
|
||||||
4. **Erweiterte Navigation** - Multi-Screen Navigation
|
|
||||||
5. **Accessibility** - Barrierefreiheit-Features
|
|
||||||
6. **Internationalisierung** - Multi-Language Support
|
|
||||||
7. **Dark/Light Theme Toggle** - Theme-Umschaltung
|
|
||||||
8. **Advanced Caching** - Intelligentere Cache-Strategien
|
|
||||||
9. **Real-time Collaboration** - WebSocket-Integration
|
|
||||||
10. **Mobile App** - React Native oder Flutter Implementation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Letzte Aktualisierung**: 25. Juli 2025
|
|
||||||
|
|
||||||
Für weitere Informationen zur Gesamtarchitektur siehe [README.md](../README.md).
|
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.compose.multiplatform)
|
|
||||||
alias(libs.plugins.compose.compiler)
|
|
||||||
alias(libs.plugins.kotlin.serialization)
|
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
id("org.jetbrains.compose")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvm("desktop")
|
jvm()
|
||||||
js(IR) {
|
js(IR) { browser() }
|
||||||
browser()
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
val commonMain by getting {
|
val commonMain by getting {
|
||||||
dependencies {
|
dependencies {
|
||||||
// Compose UI
|
// Compose Multiplatform
|
||||||
api(compose.runtime)
|
implementation(compose.runtime)
|
||||||
api(compose.foundation)
|
implementation(compose.foundation)
|
||||||
api(compose.material3)
|
implementation(compose.material3)
|
||||||
api(compose.materialIconsExtended)
|
|
||||||
|
// Serialization
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// Ktor Client for API calls
|
// Ktor Client for API calls
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
@@ -29,7 +29,7 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val desktopMain by getting {
|
val jvmMain by getting {
|
||||||
dependencies {
|
dependencies {
|
||||||
// Ktor engine for Desktop
|
// Ktor engine for Desktop
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package at.mocode.client.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PingResponse(
|
|
||||||
val status: String
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package at.mocode.client.data.service
|
||||||
|
|
||||||
|
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.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PingResponse(val status: String)
|
||||||
|
|
||||||
|
class PingService {
|
||||||
|
private val client = HttpClient {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ping(): Result<PingResponse> = try {
|
||||||
|
val response = client.get("http://localhost:8082/ping").body<PingResponse>()
|
||||||
|
Result.success(response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pingFlow(): Flow<Result<PingResponse>> = flow {
|
||||||
|
emit(ping())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,90 @@
|
|||||||
package at.mocode.client.ui
|
package at.mocode.client.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.material3.Text
|
||||||
import at.mocode.client.ui.features.ping.PingScreen
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.client.ui.components.PingTestComponent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App(baseUrl: String = "http://localhost:8080") {
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
PingScreen()
|
PingScreen(baseUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PingScreen(baseUrl: String) {
|
||||||
|
val pingComponent = remember { PingTestComponent() }
|
||||||
|
var pingState by remember { mutableStateOf(pingComponent.state) }
|
||||||
|
|
||||||
|
LaunchedEffect(pingComponent) {
|
||||||
|
pingComponent.onStateChanged = { newState ->
|
||||||
|
pingState = newState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(pingComponent) {
|
||||||
|
onDispose {
|
||||||
|
pingComponent.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Ping Backend Service",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
when {
|
||||||
|
pingState.isLoading -> {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(
|
||||||
|
text = "Testing connection...",
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pingState.error != null -> {
|
||||||
|
Text(
|
||||||
|
text = "Error: ${pingState.error}",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pingState.response != null -> {
|
||||||
|
Text(
|
||||||
|
text = "Response: ${pingState.response?.status ?: "Unknown"}",
|
||||||
|
color = if (pingState.isConnected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (pingState.isConnected) "✓ Connected" else "✗ Not Connected",
|
||||||
|
color = if (pingState.isConnected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { pingComponent.testConnection() },
|
||||||
|
enabled = !pingState.isLoading
|
||||||
|
) {
|
||||||
|
Text("Test Connection")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package at.mocode.client.ui.components
|
||||||
|
|
||||||
|
import at.mocode.client.data.service.PingService
|
||||||
|
import at.mocode.client.data.service.PingResponse
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
data class PingTestState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val response: PingResponse? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val isConnected: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
class PingTestComponent {
|
||||||
|
private val pingService = PingService()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
|
||||||
|
var state: PingTestState = PingTestState()
|
||||||
|
private set
|
||||||
|
|
||||||
|
var onStateChanged: ((PingTestState) -> Unit)? = null
|
||||||
|
|
||||||
|
fun testConnection() {
|
||||||
|
updateState(state.copy(isLoading = true, error = null))
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
pingService.ping()
|
||||||
|
.onSuccess { response ->
|
||||||
|
updateState(
|
||||||
|
state.copy(
|
||||||
|
isLoading = false,
|
||||||
|
response = response,
|
||||||
|
isConnected = response.status == "pong"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
updateState(
|
||||||
|
state.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = error.message ?: "Unbekannter Fehler",
|
||||||
|
isConnected = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateState(newState: PingTestState) {
|
||||||
|
state = newState
|
||||||
|
onStateChanged?.invoke(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
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,19 +1,28 @@
|
|||||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.compose.multiplatform)
|
kotlin("multiplatform")
|
||||||
alias(libs.plugins.compose.compiler)
|
id("org.jetbrains.compose")
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvm("desktop")
|
jvm {
|
||||||
|
compilations.all {
|
||||||
|
compilerOptions.configure {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
val desktopMain by getting {
|
val jvmMain by getting {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.compose.desktop.currentOs)
|
|
||||||
implementation(project(":client:common-ui"))
|
implementation(project(":client:common-ui"))
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
implementation(compose.material3)
|
||||||
|
implementation(compose.ui)
|
||||||
|
implementation(compose.uiTooling)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.kotlinx.coroutines.swing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,11 +30,22 @@ kotlin {
|
|||||||
|
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
application {
|
application {
|
||||||
mainClass = "MainKt"
|
mainClass = "at.mocode.client.desktop.MainKt"
|
||||||
|
|
||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
// targetFormats(Tar, Dmg, Msi) // TODO: Fix TargetFormat import
|
||||||
packageName = "MeldestellePro"
|
packageName = "Meldestelle Desktop"
|
||||||
packageVersion = "1.0.0"
|
packageVersion = "1.0.0"
|
||||||
|
|
||||||
|
windows {
|
||||||
|
iconFile.set(project.file("src/jvmMain/resources/icon.ico"))
|
||||||
|
}
|
||||||
|
linux {
|
||||||
|
iconFile.set(project.file("src/jvmMain/resources/icon.png"))
|
||||||
|
}
|
||||||
|
macOS {
|
||||||
|
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package at.mocode.client.desktop
|
||||||
|
|
||||||
|
import androidx.compose.ui.window.Window
|
||||||
|
import androidx.compose.ui.window.WindowPosition
|
||||||
|
import androidx.compose.ui.window.WindowState
|
||||||
|
import androidx.compose.ui.window.application
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.client.ui.App
|
||||||
|
|
||||||
|
fun main() = application {
|
||||||
|
Window(
|
||||||
|
onCloseRequest = ::exitApplication,
|
||||||
|
title = "Meldestelle Desktop App",
|
||||||
|
state = WindowState(
|
||||||
|
position = WindowPosition(Alignment.Center),
|
||||||
|
width = 800.dp,
|
||||||
|
height = 600.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Use the shared App component from common-ui
|
||||||
|
// This eliminates code duplication and ensures consistent UI across platforms
|
||||||
|
App(baseUrl = System.getProperty("meldestelle.api.url", "http://localhost:8080"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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,46 +1,34 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
kotlin("multiplatform")
|
||||||
alias(libs.plugins.compose.multiplatform)
|
id("org.jetbrains.compose")
|
||||||
alias(libs.plugins.compose.compiler)
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
js(IR) {
|
js(IR) {
|
||||||
|
binaries.executable()
|
||||||
browser {
|
browser {
|
||||||
commonWebpackConfig {
|
commonWebpackConfig {
|
||||||
devServer = org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig.DevServer(
|
|
||||||
open = true,
|
|
||||||
port = 8081
|
|
||||||
)
|
|
||||||
}
|
|
||||||
webpackTask {
|
|
||||||
cssSupport {
|
cssSupport {
|
||||||
enabled.set(true)
|
enabled.set(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runTask {
|
|
||||||
cssSupport {
|
|
||||||
enabled.set(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
testTask {
|
|
||||||
useKarma {
|
|
||||||
useChromeHeadless()
|
|
||||||
webpackConfig.cssSupport {
|
|
||||||
enabled.set(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
binaries.executable()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
val jsMain by getting {
|
val jsMain by getting {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":client:common-ui"))
|
implementation(project(":client:common-ui"))
|
||||||
|
implementation(compose.html.core)
|
||||||
|
implementation(compose.runtime)
|
||||||
|
implementation(libs.ktor.client.js)
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
compose.experimental {
|
||||||
|
web.application {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
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,121 @@
|
|||||||
|
@file:OptIn(org.jetbrains.compose.web.ExperimentalComposeWebApi::class)
|
||||||
|
|
||||||
|
package at.mocode.client.web
|
||||||
|
|
||||||
|
import org.jetbrains.compose.web.css.*
|
||||||
|
|
||||||
|
object AppStylesheet : StyleSheet() {
|
||||||
|
val container by style {
|
||||||
|
display(DisplayStyle.Flex)
|
||||||
|
flexDirection(FlexDirection.Column)
|
||||||
|
minHeight(100.vh)
|
||||||
|
fontFamily("'Segoe UI', system-ui, sans-serif")
|
||||||
|
margin(0.px)
|
||||||
|
padding(0.px)
|
||||||
|
backgroundColor(Color("#f5f5f5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val header by style {
|
||||||
|
backgroundColor(Color("#1976d2"))
|
||||||
|
color(Color.white)
|
||||||
|
padding(20.px)
|
||||||
|
textAlign("center")
|
||||||
|
property("box-shadow", "0 2px 4px rgba(0,0,0,0.1)")
|
||||||
|
}
|
||||||
|
|
||||||
|
val main by style {
|
||||||
|
flex(1)
|
||||||
|
display(DisplayStyle.Flex)
|
||||||
|
justifyContent(JustifyContent.Center)
|
||||||
|
alignItems(AlignItems.Center)
|
||||||
|
padding(40.px, 20.px)
|
||||||
|
}
|
||||||
|
|
||||||
|
val footer by style {
|
||||||
|
backgroundColor(Color("#333"))
|
||||||
|
color(Color.white)
|
||||||
|
textAlign("center")
|
||||||
|
padding(20.px)
|
||||||
|
fontSize(14.px)
|
||||||
|
}
|
||||||
|
|
||||||
|
val card by style {
|
||||||
|
backgroundColor(Color.white)
|
||||||
|
borderRadius(12.px)
|
||||||
|
property("box-shadow", "0 4px 6px rgba(0, 0, 0, 0.1)")
|
||||||
|
padding(32.px)
|
||||||
|
maxWidth(500.px)
|
||||||
|
width(100.percent)
|
||||||
|
textAlign("center")
|
||||||
|
}
|
||||||
|
|
||||||
|
val button by style {
|
||||||
|
border(0.px)
|
||||||
|
borderRadius(8.px)
|
||||||
|
padding(12.px, 24.px)
|
||||||
|
fontSize(16.px)
|
||||||
|
fontWeight("bold")
|
||||||
|
cursor("pointer")
|
||||||
|
property("transition", "all 0.2s ease")
|
||||||
|
width(100.percent)
|
||||||
|
marginBottom(20.px)
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonHover by style {
|
||||||
|
transform { scale(1.02) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonDisabled by style {
|
||||||
|
opacity(0.6)
|
||||||
|
cursor("not-allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
val primaryButton by style {
|
||||||
|
backgroundColor(Color("#1976d2"))
|
||||||
|
color(Color.white)
|
||||||
|
|
||||||
|
hover(self) style {
|
||||||
|
backgroundColor(Color("#1565c0"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val successMessage by style {
|
||||||
|
backgroundColor(Color("#e8f5e8"))
|
||||||
|
color(Color("#2e7d32"))
|
||||||
|
padding(16.px)
|
||||||
|
borderRadius(8.px)
|
||||||
|
marginTop(16.px)
|
||||||
|
border(1.px, LineStyle.Solid, Color("#c8e6c9"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorMessage by style {
|
||||||
|
backgroundColor(Color("#ffebee"))
|
||||||
|
color(Color("#c62828"))
|
||||||
|
padding(16.px)
|
||||||
|
borderRadius(8.px)
|
||||||
|
marginTop(16.px)
|
||||||
|
border(1.px, LineStyle.Solid, Color("#ffcdd2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val spinner by style {
|
||||||
|
display(DisplayStyle.InlineBlock)
|
||||||
|
width(16.px)
|
||||||
|
height(16.px)
|
||||||
|
border(2.px, LineStyle.Solid, Color("#f3f3f3"))
|
||||||
|
property("border-top", "2px solid #1976d2")
|
||||||
|
borderRadius(50.percent)
|
||||||
|
property("animation", "spin 1s linear infinite")
|
||||||
|
marginRight(8.px)
|
||||||
|
property("vertical-align", "middle")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
private val spinKeyframes = keyframes {
|
||||||
|
0.percent {
|
||||||
|
transform { rotate(0.deg) }
|
||||||
|
}
|
||||||
|
100.percent {
|
||||||
|
transform { rotate(360.deg) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package at.mocode.client.web
|
||||||
|
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import org.jetbrains.compose.web.css.*
|
||||||
|
import org.jetbrains.compose.web.dom.*
|
||||||
|
import org.jetbrains.compose.web.renderComposable
|
||||||
|
import at.mocode.client.ui.components.PingTestComponent
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
renderComposable(rootElementId = "root") {
|
||||||
|
Style(AppStylesheet)
|
||||||
|
MeldestelleWebApp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MeldestelleWebApp() {
|
||||||
|
val pingComponent = remember { PingTestComponent() }
|
||||||
|
var pingState by remember { mutableStateOf(pingComponent.state) }
|
||||||
|
|
||||||
|
LaunchedEffect(pingComponent) {
|
||||||
|
pingComponent.onStateChanged = { newState ->
|
||||||
|
pingState = newState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(pingComponent) {
|
||||||
|
onDispose {
|
||||||
|
pingComponent.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Div(attrs = {
|
||||||
|
classes(AppStylesheet.container)
|
||||||
|
}) {
|
||||||
|
Header(attrs = { classes(AppStylesheet.header) }) {
|
||||||
|
H1 { Text("Meldestelle Web App") }
|
||||||
|
}
|
||||||
|
|
||||||
|
Main(attrs = { classes(AppStylesheet.main) }) {
|
||||||
|
PingTestWebView(
|
||||||
|
state = pingState,
|
||||||
|
onTestConnection = { pingComponent.testConnection() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Footer(attrs = { classes(AppStylesheet.footer) }) {
|
||||||
|
P { Text("© 2025 Meldestelle - Powered by Kotlin Multiplatform") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PingTestWebView(
|
||||||
|
state: at.mocode.client.ui.components.PingTestState,
|
||||||
|
onTestConnection: () -> Unit
|
||||||
|
) {
|
||||||
|
Div(attrs = { classes(AppStylesheet.card) }) {
|
||||||
|
H2 { Text("Backend Verbindungstest") }
|
||||||
|
|
||||||
|
Button(
|
||||||
|
attrs = {
|
||||||
|
classes(AppStylesheet.button, AppStylesheet.primaryButton)
|
||||||
|
if (state.isLoading) {
|
||||||
|
attr("disabled", "")
|
||||||
|
}
|
||||||
|
onClick { onTestConnection() }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (state.isLoading) {
|
||||||
|
Span(attrs = { classes(AppStylesheet.spinner) }) {}
|
||||||
|
Text(" Testing...")
|
||||||
|
} else {
|
||||||
|
Text("Ping Backend Service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status Anzeige
|
||||||
|
when {
|
||||||
|
state.isConnected -> {
|
||||||
|
Div(attrs = { classes(AppStylesheet.successMessage) }) {
|
||||||
|
Span { Text("✅ ") }
|
||||||
|
Text("Verbindung erfolgreich: ${state.response?.status}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error != null -> {
|
||||||
|
Div(attrs = { classes(AppStylesheet.errorMessage) }) {
|
||||||
|
Span { Text("❌ ") }
|
||||||
|
Text("Fehler: ${state.error}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Meldestelle Pro</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Meldestelle Web App</title>
|
||||||
|
<meta name="description" content="Meldestelle - Vereinsverwaltung für Pferdesport">
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="root"></canvas>
|
<div id="root">
|
||||||
<script src="MeldestelleWebApp.js"></script>
|
<div style="display: flex; justify-content: center; align-items: center; height: 100vh; font-family: system-ui;">
|
||||||
|
<div>Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="web-app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ kotlin {
|
|||||||
api(projects.core.coreDomain)
|
api(projects.core.coreDomain)
|
||||||
|
|
||||||
// Asynchronität (available for all platforms) - explicit version to avoid BOM issues
|
// Asynchronität (available for all platforms) - explicit version to avoid BOM issues
|
||||||
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
|
|
||||||
// Utilities (multiplatform compatible)
|
// Utilities (multiplatform compatible)
|
||||||
api(libs.bignum)
|
api(libs.bignum)
|
||||||
|
|||||||
+1
-1
@@ -168,7 +168,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- meldestelle-network
|
- meldestelle-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/ping"]
|
test: ["CMD", "curl", "-f", "http://localhost:8082/ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ springdoc = "2.5.0"
|
|||||||
ktor = "3.0.0-beta-2"
|
ktor = "3.0.0-beta-2"
|
||||||
|
|
||||||
# --- Compose UI ---
|
# --- Compose UI ---
|
||||||
composeMultiplatform = "1.6.10"
|
composeMultiplatform = "1.8.2"
|
||||||
|
|
||||||
# --- Database & Persistence ---
|
# --- Database & Persistence ---
|
||||||
exposed = "0.51.1"
|
exposed = "0.51.1"
|
||||||
@@ -67,7 +67,7 @@ spring-cloud-dependencies = { module = "org.springframework.cloud:spring-cloud-d
|
|||||||
|
|
||||||
# --- Kotlin & Coroutines ---
|
# --- Kotlin & Coroutines ---
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" } # Version from BOM
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx" }
|
||||||
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing" } # Version from BOM
|
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing" } # Version from BOM
|
||||||
kotlinx-coroutines-reactor = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor" } # Version from BOM
|
kotlinx-coroutines-reactor = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor" } # Version from BOM
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx" }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,41 @@
|
|||||||
FROM openjdk:17-jre-slim
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Build stage: compile the ping-service JAR inside Docker
|
||||||
|
FROM gradle:8.7-jdk17-alpine AS builder
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Copy the entire repository (simplest and most robust for multi-module setups)
|
||||||
|
# This allows building the ping-service even if it depends on shared build files or platforms.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build only the ping-service artifact
|
||||||
|
RUN gradle :temp:ping-service:bootJar --no-daemon
|
||||||
|
|
||||||
|
# Runtime stage: slim JRE image to run the service
|
||||||
|
FROM eclipse-temurin:17-jre-alpine
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the ping-service JAR file
|
# Install curl for health checks (small footprint on Alpine)
|
||||||
COPY temp/ping-service/build/libs/*.jar app.jar
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
# Expose port (will be assigned dynamically by Spring Boot)
|
# Create a non-root user for better security
|
||||||
EXPOSE 8080
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
USER app
|
||||||
|
|
||||||
# Add health check
|
# Copy the built JAR from the builder stage
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
COPY --from=builder /workspace/temp/ping-service/build/libs/*.jar app.jar
|
||||||
CMD curl -f http://localhost:8080/ping || exit 1
|
|
||||||
|
# Expose application port
|
||||||
|
EXPOSE 8082
|
||||||
|
|
||||||
|
# Health check against the ping endpoint
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD curl -fsS http://localhost:8082/ping || exit 1
|
||||||
|
|
||||||
|
# JVM options can be overridden at runtime via JAVA_OPTS env
|
||||||
|
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseStringDeduplication"
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]
|
||||||
|
|||||||
Reference in New Issue
Block a user