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
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()
}