chore: remove unused meldestelle-portal module

- Deleted obsolete `meldestelle-portal` module, including all associated screens, configurations, tests, and assets.
- Includes removal of Compose multiplatform dependencies in `build.gradle.kts`.
- Cleaned up redundant files such as `AppPreview`, `AuthStatusScreen`, `DashboardScreen`, and associated core implementations.
- Streamlined module references in `settings.gradle.kts`.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-25 15:46:48 +01:00
parent 9d08cb0f72
commit 3fe850d914
47 changed files with 578 additions and 2814 deletions
+12 -9
View File
@@ -65,15 +65,18 @@ gesteuert wird und die Daten persistent im Backend (`actor-context`) ablegt.
### Phase 3: Frontend-Integration (🎨 Frontend Expert)
* [ ] **UI-Komponenten (Compose Desktop):**
* Erstellung eines "Stammdaten-Import" Screens in der Master-Desktop-App.
* Integration eines nativen File-Pickers (nur Auswahl von `.zip` zulassen).
* [ ] **Netzwerk & State-Management (KMP):**
* Ktor-Client anpassen für Multipart-Uploads.
* Implementierung eines Polling-Mechanismus: Nach erfolgreichem Upload alle X Sekunden den Status-Endpunkt abfragen.
* [ ] **User Feedback:**
* Anzeige einer Progress-Bar mit Live-Updates (z. B. "Verarbeite Reiter: 1250/5000").
* Fehler-Handling (z. B. falsches Dateiformat, Timeouts) sauber darstellen.
* [x] **UI-Komponenten (Compose Desktop):**
* `StammdatenImportScreen` in `meldestelle-desktop` erstellt. ✅
* Nativer File-Picker (`JFileChooser`, nur `.zip`) integriert. ✅
* `AppScreen.StammdatenImport` + Nav-Rail-Eintrag "Stammdaten-Import" hinzugefügt. ✅
* [x] **Netzwerk & State-Management (KMP):**
* `ZnsImportViewModel` mit Ktor Multipart-Upload (`POST /api/v1/import/zns`). ✅
* Polling-Mechanismus (alle 2 Sekunden, `GET /api/v1/import/zns/{jobId}/status`). ✅
* `ZnsImportViewModel` via Koin `viewModel { }` in `desktopModule` registriert. ✅
* [x] **User Feedback:**
* `LinearProgressIndicator` mit Live-Prozent + `progressDetail`-Text. ✅
* `StatusChip` für alle Job-Zustände (AUSSTEHEND → ABGESCHLOSSEN/FEHLER). ✅
* Fehler-Banner + scrollbare Fehler-Liste (max. 50 Einträge). ✅
### Phase 4: Testing & QA (🧐 QA Specialist)
@@ -0,0 +1,78 @@
---
date: 2026-03-25
type: Session Log
agents: [ Lead Architect, Frontend Expert, Curator ]
status: ABGESCHLOSSEN
---
# Session Log: Frontend-Architektur-Bereinigung & ZNS-Import Phase 3
## Zusammenfassung
Diese Session umfasste zwei Hauptthemen:
1. Vollständige Implementierung von **Phase 3 (ZNS-Import Frontend)**
2. Analyse und Bereinigung von **5 Frontend-Architektur-Problemen**
---
## Phase 3: ZNS-Import Frontend (✅ Abgeschlossen)
### Implementierte Komponenten
| Datei | Beschreibung |
|-----------------------------|-----------------------------------------------------------------|
| `AppScreen.kt` | `StammdatenImport`-Route + `fromRoute`-Mapping |
| `ZnsImportViewModel.kt` | Ktor Multipart-Upload, Polling (2s), Auth-Token-Injection |
| `StammdatenImportScreen.kt` | File-Picker (JFileChooser, nur .zip), ProgressBar, Fehler-Liste |
| `DesktopMainLayout.kt` | Nav-Rail-Eintrag "Stammdaten-Import" |
| `DesktopModule.kt` | ZnsImportViewModel via Koin registriert |
---
## Architektur-Bereinigung (✅ Abgeschlossen)
### Problem 1 NetworkModule.kt (🔴 Hoch)
- **Was:** Doppelter `HttpSend`-Interceptor + ~90 Zeilen auskommentierter Debug-Code
- **Fix:** Bereinigt auf 83 Zeilen, ein sauberer Interceptor
### Problem 2 meldestelle-portal (🟡 Mittel)
- **Was:** Toter Prototyp (JVM+JS+WASM) ohne Package, ohne DI, mit falschem Fenstertitel
- **Fix:** Verzeichnis gelöscht, `settings.gradle.kts` bereinigt
- **Hinweis:** Web-Portal ist in MASTER_ROADMAP als zukünftige Phase vorgesehen wird neu & sauber aufgebaut
### Problem 3 AppScreen.fromRoute (🟡 Mittel)
- **Was:** Fehlende Mappings für parametrisierte Routen (VeranstaltungDetail, TurnierDetail, TurnierNeu)
- **Fix:** Regex-Parsing ergänzt
### Problem 4 NavigationPort Interface (🟢 Niedrig)
- **Was:** `navigateToScreen` und `currentScreen` fehlten im Interface
- **Fix:** Interface erweitert, `DesktopNavigationPort` implementiert beide Methoden
### Problem 5 ZnsImportViewModel Auslagerung (🟢 Niedrig)
- **Was:** ViewModel mit Business-Logik in der Shell statt im Feature-Modul
- **Fix:** Neues Feature-Modul `frontend/features/zns-import-feature` erstellt
---
## Build-Fehler behoben
| Fehler | Fix |
|---------------------------------------------------|----------------------------------------------------|
| `:frontend:shells:meldestelle-portal` not found | `architecture-tests/build.gradle.kts` aktualisiert |
| `libs.ktor.client.content.negotiation` unresolved | → `libs.ktor.client.contentNegotiation` |
| `libs.ktor.serialization.kotlinx.json` unresolved | → `libs.ktor.client.serialization.kotlinx.json` |
| `libs.androidx.lifecycle.viewmodel` unresolved | → `libs.androidx.lifecycle.viewmodelCompose` |
---
## Offene Punkte (nächste Session)
- [ ] Phase 4: QA & Testing des ZNS-Imports (🧐 QA Specialist)
- [ ] `competition-context` Backend (👷 Backend Developer)
- [ ] `event-management-context` Backend (👷 Backend Developer)
@@ -23,6 +23,7 @@ kotlin {
commonMain.dependencies {
// Depend on core domain for User/Role types used by navigation API
implementation(projects.frontend.core.domain)
implementation(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
@@ -28,8 +28,13 @@ sealed class AppScreen(val route: String) {
data object Funktionaere : AppScreen("/funktionaere")
data object Meisterschaften : AppScreen("/meisterschaften")
data object Cups : AppScreen("/cups")
data object StammdatenImport : AppScreen("/stammdaten/import")
companion object {
private val VERANSTALTUNG_DETAIL = Regex("/veranstaltung/(\\d+)$")
private val TURNIER_DETAIL = Regex("/veranstaltung/(\\d+)/turnier/(\\d+)$")
private val TURNIER_NEU = Regex("/veranstaltung/(\\d+)/turnier/neu$")
fun fromRoute(route: String): AppScreen {
return when (route) {
Routes.HOME -> Landing
@@ -49,7 +54,19 @@ sealed class AppScreen(val route: String) {
"/funktionaere" -> Funktionaere
"/meisterschaften" -> Meisterschaften
"/cups" -> Cups
else -> Landing // Default fallback
"/stammdaten/import" -> StammdatenImport
else -> {
TURNIER_DETAIL.matchEntire(route)?.destructured?.let { (vId, tId) ->
return TurnierDetail(vId.toLong(), tId.toLong())
}
TURNIER_NEU.matchEntire(route)?.destructured?.let { (vId) ->
return TurnierNeu(vId.toLong())
}
VERANSTALTUNG_DETAIL.matchEntire(route)?.destructured?.let { (id) ->
return VeranstaltungDetail(id.toLong())
}
Landing // Default fallback
}
}
}
}
@@ -1,9 +1,18 @@
package at.mocode.frontend.core.navigation
import kotlinx.coroutines.flow.StateFlow
/**
* Minimal navigation abstraction used by core navigation components.
* The actual implementation lives in shells/apps and delegates to the app's router.
* Navigations-Abstraktion für alle Shells.
* Die konkrete Implementierung liegt in der jeweiligen Shell (z.B. DesktopNavigationPort).
*/
interface NavigationPort {
/** Aktuell angezeigter Screen als reaktiver State. */
val currentScreen: StateFlow<AppScreen>
/** Navigation via Route-String (z.B. für Deep-Links). */
fun navigateTo(route: String)
/** Typsichere Navigation direkt via AppScreen-Objekt. */
fun navigateToScreen(screen: AppScreen)
}
@@ -11,18 +11,20 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module
/**
* Simple token provider interface so the core network module does not depend on auth-feature.
* Schnittstelle zur Token-Bereitstellung entkoppelt core-network von core-auth.
*/
interface TokenProvider {
fun getAccessToken(): String?
}
/**
* Koin module providing HttpClients.
* Koin-Modul mit zwei HttpClient-Instanzen:
* - "baseHttpClient": Roh-Client für Auth/Keycloak (kein Token-Header)
* - "apiClient": Konfigurierter Client für das API-Gateway (Auth-Header, Retry, Timeout)
*/
val networkModule = module {
// 1. Base Client (Raw, for Auth/Keycloak)
// 1. Basis-Client (für Auth-Endpunkte, ohne Bearer-Token)
single(named("baseHttpClient")) {
HttpClient {
install(ContentNegotiation) {
@@ -39,48 +41,27 @@ val networkModule = module {
}
}
// 2. API Client (Configured for Gateway & Auth Header)
// 2. API-Client (Gateway, mit Bearer-Token, Retry & Timeout)
single(named("apiClient")) {
// Resolve TokenProvider lazily to avoid circular dependency issues during init
// We use a provider lambda to get the TokenProvider instance when needed
// This avoids resolving it immediately during module definition
val koinScope = this@single
HttpClient {
// JSON (kotlinx) configuration
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
}
)
json(Json { ignoreUnknownKeys = true; isLenient = true; encodeDefaults = true })
}
// Request timeouts
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 15_000
}
// Automatic simple retry on network exceptions and 5xx
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
val s = response.status.value
s == 0 || s >= 500
}
retryIf { _, response -> response.status.value.let { it == 0 || it >= 500 } }
exponentialDelay()
}
// Base URL configuration
defaultRequest {
val base = NetworkConfig.baseUrl.trimEnd('/')
url(base)
url(NetworkConfig.baseUrl.trimEnd('/'))
}
// Logging for development
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
@@ -90,79 +71,13 @@ val networkModule = module {
level = LogLevel.INFO
}
}.also { client ->
// Dynamic Auth Header Injection via HttpSend plugin
// This ensures we get the CURRENT token for each request
// Bearer-Token pro Request dynamisch injizieren (lazy, damit kein Circular-Dependency)
client.plugin(HttpSend).intercept { request ->
try {
// Resolve TokenProvider dynamically from Koin scope
// This assumes Koin is initialized and accessible
// Since we are inside a Koin component, we should be able to get it?
// No, 'this' here is HttpSendScope.
// We need to capture the Koin scope or use GlobalContext if necessary,
// BUT better: we inject the TokenProvider into the module definition lambda
// and use it here.
// However, `get<TokenProvider>()` might fail if not yet registered.
// Let's try to resolve it safely.
// The issue with the previous code was likely that `get<TokenProvider>()` was called
// during module definition time (or bean creation time), and if it wasn't ready or
// if it was null (due to try-catch), the interceptor logic was skipped or broken.
// Let's try to get it from the Koin instance that created this client.
// But we are inside `single { ... }`.
// We can capture the `Scope` from the `single` block.
// val scope = this // Koin Scope
// But we can't easily pass `scope` into `intercept`.
// Let's try to resolve TokenProvider lazily using a lazy delegate or similar.
// Or just resolve it inside the interceptor if we can access Koin.
// Since we are in `single`, we can get the provider.
// The previous error `TypeError: this.getToken_wiq2bn_k$ is not a function`
// was in AuthModule, which we fixed.
// The current error `Error_0: Fail to fetch` is a CORS error on the network level,
// NOT a JS runtime error in the interceptor (unless the interceptor causes it).
// Wait, the logs show:
// [baseClient] REQUEST: .../token
// Access to fetch at ... blocked by CORS policy
// This confirms it is a CORS issue on the Keycloak server side or the browser side.
// The JS error `TypeError` is GONE in the latest log!
// So the interceptor logic in NetworkModule might be fine, or at least not the cause of the CORS error.
// But let's make it robust anyway.
// We will use a safe lazy resolution pattern.
} catch (_: Exception) {
// ignore
}
execute(request)
}
// Re-applying the logic with proper Koin resolution
val koinScope = this@single
client.plugin(HttpSend).intercept { request ->
try {
// Attempt to resolve TokenProvider from the capturing scope
val tokenProvider = try {
koinScope.get<TokenProvider>()
} catch (_: Exception) {
null
}
val token = tokenProvider?.getAccessToken()
if (token != null) {
request.header("Authorization", "Bearer $token")
}
val token = koinScope.get<TokenProvider>().getAccessToken()
if (token != null) request.header("Authorization", "Bearer $token")
} catch (e: Exception) {
println("[apiClient] Error injecting auth header: $e")
println("[apiClient] Auth-Header konnte nicht gesetzt werden: $e")
}
execute(request)
}
@@ -0,0 +1,28 @@
/**
* Feature-Modul: ZNS-Stammdaten-Import (Desktop-only)
* Kapselt ViewModel, State und API-Kommunikation für den ZNS-Import.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeCompiler)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.auth)
implementation(libs.bundles.kmp.common)
implementation(libs.koin.core)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
}
}
@@ -0,0 +1,140 @@
package at.mocode.zns.feature
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.network.NetworkConfig
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
data class ZnsImportState(
val selectedFilePath: String? = null,
val isUploading: Boolean = false,
val jobId: String? = null,
val jobStatus: String? = null,
val progress: Int = 0,
val progressDetail: String = "",
val errors: List<String> = emptyList(),
val errorMessage: String? = null,
val isFinished: Boolean = false,
)
@Serializable
internal data class JobIdResponse(val jobId: String)
@Serializable
internal data class JobStatusResponse(
val jobId: String,
val status: String,
val progress: Int = 0,
val progressDetail: String = "",
val errors: List<String> = emptyList(),
)
private val TERMINAL_STATES = setOf("ABGESCHLOSSEN", "FEHLER")
private const val POLLING_INTERVAL_MS = 2000L
private const val MAX_VISIBLE_ERRORS = 50
class ZnsImportViewModel(
private val httpClient: HttpClient,
private val authTokenManager: AuthTokenManager,
) : ViewModel() {
var state by mutableStateOf(ZnsImportState())
private set
private var pollingJob: Job? = null
private val json = Json { ignoreUnknownKeys = true }
fun onFileSelected(path: String) {
state = ZnsImportState(selectedFilePath = path)
}
fun startImport() {
val filePath = state.selectedFilePath ?: return
val file = File(filePath)
if (!file.exists() || !file.name.endsWith(".zip", ignoreCase = true)) {
state = state.copy(errorMessage = "Bitte eine gültige .zip-Datei auswählen.")
return
}
viewModelScope.launch {
state = state.copy(
isUploading = true, errorMessage = null, isFinished = false,
jobId = null, progress = 0, progressDetail = "", errors = emptyList()
)
try {
val token = authTokenManager.authState.value.token
val response: HttpResponse = httpClient.post("${NetworkConfig.baseUrl}/api/v1/import/zns") {
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
setBody(MultiPartFormDataContent(formData {
append("file", file.readBytes(), Headers.build {
append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"")
append(HttpHeaders.ContentType, "application/zip")
})
}))
}
if (response.status == HttpStatusCode.Accepted) {
val body = json.decodeFromString<JobIdResponse>(response.bodyAsText())
state = state.copy(isUploading = false, jobId = body.jobId, jobStatus = "AUSSTEHEND")
startPolling(body.jobId)
} else {
state = state.copy(isUploading = false, errorMessage = "Upload fehlgeschlagen: HTTP ${response.status.value}")
}
} catch (e: Exception) {
state = state.copy(isUploading = false, errorMessage = "Fehler beim Upload: ${e.message}")
}
}
}
private fun startPolling(jobId: String) {
pollingJob?.cancel()
pollingJob = viewModelScope.launch {
while (true) {
try {
val token = authTokenManager.authState.value.token
val response: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/import/zns/$jobId/status") {
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
}
if (response.status.isSuccess()) {
val status = json.decodeFromString<JobStatusResponse>(response.bodyAsText())
state = state.copy(
jobStatus = status.status,
progress = status.progress,
progressDetail = status.progressDetail,
errors = status.errors.takeLast(MAX_VISIBLE_ERRORS),
isFinished = status.status in TERMINAL_STATES,
)
if (status.status in TERMINAL_STATES) break
}
} catch (e: Exception) {
state = state.copy(errorMessage = "Polling-Fehler: ${e.message}", isFinished = true)
break
}
delay(POLLING_INTERVAL_MS)
}
}
}
fun reset() {
pollingJob?.cancel()
state = ZnsImportState()
}
override fun onCleared() {
super.onCleared()
pollingJob?.cancel()
}
}
@@ -0,0 +1,9 @@
package at.mocode.zns.feature.di
import at.mocode.zns.feature.ZnsImportViewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
val znsImportModule = module {
factory { ZnsImportViewModel(get(named("apiClient")), get()) }
}
@@ -29,6 +29,7 @@ kotlin {
// Feature-Module
implementation(projects.frontend.features.nennungFeature)
implementation(projects.frontend.features.pingFeature)
implementation(projects.frontend.features.znsImportFeature)
// Compose Desktop
implementation(compose.desktop.currentOs)
@@ -13,6 +13,7 @@ import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.nennung.feature.di.nennungFeatureModule
import at.mocode.ping.feature.di.pingFeatureModule
import at.mocode.zns.feature.di.znsImportModule
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext
import org.koin.core.context.loadKoinModules
@@ -29,6 +30,7 @@ fun main() = application {
localDbModule,
pingFeatureModule,
nennungFeatureModule,
znsImportModule,
desktopModule,
)
}
@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow
*/
class DesktopNavigationPort : NavigationPort {
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Login())
val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
override val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
override fun navigateTo(route: String) {
val screen = AppScreen.fromRoute(route)
@@ -20,7 +20,7 @@ class DesktopNavigationPort : NavigationPort {
_currentScreen.value = screen
}
fun navigateToScreen(screen: AppScreen) {
override fun navigateToScreen(screen: AppScreen) {
println("[DesktopNav] navigateToScreen -> $screen")
_currentScreen.value = screen
}
@@ -67,6 +67,7 @@ private val navItems = listOf(
NavItem("Funktionäre", Icons.Default.Badge, AppScreen.Funktionaere),
NavItem("Meisterschaften", Icons.Default.EmojiEvents, AppScreen.Meisterschaften),
NavItem("Cups", Icons.Default.WorkspacePremium, AppScreen.Cups),
NavItem("Stammdaten-Import", Icons.Default.CloudUpload, AppScreen.StammdatenImport),
)
@Composable
@@ -221,6 +222,7 @@ private fun DesktopContentArea(
is AppScreen.Funktionaere -> FunktionaereScreen()
is AppScreen.Meisterschaften -> MeisterschaftenScreen()
is AppScreen.Cups -> CupsScreen()
is AppScreen.StammdatenImport -> StammdatenImportScreen()
// Fallback für alle anderen Screens (Dashboard, Ping etc.)
else -> VeranstaltungenScreen(
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungNeu) },
@@ -0,0 +1,257 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import at.mocode.zns.feature.ZnsImportViewModel
import org.koin.compose.viewmodel.koinViewModel
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
@Composable
fun StammdatenImportScreen(
viewModel: ZnsImportViewModel = koinViewModel(),
) {
val state = viewModel.state
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Titel
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.CloudUpload, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text("Stammdaten-Import (ZNS)", style = MaterialTheme.typography.headlineSmall)
}
HorizontalDivider()
// Datei-Auswahl
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Datei auswählen", style = MaterialTheme.typography.titleMedium)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = state.selectedFilePath ?: "",
onValueChange = {},
readOnly = true,
placeholder = { Text("Keine Datei ausgewählt…") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Button(
onClick = {
val path = pickZipFile()
if (path != null) viewModel.onFileSelected(path)
},
enabled = !state.isUploading && !(!state.isFinished && state.jobId != null),
) {
Icon(Icons.Default.FolderOpen, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Durchsuchen")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { viewModel.startImport() },
enabled = state.selectedFilePath != null && !state.isUploading && !(state.jobId != null && !state.isFinished),
) {
if (state.isUploading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
} else {
Icon(Icons.Default.Upload, contentDescription = null)
Spacer(Modifier.width(4.dp))
}
Text("Import starten")
}
if (state.isFinished || state.errorMessage != null) {
OutlinedButton(onClick = { viewModel.reset() }) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Zurücksetzen")
}
}
}
}
}
// Fehler-Banner
if (state.errorMessage != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Text(state.errorMessage, color = MaterialTheme.colorScheme.onErrorContainer)
}
}
}
// Fortschritt
if (state.jobId != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Status", style = MaterialTheme.typography.titleMedium)
StatusChip(state.jobStatus)
}
LinearProgressIndicator(
progress = { state.progress / 100f },
modifier = Modifier.fillMaxWidth().height(8.dp),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
state.progressDetail.ifBlank { "Warte auf Server…" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"${state.progress}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.isFinished && state.jobStatus == "ABGESCHLOSSEN") {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Text(
"Import erfolgreich abgeschlossen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
// Fehler-Liste
if (state.errors.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth().weight(1f),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
Text(
"Import-Fehler (${state.errors.size})",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.error,
)
}
HorizontalDivider()
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(state.errors) { error ->
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text("", color = MaterialTheme.colorScheme.error)
Text(
error,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
}
}
}
}
}
}
}
}
@Composable
private fun StatusChip(status: String?) {
val (label, color) = when (status) {
"AUSSTEHEND" -> "Ausstehend" to MaterialTheme.colorScheme.outline
"ENTPACKEN" -> "Entpacken…" to MaterialTheme.colorScheme.tertiary
"LADE_VEREINE" -> "Lade Vereine…" to MaterialTheme.colorScheme.secondary
"LADE_REITER" -> "Lade Reiter…" to MaterialTheme.colorScheme.secondary
"LADE_PFERDE" -> "Lade Pferde…" to MaterialTheme.colorScheme.secondary
"LADE_RICHTER" -> "Lade Richter…" to MaterialTheme.colorScheme.secondary
"ABGESCHLOSSEN" -> "Abgeschlossen ✓" to MaterialTheme.colorScheme.primary
"FEHLER" -> "Fehler ✗" to MaterialTheme.colorScheme.error
else -> (status ?: "") to MaterialTheme.colorScheme.outline
}
Surface(
shape = RoundedCornerShape(12.dp),
color = color.copy(alpha = 0.15f),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = color,
)
}
}
/** Öffnet einen nativen JFileChooser (JVM-only) und gibt den Pfad der gewählten ZIP zurück. */
private fun pickZipFile(): String? {
val chooser = JFileChooser()
chooser.dialogTitle = "ZNS.zip auswählen"
chooser.fileFilter = FileNameExtensionFilter("ZIP-Archiv (*.zip)", "zip")
chooser.isAcceptAllFileFilterUsed = false
val result = chooser.showOpenDialog(null)
return if (result == JFileChooser.APPROVE_OPTION) chooser.selectedFile.absolutePath else null
}
@@ -1,134 +0,0 @@
@file:OptIn(ExperimentalKotlinGradlePluginApi::class)
@file:Suppress("DEPRECATION")
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
/**
* Dieses Modul ist der "Host". Es kennt alle Features und die Shared-Module und
* setzt sie zu einer lauffähigen Anwendung zusammen.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
// JVM Target für Desktop
jvm {
binaries {
executable {
mainClass.set("MainKt")
}
}
}
// JavaScript Target für Web
js {
browser {
commonWebpackConfig {
cssSupport { enabled = true }
mode = if (project.hasProperty("production"))
KotlinWebpackConfig.Mode.PRODUCTION
else
KotlinWebpackConfig.Mode.DEVELOPMENT
// Source Maps: Im Production-Mode standardmäßig AUS (außer explizit via -PenableSourceMaps).
// Beschleunigt den Build massiv und reduziert Bundle-Größe.
if (mode == KotlinWebpackConfig.Mode.PRODUCTION && !project.hasProperty("enableSourceMaps")) {
sourceMaps = false
}
}
webpackTask {
mainOutputFileName = "web-app.js"
// Minification wird via webpack.config.d/z_disable-minification.js deaktiviert,
// um den Terser-Crash mit SQLite-WASM (sqlite3-worker1.mjs) zu verhindern.
// Siehe: webpack.config.d/z_disable-minification.js
}
// Development Server konfigurieren
runTask {
mainOutputFileName.set("web-app.js")
}
}
binaries.executable()
}
sourceSets {
commonMain.dependencies {
// Shared modules
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.navigation)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.sync)
implementation(projects.frontend.core.localDb)
implementation(projects.frontend.core.auth)
implementation(projects.frontend.features.pingFeature)
// DI (Koin) needed to call initKoin { modules(...) }
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// Compose Multiplatform
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
implementation(compose.components.uiToolingPreview)
// Bundles
implementation(libs.bundles.kmp.common) // Coroutines, Serialization, DateTime
implementation(libs.bundles.compose.common) // ViewModel & Lifecycle
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
implementation(compose.uiTooling)
implementation(libs.koin.core)
implementation(project(":frontend:features:nennung-feature"))
}
jsMain.dependencies {
implementation(compose.html.core)
// Benötigt für custom webpack config (wasm.js)
implementation(devNpm("copy-webpack-plugin", libs.versions.copyWebpackPlugin.get()))
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
// Duplicate-Handling für Distribution (Zentralisiert in Root build.gradle.kts, aber hier spezifisch für Distribution Tasks)
tasks.withType<Tar> {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.withType<Zip> {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
// Desktop Application Configuration
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Meldestelle"
packageVersion = "1.0.0"
description = "Meldestelle Development App"
}
}
}
@@ -1 +0,0 @@
expect fun isDevelopmentMode(): Boolean
@@ -1,163 +0,0 @@
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.auth.presentation.LoginScreen
import at.mocode.frontend.core.auth.presentation.LoginViewModel
import at.mocode.frontend.core.designsystem.theme.AppTheme
import at.mocode.frontend.core.domain.PlatformType
import at.mocode.frontend.core.domain.currentPlatform
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.ping.feature.presentation.PingScreen
import at.mocode.ping.feature.presentation.PingViewModel
import navigation.StateNavigationPort
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import screens.*
@Composable
fun MainApp() {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val navigationPort = koinInject<StateNavigationPort>()
val currentScreen by navigationPort.currentScreen.collectAsState()
val authTokenManager = koinInject<AuthTokenManager>()
val pingViewModel: PingViewModel = koinViewModel()
val loginViewModel: LoginViewModel = koinViewModel()
when (val screen = currentScreen) {
is AppScreen.Landing -> {
if (currentPlatform() == PlatformType.DESKTOP) {
val authState = authTokenManager.authState.collectAsState().value
if (authState.isAuthenticated) {
DashboardScreen(
authTokenManager = authTokenManager,
onLogout = {
authTokenManager.clearToken()
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
},
onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) }
)
} else {
LaunchedEffect(Unit) {
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
}
}
} else {
LandingScreen(
onPrimaryCta = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) },
onOpenPing = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.OrganizerProfile)) }
)
}
}
is AppScreen.Dashboard -> DashboardScreen(
authTokenManager = authTokenManager,
onNennungOeffnen = { navigationPort.navigateToScreen(AppScreen.Nennung) },
onLogout = {
authTokenManager.clearToken()
if (currentPlatform() == PlatformType.DESKTOP) {
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
} else {
navigationPort.navigateToScreen(AppScreen.Landing)
}
},
onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) }
)
is AppScreen.Home -> DashboardScreen(
authTokenManager = authTokenManager,
onLogout = {
authTokenManager.clearToken()
if (currentPlatform() == PlatformType.DESKTOP) {
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
} else {
navigationPort.navigateToScreen(AppScreen.Landing)
}
},
onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) }
)
is AppScreen.CreateTournament -> CreateTournamentScreen(
onBack = { navigationPort.navigateToScreen(AppScreen.Dashboard) },
onSave = { navigationPort.navigateToScreen(AppScreen.Dashboard) }
)
is AppScreen.Login -> {
LoginScreen(
viewModel = loginViewModel,
onLoginSuccess = {
val returnTo = screen.returnTo
if (returnTo != null) {
navigationPort.navigateToScreen(returnTo)
} else {
navigationPort.navigateToScreen(AppScreen.Dashboard)
}
},
onBack = {
if (currentPlatform() == PlatformType.DESKTOP) {
// Desktop hat keine Landing Page — bleibt auf Login
} else {
navigationPort.navigateToScreen(AppScreen.Landing)
}
}
)
}
is AppScreen.Ping -> PingScreen(
viewModel = pingViewModel,
onBack = {
if (currentPlatform() == PlatformType.DESKTOP) {
navigationPort.navigateToScreen(AppScreen.Dashboard)
} else {
navigationPort.navigateToScreen(AppScreen.Landing)
}
}
)
is AppScreen.Nennung -> {
// NennungsMaske wird über das nennung-feature eingebunden (jvmMain only)
// Placeholder, bis das Feature vollständig integriert ist
NennungScreenContent()
}
is AppScreen.OrganizerProfile -> OrganizerProfileScreen(
authTokenManager = authTokenManager,
onLogout = {
authTokenManager.clearToken()
navigationPort.navigateToScreen(AppScreen.Landing)
},
onNavigateToDashboard = { navigationPort.navigateToScreen(AppScreen.Dashboard) }
)
is AppScreen.AuthCallback -> { /* OIDC Callback wird vom Auth-Modul verarbeitet */
}
is AppScreen.Profile -> AuthStatusScreen(
authTokenManager = authTokenManager,
onNavigateToLogin = {
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Profile))
},
onNavigateToPing = { navigationPort.navigateToScreen(AppScreen.Ping) },
onBackToHome = {
if (currentPlatform() == PlatformType.DESKTOP) {
navigationPort.navigateToScreen(AppScreen.Dashboard)
} else {
navigationPort.navigateToScreen(AppScreen.Landing)
}
}
)
else -> {}
}
}
}
}
@@ -1,4 +0,0 @@
import androidx.compose.runtime.Composable
@Composable
expect fun NennungScreenContent()
@@ -1,145 +0,0 @@
package components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
// Dummy-Datenklasse für Turnier-Einträge (wird später durch echtes Domain-Model ersetzt)
data class TournamentData(
val id: String,
val date: String,
val title: String,
val location: String
)
@Composable
fun TournamentCard(data: TournamentData) {
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Left: Logo Placeholder
Surface(
modifier = Modifier.size(100.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium
) {
Box(contentAlignment = Alignment.Center) {
Text(
"URFV\nLogo",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.width(24.dp))
// Middle: Info
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = data.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${data.location} ${data.date}",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Turnier-Nr.:${data.id}",
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.width(24.dp))
// Right: Actions
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.width(200.dp)
) {
OutlinedButton(
onClick = { /* TODO */ },
modifier = Modifier.fillMaxWidth()
) {
Text("Ausschreibung")
}
OutlinedButton(
onClick = { /* TODO */ },
modifier = Modifier.fillMaxWidth()
) {
Text("Nennen")
}
OutlinedButton(
onClick = { /* TODO */ },
modifier = Modifier.fillMaxWidth()
) {
Text("Start- Ergebnislisten")
}
}
}
}
}
@Composable
fun ToggleRow(label: String, isOnline: Boolean, isInteractive: Boolean = false) {
Surface(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth().height(40.dp),
color = if (isInteractive) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val statusColor = if (isOnline) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
Surface(
modifier = Modifier.size(16.dp),
shape = CircleShape,
color = statusColor
) {}
if (isInteractive) {
Surface(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
modifier = Modifier.width(40.dp).height(24.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(if (isOnline) "on" else "off", style = MaterialTheme.typography.labelSmall)
}
}
} else {
Text(
if (isOnline) "Online" else "Offline",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(40.dp),
textAlign = TextAlign.Center
)
}
}
}
}
}
@@ -1,55 +0,0 @@
package navigation
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.domain.models.User
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.navigation.CurrentUserProvider
import at.mocode.frontend.core.navigation.DeepLinkHandler
import at.mocode.frontend.core.navigation.NavigationPort
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.dsl.module
class ShellCurrentUserProvider(
private val authTokenManager: AuthTokenManager,
) : CurrentUserProvider {
override fun getCurrentUser(): User? {
val state = authTokenManager.authState.value
if (!state.isAuthenticated) return null
// Roles are not yet modeled in AuthState; provide an empty list for now
return User(
id = state.userId ?: state.username ?: "unknown",
username = state.username ?: state.userId ?: "unknown",
displayName = null,
roles = emptyList(),
)
}
}
/**
* A real implementation of NavigationPort that updates a StateFlow.
* This allows the MainApp to observe changes and update the UI.
*/
class StateNavigationPort : NavigationPort {
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Landing)
val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
override fun navigateTo(route: String) {
val screen = AppScreen.fromRoute(route)
println("[NavigationPort] navigateTo $route -> $screen")
_currentScreen.value = screen
}
fun navigateToScreen(screen: AppScreen) {
_currentScreen.value = screen
}
}
val navigationModule = module {
single<CurrentUserProvider> { ShellCurrentUserProvider(get()) }
// Bind as both NavigationPort (for Core) and StateNavigationPort (for Shell)
single { StateNavigationPort() }
single<NavigationPort> { get<StateNavigationPort>() }
single { DeepLinkHandler(get(), get()) }
}
@@ -1,68 +0,0 @@
package screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.auth.data.AuthTokenManager
@Composable
fun AuthStatusScreen(
authTokenManager: AuthTokenManager,
onNavigateToLogin: () -> Unit,
onNavigateToPing: () -> Unit,
onBackToHome: () -> Unit
) {
val authState by authTokenManager.authState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Ping-Service / System Status", style = MaterialTheme.typography.headlineMedium)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (authState.isAuthenticated) {
Text(
"Du bist angemeldet als: ${authState.username ?: authState.userId ?: "unbekannt"}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = onNavigateToPing) {
Text("Ping-Service Tests durchführen")
}
OutlinedButton(onClick = {
authTokenManager.clearToken()
}) { Text("Abmelden") }
}
} else {
Text(
"Du bist abgemeldet.",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = onNavigateToLogin) {
Text("Login")
}
OutlinedButton(onClick = onNavigateToPing) {
Text("Ping-Service (eingeschränkt testen)")
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
OutlinedButton(onClick = onBackToHome) { Text("Zurück zur Startseite") }
}
}
}
}
@@ -1,390 +0,0 @@
package screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun CreateTournamentScreen(
onBack: () -> Unit,
onSave: () -> Unit
) {
var currentStep by remember { mutableStateOf(1) }
Column(modifier = Modifier.fillMaxSize()) {
// App Header
Surface(
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
TextButton(onClick = onBack) {
Text("← Zurück")
}
Text(
text = "Neues Turnier anlegen (Desktop Client)",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
}
}
// Stepper / Progress Bar
Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StepIndicator(step = 1, title = "Transfer", isActive = currentStep == 1, isCompleted = currentStep > 1)
StepIndicator(step = 2, title = "Stammdaten", isActive = currentStep == 2, isCompleted = currentStep > 2)
StepIndicator(step = 3, title = "Konfiguration", isActive = currentStep == 3, isCompleted = currentStep > 3)
StepIndicator(step = 4, title = "Funktionäre", isActive = currentStep == 4, isCompleted = currentStep > 4)
StepIndicator(step = 5, title = "Bewerbe", isActive = currentStep == 5, isCompleted = currentStep > 5)
}
}
// Wizard Content Area
Box(modifier = Modifier.weight(1f).padding(24.dp)) {
when (currentStep) {
1 -> TournamentStepTransfer()
2 -> TournamentStepStammdaten()
3 -> TournamentStepKonfiguration()
4 -> TournamentStepFunktionaere()
5 -> TournamentStepBewerbe()
}
}
// Bottom Navigation Bar
Surface(shadowElevation = 8.dp, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(24.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
if (currentStep > 1) {
OutlinedButton(onClick = { currentStep-- }) { Text("Zurück") }
} else {
Spacer(modifier = Modifier.width(1.dp))
}
if (currentStep < 5) {
Button(onClick = { currentStep++ }) { Text("Weiter") }
} else {
Button(onClick = onSave) { Text("Turnier speichern") }
}
}
}
}
}
@Composable
fun StepIndicator(step: Int, title: String, isActive: Boolean, isCompleted: Boolean) {
val color = when {
isActive -> MaterialTheme.colorScheme.primary
isCompleted -> MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
}
val fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Surface(
shape = MaterialTheme.shapes.small,
color = color,
modifier = Modifier.size(24.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(step.toString(), color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelSmall)
}
}
Text(title, color = color, fontWeight = fontWeight)
}
}
@Composable
fun TournamentStepTransfer() {
Column(verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.fillMaxWidth(0.8f)) {
Text("Schritt 1: Transfer & Initialisierung", style = MaterialTheme.typography.headlineSmall)
Text(
"In diesem Schritt erschaffen wir eine separate Datenbank für dieses spezifische Turnier. " +
"Diese Datenbank kann für den komplett isolierten Offline-Betrieb (z.B. am USB-Stick) auf andere Laptops übertragen werden.",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = "",
onValueChange = {},
label = { Text("Turniernummer OEPS (z.B. 26128)") },
modifier = Modifier.weight(1f)
)
Button(onClick = { /*TODO*/ }, modifier = Modifier.align(Alignment.CenterVertically)) {
Text("Turnierdatenbank Initialisieren")
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text("Datenaustausch (OEPS / Externe Systeme)", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Card(modifier = Modifier.weight(1f)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("ZNS / Stammdaten Import", fontWeight = FontWeight.Bold)
Text(
"Aktualisieren Sie die Reiter, Pferde und Funktionäre aus dem zentralen System.",
style = MaterialTheme.typography.bodyMedium
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = "",
onValueChange = {},
label = { Text("Pfad zur ZNS.zip") },
modifier = Modifier.weight(1f),
readOnly = true
)
Button(onClick = { /* Öffnet File Picker */ }) { Text("...") }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = "",
onValueChange = {},
label = { Text("Pfad zur AWÖ/Zucht-Datei") },
modifier = Modifier.weight(1f),
readOnly = true
)
Button(onClick = { /* Öffnet File Picker */ }) { Text("...") }
}
}
}
Card(modifier = Modifier.weight(1f)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("OEPS Export", fontWeight = FontWeight.Bold)
Text(
"Erzeugt die xxxxx.erg Datei für die offizielle Ergebnismeldung nach dem Turnier.",
style = MaterialTheme.typography.bodyMedium
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = "",
onValueChange = {},
label = { Text("Ziel-Ordner für .erg Export") },
modifier = Modifier.weight(1f),
readOnly = true
)
Button(onClick = { /* Öffnet Directory Picker */ }) { Text("...") }
}
Button(
onClick = { /*TODO*/ },
modifier = Modifier.fillMaxWidth(),
enabled = false
) { Text("Ergebnis-Export (.erg)") }
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text("Offline-Sync (USB-Stick / Lokales Netzwerk)", style = MaterialTheme.typography.titleMedium)
Text(
"Übertragen Sie den kompletten Turnierstand zwischen Master-Meldestelle und Richterturm-Laptops ohne Internet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = "",
onValueChange = {},
label = { Text("Ziel-Pfad (z.B. D:/Export)") },
modifier = Modifier.weight(1f),
readOnly = true
)
Button(onClick = { /* Öffnet Directory Picker */ }) { Text("...") }
}
OutlinedButton(onClick = { /*TODO*/ }, modifier = Modifier.fillMaxWidth()) { Text("↑ Turnier Exportieren") }
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = "",
onValueChange = {},
label = { Text("Quell-Datei (z.B. D:/turnier.db)") },
modifier = Modifier.weight(1f),
readOnly = true
)
Button(onClick = { /* Öffnet File Picker */ }) { Text("...") }
}
OutlinedButton(onClick = { /*TODO*/ }, modifier = Modifier.fillMaxWidth()) { Text("↓ Turnier Importieren") }
}
}
}
}
@Composable
fun TournamentStepStammdaten() {
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth(0.6f)) {
Text("Schritt 2: Turnier-Stammdaten", style = MaterialTheme.typography.headlineSmall)
OutlinedTextField(
value = "CSN-C NEU Neumarkt",
onValueChange = {},
label = { Text("Turniername") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = "25.04.2026",
onValueChange = {},
label = { Text("Datum von") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = "25.04.2026",
onValueChange = {},
label = { Text("Datum bis") },
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
fun TournamentStepKonfiguration() {
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth(0.6f)) {
Text("Schritt 3: Konfiguration", style = MaterialTheme.typography.headlineSmall)
Text("Austragungsplätze und Preisliste")
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Austragungsplätze", fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
FilterChip(selected = true, onClick = {}, label = { Text("Platz 1 (Sand/Vlies 45x65m)") })
FilterChip(selected = false, onClick = {}, label = { Text("Halle (Sand/Vlies 20x40m)") })
FilterChip(selected = false, onClick = {}, label = { Text("+ Hinzufügen") })
}
}
}
}
}
@Composable
fun TournamentStepFunktionaere() {
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth(0.6f)) {
Text("Schritt 4: Team & Funktionäre", style = MaterialTheme.typography.headlineSmall)
Text("Zuweisung von Richtern und Parcoursbauern (aus ZNS)")
OutlinedTextField(
value = "Rudi Kreupl",
onValueChange = {},
label = { Text("Turnierbeauftragter (Suche nach Name oder ID)") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = "Helmut Riedler",
onValueChange = {},
label = { Text("Richter (Suche nach Name oder ID)") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = "Kurt Reitetschlägerr",
onValueChange = {},
label = { Text("Parcoursbauchef") },
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
fun TournamentStepBewerbe() {
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxSize()) {
Text("Schritt 5: Bewerbe anlegen", style = MaterialTheme.typography.headlineSmall)
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
// Left: List of Bewerbe
Card(modifier = Modifier.weight(1f).fillMaxHeight()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Bewerbe", fontWeight = FontWeight.Bold)
TextButton(onClick = {}) { Text("+ Neu") }
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text("1: Pony Stilspringprüfung (60cm)", modifier = Modifier.padding(vertical = 4.dp))
Text("2: Einlaufspringprüfung (60cm)", modifier = Modifier.padding(vertical = 4.dp))
Text("3: Pony Stilspringprüfung (70cm)", modifier = Modifier.padding(vertical = 4.dp))
Text("4: Einlaufspringprüfung (70cm)", modifier = Modifier.padding(vertical = 4.dp))
Text("...", modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
// Right: Detail Tabs for selected Bewerb
Card(modifier = Modifier.weight(2f).fillMaxHeight()) {
Column {
PrimaryTabRow(selectedTabIndex = 0) {
Tab(selected = true, onClick = {}, text = { Text("Bewertung") })
Tab(selected = false, onClick = {}, text = { Text("Geldpreis") })
Tab(selected = false, onClick = {}, text = { Text("Ort/Zeit") })
}
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = "2",
onValueChange = {},
label = { Text("Bewerb Nr.") },
modifier = Modifier.width(100.dp)
)
OutlinedTextField(
value = "Einlaufspringprüfung",
onValueChange = {},
label = { Text("Bezeichnung") },
modifier = Modifier.weight(1f)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = "60cm",
onValueChange = {},
label = { Text("Klasse / Höhe") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = "§ 218",
onValueChange = {},
label = { Text("Richtverfahren") },
modifier = Modifier.weight(1f)
)
}
Text("Abteilungen", fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = true, onCheckedChange = {})
Text("1. Abt: lizenzfrei")
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = true, onCheckedChange = {})
Text("2. Abt: mit Lizenz")
}
}
}
}
}
}
}
@@ -1,382 +0,0 @@
package screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.domain.PlatformType
import at.mocode.frontend.core.domain.currentPlatform
import components.ToggleRow
import components.TournamentData
@Composable
fun DashboardScreen(
authTokenManager: AuthTokenManager,
onLogout: () -> Unit,
onCreateTournament: () -> Unit,
onNennungOeffnen: () -> Unit = {},
) {
val authState by authTokenManager.authState.collectAsState()
val scrollState = rememberScrollState()
val isDesktop = currentPlatform() == PlatformType.DESKTOP
// Security Check für das Dashboard
if (!authState.isAuthenticated) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
val isAdmin = authTokenManager.isAdmin()
Column(
modifier = Modifier.fillMaxSize()
) {
// App Header (Meldestelle Toolbar)
Surface(
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = if (isDesktop) "Master-Meldestelle Steuerungszentrale" else if (isAdmin) "Admin-Dashboard (Web)" else "Veranstalter-Dashboard",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Angemeldet als: ${authState.username ?: "Admin"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = onLogout) {
Text("Abmelden")
}
}
}
}
// Main Content Area
Column(
modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
if (isDesktop && isAdmin) {
// DESKTOP VIEW - STEUERUNGSZENTRALE FÜR DEN ADMIN
Button(
onClick = onNennungOeffnen,
modifier = Modifier.fillMaxWidth().height(64.dp)
) {
Text(
text = "📋 Nennungs-Maske öffnen",
style = MaterialTheme.typography.titleMedium
)
}
OutlinedButton(
onClick = onCreateTournament,
modifier = Modifier.fillMaxWidth().height(64.dp)
) {
Text(
text = "+ neues Turnier anlegen",
style = MaterialTheme.typography.titleMedium
)
}
Text(
text = "Alle verwalteten Turniere",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
// Filters (Mockup)
Surface(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Zeitraum:", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = "März",
onValueChange = {},
modifier = Modifier.width(100.dp).height(48.dp),
singleLine = true
)
Text("bis", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = "Dezember",
onValueChange = {},
modifier = Modifier.width(120.dp).height(48.dp),
singleLine = true
)
OutlinedTextField(
value = "2026",
onValueChange = {},
modifier = Modifier.width(80.dp).height(48.dp),
singleLine = true
)
OutlinedTextField(
value = "Bundesland",
onValueChange = {},
modifier = Modifier.width(150.dp).height(48.dp),
singleLine = true
)
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE67E22))
) {
Text("Anzeigen")
}
}
Text("Zusätzliche Filter auf Suchergebnisse:", style = MaterialTheme.typography.bodyMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = "Veranstalter (Verein)",
onValueChange = {},
modifier = Modifier.width(180.dp).height(48.dp),
singleLine = true
)
OutlinedTextField(
value = "Ort",
onValueChange = {},
modifier = Modifier.width(120.dp).height(48.dp),
singleLine = true
)
OutlinedTextField(
value = "Sparte",
onValueChange = {},
modifier = Modifier.width(120.dp).height(48.dp),
singleLine = true
)
OutlinedTextField(
value = "Turnierart",
onValueChange = {},
modifier = Modifier.width(120.dp).height(48.dp),
singleLine = true
)
}
}
}
// Desktop Tournament Card (Steuerungszentrale Ansicht)
Card(
modifier = Modifier.fillMaxWidth(),
border = BorderStroke(2.dp, MaterialTheme.colorScheme.outlineVariant),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.weight(1f)
) {
Surface(
modifier = Modifier.size(120.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.small
) {
Box(contentAlignment = Alignment.Center) {
Text("NEUMARKT\nLOGO", textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall)
}
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("CDN-C NEU CDNP-C", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Veranstalter: URFV Neumarkt",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
Text("NEUMARKT/M., OÖ 26. APRIL 2026", style = MaterialTheme.typography.bodyMedium)
Text("Turnier-Nr.: 26129", style = MaterialTheme.typography.bodyMedium)
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
var meldestelleOnline by remember { mutableStateOf(true) }
var nennsystemOnline by remember { mutableStateOf(true) }
var startlisteOnline by remember { mutableStateOf(true) }
ToggleRow("Meldestelle-Desktop online", isOnline = meldestelleOnline, isInteractive = false)
ToggleRow("Nennsystem online", isOnline = nennsystemOnline, isInteractive = true)
ToggleRow("Start- Ergebnislisten online", isOnline = startlisteOnline, isInteractive = true)
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(
onClick = { /* Link kopieren oder Email Dialog öffnen */ },
modifier = Modifier.fillMaxWidth()
) {
Text("Veranstalter-Link senden")
}
}
}
}
} else if (isDesktop && !isAdmin) {
// DESKTOP VIEW - VERANSTALTER (Meldestelle am Platz)
Text(
"Willkommen in der Meldestellen-Software für Turnier 26129",
style = MaterialTheme.typography.headlineMedium
)
Text("Bitte initialisieren Sie die lokale Datenbank oder importieren Sie einen Stand vom USB-Stick.")
Spacer(Modifier.height(16.dp))
Button(onClick = onCreateTournament) {
Text("Turnier initialisieren / Importieren")
}
} else if (!isDesktop && isAdmin) {
// WEB VIEW - ADMIN PORTAL
Text(
text = "Alle verwalteten Turniere",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"CDN-C NEU CDNP-C Neumarkt",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text("Veranstalter: URFV Neumarkt", style = MaterialTheme.typography.bodyMedium)
Text("Nr: 26129 | 26. APRIL 2026", style = MaterialTheme.typography.bodyMedium)
}
Button(onClick = { /* TODO: Download Trigger for Master App */ }) {
Text("Master-Desktop-App herunterladen")
}
}
}
} else {
// WEB VIEW - VERANSTALTER PORTAL
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
) {
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Aktuelles Turnier: CDN-C NEU CDNP-C Neumarkt",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
"Turnier-Nr.: 26129 | 26. APRIL 2026",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(Modifier.height(8.dp))
Text(
"Bitte laden Sie die Desktop-Anwendung herunter, um die Meldestelle lokal an Ihrem Turnierplatz zu betreiben.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(top = 8.dp)
) {
Button(onClick = { /* TODO: Download Trigger for generic app */ }) {
Text("Meldestelle-App herunterladen (.exe)")
}
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
) {
Text(
"Ihr Aktivierungs-Code: X7F9-K2M4",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
// Turnier-Historie
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
Text("Turnier-Historie (Archiv)", style = MaterialTheme.typography.headlineSmall)
val turniere = listOf(
TournamentData(
id = "25044",
date = "24. APRIL 2025",
title = "CSN-C CSNP-C Neumarkt",
location = "NEUMARKT/M., OÖ"
),
TournamentData(
id = "24012",
date = "28. APRIL 2024",
title = "CDN-C CDNP-C Neumarkt",
location = "NEUMARKT/M., OÖ"
)
)
turniere.forEach { turnier ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(turnier.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Nr: ${turnier.id} | ${turnier.date}", style = MaterialTheme.typography.bodyMedium)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { /* TODO: Open PDF Archive */ }) {
Text("Ergebnislisten (PDF)")
}
OutlinedButton(onClick = { /* TODO: Open Stats */ }) {
Text("Statistiken")
}
}
}
}
}
}
}
}
}
}
@@ -1,165 +0,0 @@
package screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.AppFooter
@Composable
fun LandingScreen(
onPrimaryCta: () -> Unit,
onOpenPing: () -> Unit
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
) {
// Top Bar
Surface(
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "mo-code.at",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Button(onClick = onPrimaryCta) {
Text("Login Meldestelle")
}
}
}
// Hero
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 60.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Die moderne Meldestelle",
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Von Praktikern für Praktiker. Schneller Nennen, fehlerfrei Richten und stressfrei Auswerten konform nach ÖTO & FEI.",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Manifest / Intro
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 60.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Unser Anspruch: Ein durchdachtes System.", style = MaterialTheme.typography.headlineMedium)
Text(
"Die Meldestelle ist das Herzstück jedes Turniers. Wenn sie stolpert, stockt der Sport. Wir verstehen den Balanceakt zwischen Veranstaltern, Reitern und den Verbänden.",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Deshalb entwickeln wir diese Plattform nicht am Reißbrett, sondern direkt am Turnierplatz aus der Sicht der Meldestelle, der Richter, der Zeitnehmer und aller Funktionäre.",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Mit Fokus auf die Praxis: Tastaturbedienung für höchste Geschwindigkeit, Offline-Fähigkeit für das 'Plumpsklo' am Rand des Abreiteplatzes und eine integrierte Kassenführung.",
style = MaterialTheme.typography.bodyLarge
)
}
}
// Kern-Säulen
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 60.dp),
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
Text("Die Kern-Säulen", style = MaterialTheme.typography.headlineMedium)
Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
LandingFeatureCard(
number = "01",
title = "Regelwerks-Intelligenz (ÖTO)",
body = "Wir nehmen Ihnen die Validierungs-Komplexität ab. Von der Lizenzprüfung der Reiter bis zur Kontrolle der Richterqualifikationen beim Anlegen der Bewerbe."
)
LandingFeatureCard(
number = "02",
title = "Offline-First & Resilient",
body = "Stabil auf dem Laptop. Dank Offline-Unterstützung und lokaler Datenbank arbeiten Sie nahtlos weiter, selbst wenn die Internetverbindung am Platz wieder einmal abreißt."
)
LandingFeatureCard(
number = "03",
title = "Speed-Workflow",
body = "Die Nennungsmaske und die Ergebniserfassung sind kompromisslos auf Geschwindigkeit und Tastaturbedienung (Enter & Tab) optimiert. Weil am Turniertag jede Sekunde zählt."
)
LandingFeatureCard(
number = "04",
title = "Smarte Kassenführung",
body = "Kontobasierte Abrechnung für Reiter und Besitzer. Nenngelder, Startgelder und Nachnenngebühren sauber getrennt selbst ein Nennungstausch wird als einfacher Transfer verbucht."
)
}
}
// System Status Link
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedButton(onClick = onOpenPing) {
Text("System Status (Ping-Service)")
}
}
AppFooter()
}
}
@Composable
private fun LandingFeatureCard(number: String, title: String, body: String) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = number,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Black,
modifier = Modifier.width(64.dp)
)
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text(body, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@@ -1,345 +0,0 @@
package screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.auth.data.AuthTokenManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OrganizerProfileScreen(
authTokenManager: AuthTokenManager,
onLogout: () -> Unit,
onNavigateToDashboard: () -> Unit
) {
val authState by authTokenManager.authState.collectAsState()
val scrollState = rememberScrollState()
// Formular-Felder
var vereinsname by remember { mutableStateOf("URFV Neumarkt") }
var vereinskuerzel by remember { mutableStateOf("URFV") }
var adresse by remember { mutableStateOf("") }
var plz by remember { mutableStateOf("") }
var ort by remember { mutableStateOf("") }
var land by remember { mutableStateOf("Österreich") }
var mapsLink by remember { mutableStateOf("") }
// Ansprechpersonen
var kontakt1Name by remember { mutableStateOf("") }
var kontakt1Email by remember { mutableStateOf("") }
var kontakt1Telefon by remember { mutableStateOf("") }
var kontakt2Name by remember { mutableStateOf("") }
var kontakt2Email by remember { mutableStateOf("") }
var kontakt2Telefon by remember { mutableStateOf("") }
// Social / Links
var webseite by remember { mutableStateOf("") }
var facebook by remember { mutableStateOf("") }
var instagram by remember { mutableStateOf("") }
var youtube by remember { mutableStateOf("") }
// Weitere Infos
var vereinsbeschreibung by remember { mutableStateOf("") }
var bankverbindung by remember { mutableStateOf("") }
var uid by remember { mutableStateOf("") }
var saveSuccess by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Veranstalter Profil") },
navigationIcon = {
IconButton(onClick = onNavigateToDashboard) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
actions = {
Text(
text = authState.username ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 8.dp)
)
TextButton(onClick = onLogout) { Text("Abmelden") }
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(scrollState)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// --- Logo & Vereinsname ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Verein / Veranstalter", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
// Logo Placeholder
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(
modifier = Modifier.padding(24.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("🏆", style = MaterialTheme.typography.displayMedium)
Text(
"Vereins-/Veranstaltungslogo",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedButton(onClick = { /* TODO: File Picker */ }) {
Text("Logo hochladen")
}
}
}
OutlinedTextField(
value = vereinsname,
onValueChange = { vereinsname = it },
label = { Text("Vereinsname / Veranstalter") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = vereinskuerzel,
onValueChange = { vereinskuerzel = it },
label = { Text("Kürzel") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = vereinsbeschreibung,
onValueChange = { vereinsbeschreibung = it },
label = { Text("Kurzbeschreibung / Über uns") },
minLines = 3,
maxLines = 6,
modifier = Modifier.fillMaxWidth()
)
}
}
// --- Adresse ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Adresse", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = adresse,
onValueChange = { adresse = it },
label = { Text("Straße & Hausnummer") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = plz,
onValueChange = { plz = it },
label = { Text("PLZ") },
singleLine = true,
modifier = Modifier.width(100.dp)
)
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Ort") },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = land,
onValueChange = { land = it },
label = { Text("Land") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = mapsLink,
onValueChange = { mapsLink = it },
label = { Text("Google Maps / OpenStreetMap Link") },
placeholder = { Text("https://maps.google.com/...") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
// --- Ansprechpersonen ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Ansprechpersonen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Hauptkontakt", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
OutlinedTextField(
value = kontakt1Name,
onValueChange = { kontakt1Name = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt1Email,
onValueChange = { kontakt1Email = it },
label = { Text("E-Mail") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt1Telefon,
onValueChange = { kontakt1Telefon = it },
label = { Text("Telefon / Mobil") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider()
Text(
"Weiterer Kontakt (optional)",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.secondary
)
OutlinedTextField(
value = kontakt2Name,
onValueChange = { kontakt2Name = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt2Email,
onValueChange = { kontakt2Email = it },
label = { Text("E-Mail") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt2Telefon,
onValueChange = { kontakt2Telefon = it },
label = { Text("Telefon / Mobil") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
// --- Social Media & Links ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Links & Social Media", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = webseite,
onValueChange = { webseite = it },
label = { Text("Webseite") },
placeholder = { Text("https://www.meinverein.at") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = facebook,
onValueChange = { facebook = it },
label = { Text("Facebook") },
placeholder = { Text("https://facebook.com/...") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = instagram,
onValueChange = { instagram = it },
label = { Text("Instagram") },
placeholder = { Text("https://instagram.com/...") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = youtube,
onValueChange = { youtube = it },
label = { Text("YouTube") },
placeholder = { Text("https://youtube.com/...") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
// --- Weitere Vereinsdaten ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = bankverbindung,
onValueChange = { bankverbindung = it },
label = { Text("IBAN / Bankverbindung") },
placeholder = { Text("AT12 3456 7890 1234 5678") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = uid,
onValueChange = { uid = it },
label = { Text("UID-Nummer / ZVR-Zahl") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
// --- Speichern ---
if (saveSuccess) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
modifier = Modifier.fillMaxWidth()
) {
Text(
"✓ Profil erfolgreich gespeichert!",
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.bodyLarge
)
}
}
Button(
onClick = {
// TODO: Backend-Anbindung (PUT /api/organizer/profile)
saveSuccess = true
},
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
Text("Profil speichern", style = MaterialTheme.typography.titleMedium)
}
OutlinedButton(
onClick = onNavigateToDashboard,
modifier = Modifier.fillMaxWidth()
) {
Text("Zum Dashboard")
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@@ -1,10 +0,0 @@
import kotlin.test.Test
import kotlin.test.assertEquals
class ComposeAppCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}
@@ -1,40 +0,0 @@
import kotlinx.browser.window
import kotlinx.coroutines.await
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.w3c.fetch.RequestInit
private val AppJson = Json { ignoreUnknownKeys = true }
@Serializable
data class AppConfig(
val apiBaseUrl: String,
val keycloakUrl: String? = null // Optional, da nicht immer vorhanden
)
suspend fun loadAppConfig(): AppConfig {
return try {
// ?_nocache erzwingt einen SW-bypassing Request (SW sieht andere URL → Cache-Miss → Netzwerk)
val response = window.fetch("/config.json?_nocache=1", RequestInit()).await()
if (!response.ok) {
console.warn("[Config] Failed to load config.json, falling back to globalThis")
return fallbackFromGlobal()
}
val text = response.text().await()
AppJson.decodeFromString(AppConfig.serializer(), text)
} catch (e: dynamic) {
console.error("[Config] Error loading config:", e)
// Fallback: Caddy-injizierte Werte aus index.html (globalThis.API_BASE_URL / KEYCLOAK_URL)
fallbackFromGlobal()
}
}
private fun fallbackFromGlobal(): AppConfig {
val apiBase = (js("globalThis.API_BASE_URL") as? String)
?.takeIf { it.isNotBlank() && !it.startsWith($$"${") }
?: window.location.origin
val kcUrl = (js("globalThis.KEYCLOAK_URL") as? String)
?.takeIf { it.isNotBlank() && !it.startsWith($$"${") }
console.log("[Config] Fallback: apiBaseUrl=$apiBase, keycloakUrl=$kcUrl")
return AppConfig(apiBaseUrl = apiBase, keycloakUrl = kcUrl)
}
@@ -1,2 +0,0 @@
actual fun isDevelopmentMode(): Boolean =
kotlinx.browser.window.location.hostname == "localhost"
@@ -1,8 +0,0 @@
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
actual fun NennungScreenContent() {
// Nennungs-Maske ist nur für Desktop (JVM) verfügbar
Text("Nennungs-Maske ist nur in der Desktop-App verfügbar.")
}
@@ -1,113 +0,0 @@
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import at.mocode.frontend.core.auth.di.authModule
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.localdb.DatabaseProvider
import at.mocode.frontend.core.localdb.localDbModule
import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.ping.feature.di.pingFeatureModule
import kotlinx.browser.document
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import navigation.navigationModule
import org.koin.core.context.GlobalContext
import org.koin.core.context.loadKoinModules
import org.koin.core.context.startKoin
import org.koin.dsl.module
import org.w3c.dom.HTMLElement
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
console.log("[WebApp] main() entered")
MainScope().launch {
try {
// 1. Load Runtime Configuration (Async)
console.log("[WebApp] Loading configuration...")
val config = loadAppConfig()
console.log("[WebApp] Configuration loaded: apiBaseUrl=${config.apiBaseUrl}, keycloakUrl=${config.keycloakUrl}")
// Inject config into the global JS scope for PlatformConfig to find
val global = js("typeof globalThis !== 'undefined' ? globalThis : window")
global.API_BASE_URL = config.apiBaseUrl
config.keycloakUrl?.let { global.KEYCLOAK_URL = it }
// 2. Initialize DI (Koin)
// We register the config immediately so other modules can use it
startKoin {
modules(
module { single { config } }, // Make AppConfig available for injection
networkModule,
localDbModule,
syncModule,
pingFeatureModule,
authModule,
navigationModule
)
}
console.log("[WebApp] Koin initialized")
// 3. Initialize Database (Async)
val provider = GlobalContext.get().get<DatabaseProvider>()
console.log("[WebApp] Initializing Database...")
val db = provider.createDatabase()
// Register the created DB instance into Koin
loadKoinModules(
module {
single<AppDatabase> { db }
}
)
console.log("[WebApp] Local DB created and registered in Koin")
// 4. Start UI
startAppWhenDomReady()
} catch (e: dynamic) {
console.error("[WebApp] CRITICAL: Initialization failed:", e)
renderFatalError("Initialization failed: ${e?.message ?: e}")
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
fun startAppWhenDomReady() {
val state = document.asDynamic().readyState as String?
if (state == "interactive" || state == "complete") {
mountComposeApp()
} else {
document.addEventListener("DOMContentLoaded", { mountComposeApp() })
}
}
@OptIn(ExperimentalComposeUiApi::class)
fun mountComposeApp() {
try {
console.log("[WebApp] Mounting Compose App...")
val root = document.getElementById("ComposeTarget") as HTMLElement
ComposeViewport(root) {
MainApp()
}
// Remove loading spinner
(document.querySelector(".loading") as? HTMLElement)?.let { it.parentElement?.removeChild(it) }
console.log("[WebApp] App mounted successfully")
} catch (e: Exception) {
console.error("Failed to start Compose Web app", e)
renderFatalError("UI Mount failed: ${e.message}")
}
}
fun renderFatalError(message: String) {
val fallbackTarget = (document.getElementById("ComposeTarget") ?: document.body) as HTMLElement
fallbackTarget.innerHTML = """
<div style='padding: 50px; text-align: center; color: #D32F2F; font-family: sans-serif;'>
<h1>System Error</h1>
<p>The application could not be started.</p>
<pre style='background: #FFEBEE; padding: 10px; border-radius: 4px; text-align: left; display: inline-block;'>$message</pre>
</div>
""".trimIndent()
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 KiB

@@ -1,73 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meldestelle - Web</title>
<link type="text/css" rel="stylesheet" href="styles.css">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#0f172a">
</head>
<body>
<div id="ComposeTarget">
<div class="loading">Loading...</div>
</div>
<!--
Schritt 1: Der Container-Entrypoint ersetzt ${API_BASE_URL} und ${KEYCLOAK_URL}
via envsubst beim Start. Caddy bekommt eine fertige statische Datei ohne Template-Parsing.
-->
<script id="app-config" type="application/json">
{
"apiBaseUrl": "${API_BASE_URL}",
"keycloakUrl": "${KEYCLOAK_URL}"
}
</script>
<script>
// Schritt 2: Das Haupt-Skript liest die Konfiguration aus dem JSON-Block.
(function () {
try {
const configElement = document.getElementById('app-config');
const configJson = configElement.textContent || '{}';
const config = JSON.parse(configJson);
const params = new URLSearchParams(window.location.search);
const notReplaced = '${'; // Fallback-Erkennung: envsubst hat den Wert NICHT ersetzt
const apiFromCaddy = config.apiBaseUrl;
const apiOverride = params.get('apiBaseUrl');
globalThis.API_BASE_URL = apiOverride
? apiOverride.replace(/\/$/, '')
: (apiFromCaddy && !apiFromCaddy.startsWith(notReplaced)
? apiFromCaddy.replace(/\/$/, '')
: 'http://' + window.location.hostname + ':8081');
const kcFromCaddy = config.keycloakUrl;
const kcOverride = params.get('keycloakUrl');
globalThis.KEYCLOAK_URL = kcOverride
? kcOverride.replace(/\/$/, '')
: (kcFromCaddy && !kcFromCaddy.startsWith(notReplaced)
? kcFromCaddy.replace(/\/$/, '')
: 'http://' + window.location.hostname + ':8180');
} catch (e) {
console.error('Error loading runtime configuration:', e);
globalThis.API_BASE_URL = 'http://localhost:8081';
globalThis.KEYCLOAK_URL = 'http://localhost:8180';
}
})();
</script>
<script src="web-app.js"></script>
<script>
// Register Service Worker only in non-localhost environments
if ('serviceWorker' in navigator && !['localhost', '127.0.0.1', '::1'].includes(location.hostname)) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js').catch(function (err) {
console.warn('ServiceWorker registration failed:', err);
});
});
}
</script>
</body>
</html>
@@ -1,13 +0,0 @@
{
"name": "Meldestelle",
"short_name": "Melde",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0f172a",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
@@ -1,22 +0,0 @@
html, body {
margin: 0;
padding: 0;
font-family: system-ui, -apple-system, sans-serif;
background: #fafafa;
overflow: hidden; /* Verhindert Scrollbalken durch die Canvas */
}
#ComposeTarget {
height: 100vh;
display: flex;
flex-direction: column;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 18px;
color: #666;
}
@@ -1,114 +0,0 @@
const IS_DEV = self.location.hostname === 'localhost' || self.location.hostname === '127.0.0.1' || self.location.hostname === '::1';
const CACHE_NAME = 'meldestelle-cache-v4';
const PRECACHE_URLS = [
'/',
'/index.html',
'/styles.css'
];
self.addEventListener('install', (event) => {
if (IS_DEV) {
// In dev, don't precache. Just activate the SW immediately.
self.skipWaiting().then(_ => {
});
return;
}
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
if (IS_DEV) {
event.waitUntil(self.clients.claim());
return;
}
event.waitUntil(
caches.keys().then((keys) => Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
)).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
if (IS_DEV) {
return; // don't interfere with dev server/HMR
}
const req = event.request;
const url = new URL(req.url);
const isHttp = url.protocol === 'http:' || url.protocol === 'https:';
const sameOrigin = url.origin === self.location.origin;
const isExtension = url.protocol === 'chrome-extension:';
const isHotUpdate = url.pathname.includes('hot-update');
const isWebSocketUpgrade = req.headers.get('upgrade') === 'websocket';
// Ignore non-GET, cross-origin, browser extensions, HMR, and WebSocket upgrade requests
if (req.method !== 'GET' || !isHttp || !sameOrigin || isExtension || isHotUpdate || isWebSocketUpgrade) {
return; // Let the browser handle it
}
if (req.mode === 'navigate') {
// Network-first for navigation
event.respondWith(
fetch(req)
.then((resp) => {
if (resp && resp.status === 200 && resp.type === 'basic') {
const copy = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy)).catch(() => {
});
}
return resp;
})
.catch(() => caches.match('/index.html'))
);
return;
}
// Laufzeit-Config immer direkt vom Netzwerk niemals aus dem Cache
if (url.pathname === '/config.json') {
event.respondWith(fetch(req).catch(() => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })));
return;
}
// API-Requests immer direkt vom Netzwerk niemals aus dem Cache (Auth-Header müssen erhalten bleiben)
if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(req));
return;
}
// App-Bundle immer vom Netzwerk niemals aus dem Cache (verhindert veraltete JS-Versionen)
if (url.pathname.endsWith('web-app.js') || url.pathname.endsWith('web-app.js.map')) {
event.respondWith(fetch(req));
return;
}
// Avoid noisy errors for favicon during dev/prod when missing
if (url.pathname === '/favicon.ico') {
event.respondWith(
fetch(req).catch(() => caches.match(req))
);
return;
}
// Cache-first for static assets
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;
return fetch(req)
.then((resp) => {
if (resp && resp.status === 200 && resp.type === 'basic') {
const copy = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => {
});
}
return resp;
})
.catch(() => caches.match(req));
})
);
});
@@ -1,52 +0,0 @@
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.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import at.mocode.frontend.core.designsystem.theme.AppTheme
import at.mocode.frontend.core.designsystem.theme.Dimens
@Preview
@Composable
fun LandingPagePreview() {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.padding(Dimens.SpacingM),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
) {
Text(
text = "Landing Page Preview",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Dies ist eine Live-Vorschau. Du kannst hier deine UI-Komponenten isoliert bauen und sofort sehen, wie sie aussehen. Ändere Text, Farben oder Layouts im Code die Preview aktualisiert sich automatisch in IntelliJ (Hot-Reload).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground
)
Button(onClick = {}) {
Text("Ein Button im Primary-Design")
}
}
}
}
}
// Fallback für IntelliJ, falls das Compose Plugin die Preview nicht erkennt
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "Design Preview Sandbox") {
LandingPagePreview()
}
}
@@ -1,2 +0,0 @@
actual fun isDevelopmentMode(): Boolean =
System.getProperty("development.mode", "false").toBoolean()
@@ -1,10 +0,0 @@
import androidx.compose.runtime.Composable
import at.mocode.nennung.feature.presentation.NennungsMaske
import at.mocode.nennung.feature.presentation.NennungViewModel
import org.koin.compose.viewmodel.koinViewModel
@Composable
actual fun NennungScreenContent() {
val viewModel: NennungViewModel = koinViewModel()
NennungsMaske(viewModel = viewModel)
}
@@ -1,59 +0,0 @@
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import at.mocode.frontend.core.auth.di.authModule
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.localdb.DatabaseProvider
import at.mocode.frontend.core.localdb.localDbModule
import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.ping.feature.di.pingFeatureModule
import at.mocode.nennung.feature.di.nennungFeatureModule
import kotlinx.coroutines.runBlocking
import navigation.navigationModule
import org.koin.core.context.loadKoinModules
import org.koin.core.context.startKoin
import org.koin.dsl.module
fun main() = application {
// Initialize DI (Koin) with shared modules + network module
try {
// Updated: Only load the consolidated pingFeatureModule from at.mocode.ping.feature.di
startKoin {
modules(
networkModule,
syncModule,
pingFeatureModule,
nennungFeatureModule,
authModule,
navigationModule,
localDbModule
)
}
println("[DesktopApp] Koin initialized with networkModule + authModule + navigationModule + pingFeatureModule + localDbModule")
} catch (e: Exception) {
println("[DesktopApp] Koin initialization warning: ${e.message}")
}
// Create the local DB once and register it into Koin so feature repositories can resolve it.
try {
val provider = org.koin.core.context.GlobalContext.get().get<DatabaseProvider>()
val db = runBlocking { provider.createDatabase() }
loadKoinModules(
module {
single<AppDatabase> { db }
}
)
println("[DesktopApp] Local DB created and registered in Koin")
} catch (e: Exception) {
println("[DesktopApp] Local DB init warning: ${e.message}")
}
Window(
onCloseRequest = ::exitApplication,
title = "Master Desktop",
state = WindowState(width = 1200.dp, height = 800.dp)
) {
MainApp()
}
}
@@ -1,54 +0,0 @@
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document
import org.w3c.dom.HTMLElement
import at.mocode.shared.di.initKoin
import at.mocode.frontend.core.network.networkModule
import at.mocode.clients.authfeature.di.authFeatureModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.ping.feature.di.pingFeatureModule
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.localdb.DatabaseProvider
import navigation.navigationModule
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import org.koin.core.context.GlobalContext
import org.koin.core.context.loadKoinModules
import org.koin.dsl.module
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
// Initialize DI
try {
initKoin { modules(networkModule, syncModule, pingFeatureModule, authFeatureModule, navigationModule) }
println("[WasmApp] Koin initialized (with navigationModule)")
} catch (e: Exception) {
println("[WasmApp] Koin init failed: ${e.message}")
}
// Create the local DB asynchronously and register it into Koin.
try {
val provider = GlobalContext.get().get<DatabaseProvider>()
MainScope().launch {
try {
val db = provider.createDatabase()
loadKoinModules(
module {
single<AppDatabase> { db }
}
)
println("[WasmApp] Local DB created and registered in Koin")
} catch (e: dynamic) {
println("[WasmApp] Local DB init warning: ${e?.message ?: e}")
}
}
} catch (e: Exception) {
println("[WasmApp] Local DB init warning: ${e.message}")
}
val root = document.getElementById("ComposeTarget") as HTMLElement
ComposeViewport(root) {
MainApp()
}
}
@@ -1,8 +0,0 @@
// Essenzielle Header für OPFS Support (SharedArrayBuffer)
// Siehe: https://sqlite.org/wasm/doc/trunk/persistence.html#opfs
config.devServer = config.devServer || {};
config.devServer.headers = {
...config.devServer.headers,
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp"
};
@@ -1,173 +0,0 @@
// Webpack configuration for SQLite WASM support AND Skiko fixes
const CopyWebpackPlugin = require('copy-webpack-plugin');
const webpack = require('webpack');
const path = require('path');
const fs = require('fs');
console.log("SQLite Config: Current working directory (cwd):", process.cwd());
console.log("SQLite Config: __dirname:", __dirname);
config.resolve = config.resolve || {};
config.resolve.fallback = config.resolve.fallback || {};
config.resolve.alias = config.resolve.alias || {};
// 1. Fallbacks for Node.js core modules
config.resolve.fallback.fs = false;
config.resolve.fallback.path = false;
config.resolve.fallback.crypto = false;
// 2. Resolve sqlite3 paths
let sqliteBaseDir;
try {
const packagePath = path.dirname(require.resolve('@sqlite.org/sqlite-wasm/package.json'));
sqliteBaseDir = path.join(packagePath, 'sqlite-wasm/jswasm');
} catch (e) {
console.warn("Could not resolve @sqlite.org/sqlite-wasm path automatically. Using fallback path.");
sqliteBaseDir = path.resolve(__dirname, '../../../../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm');
}
// 3. Copy ALL sqlite3 assets (wasm, js, and auxiliary workers)
const copyPatterns = [];
if (fs.existsSync(sqliteBaseDir)) {
console.log("Copying sqlite3 assets from:", sqliteBaseDir);
copyPatterns.push({
from: sqliteBaseDir,
to: '.', // Copy to root of dist
globOptions: {
ignore: ['**/package.json']
},
noErrorOnMissing: true
});
} else {
console.error("ERROR: sqlite3 base directory does not exist:", sqliteBaseDir);
}
// 4. Copy sqlite.worker.js from source
// Try multiple strategies to find the file
// Strategy A: Relative to __dirname (webpack.config.d)
// ../../../core/local-db/src/jsMain/resources/sqlite.worker.js
const pathA = path.resolve(__dirname, '../../../core/local-db/src/jsMain/resources/sqlite.worker.js');
// Strategy B: Relative to process.cwd() (project root usually)
// ../../core/local-db/src/jsMain/resources/sqlite.worker.js (assuming cwd is meldestelle-portal)
const pathB = path.resolve(process.cwd(), '../../core/local-db/src/jsMain/resources/sqlite.worker.js');
// Strategy C: Hardcoded fallback based on typical structure
const pathC = path.resolve(__dirname, '../../../../core/local-db/src/jsMain/resources/sqlite.worker.js');
// Strategy D: From processedResources of local-db module (Kotlin/JS build output)
const pathD = path.resolve(__dirname, '../../../../core/local-db/build/processedResources/js/main/sqlite.worker.js');
// Strategy E: Via process.cwd() 4 levels up = project root (works when Webpack runs from build/js/packages/<module>/)
const pathE = path.resolve(process.cwd(), '../../../../frontend/core/local-db/src/jsMain/resources/sqlite.worker.js');
// Strategy F: processedResources via process.cwd() 4 levels up
const pathF = path.resolve(process.cwd(), '../../../../frontend/core/local-db/build/processedResources/js/main/sqlite.worker.js');
// Strategy G: Via process.cwd() when Gradle runs from project root
const pathG = path.resolve(process.cwd(), 'frontend/core/local-db/src/jsMain/resources/sqlite.worker.js');
// Strategy H: processedResources via process.cwd() from project root
const pathH = path.resolve(process.cwd(), 'frontend/core/local-db/build/processedResources/js/main/sqlite.worker.js');
let workerSourcePath = null;
if (fs.existsSync(pathA)) {
workerSourcePath = pathA;
console.log("Found sqlite.worker.js at (Strategy A):", pathA);
} else if (fs.existsSync(pathB)) {
workerSourcePath = pathB;
console.log("Found sqlite.worker.js at (Strategy B):", pathB);
} else if (fs.existsSync(pathC)) {
workerSourcePath = pathC;
console.log("Found sqlite.worker.js at (Strategy C):", pathC);
} else if (fs.existsSync(pathD)) {
workerSourcePath = pathD;
console.log("Found sqlite.worker.js at (Strategy D - processedResources):", pathD);
} else if (fs.existsSync(pathE)) {
workerSourcePath = pathE;
console.log("Found sqlite.worker.js at (Strategy E - build dir relative):", pathE);
} else if (fs.existsSync(pathF)) {
workerSourcePath = pathF;
console.log("Found sqlite.worker.js at (Strategy F - build dir processedResources):", pathF);
} else if (fs.existsSync(pathG)) {
workerSourcePath = pathG;
console.log("Found sqlite.worker.js at (Strategy G - cwd project root):", pathG);
} else if (fs.existsSync(pathH)) {
workerSourcePath = pathH;
console.log("Found sqlite.worker.js at (Strategy H - cwd project root processedResources):", pathH);
} else {
console.error("ERROR: Could not find sqlite.worker.js in any expected location!");
console.error("Checked A:", pathA);
console.error("Checked B:", pathB);
console.error("Checked C:", pathC);
console.error("Checked D:", pathD);
console.error("Checked E:", pathE);
console.error("Checked F:", pathF);
console.error("Checked G:", pathG);
console.error("Checked H:", pathH);
}
if (workerSourcePath) {
copyPatterns.push({
from: workerSourcePath,
to: 'sqlite.worker.js',
noErrorOnMissing: true
});
}
config.plugins.push(
new CopyWebpackPlugin({
patterns: copyPatterns
})
);
// 5. Alias sqlite3.wasm (still needed for some internal checks maybe)
const sqliteWasmPath = path.join(sqliteBaseDir, 'sqlite3.wasm');
config.resolve.alias['sqlite3.wasm'] = sqliteWasmPath;
config.resolve.alias['./sqlite3.wasm'] = sqliteWasmPath;
// 6. Handle .wasm files
config.experiments = config.experiments || {};
config.experiments.asyncWebAssembly = true;
config.module = config.module || {};
config.module.rules = config.module.rules || [];
// Treat Skiko WASM as resource to avoid parsing errors
config.module.rules.push({
test: /skiko\.wasm$/,
type: 'asset/resource'
});
// Treat other WASM as async (default)
config.module.rules.push({
test: /\.wasm$/,
exclude: /skiko\.wasm$/,
type: 'webassembly/async'
});
// 7. Ignore warnings
config.ignoreWarnings = config.ignoreWarnings || [];
config.ignoreWarnings.push(/Critical dependency: the request of a dependency is an expression/);
// 8. Fix for "webpackEmptyContext" in sqlite3.mjs
config.plugins.push(
new webpack.ContextReplacementPlugin(
/@sqlite\.org\/sqlite-wasm/,
(data) => {
delete data.dependencies;
return data;
}
)
);
// 9. MIME types
config.devServer = config.devServer || {};
config.devServer.devMiddleware = config.devServer.devMiddleware || {};
config.devServer.devMiddleware.mimeTypes = {
'application/wasm': ['wasm'],
'application/javascript': ['js']
};
// 10. OPTIMIZATION: Handled by z_disable-minification.js
// Minification is disabled in the alphabetically-last config file to ensure
// it cannot be overridden by any subsequent Kotlin/JS or plugin step.
// See: webpack.config.d/z_disable-minification.js
@@ -1,67 +0,0 @@
// HTML template will be handled by Kotlin/JS build system
// No need for custom HtmlWebpackPlugin configuration
// Bundle-Analyze für Development (optional, only if package is available)
if (process.env.ANALYZE_BUNDLE === 'true') {
try {
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
config.plugins.push(new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false,
reportFilename: 'bundle-report.html'
}));
console.log('Bundle analyzer enabled');
} catch (e) {
console.log('Bundle analyzer not available (webpack-bundle-analyzer not installed)');
}
}
// Hinweis: Wir liefern eine statische index.html aus src/jsMain/resources aus.
// Diese Datei enthält nur ein Script-Tag zu "web-app.js" und wird NICHT
// vom HtmlWebpackPlugin generiert. Zusätzliche Chunks (z. B. vendor/runtime)
// würden dann nicht automatisch injiziert und führen dazu, dass die App nicht startet
// (Bildschirm bleibt auf "Loading ...").
//
// Daher überschreiben wir config.optimization NICHT mehr mit splitChunks.
// Wenn später Chunking gewünscht ist, muss die index.html durch das generierte
// HTML ersetzt oder die zusätzlichen Chunks manuell eingebunden werden.
//
// (Frühere splitChunks-Konfiguration wurde bewusst entfernt.)
// Development Server Konfiguration erweitern
if (config.devServer) {
config.devServer = {
...config.devServer,
historyApiFallback: true,
hot: true,
// API Proxy für Backend-Anfragen (Array-Format für modernen Webpack)
proxy: [
{
context: ['/api'],
target: 'http://localhost:8081',
changeOrigin: true,
secure: false,
// WICHTIG: pathRewrite entfernt /api, wenn das Backend unter /api lauscht,
// ist das falsch. Wenn das Backend unter / lauscht, ist es richtig.
// Das API Gateway lauscht unter http://localhost:8081/api/...
// Wenn wir also /api/ping aufrufen, soll es zu http://localhost:8081/api/ping gehen.
// Daher KEIN pathRewrite, wenn das Gateway selbst /api erwartet.
// Wenn das Gateway aber die Routen ohne /api mappt (z.B. /ping), dann brauchen wir Rewrite.
//
// Analyse:
// Gateway Routes sind oft: /api/ping -> Ping Service /api/ping oder /ping
// Wenn Gateway Routes definiert sind als:
// - id: ping-service
// uri: lb://ping-service
// predicates:
// - Path=/api/ping/**
//
// Dann leitet das Gateway /api/ping weiter.
// Wenn wir pathRewrite machen, kommt beim Gateway nur /ping an.
// Das Gateway matcht aber auf /api/ping.
// Also: pathRewrite entfernen!
// pathRewrite: {'^/api': ''}
}
]
}
}
@@ -1,27 +0,0 @@
// ============================================================
// FINAL GUARD: Disable ALL Minification for SQLite-WASM stability
// ============================================================
// WHY THIS FILE EXISTS:
// Kotlin/JS loads webpack.config.d files in alphabetical order.
// This file is prefixed with "z_" to ensure it runs LAST —
// AFTER Kotlin/ JS, and all other plugins have had their chance
// to set config.optimization. This guarantees that no Terser
// or another minimizer plugin can re-enable itself after this point.
//
// ROOT CAUSE:
// sqlite3-worker1.mjs contains a top-level `return` statement
// that is valid for browser-worker scripts but crashes Terser.
// Disabling minification for the entire shell module is the
// most stable fix in the context of WASM-heavy KMP projects.
// (Performance trade-off is negligible for WASM bundles.)
//
// REFERENCE: docs/99_Journal for build history.
// ============================================================
console.log("[z_disable-minification] Enforcing minimize=false as FINAL webpack config step.");
config.optimization = config.optimization || {};
config.optimization.minimize = false;
config.optimization.minimizer = [];
console.log("[z_disable-minification] Terser and all minimizers are now DISABLED.");
+2 -1
View File
@@ -35,5 +35,6 @@ dependencies {
implementation(project(":frontend:core:network"))
implementation(project(":frontend:core:local-db"))
implementation(project(":frontend:core:sync"))
implementation(project(":frontend:shells:meldestelle-portal"))
implementation(project(":frontend:shells:meldestelle-desktop"))
implementation(project(":frontend:features:zns-import-feature"))
}
+1 -1
View File
@@ -134,9 +134,9 @@ include(":frontend:core:sync")
// include(":frontend:features:members-feature")
include(":frontend:features:ping-feature")
include(":frontend:features:nennung-feature")
include(":frontend:features:zns-import-feature")
// --- SHELLS ---
include(":frontend:shells:meldestelle-portal")
include(":frontend:shells:meldestelle-desktop")
// ==========================================================================