fixing(client)

This commit is contained in:
2025-08-13 00:07:08 +02:00
parent 23b6708197
commit 26e826d32c
23 changed files with 3650 additions and 1077 deletions
+123
View File
@@ -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 DesktopBuild 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.
-892
View File
@@ -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).
+13 -13
View File
@@ -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")
}
} }
} }
@@ -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()
}
}
@@ -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)
)
}
}
@@ -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"
}
}
}
+31 -11
View File
@@ -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()
}
}
+11 -23
View File
@@ -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 {}
}
-11
View File
@@ -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}")
}
}
}
}
}
+11 -4
View File
@@ -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>
+2 -1
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
+33 -9
View File
@@ -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"]