chore(MP-23): network DI client, frontend architecture guards, detekt & ktlint setup, docs, ping DI factory (#21)
* chore(MP-21): snapshot pre-refactor state (Epic 1) * chore(MP-22): scaffold new repo structure, relocate Docker Compose, move frontend/backend modules, update Makefile; add docs mapping and env template * MP-22 Epic 2: Erfolgreich umgesetzt und verifiziert * MP-23 Epic 3: Gradle/Build Governance zentralisieren
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
@file:OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
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 {
|
||||
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
|
||||
|
||||
jvmToolchain(21)
|
||||
|
||||
// JVM Target für Desktop
|
||||
jvm {
|
||||
binaries {
|
||||
executable {
|
||||
mainClass.set("MainKt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JavaScript Target für Web
|
||||
js(IR) {
|
||||
browser {
|
||||
commonWebpackConfig {
|
||||
cssSupport { enabled = true }
|
||||
// Webpack-Mode abhängig von Build-Typ
|
||||
mode = if (project.hasProperty("production"))
|
||||
KotlinWebpackConfig.Mode.PRODUCTION
|
||||
else
|
||||
KotlinWebpackConfig.Mode.DEVELOPMENT
|
||||
}
|
||||
|
||||
webpackTask {
|
||||
mainOutputFileName = "web-app.js"
|
||||
}
|
||||
|
||||
// Development Server konfigurieren
|
||||
runTask {
|
||||
mainOutputFileName.set("web-app.js")
|
||||
}
|
||||
// Browser-Tests komplett deaktivieren (Configuration Cache kompatibel)
|
||||
testTask {
|
||||
// enabled = false
|
||||
|
||||
useKarma {
|
||||
useChromeHeadless()
|
||||
environment("CHROME_BIN", "/usr/bin/google-chrome-stable")
|
||||
}
|
||||
}
|
||||
}
|
||||
binaries.executable()
|
||||
}
|
||||
|
||||
// WASM, nur wenn explizit aktiviert
|
||||
if (enableWasm) {
|
||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
browser()
|
||||
binaries.executable()
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// Shared modules
|
||||
implementation(project(":clients:shared"))
|
||||
implementation(project(":frontend:core:design-system"))
|
||||
implementation(project(":frontend:core:navigation"))
|
||||
implementation(project(":frontend:core:network"))
|
||||
implementation(project(":clients:auth-feature"))
|
||||
implementation(project(":clients:ping-feature"))
|
||||
|
||||
// DI (Koin) needed to call initKoin { modules(...) }
|
||||
implementation(libs.koin.core)
|
||||
|
||||
// Compose Multiplatform
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.components.resources)
|
||||
implementation(compose.materialIconsExtended)
|
||||
|
||||
// ViewModel lifecycle
|
||||
implementation(libs.bundles.compose.common)
|
||||
|
||||
// Coroutines, Serialization, DateTime
|
||||
implementation(libs.bundles.kotlinx.core)
|
||||
}
|
||||
|
||||
jvmMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.koin.core)
|
||||
}
|
||||
|
||||
jsMain.dependencies {
|
||||
implementation(compose.html.core)
|
||||
}
|
||||
|
||||
// WASM SourceSet, nur wenn aktiviert
|
||||
if (enableWasm) {
|
||||
val wasmJsMain = getByName("wasmJsMain")
|
||||
wasmJsMain.dependencies {
|
||||
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
|
||||
|
||||
// ✅ HINZUFÜGEN: Compose für shared UI components für WASM
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
}
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KMP Compile-Optionen
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
freeCompilerArgs.addAll(
|
||||
"-opt-in=kotlin.RequiresOptIn",
|
||||
"-Xskip-metadata-version-check", // Für bleeding-edge Versionen
|
||||
// Suppress beta warning for expect/actual declarations used in this module
|
||||
"-Xexpect-actual-classes"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Configure a duplicate handling strategy for distribution tasks
|
||||
tasks.withType<Tar> {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
tasks.withType<Zip> {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
// Duplicate-Handling für Distribution
|
||||
tasks.withType<Copy> {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE // Statt EXCLUDE
|
||||
}
|
||||
|
||||
tasks.withType<Sync> {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
expect fun isDevelopmentMode(): Boolean
|
||||
@@ -0,0 +1,259 @@
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import at.mocode.clients.shared.navigation.AppScreen
|
||||
import at.mocode.clients.authfeature.AuthenticatedHttpClient
|
||||
import at.mocode.clients.authfeature.AuthTokenManager
|
||||
import at.mocode.clients.pingfeature.PingScreen
|
||||
import at.mocode.clients.pingfeature.PingViewModel
|
||||
import at.mocode.clients.shared.core.AppConstants
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import at.mocode.clients.authfeature.AuthApiClient
|
||||
import at.mocode.clients.authfeature.oauth.OAuthPkceService
|
||||
import at.mocode.clients.authfeature.oauth.AuthCallbackParams
|
||||
import at.mocode.clients.authfeature.oauth.CallbackParams
|
||||
|
||||
@Composable
|
||||
fun MainApp() {
|
||||
MaterialTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) }
|
||||
|
||||
val authTokenManager = remember { AuthenticatedHttpClient.getAuthTokenManager() }
|
||||
val pingViewModel = remember { PingViewModel() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Handle PKCE callback on an app load (web)
|
||||
LaunchedEffect(Unit) {
|
||||
val callback: CallbackParams? = AuthCallbackParams.parse()
|
||||
if (callback != null) {
|
||||
val code = callback.code
|
||||
val state = callback.state
|
||||
val pkce = OAuthPkceService.current()
|
||||
if (pkce != null && pkce.state == state) {
|
||||
val api = AuthApiClient()
|
||||
val res = api.exchangeAuthorizationCode(code, pkce.codeVerifier, AppConstants.webRedirectUri())
|
||||
val token = res.token
|
||||
if (res.success && token != null) {
|
||||
authTokenManager.setToken(token)
|
||||
OAuthPkceService.clear()
|
||||
currentScreen = AppScreen.Profile
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (currentScreen) {
|
||||
is AppScreen.Home -> WelcomeScreen(
|
||||
authTokenManager = authTokenManager,
|
||||
onOpenPing = { AppScreen.Ping },
|
||||
onOpenLogin = {
|
||||
// Fallback to the local LoginScreen (Password Grant) if PKCE cannot be started
|
||||
currentScreen = AppScreen.Login
|
||||
},
|
||||
onOpenProfile = { currentScreen = AppScreen.Profile }
|
||||
)
|
||||
|
||||
is AppScreen.Login -> LoginScreen(
|
||||
authTokenManager = authTokenManager,
|
||||
onLoginSuccess = { currentScreen = AppScreen.Profile }
|
||||
)
|
||||
|
||||
is AppScreen.Ping -> PingScreen(viewModel = pingViewModel)
|
||||
is AppScreen.Profile -> AuthStatusScreen(
|
||||
authTokenManager = authTokenManager,
|
||||
onBackToHome = { currentScreen = AppScreen.Home }
|
||||
)
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeScreen(
|
||||
authTokenManager: AuthTokenManager,
|
||||
onOpenPing: () -> Unit,
|
||||
onOpenLogin: () -> Unit,
|
||||
onOpenProfile: () -> Unit
|
||||
) {
|
||||
val authState by authTokenManager.authState.collectAsState()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Willkommen zur Meldestelle",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
|
||||
// Auth info
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
if (authState.isAuthenticated) {
|
||||
Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = onOpenProfile) { Text("Profil anzeigen") }
|
||||
} else {
|
||||
Text("Du bist nicht angemeldet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onOpenPing, modifier = Modifier.weight(1f)) { Text("Ping-Service") }
|
||||
if (!authState.isAuthenticated) {
|
||||
Button(
|
||||
onClick = {
|
||||
// Try PKCE login (Authorization Code Flow w/ PKCE)
|
||||
scope.launch {
|
||||
try {
|
||||
val pkce = OAuthPkceService.startAuth()
|
||||
val url = OAuthPkceService.buildAuthorizeUrl(pkce, AppConstants.webRedirectUri())
|
||||
uriHandler.openUri(url)
|
||||
} catch (_: Throwable) {
|
||||
// Fallback: open the local Login screen (Password Grant)
|
||||
onOpenLogin()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("Login") }
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { uriHandler.openUri(AppConstants.registerUrl()) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("Registrieren (Keycloak)") }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { uriHandler.openUri(AppConstants.loginUrl()) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("Keycloak Login-Seite") }
|
||||
}
|
||||
|
||||
// Desktop Download Link
|
||||
OutlinedButton(
|
||||
onClick = { uriHandler.openUri(AppConstants.desktopDownloadUrl()) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text("Desktop-App herunterladen") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthStatusScreen(
|
||||
authTokenManager: AuthTokenManager,
|
||||
onBackToHome: () -> Unit
|
||||
) {
|
||||
val authState by authTokenManager.authState.collectAsState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("Profil / Status", style = MaterialTheme.typography.headlineMedium)
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
if (authState.isAuthenticated) {
|
||||
Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
authTokenManager.clearToken()
|
||||
onBackToHome()
|
||||
}) { Text("Abmelden") }
|
||||
} else {
|
||||
Text("Nicht angemeldet.")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = onBackToHome) { Text("Zurück zur Startseite") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoginScreen(
|
||||
authTokenManager: AuthTokenManager,
|
||||
onLoginSuccess: () -> Unit
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val api = remember { AuthApiClient() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Anmeldung", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Benutzername") },
|
||||
singleLine = true,
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Passwort") },
|
||||
singleLine = true,
|
||||
enabled = !isLoading,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
error = null
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
val res = api.login(username.trim(), password)
|
||||
val token = res.token
|
||||
if (res.success && token != null) {
|
||||
authTokenManager.setToken(token)
|
||||
isLoading = false
|
||||
onLoginSuccess()
|
||||
} else {
|
||||
isLoading = false
|
||||
error = res.message ?: "Login fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isLoading && username.isNotBlank() && password.isNotBlank()
|
||||
) { Text(if (isLoading) "Bitte warten…" else "Login") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ComposeAppCommonTest {
|
||||
|
||||
@Test
|
||||
fun example() {
|
||||
assertEquals(3, 1 + 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
actual fun isDevelopmentMode(): Boolean =
|
||||
kotlinx.browser.window.location.hostname == "localhost"
|
||||
@@ -0,0 +1,67 @@
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.ComposeViewport
|
||||
import kotlinx.browser.document
|
||||
import org.w3c.dom.HTMLElement
|
||||
import at.mocode.clients.shared.di.initKoin
|
||||
import at.mocode.frontend.core.network.networkModule
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.qualifier.named
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
console.log("[WebApp] main() entered")
|
||||
// Initialize DI (Koin) with shared modules + network module
|
||||
try {
|
||||
initKoin { modules(networkModule) }
|
||||
console.log("[WebApp] Koin initialized with networkModule")
|
||||
} catch (e: dynamic) {
|
||||
console.warn("[WebApp] Koin initialization warning:", e)
|
||||
}
|
||||
// Simple smoke request using DI apiClient
|
||||
try {
|
||||
val client = GlobalContext.get().get<HttpClient>(named("apiClient"))
|
||||
MainScope().launch {
|
||||
try {
|
||||
val resp: String = client.get("/api/ping/health").body()
|
||||
console.log("[WebApp] /api/ping/health → ", resp)
|
||||
} catch (e: dynamic) {
|
||||
console.warn("[WebApp] /api/ping/health failed:", e?.message ?: e)
|
||||
}
|
||||
}
|
||||
} catch (e: dynamic) {
|
||||
console.warn("[WebApp] Unable to resolve apiClient from Koin:", e)
|
||||
}
|
||||
fun startApp() {
|
||||
try {
|
||||
console.log("[WebApp] startApp(): readyState=", document.asDynamic().readyState)
|
||||
val root = document.getElementById("ComposeTarget") as HTMLElement
|
||||
console.log("[WebApp] ComposeTarget exists? ", (root != null))
|
||||
ComposeViewport(root) {
|
||||
MainApp()
|
||||
}
|
||||
// Remove the static loading placeholder if present
|
||||
(document.querySelector(".loading") as? HTMLElement)?.let { it.parentElement?.removeChild(it) }
|
||||
console.log("[WebApp] ComposeViewport mounted, loading placeholder removed")
|
||||
} catch (e: Exception) {
|
||||
console.error("Failed to start Compose Web app", e)
|
||||
val fallbackTarget = (document.getElementById("ComposeTarget") ?: document.body) as HTMLElement
|
||||
fallbackTarget.innerHTML =
|
||||
"<div style='padding: 50px; text-align: center;'>❌ Failed to load app: ${e.message}</div>"
|
||||
}
|
||||
}
|
||||
|
||||
// Start immediately if DOM is already parsed, otherwise wait for DOMContentLoaded.
|
||||
val state = document.asDynamic().readyState as String?
|
||||
if (state == "interactive" || state == "complete") {
|
||||
console.log("[WebApp] DOM already ready (", state, ") → starting immediately")
|
||||
startApp()
|
||||
} else {
|
||||
console.log("[WebApp] Waiting for DOMContentLoaded, current state:", state)
|
||||
document.addEventListener("DOMContentLoaded", { startApp() })
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 560 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 667 KiB |
@@ -0,0 +1,45 @@
|
||||
<!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>
|
||||
<script>
|
||||
// Prefer explicit query param override (?apiBaseUrl=http://host:port),
|
||||
// then fall back to same-origin. This avoids Docker secrets and works with Nginx proxy.
|
||||
(function(){
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const override = params.get('apiBaseUrl');
|
||||
if (override) {
|
||||
globalThis.API_BASE_URL = override.replace(/\/$/, '');
|
||||
} else {
|
||||
globalThis.API_BASE_URL = window.location.origin.replace(/\/$/, '');
|
||||
}
|
||||
} catch (e) {
|
||||
globalThis.API_BASE_URL = 'http://localhost:8081';
|
||||
}
|
||||
})();
|
||||
// KMP bundle will read globalThis.API_BASE_URL in PlatformConfig.js
|
||||
</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>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
const IS_DEV = self.location.hostname === 'localhost' || self.location.hostname === '127.0.0.1' || self.location.hostname === '::1';
|
||||
|
||||
const CACHE_NAME = 'meldestelle-cache-v2';
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
// 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));
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
actual fun isDevelopmentMode(): Boolean =
|
||||
System.getProperty("development.mode", "false").toBoolean()
|
||||
@@ -0,0 +1,23 @@
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.clients.shared.di.initKoin
|
||||
import at.mocode.frontend.core.network.networkModule
|
||||
|
||||
fun main() = application {
|
||||
// Initialize DI (Koin) with shared modules + network module
|
||||
try {
|
||||
initKoin { modules(networkModule) }
|
||||
println("[DesktopApp] Koin initialized with networkModule")
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopApp] Koin initialization warning: ${e.message}")
|
||||
}
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Meldestelle - Desktop Development",
|
||||
state = WindowState(width = 1200.dp, height = 800.dp)
|
||||
) {
|
||||
MainApp()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.ComposeViewport
|
||||
import kotlinx.browser.document
|
||||
import org.w3c.dom.HTMLElement
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
val root = document.getElementById("ComposeTarget") as HTMLElement
|
||||
ComposeViewport(root) {
|
||||
MainApp()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// HTML template will be handled by Kotlin/JS build system
|
||||
// No need for custom HtmlWebpackPlugin configuration
|
||||
|
||||
// Bundle-Analyse 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 einen 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 die 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,
|
||||
pathRewrite: {'^/api': ''}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user