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:
StefanMo
2025-11-30 23:14:00 +01:00
committed by GitHub
parent 89bbd42245
commit 034892e890
101 changed files with 857 additions and 407 deletions
+7
View File
@@ -0,0 +1,7 @@
# Frontend
Kotlin Multiplatform Frontend layer.
- shells: ausführbare Anwendungen (Assembler)
- features: Vertical Slices (kein Feature→Feature Import)
- core: gemeinsame Basis (Design-System, Network, Local-DB, Auth, Domain)
View File
@@ -0,0 +1,59 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21)
jvm()
js(IR) {
browser()
// nodejs()
binaries.executable()
}
// WASM, nur wenn explizit aktiviert
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
}
sourceSets {
commonMain.dependencies {
// Shared module dependency
implementation(project(":clients:shared"))
// Compose dependencies
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
// Coroutines
implementation(libs.kotlinx.coroutines.core)
// Serialization
implementation(libs.kotlinx.serialization.json)
// DateTime
implementation(libs.kotlinx.datetime)
}
jsMain.dependencies {
// JS-specific UI dependencies if needed
}
jvmMain.dependencies {
// JVM-specific UI dependencies if needed
}
}
}
@@ -0,0 +1,29 @@
package at.mocode.clients.shared.commonui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun AppFooter() {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "© 2025 Meldestelle - Built with Kotlin Multiplatform",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
@@ -0,0 +1,74 @@
package at.mocode.clients.shared.commonui.components
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontWeight
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppHeader(
title: String,
onNavigateToPing: (() -> Unit)? = null,
onNavigateToLogin: (() -> Unit)? = null,
onLogout: (() -> Unit)? = null,
isAuthenticated: Boolean = false,
username: String? = null,
userPermissions: List<String> = emptyList()
) {
TopAppBar(
title = {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
},
actions = {
// Ping Service button
onNavigateToPing?.let { navigateAction ->
TextButton(
onClick = navigateAction
) {
Text("Ping Service")
}
}
// Authentication buttons
if (isAuthenticated) {
// Show username with admin indicator if user has delete permissions
username?.let { user ->
val isAdmin = userPermissions.any { it.contains("DELETE") }
Text(
text = if (isAdmin) "👑 Hallo, $user (Admin)" else "Hallo, $user",
style = MaterialTheme.typography.bodyMedium,
color = if (isAdmin)
MaterialTheme.colorScheme.tertiary
else
MaterialTheme.colorScheme.onPrimaryContainer
)
}
onLogout?.let { logoutAction ->
TextButton(
onClick = logoutAction
) {
Text("Abmelden")
}
}
} else {
// Show login button
onNavigateToLogin?.let { loginAction ->
TextButton(
onClick = loginAction
) {
Text("Anmelden")
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
@@ -0,0 +1,28 @@
package at.mocode.clients.shared.commonui.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppScaffold(
header: @Composable () -> Unit = {
AppHeader(title = "Meldestelle")
},
content: @Composable (PaddingValues) -> Unit,
footer: @Composable () -> Unit = {
AppFooter()
},
) {
Scaffold(
topBar = header,
bottomBar = footer,
modifier = Modifier.fillMaxSize()
) { paddingValues ->
content(paddingValues)
}
}
@@ -0,0 +1,109 @@
package at.mocode.clients.shared.commonui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
enum class LoadingSize {
SMALL, MEDIUM, LARGE
}
@Composable
fun LoadingIndicator(
modifier: Modifier = Modifier,
size: LoadingSize = LoadingSize.MEDIUM,
message: String? = null
) {
val indicatorSize = when (size) {
LoadingSize.SMALL -> 24.dp
LoadingSize.MEDIUM -> 32.dp
LoadingSize.LARGE -> 48.dp
}
val strokeWidth = when (size) {
LoadingSize.SMALL -> 2.dp
LoadingSize.MEDIUM -> 3.dp
LoadingSize.LARGE -> 4.dp
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(indicatorSize),
strokeWidth = strokeWidth
)
if (message != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun FullScreenLoading(
message: String = "Loading...",
modifier: Modifier = Modifier
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator(
size = LoadingSize.LARGE,
message = message
)
}
}
@Composable
fun InlineLoading(
message: String? = null,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
LoadingIndicator(
size = LoadingSize.SMALL,
message = message
)
}
}
@Composable
fun LinearLoadingIndicator(
modifier: Modifier = Modifier,
message: String? = null
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
if (message != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
}
}
}
@@ -0,0 +1,124 @@
package at.mocode.clients.shared.commonui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
enum class ButtonVariant {
PRIMARY, SECONDARY, OUTLINE, TEXT
}
enum class ButtonSize {
SMALL, MEDIUM, LARGE
}
@Composable
fun MeldestelleButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
variant: ButtonVariant = ButtonVariant.PRIMARY,
size: ButtonSize = ButtonSize.MEDIUM,
enabled: Boolean = true,
isLoading: Boolean = false,
fullWidth: Boolean = false
) {
val buttonModifier = modifier.then(
if (fullWidth) Modifier.fillMaxWidth() else Modifier
).then(
when (size) {
ButtonSize.SMALL -> Modifier.height(32.dp)
ButtonSize.MEDIUM -> Modifier.height(40.dp)
ButtonSize.LARGE -> Modifier.height(48.dp)
}
)
when (variant) {
ButtonVariant.PRIMARY -> Button(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
ButtonVariant.SECONDARY -> FilledTonalButton(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
ButtonVariant.OUTLINE -> OutlinedButton(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
ButtonVariant.TEXT -> TextButton(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
}
}
@Composable
private fun ButtonContent(
text: String,
isLoading: Boolean
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.padding(2.dp),
strokeWidth = 2.dp
)
} else {
Text(text)
}
}
@Composable
fun PrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isLoading: Boolean = false,
fullWidth: Boolean = false
) = MeldestelleButton(
text = text,
onClick = onClick,
modifier = modifier,
variant = ButtonVariant.PRIMARY,
enabled = enabled,
isLoading = isLoading,
fullWidth = fullWidth
)
@Composable
fun SecondaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isLoading: Boolean = false,
fullWidth: Boolean = false
) = MeldestelleButton(
text = text,
onClick = onClick,
modifier = modifier,
variant = ButtonVariant.SECONDARY,
enabled = enabled,
isLoading = isLoading,
fullWidth = fullWidth
)
@@ -0,0 +1,193 @@
package at.mocode.clients.shared.commonui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun MeldestelleTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
placeholder: String? = null,
leadingIcon: ImageVector? = null,
trailingIcon: ImageVector? = null,
onTrailingIconClick: (() -> Unit)? = null,
isError: Boolean = false,
errorMessage: String? = null,
helperText: String? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
singleLine: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
keyboardType: KeyboardType = KeyboardType.Text,
imeAction: ImeAction = ImeAction.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None
) {
Column(modifier = modifier) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
label = label?.let { { Text(it) } },
placeholder = placeholder?.let { { Text(it) } },
leadingIcon = leadingIcon?.let { icon ->
{ Icon(imageVector = icon, contentDescription = null) }
},
trailingIcon = if (trailingIcon != null) {
{
IconButton(
onClick = onTrailingIconClick ?: {}
) {
Icon(imageVector = trailingIcon, contentDescription = null)
}
}
} else null,
isError = isError,
enabled = enabled,
readOnly = readOnly,
singleLine = singleLine,
maxLines = maxLines,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = imeAction
),
keyboardActions = keyboardActions,
visualTransformation = visualTransformation
)
// Error or helper text
when {
isError && errorMessage != null -> {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
helperText != null -> {
Text(
text = helperText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
}
}
}
@Composable
fun MeldestellePasswordField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String = "Password",
placeholder: String? = null,
isError: Boolean = false,
errorMessage: String? = null,
helperText: String? = null,
enabled: Boolean = true,
imeAction: ImeAction = ImeAction.Done,
keyboardActions: KeyboardActions = KeyboardActions.Default
) {
var passwordVisible by remember { mutableStateOf(false) }
MeldestelleTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
label = label,
placeholder = placeholder,
trailingIcon = if (passwordVisible) {
// You would need to import the actual icon from Material Icons
null // Placeholder for visibility off icon
} else {
null // Placeholder for visibility on icon
},
onTrailingIconClick = { passwordVisible = !passwordVisible },
isError = isError,
errorMessage = errorMessage,
helperText = helperText,
enabled = enabled,
keyboardType = KeyboardType.Password,
imeAction = imeAction,
keyboardActions = keyboardActions,
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
}
)
}
@Composable
fun MeldestelleEmailField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String = "Email",
placeholder: String? = null,
isError: Boolean = false,
errorMessage: String? = null,
helperText: String? = null,
enabled: Boolean = true,
imeAction: ImeAction = ImeAction.Next,
keyboardActions: KeyboardActions = KeyboardActions.Default
) {
MeldestelleTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
label = label,
placeholder = placeholder,
isError = isError,
errorMessage = errorMessage,
helperText = helperText,
enabled = enabled,
keyboardType = KeyboardType.Email,
imeAction = imeAction,
keyboardActions = keyboardActions
)
}
/**
* Form validation utilities
*/
object FormValidation {
fun validateEmail(email: String): String? {
return when {
email.isEmpty() -> "Email is required"
!email.contains("@") -> "Invalid email format"
!email.matches(Regex("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$")) -> "Invalid email format"
else -> null
}
}
fun validatePassword(password: String): String? {
return when {
password.isEmpty() -> "Password is required"
password.length < 6 -> "Password must be at least 6 characters"
else -> null
}
}
fun validateRequired(value: String, fieldName: String): String? {
return if (value.isEmpty()) "$fieldName is required" else null
}
}
@@ -0,0 +1,5 @@
package at.mocode.clients.shared.commonui.components
// Legacy notification components removed due to dependency on old presentation layer.
// Intentionally left empty as part of cleanup. You can safely delete this file
// if no modules import it anymore.
@@ -0,0 +1,49 @@
package at.mocode.clients.shared.commonui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Define custom colors for the app
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1976D2),
onPrimary = Color.White,
primaryContainer = Color(0xFFBBDEFB),
onPrimaryContainer = Color(0xFF0D47A1),
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black,
tertiary = Color(0xFF03A9F4),
background = Color(0xFFFAFAFA),
surface = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F)
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF90CAF9),
onPrimary = Color(0xFF0D47A1),
primaryContainer = Color(0xFF1565C0),
onPrimaryContainer = Color(0xFFBBDEFB),
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black,
tertiary = Color(0xFF03A9F4),
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E),
onBackground = Color(0xFFE0E0E0),
onSurface = Color(0xFFE0E0E0)
)
@Composable
fun AppTheme(
darkTheme: Boolean = false, // For now, we'll default to light theme
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Dieses Modul definiert nur die Navigationsrouten.
* Es ist noch simpler.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
}
group = "at.mocode.clients.shared"
version = "1.0.0"
kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21)
jvm()
js {
browser()
binaries.executable()
}
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
}
sourceSets {
commonMain.dependencies {
// No specific dependencies needed for navigation routes
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
@@ -0,0 +1,9 @@
package at.mocode.clients.shared.navigation
sealed class AppScreen {
data object Home : AppScreen()
data object Login : AppScreen()
data object Ping : AppScreen()
data object Profile : AppScreen()
data object AuthCallback : AppScreen()
}
View File
@@ -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': ''}
}
]
}
}