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,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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+74
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
+28
@@ -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)
|
||||
}
|
||||
}
|
||||
+109
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -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
|
||||
)
|
||||
+193
@@ -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
|
||||
}
|
||||
}
|
||||
+5
@@ -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.
|
||||
+49
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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()
|
||||
}
|
||||
@@ -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