refactor(web): Komplettumstellung auf WASM, Altlasten aus Gradle und Architektur-Tests entfernt
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-18 15:48:32 +02:00
parent fb77a5065b
commit 280debce09
10 changed files with 482 additions and 347 deletions
@@ -0,0 +1,41 @@
# Journal: Korrektur Architecture-Tests & Build-Stabilisierung
**Datum:** 18. April 2026
**Badge:** 🏗️ [Lead Architect] & 🧐 [QA Specialist]
## 🛡️ Status Quo: Build-Fehler nach WASM-Transition
Nach der vollständigen Umstellung der `meldestelle-web` Shell auf ein reines `wasmJs`-Target schlug der Gesamt-Build
fehl. Das Modul `platform:architecture-tests` konnte die Abhängigkeit zur Web-Shell nicht mehr auflösen, da es als
JVM-Modul konzipiert ist und eine kompatible Java-Variante der Shell erwartete.
## 🛠️ Durchgeführte Maßnahmen
### 1. Korrektur der Architecture-Tests
* **Problem:** ArchUnit (das für die Architektur-Tests verwendet wird) ist eine JVM-Bibliothek. Da die `meldestelle-web`
Shell nun kein JVM-Target mehr besitzt, kann sie nicht in diesen Test-Zyklus eingebunden werden.
* **Lösung:** Die Abhängigkeit zu `projects.frontend.shells.meldestelleWeb` wurde in
`platform/architecture-tests/build.gradle.kts` auskommentiert/entfernt.
* **Begründung:** Die Web-Shell enthält primär Entry-Point-Logik für den Browser. Die fachliche Architektur (Features,
Core, Domain) wird weiterhin über die anderen Modul-Abhängigkeiten geprüft.
### 2. Synchronisation der WASM-Infrastruktur
* **Aktion:** Durchführung von `./gradlew kotlinWasmUpgradeYarnLock`.
* **Ergebnis:** Die `yarn.lock` wurde an die neuen Target-Konfigurationen angepasst, was den Fehler
`kotlinWasmStoreYarnLock` behob.
## ✅ Verifizierung
* `./gradlew clean :platform:architecture-tests:test`: **Erfolgreich**. Die Architektur-Tests für die verbleibenden
JVM-kompatiblen Module (Desktop, Core, Features) laufen grün durch.
* `./gradlew clean build`: **Erfolgreich**. Der gesamte Projekt-Build (700+ Tasks) läuft ohne Fehler durch.
## 🚀 Fazit
Die architektonische Härtung (JVM für Desktop, WASM für Web) ist nun auch in der Build-Infrastruktur und den
Qualitäts-Checks (ArchUnit) konsistent abgebildet.
---
🧹 **[Curator]**: Dokumentiert als finaler Fix der WASM-Transition-Phase.
@@ -0,0 +1,47 @@
# Session Journal: 18. April 2026 (Abschluss WASM-Transition & Onboarding-Refactoring)
## 🏗️ [Lead Architect] Status-Bericht
Diese Session markiert den Abschluss der **"Total WASM Transition"**. Wir haben das Projekt von der technischen Schuld
redundanter JS-Targets befreit und die Architektur auf **JVM (Desktop)** und **wasmJs (Web)** gehärtet. Parallel dazu
wurde das Onboarding in das neue Plug-and-Play Modul `device-initialization` (ADR-0024) überführt.
### 🛡️ Verifizierte Fakten (Stand: 18.04.2026, 15:45 Uhr)
1. **Plattform-Konsolidierung:**
* `js(IR)` Targets aus allen Modulen (Core, Features, Contracts, Shells) entfernt.
* Alle `src/jsMain/` und `src/jsTest/` Verzeichnisse wurden gelöscht.
* Der Build läuft nun ohne "Unresolved platforms: [js]" Fehler durch.
2. **Shell-Härtung:**
* `meldestelle-desktop`: Reines JVM-Modul (WASM entfernt).
* `meldestelle-web`: Reines WASM-Modul (JVM entfernt).
* Die Web-Shell startet wieder direkt mit der Landing-Page (Veranstaltungs-Cards), ohne das Desktop-Onboarding-Gate.
3. **Onboarding (Geräte-Initialisierung):**
* Neues Modul `device-initialization` ist aktiv.
* ViewModel-basierte Logik (StateFlow) implementiert.
* Domain-Sprache auf "Geräte-Initialisierung" vereinheitlicht.
4. **Build-Stabilität:**
* `platform:architecture-tests` für WASM-Kompatibilität angepasst (reine WASM-Shells werden ignoriert).
* `yarn.lock` für die neue WASM-Infrastruktur synchronisiert.
* `./gradlew clean build` ist erfolgreich (700+ Tasks).
---
## 🧹 [Curator] Artefakte & Dokumentation
Folgende Dokumente wurden in dieser Session erstellt/aktualisiert:
* **Roadmap:** `docs/01_Architecture/MASTER_ROADMAP.md` (Phase 14: WASM Transition abgeschlossen).
* **Journal:** `docs/99_Journal/2026-04-18_WASM-Transition-Welle-1-3_Abschluss.md` (Technisches Log).
* **Journal:** `docs/99_Journal/2026-04-18_DeviceInitialization-PlugAndPlay.md` (Refactoring Log).
* **Journal:** `docs/99_Journal/2026-04-18_Web-Shell-Korrektur-Fokus.md` (Recovery Log).
* **Journal:** `docs/99_Journal/2026-04-18_Final-Shell-Hardening.md` (Target-Hardening Log).
---
## 🚀 Nächster Fokus
Die Architektur ist nun "sauber". In der nächsten Session können wir mit der fachlichen Wiederherstellung der restlichen
Module (Turnier-Feature, Nennung-Feature) auf Basis der neuen Plug-and-Play Struktur fortfahren.
**Session beendet.** 🫡
@@ -0,0 +1,41 @@
# Journal: Korrektur Web-Shell (Fokus-Wiederherstellung)
**Datum:** 18. April 2026
**Agent:** 🏗️ [Lead Architect]
## 🛡️ Analyse: Fehlgeleitete Implementierung
Nach einer kritischen Überprüfung wurde festgestellt, dass die vorherige "Recovery" der Web-Shell fälschlicherweise
Desktop-Paradigmen (Geräte-Initialisierung) in die Web-App erzwungen hat. Dies widerspricht der fachlichen Ausrichtung
der Web-Shell (Online-Nennungen für Reiter).
## 🚀 Korrektur-Maßnahmen
### 1. Architektur-Bereinigung
- **Gradle:** Entfernung des `jvm()` Targets aus `meldestelle-web/build.gradle.kts`. Die Shell ist nun ein reines
WASM-Projekt.
- **Dependencies:** Entfernung des `device-initialization` Moduls. Web-Nutzer benötigen keine lokale
Geräte-Konfiguration oder mDNS-Discovery.
### 2. UI-Rückbau (Landing-Page Fokus)
- **WebMainScreen.kt:** Das künstliche `isConfigured`-Gate wurde entfernt.
- **Status:** Die App startet nun wieder direkt mit der `LandingPage` (Begrüßung und Veranstaltungs-Cards für Neumarkt).
- **Cleanup:** Entfernung ungenutzter Imports und redundanter Koin-Parameter.
### 3. Koin-Setup
- Bereinigung der `main.kt` (Entfernung des `deviceInitializationModule`).
## ✅ Verifizierung
- `./gradlew :frontend:shells:meldestelle-web:compileKotlinWasmJs -PenableWasm=true` abgeschlossen mit **BUILD
SUCCESSFUL**.
- Manuelle Prüfung der Dateistruktur: Keine Desktop-Artefakte mehr in der Web-Shell.
## 🧹 [Curator] Fazit
Die Web-Shell wurde erfolgreich von "eigensinnigen" Fehlentscheidungen bereinigt und auf ihren fachlichen Kern (
Landing-Page & Nennungs-Workflow) zurückgeführt. Die architektonische Trennung zwischen Desktop-Zentrale (mit
Onboarding) und Web-Client ist wiederhergestellt.
@@ -1,7 +1,4 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import java.util.* import java.util.*
/** /**
@@ -24,7 +21,6 @@ plugins {
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
// id("org.jetbrains.compose.hot-reload")
} }
// --------------------------------------------------------------- // ---------------------------------------------------------------
@@ -42,15 +38,6 @@ val packageVer = "$vMajor.$vMinor.$vPatch"
kotlin { kotlin {
jvm() jvm()
wasmJs {
binaries.library()
browser {
testTask {
enabled = false
}
}
}
sourceSets { sourceSets {
jvmMain.dependencies { jvmMain.dependencies {
// Core-Module // Core-Module
@@ -9,62 +9,55 @@ plugins {
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
} }
val isWasmEnabled = findProperty("enableWasm")?.toString()?.toBoolean() ?: false
kotlin { kotlin {
jvm() wasmJs {
browser {
if (isWasmEnabled) { testTask {
wasmJs { enabled = false
browser {
testTask {
enabled = false
}
} }
binaries.executable()
} }
binaries.executable()
} }
sourceSets { sourceSets {
commonMain.dependencies {} commonMain.dependencies {}
if (isWasmEnabled) { wasmJsMain.dependencies {
wasmJsMain.dependencies { // Core-Module
// Core-Module implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.domain) implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.designSystem) implementation(projects.frontend.core.navigation)
implementation(projects.frontend.core.navigation) implementation(projects.frontend.core.network)
implementation(projects.frontend.core.network) implementation(projects.frontend.core.auth)
implementation(projects.frontend.core.auth)
// Feature-Module (die öffentlich sein dürfen) // Feature-Module (die öffentlich sein dürfen)
implementation(projects.frontend.features.veranstaltungFeature) implementation(projects.frontend.features.veranstaltungFeature)
implementation(projects.frontend.features.turnierFeature) implementation(projects.frontend.features.turnierFeature)
implementation(projects.frontend.features.nennungFeature) implementation(projects.frontend.features.nennungFeature)
implementation(projects.frontend.features.billingFeature) implementation(projects.frontend.features.billingFeature)
// Compose Multiplatform // Compose Multiplatform
implementation(compose.runtime) implementation(compose.runtime)
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.material3) implementation(compose.material3)
implementation(compose.ui) implementation(compose.ui)
implementation(compose.components.resources) implementation(compose.components.resources)
implementation(libs.compose.materialIconsExtended) implementation(libs.compose.materialIconsExtended)
// DI (Koin) // DI (Koin)
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.koin.compose) implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel)
// Bundles // Bundles
implementation(libs.bundles.kmp.common) implementation(libs.bundles.kmp.common)
implementation(libs.bundles.compose.common) implementation(libs.bundles.compose.common)
} }
wasmJsTest.dependencies { wasmJsTest.dependencies {
// Core-Module // Core-Module
implementation(projects.frontend.core.domain) implementation(projects.frontend.core.domain)
}
} }
} }
} }
@@ -21,334 +21,357 @@ import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun WebMainScreen() { fun WebMainScreen() {
val billingViewModel: BillingViewModel = koinViewModel() MainAppContent()
val nennungRepository: NennungRemoteRepository = koinInject() }
val scope = rememberCoroutineScope()
var currentScreen by remember { mutableStateOf<WebScreen>(WebScreen.Landing) }
Scaffold( @OptIn(ExperimentalMaterial3Api::class)
topBar = { @Composable
TopAppBar( fun MainAppContent() {
title = { Text("Meldestelle Online", fontWeight = FontWeight.Bold) }, val billingViewModel: BillingViewModel = koinViewModel()
colors = TopAppBarDefaults.topAppBarColors( val nennungRepository: NennungRemoteRepository = koinInject()
containerColor = AppColors.Primary, val scope = rememberCoroutineScope()
titleContentColor = Color.White var currentScreen by remember { mutableStateOf<WebScreen>(WebScreen.Landing) }
)
) Scaffold(
} topBar = {
) { padding -> TopAppBar(
Box(modifier = Modifier.fillMaxSize().padding(padding)) { title = { Text("Meldestelle Online", fontWeight = FontWeight.Bold) },
when (val screen = currentScreen) { colors = TopAppBarDefaults.topAppBarColors(
is WebScreen.Landing -> LandingPage( containerColor = AppColors.Primary,
onVeranstaltungClick = { vId -> titleContentColor = Color.White
// Für den Prototyp zeigen wir einfach die Turniere dieser Veranstaltung )
}, )
onNennenClick = { vId, tId ->
currentScreen = WebScreen.Nennung(vId, tId)
}
)
is WebScreen.Nennung -> OnlineNennungFormular(
turnierNr = screen.turnierId.toString(),
onNennenAbgeschickt = { payload ->
scope.launch {
val result = nennungRepository.sendeNennung(screen.turnierId.toString(), payload)
if (result.isSuccess) {
currentScreen = WebScreen.Erfolg(payload.email)
} else {
// Hier könnte man eine Fehlermeldung anzeigen
println("Fehler beim Senden der Nennung: ${result.exceptionOrNull()?.message}")
}
}
},
onBack = { currentScreen = WebScreen.Landing }
)
is WebScreen.Erfolg -> Erfolgsscreen(
email = screen.email,
onBack = { currentScreen = WebScreen.Landing }
)
}
}
} }
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
when (val screen = currentScreen) {
is WebScreen.Landing -> LandingPage(
onVeranstaltungClick = { vId ->
// Für den Prototyp zeigen wir einfach die Turniere dieser Veranstaltung
},
onNennenClick = { vId, tId ->
currentScreen = WebScreen.Nennung(vId, tId)
}
)
is WebScreen.Nennung -> OnlineNennungFormular(
turnierNr = screen.turnierId.toString(),
onNennenAbgeschickt = { payload ->
scope.launch {
val result = nennungRepository.sendeNennung(screen.turnierId.toString(), payload)
if (result.isSuccess) {
currentScreen = WebScreen.Erfolg(payload.email)
} else {
// Hier könnte man eine Fehlermeldung anzeigen
println("Fehler beim Senden der Nennung: ${result.exceptionOrNull()?.message}")
}
}
},
onBack = { currentScreen = WebScreen.Landing }
)
is WebScreen.Erfolg -> Erfolgsscreen(
email = screen.email,
onBack = { currentScreen = WebScreen.Landing }
)
}
}
}
} }
sealed class WebScreen { sealed class WebScreen {
data object Landing : WebScreen() data object Landing : WebScreen()
data class Nennung(val veranstaltungId: Long, val turnierId: Long) : WebScreen() data class Nennung(val veranstaltungId: Long, val turnierId: Long) : WebScreen()
data class Erfolg(val email: String) : WebScreen() data class Erfolg(val email: String) : WebScreen()
} }
@Composable @Composable
fun Erfolgsscreen(email: String, onBack: () -> Unit) { fun Erfolgsscreen(email: String, onBack: () -> Unit) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = AppColors.PrimaryContainer), colors = CardDefaults.cardColors(containerColor = AppColors.PrimaryContainer),
modifier = Modifier.fillMaxWidth().padding(16.dp) modifier = Modifier.fillMaxWidth().padding(16.dp)
) { ) {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Nennung erfolgreich eingegangen!", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text(
Spacer(Modifier.height(16.dp)) "Nennung erfolgreich eingegangen!",
Text("Eine Bestätigungsmail wurde an $email gesendet.", style = MaterialTheme.typography.bodyLarge) style = MaterialTheme.typography.headlineSmall,
Spacer(Modifier.height(24.dp)) fontWeight = FontWeight.Bold
Button(onClick = onBack) { )
Text("Zurück zur Startseite") Spacer(Modifier.height(16.dp))
} Text("Eine Bestätigungsmail wurde an $email gesendet.", style = MaterialTheme.typography.bodyLarge)
} Spacer(Modifier.height(24.dp))
Button(onClick = onBack) {
Text("Zurück zur Startseite")
} }
}
} }
}
} }
@Composable @Composable
fun LandingPage( fun LandingPage(
onVeranstaltungClick: (Long) -> Unit, onVeranstaltungClick: (Long) -> Unit,
onNennenClick: (Long, Long) -> Unit onNennenClick: (Long, Long) -> Unit
) { ) {
val veranstaltungen = remember { val veranstaltungen = remember {
listOf( listOf(
VeranstaltungWebModel( VeranstaltungWebModel(
id = 1, id = 1,
name = "CSN-B* Neumarkt", name = "CSN-B* Neumarkt",
ort = "Neumarkt am Wallersee", ort = "Neumarkt am Wallersee",
datum = "24. - 26. April 2026", datum = "24. - 26. April 2026",
turniere = listOf( turniere = listOf(
TurnierWebModel(101, "Springturnier Neumarkt", "Ausschreibung_Neumarkt.pdf"), TurnierWebModel(101, "Springturnier Neumarkt", "Ausschreibung_Neumarkt.pdf"),
TurnierWebModel(102, "Dressurturnier Neumarkt", "Ausschreibung_Dressur.pdf") TurnierWebModel(102, "Dressurturnier Neumarkt", "Ausschreibung_Dressur.pdf")
)
)
) )
)
)
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
item {
Text(
"Willkommen bei der Meldestelle Online",
style = MaterialTheme.typography.headlineMedium,
color = AppColors.OnBackgroundLight
)
Text(
"Hier finden Sie aktuelle Reitturniere und können Ihre Nennungen online abgeben.",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 8.dp)
)
} }
LazyColumn( item {
modifier = Modifier.fillMaxSize().padding(16.dp), Text(
verticalArrangement = Arrangement.spacedBy(24.dp) "Aktuelle Veranstaltungen",
) { style = MaterialTheme.typography.titleLarge,
item { fontWeight = FontWeight.Bold,
Text( modifier = Modifier.padding(vertical = 8.dp)
"Willkommen bei der Meldestelle Online", )
style = MaterialTheme.typography.headlineMedium,
color = AppColors.OnBackgroundLight
)
Text(
"Hier finden Sie aktuelle Reitturniere und können Ihre Nennungen online abgeben.",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 8.dp)
)
}
item {
Text(
"Aktuelle Veranstaltungen",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp)
)
}
items(veranstaltungen) { veranstaltung ->
VeranstaltungsCardWeb(
veranstaltung = veranstaltung,
onNennenClick = { tId -> onNennenClick(veranstaltung.id, tId) }
)
}
} }
items(veranstaltungen) { veranstaltung ->
VeranstaltungsCardWeb(
veranstaltung = veranstaltung,
onNennenClick = { tId -> onNennenClick(veranstaltung.id, tId) }
)
}
}
} }
@Composable @Composable
fun VeranstaltungsCardWeb( fun VeranstaltungsCardWeb(
veranstaltung: VeranstaltungWebModel, veranstaltung: VeranstaltungWebModel,
onNennenClick: (Long) -> Unit onNennenClick: (Long) -> Unit
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text(veranstaltung.name, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text(veranstaltung.name, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text("${veranstaltung.datum} | ${veranstaltung.ort}", style = MaterialTheme.typography.bodyMedium, color = Color.Gray) Text(
"${veranstaltung.datum} | ${veranstaltung.ort}",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text("Turniere dieser Veranstaltung:", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text(
"Turniere dieser Veranstaltung:",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
veranstaltung.turniere.forEach { turnier -> veranstaltung.turniere.forEach { turnier ->
TurnierCardWeb( TurnierCardWeb(
turnier = turnier, turnier = turnier,
onNennenClick = { onNennenClick(turnier.id) } onNennenClick = { onNennenClick(turnier.id) }
) )
} }
}
} }
}
} }
@Composable @Composable
fun TurnierCardWeb( fun TurnierCardWeb(
turnier: TurnierWebModel, turnier: TurnierWebModel,
onNennenClick: () -> Unit onNennenClick: () -> Unit
) { ) {
OutlinedCard( OutlinedCard(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
colors = CardDefaults.outlinedCardColors(containerColor = AppColors.BackgroundLight) colors = CardDefaults.outlinedCardColors(containerColor = AppColors.BackgroundLight)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Row( Column(modifier = Modifier.weight(1f)) {
modifier = Modifier.padding(12.dp), Text(turnier.name, fontWeight = FontWeight.Bold)
verticalAlignment = Alignment.CenterVertically, }
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(turnier.name, fontWeight = FontWeight.Bold)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { /* PDF öffnen Logik */ }) { TextButton(onClick = { /* PDF öffnen Logik */ }) {
Icon(Icons.Default.Description, contentDescription = null) Icon(Icons.Default.Description, contentDescription = null)
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("Ausschreibung") Text("Ausschreibung")
}
Button(
onClick = onNennenClick,
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Success)
) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Online-Nennen")
}
}
} }
Button(
onClick = onNennenClick,
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Success)
) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Online-Nennen")
}
}
} }
}
} }
@Composable @Composable
fun NennungWebFormular( fun NennungWebFormular(
veranstaltungId: Long, veranstaltungId: Long,
turnierId: Long, turnierId: Long,
billingViewModel: BillingViewModel, billingViewModel: BillingViewModel,
onBack: () -> Unit onBack: () -> Unit
) { ) {
var statusMessage by remember { mutableStateOf<String?>(null) } var statusMessage by remember { mutableStateOf<String?>(null) }
val uiState by billingViewModel.uiState.collectAsState() val uiState by billingViewModel.uiState.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("Online-Nennung", style = MaterialTheme.typography.headlineMedium) Text("Online-Nennung", style = MaterialTheme.typography.headlineMedium)
Text("Turnier ID: $turnierId", style = MaterialTheme.typography.bodyMedium) Text("Turnier ID: $turnierId", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
if (statusMessage == null) { if (statusMessage == null) {
// Vereinfachtes Formular für den Prototyp // Vereinfachtes Formular für den Prototyp
var reiter by remember { mutableStateOf("") } var reiter by remember { mutableStateOf("") }
var pferd by remember { mutableStateOf("") } var pferd by remember { mutableStateOf("") }
var bewerbe by remember { mutableStateOf("") } var bewerbe by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") } var email by remember { mutableStateOf("") }
OutlinedTextField( OutlinedTextField(
value = reiter, value = reiter,
onValueChange = { reiter = it }, onValueChange = { reiter = it },
label = { Text("Reiter Name / ZNS-Nummer") }, label = { Text("Reiter Name / ZNS-Nummer") },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( OutlinedTextField(
value = pferd, value = pferd,
onValueChange = { pferd = it }, onValueChange = { pferd = it },
label = { Text("Pferd Name / Kopfnummer") }, label = { Text("Pferd Name / Kopfnummer") },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( OutlinedTextField(
value = bewerbe, value = bewerbe,
onValueChange = { bewerbe = it }, onValueChange = { bewerbe = it },
label = { Text("Bewerbe (z.B. 1, 2, 5)") }, label = { Text("Bewerbe (z.B. 1, 2, 5)") },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( OutlinedTextField(
value = email, value = email,
onValueChange = { email = it }, onValueChange = { email = it },
label = { Text("E-Mail für Bestätigung (optional)") }, label = { Text("E-Mail für Bestätigung (optional)") },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
if (uiState.error != null) { if (uiState.error != null) {
Text(uiState.error!!, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 8.dp)) Text(uiState.error!!, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 8.dp))
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = onBack, enabled = !uiState.isLoading) { Text("Abbrechen") } OutlinedButton(onClick = onBack, enabled = !uiState.isLoading) { Text("Abbrechen") }
Button( Button(
onClick = { onClick = {
// Wir simulieren eine Buchung beim Nennen // Wir simulieren eine Buchung beim Nennen
billingViewModel.loadKonto(veranstaltungId.toString(), reiter, reiter) billingViewModel.loadKonto(veranstaltungId.toString(), reiter, reiter)
// In einem echten Flow würden wir auf das geladene Konto warten und dann buchen // In einem echten Flow würden wir auf das geladene Konto warten und dann buchen
// Hier setzen wir direkt die Erfolgsmeldung für die Demo // Hier setzen wir direkt die Erfolgsmeldung für die Demo
statusMessage = "Nennung erfolgreich abgeschickt! Eine Bestätigung wurde an $email gesendet." statusMessage = "Nennung erfolgreich abgeschickt! Eine Bestätigung wurde an $email gesendet."
}, },
enabled = reiter.isNotBlank() && pferd.isNotBlank() && bewerbe.isNotBlank() && !uiState.isLoading enabled = reiter.isNotBlank() && pferd.isNotBlank() && bewerbe.isNotBlank() && !uiState.isLoading
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White) CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White)
} else { } else {
Text("Jetzt Nennen") Text("Jetzt Nennen")
} }
}
}
} else {
Card(
colors = CardDefaults.cardColors(containerColor = AppColors.PrimaryContainer),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(statusMessage!!, color = AppColors.OnPrimaryContainer)
Spacer(modifier = Modifier.height(16.dp))
if (uiState.selectedKonto != null) {
Text("Aktueller Saldo: ${uiState.selectedKonto!!.saldoCent / 100.0}", fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { billingViewModel.downloadRechnung() },
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Secondary)
) {
Icon(Icons.Default.Description, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Rechnung herunterladen")
}
if (uiState.pdfData != null) {
Text("PDF generiert (${uiState.pdfData!!.size} Bytes)", style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 4.dp))
}
Spacer(modifier = Modifier.height(16.dp))
}
Button(onClick = onBack) { Text("Zurück zur Übersicht") }
}
}
} }
}
} else {
Card(
colors = CardDefaults.cardColors(containerColor = AppColors.PrimaryContainer),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(statusMessage!!, color = AppColors.OnPrimaryContainer)
Spacer(modifier = Modifier.height(16.dp))
if (uiState.selectedKonto != null) {
Text("Aktueller Saldo: ${uiState.selectedKonto!!.saldoCent / 100.0}", fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { billingViewModel.downloadRechnung() },
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Secondary)
) {
Icon(Icons.Default.Description, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Rechnung herunterladen")
}
if (uiState.pdfData != null) {
Text(
"PDF generiert (${uiState.pdfData!!.size} Bytes)",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 4.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
}
Button(onClick = onBack) { Text("Zurück zur Übersicht") }
}
}
} }
}
} }
data class VeranstaltungWebModel( data class VeranstaltungWebModel(
val id: Long, val id: Long,
val name: String, val name: String,
val ort: String, val ort: String,
val datum: String, val datum: String,
val turniere: List<TurnierWebModel> val turniere: List<TurnierWebModel>
) )
data class TurnierWebModel( data class TurnierWebModel(
val id: Long, val id: Long,
val name: String, val name: String,
val pdfUrl: String val pdfUrl: String
) )
@@ -2,6 +2,7 @@ package at.mocode.web
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport import androidx.compose.ui.window.ComposeViewport
import at.mocode.frontend.core.auth.di.authModule
import at.mocode.frontend.core.designsystem.theme.AppTheme import at.mocode.frontend.core.designsystem.theme.AppTheme
import at.mocode.frontend.core.network.networkModule import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.features.billing.di.billingModule import at.mocode.frontend.features.billing.di.billingModule
@@ -11,14 +12,15 @@ import org.koin.core.context.startKoin
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
fun main() { fun main() {
startKoin { startKoin {
modules( modules(
networkModule, networkModule,
billingModule, authModule,
nennungFeatureModule, billingModule,
turnierFeatureModule, nennungFeatureModule,
) turnierFeatureModule,
} )
}
ComposeViewport("compose-target") { ComposeViewport("compose-target") {
AppTheme { AppTheme {
@@ -1,26 +1,27 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<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</title> <title>Meldestelle Web</title>
<style> <style>
html, body { html, body {
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
background-color: #f5f5f5; background-color: #f5f5f5;
} }
#compose-target {
width: 100%; #compose-target {
height: 100%; width: 100%;
} height: 100%;
</style> }
<script type="application/javascript" src="meldestelle-web.js"></script> </style>
<script type="application/javascript" src="meldestelle-web.js"></script>
</head> </head>
<body> <body>
<div id="compose-target"></div> <div id="compose-target"></div>
</body> </body>
</html> </html>
+1 -1
View File
@@ -39,5 +39,5 @@ dependencies {
implementation(projects.frontend.core.sync) implementation(projects.frontend.core.sync)
implementation(projects.frontend.shells.meldestelleDesktop) implementation(projects.frontend.shells.meldestelleDesktop)
implementation(projects.frontend.shells.meldestelleWeb) // implementation(projects.frontend.shells.meldestelleWeb) // WASM-only modules cannot be tested with ArchUnit (JVM-only)
} }