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:
@@ -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)
|
||||
|
||||
+18
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -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)
|
||||
}
|
||||
|
||||
+14
-99
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -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()
|
||||
}
|
||||
}
|
||||
+9
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
+2
@@ -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) },
|
||||
|
||||
+257
@@ -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()
|
||||
-145
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-55
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-390
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-345
@@ -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.");
|
||||
@@ -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
@@ -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")
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
Reference in New Issue
Block a user