- Frontend-Aufbau für Meldestelle KMP
- Network Layer - Shared Foundation - Service Layer and API Integration - Test-Fix und Development Screen - WASM-Js Test-Implementation - Build-Konfiguration reparieren
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
js(IR) {
|
||||
browser()
|
||||
nodejs()
|
||||
}
|
||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
browser()
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// Coroutines für asynchrone Programmierung
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
// Serialization für JSON
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// HTTP Client
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.contentNegotiation)
|
||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation(libs.ktor.client.auth)
|
||||
|
||||
// DateTime
|
||||
implementation(libs.kotlinx.datetime)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
jsMain.dependencies {
|
||||
implementation(libs.ktor.client.js)
|
||||
}
|
||||
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.ktor.client.cio)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,51 @@
|
||||
/**
|
||||
* Dieses Modul stellt "dumme", wiederverwendbare UI-Komponenten und das Theme bereit.
|
||||
* Es darf keine Ahnung von irgendeiner Fachlichkeit haben.
|
||||
*/
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
}
|
||||
|
||||
group = "at.mocode.clients.shared"
|
||||
version = "1.0.0"
|
||||
|
||||
kotlin {
|
||||
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
|
||||
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
|
||||
js {
|
||||
browser {
|
||||
testTask {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
js(IR) {
|
||||
browser()
|
||||
nodejs()
|
||||
}
|
||||
|
||||
if (enableWasm) {
|
||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
browser()
|
||||
}
|
||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
browser()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
|
||||
jsMain.dependencies {
|
||||
// JS-specific UI dependencies if needed
|
||||
}
|
||||
|
||||
jvmMain.dependencies {
|
||||
// JVM-specific UI dependencies if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package at.mocode.clients.shared.commonui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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
|
||||
)
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
package at.mocode.clients.shared.commonui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.clients.shared.presentation.state.Notification
|
||||
import at.mocode.clients.shared.presentation.state.NotificationType
|
||||
|
||||
@Composable
|
||||
fun NotificationCard(
|
||||
notification: Notification,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val backgroundColor = when (notification.type) {
|
||||
NotificationType.SUCCESS -> Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||
NotificationType.ERROR -> Color(0xFFF44336).copy(alpha = 0.1f)
|
||||
NotificationType.WARNING -> Color(0xFFFF9800).copy(alpha = 0.1f)
|
||||
NotificationType.INFO -> Color(0xFF2196F3).copy(alpha = 0.1f)
|
||||
}
|
||||
|
||||
val borderColor = when (notification.type) {
|
||||
NotificationType.SUCCESS -> Color(0xFF4CAF50)
|
||||
NotificationType.ERROR -> Color(0xFFF44336)
|
||||
NotificationType.WARNING -> Color(0xFFFF9800)
|
||||
NotificationType.INFO -> Color(0xFF2196F3)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, borderColor)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = notification.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
if (notification.message.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = notification.message,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = notification.timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Text("×", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationList(
|
||||
notifications: List<Notification>,
|
||||
onDismissNotification: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
notifications.forEach { notification ->
|
||||
NotificationCard(
|
||||
notification = notification,
|
||||
onDismiss = { onDismissNotification(notification.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SnackbarNotification(
|
||||
notification: Notification,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val backgroundColor = when (notification.type) {
|
||||
NotificationType.SUCCESS -> Color(0xFF4CAF50)
|
||||
NotificationType.ERROR -> Color(0xFFF44336)
|
||||
NotificationType.WARNING -> Color(0xFFFF9800)
|
||||
NotificationType.INFO -> Color(0xFF2196F3)
|
||||
}
|
||||
|
||||
Snackbar(
|
||||
modifier = modifier,
|
||||
containerColor = backgroundColor,
|
||||
contentColor = Color.White,
|
||||
action = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = notification.title,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (notification.message.isNotBlank()) {
|
||||
Text(
|
||||
text = notification.message,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToastNotification(
|
||||
message: String,
|
||||
type: NotificationType = NotificationType.INFO,
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (visible) {
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(3000) // Auto dismiss after 3 seconds
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
val backgroundColor = when (type) {
|
||||
NotificationType.SUCCESS -> Color(0xFF4CAF50)
|
||||
NotificationType.ERROR -> Color(0xFFF44336)
|
||||
NotificationType.WARNING -> Color(0xFFFF9800)
|
||||
NotificationType.INFO -> Color(0xFF2196F3)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package at.mocode.clients.shared.commonui.layout
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.clients.shared.commonui.components.*
|
||||
import at.mocode.clients.shared.commonui.screens.LoginScreenContainer
|
||||
import at.mocode.clients.shared.presentation.state.AppState
|
||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainLayout(
|
||||
appState: AppState,
|
||||
onDispatchAction: (AppAction) -> Unit,
|
||||
onNavigateTo: (String) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showUserMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Meldestelle",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
// Notifications
|
||||
if (appState.ui.notifications.isNotEmpty()) {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
Badge(
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
) {
|
||||
Text(appState.ui.notifications.size.toString())
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { onNavigateTo("/notifications") }
|
||||
) {
|
||||
Text("🔔")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = { onNavigateTo("/notifications") }
|
||||
) {
|
||||
Text("🔔")
|
||||
}
|
||||
}
|
||||
|
||||
// Theme toggle
|
||||
IconButton(
|
||||
onClick = { onDispatchAction(AppAction.UI.ToggleDarkMode) }
|
||||
) {
|
||||
Text(if (appState.ui.isDarkMode) "☀️" else "🌙")
|
||||
}
|
||||
|
||||
// User menu
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { showUserMenu = true }
|
||||
) {
|
||||
Text("👤")
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showUserMenu,
|
||||
onDismissRequest = { showUserMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = appState.auth.user?.firstName ?: "User",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = appState.auth.user?.email ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
showUserMenu = false
|
||||
onNavigateTo("/profile")
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text("Settings") },
|
||||
onClick = {
|
||||
showUserMenu = false
|
||||
onNavigateTo("/settings")
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text("Help") },
|
||||
onClick = {
|
||||
showUserMenu = false
|
||||
onNavigateTo("/help")
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = "Logout",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showUserMenu = false
|
||||
onDispatchAction(AppAction.Auth.Logout)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (appState.ui.notifications.isNotEmpty()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "${appState.ui.notifications.size} notification(s)",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
TextButton(
|
||||
onClick = { onNavigateTo("/notifications") }
|
||||
) {
|
||||
Text("View All")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Loading overlay
|
||||
if (appState.ui.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
FullScreenLoading("Loading...")
|
||||
}
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthenticatedLayout(
|
||||
appState: AppState,
|
||||
onDispatchAction: (AppAction) -> Unit,
|
||||
onNavigateTo: (String) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (appState.auth.isAuthenticated) {
|
||||
MainLayout(
|
||||
appState = appState,
|
||||
onDispatchAction = onDispatchAction,
|
||||
onNavigateTo = onNavigateTo,
|
||||
content = content,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
// Show login screen if not authenticated
|
||||
LoginScreenContainer(
|
||||
authState = appState.auth,
|
||||
onDispatchAction = onDispatchAction,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ResponsiveLayout(
|
||||
appState: AppState,
|
||||
onDispatchAction: (AppAction) -> Unit,
|
||||
onNavigateTo: (String) -> Unit,
|
||||
content: @Composable (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Simple responsive design - could be enhanced with actual screen size detection
|
||||
val isCompact = remember { mutableStateOf(false) }
|
||||
|
||||
AuthenticatedLayout(
|
||||
appState = appState,
|
||||
onDispatchAction = onDispatchAction,
|
||||
onNavigateTo = onNavigateTo,
|
||||
content = { content(isCompact.value) },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
package at.mocode.clients.shared.commonui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.clients.shared.commonui.components.*
|
||||
import at.mocode.clients.shared.presentation.state.AppState
|
||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
appState: AppState,
|
||||
onDispatchAction: (AppAction) -> Unit,
|
||||
onNavigateTo: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val user = appState.auth.user
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Welcome Header
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Welcome back, ${user?.firstName ?: "User"}!",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Here's what's happening in your Meldestelle dashboard",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Quick Actions
|
||||
Text(
|
||||
text = "Quick Actions",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
PrimaryButton(
|
||||
text = "New Report",
|
||||
onClick = { onNavigateTo("/reports/new") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SecondaryButton(
|
||||
text = "View Reports",
|
||||
onClick = { onNavigateTo("/reports") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Statistics Cards
|
||||
Text(
|
||||
text = "Overview",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
StatisticCard(
|
||||
title = "Total Reports",
|
||||
value = "142",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
StatisticCard(
|
||||
title = "Open Issues",
|
||||
value = "23",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
StatisticCard(
|
||||
title = "Resolved",
|
||||
value = "119",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
StatisticCard(
|
||||
title = "This Month",
|
||||
value = "18",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Recent Activity
|
||||
Text(
|
||||
text = "Recent Activity",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ActivityItem(
|
||||
title = "Report #1234 updated",
|
||||
subtitle = "Status changed to 'In Progress'",
|
||||
timestamp = "2 hours ago"
|
||||
)
|
||||
HorizontalDivider()
|
||||
ActivityItem(
|
||||
title = "New report submitted",
|
||||
subtitle = "Report #1235 - Urgent priority",
|
||||
timestamp = "4 hours ago"
|
||||
)
|
||||
HorizontalDivider()
|
||||
ActivityItem(
|
||||
title = "Report #1230 resolved",
|
||||
subtitle = "Issue successfully closed",
|
||||
timestamp = "1 day ago"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Connection Status
|
||||
if (!appState.network.isOnline) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "⚠️",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Offline Mode",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = "Some features may be limited",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatisticCard(
|
||||
title: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityItem(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
timestamp: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package at.mocode.clients.shared.commonui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.clients.shared.commonui.components.*
|
||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||
import at.mocode.clients.shared.presentation.state.AuthState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
authState: AuthState,
|
||||
onLoginClick: (String, String) -> Unit,
|
||||
onNavigateToRegister: () -> Unit = {},
|
||||
onForgotPassword: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var usernameError by remember { mutableStateOf<String?>(null) }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
// Validate form
|
||||
val isFormValid = username.isNotBlank() && password.isNotBlank() &&
|
||||
usernameError == null && passwordError == null
|
||||
|
||||
fun validateUsername(value: String) {
|
||||
usernameError = FormValidation.validateRequired(value, "Username")
|
||||
}
|
||||
|
||||
fun validatePassword(value: String) {
|
||||
passwordError = FormValidation.validatePassword(value)
|
||||
}
|
||||
|
||||
fun handleLogin() {
|
||||
validateUsername(username)
|
||||
validatePassword(password)
|
||||
|
||||
if (isFormValid) {
|
||||
onLoginClick(username.trim(), password)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "Meldestelle",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Sign in to your account",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Username Field
|
||||
MeldestelleTextField(
|
||||
value = username,
|
||||
onValueChange = {
|
||||
username = it
|
||||
if (usernameError != null) validateUsername(it)
|
||||
},
|
||||
label = "Username",
|
||||
placeholder = "Enter your username",
|
||||
isError = usernameError != null,
|
||||
errorMessage = usernameError,
|
||||
enabled = !authState.isLoading,
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
)
|
||||
)
|
||||
|
||||
// Password Field
|
||||
MeldestellePasswordField(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
password = it
|
||||
if (passwordError != null) validatePassword(it)
|
||||
},
|
||||
label = "Password",
|
||||
placeholder = "Enter your password",
|
||||
isError = passwordError != null,
|
||||
errorMessage = passwordError,
|
||||
enabled = !authState.isLoading,
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (isFormValid) handleLogin()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Error display
|
||||
authState.error?.let { errorMessage ->
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Login Button
|
||||
PrimaryButton(
|
||||
text = "Sign In",
|
||||
onClick = ::handleLogin,
|
||||
enabled = isFormValid && !authState.isLoading,
|
||||
isLoading = authState.isLoading,
|
||||
fullWidth = true
|
||||
)
|
||||
|
||||
// Forgot Password
|
||||
TextButton(
|
||||
onClick = onForgotPassword,
|
||||
enabled = !authState.isLoading
|
||||
) {
|
||||
Text("Forgot Password?")
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Register Link
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Don't have an account?",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
TextButton(
|
||||
onClick = onNavigateToRegister,
|
||||
enabled = !authState.isLoading
|
||||
) {
|
||||
Text("Sign Up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginScreenContainer(
|
||||
authState: AuthState,
|
||||
onDispatchAction: (AppAction) -> Unit,
|
||||
onNavigateToRegister: () -> Unit = {},
|
||||
onForgotPassword: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LoginScreen(
|
||||
authState = authState,
|
||||
onLoginClick = { username, password ->
|
||||
onDispatchAction(AppAction.Auth.LoginStart(username, password))
|
||||
},
|
||||
onNavigateToRegister = onNavigateToRegister,
|
||||
onForgotPassword = onForgotPassword,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package at.mocode.clients.shared.data.repository
|
||||
|
||||
import at.mocode.clients.shared.domain.models.User
|
||||
import at.mocode.clients.shared.domain.models.AuthToken
|
||||
import at.mocode.clients.shared.domain.models.ApiResponse
|
||||
import at.mocode.clients.shared.network.HttpClientConfig
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Authentication repository handling all authentication-related operations
|
||||
* with Keycloak integration.
|
||||
*/
|
||||
class AuthRepository(
|
||||
private val baseUrl: String = "http://localhost:8080",
|
||||
private val keycloakUrl: String = "http://localhost:8180",
|
||||
private val realm: String = "meldestelle",
|
||||
private val clientId: String = "meldestelle-client"
|
||||
) : Repository {
|
||||
|
||||
private val httpClient: HttpClient = HttpClientConfig.createClient(baseUrl)
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KeycloakTokenResponse(
|
||||
val access_token: String,
|
||||
val refresh_token: String,
|
||||
val expires_in: Long,
|
||||
val token_type: String = "Bearer"
|
||||
)
|
||||
|
||||
/**
|
||||
* Authenticate user with username and password via Keycloak
|
||||
*/
|
||||
suspend fun login(username: String, password: String): RepositoryResult<AuthToken> {
|
||||
return try {
|
||||
val response = httpClient.submitForm(
|
||||
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "password")
|
||||
append("client_id", clientId)
|
||||
append("username", username)
|
||||
append("password", password)
|
||||
}
|
||||
).body<KeycloakTokenResponse>()
|
||||
|
||||
val authToken = AuthToken(
|
||||
accessToken = response.access_token,
|
||||
refreshToken = response.refresh_token,
|
||||
expiresIn = response.expires_in,
|
||||
tokenType = response.token_type
|
||||
)
|
||||
|
||||
RepositoryResult.Success(authToken)
|
||||
} catch (e: Exception) {
|
||||
RepositoryResult.Error(
|
||||
at.mocode.clients.shared.domain.models.ApiError(
|
||||
code = "LOGIN_FAILED",
|
||||
message = "Login failed: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh authentication token
|
||||
*/
|
||||
suspend fun refreshToken(refreshToken: String): RepositoryResult<AuthToken> {
|
||||
return try {
|
||||
val response = httpClient.submitForm(
|
||||
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "refresh_token")
|
||||
append("client_id", clientId)
|
||||
append("refresh_token", refreshToken)
|
||||
}
|
||||
).body<KeycloakTokenResponse>()
|
||||
|
||||
val authToken = AuthToken(
|
||||
accessToken = response.access_token,
|
||||
refreshToken = response.refresh_token,
|
||||
expiresIn = response.expires_in,
|
||||
tokenType = response.token_type
|
||||
)
|
||||
|
||||
RepositoryResult.Success(authToken)
|
||||
} catch (e: Exception) {
|
||||
RepositoryResult.Error(
|
||||
at.mocode.clients.shared.domain.models.ApiError(
|
||||
code = "TOKEN_REFRESH_FAILED",
|
||||
message = "Token refresh failed: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user information using access token
|
||||
*/
|
||||
suspend fun getCurrentUser(accessToken: String): RepositoryResult<User> {
|
||||
return try {
|
||||
val response = httpClient.get("$baseUrl/api/auth/me") {
|
||||
header("Authorization", "Bearer $accessToken")
|
||||
}.body<ApiResponse<User>>()
|
||||
|
||||
response.toRepositoryResult()
|
||||
} catch (e: Exception) {
|
||||
RepositoryResult.Error(
|
||||
at.mocode.clients.shared.domain.models.ApiError(
|
||||
code = "USER_INFO_FAILED",
|
||||
message = "Failed to get user info: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user by invalidating tokens
|
||||
*/
|
||||
suspend fun logout(refreshToken: String): RepositoryResult<Unit> {
|
||||
return try {
|
||||
httpClient.submitForm(
|
||||
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/logout",
|
||||
formParameters = Parameters.build {
|
||||
append("client_id", clientId)
|
||||
append("refresh_token", refreshToken)
|
||||
}
|
||||
)
|
||||
|
||||
RepositoryResult.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
RepositoryResult.Error(
|
||||
at.mocode.clients.shared.domain.models.ApiError(
|
||||
code = "LOGOUT_FAILED",
|
||||
message = "Logout failed: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is still valid
|
||||
*/
|
||||
suspend fun validateToken(accessToken: String): RepositoryResult<Boolean> {
|
||||
return try {
|
||||
val response = httpClient.get("$baseUrl/api/auth/validate") {
|
||||
header("Authorization", "Bearer $accessToken")
|
||||
}.body<ApiResponse<Boolean>>()
|
||||
|
||||
response.toRepositoryResult()
|
||||
} catch (e: Exception) {
|
||||
RepositoryResult.Success(false) // Token is invalid
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package at.mocode.clients.shared.data.repository
|
||||
|
||||
import at.mocode.clients.shared.domain.models.ApiResponse
|
||||
import at.mocode.clients.shared.domain.models.ApiError
|
||||
|
||||
/**
|
||||
* Base repository interface defining common operations and patterns
|
||||
* for data access across the application.
|
||||
*/
|
||||
interface Repository
|
||||
|
||||
/**
|
||||
* Result wrapper for repository operations to handle success/error states
|
||||
*/
|
||||
sealed class RepositoryResult<out T> {
|
||||
data class Success<T>(val data: T) : RepositoryResult<T>()
|
||||
data class Error(val error: ApiError) : RepositoryResult<Nothing>()
|
||||
data class Loading(val message: String = "Loading...") : RepositoryResult<Nothing>()
|
||||
|
||||
fun isSuccess(): Boolean = this is Success
|
||||
fun isError(): Boolean = this is Error
|
||||
fun isLoading(): Boolean = this is Loading
|
||||
|
||||
fun getOrNull(): T? = when (this) {
|
||||
is Success -> data
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun getErrorOrNull(): ApiError? = when (this) {
|
||||
is Error -> error
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to convert ApiResponse to RepositoryResult
|
||||
*/
|
||||
fun <T> ApiResponse<T>.toRepositoryResult(): RepositoryResult<T> {
|
||||
return if (success && data != null) {
|
||||
RepositoryResult.Success(data)
|
||||
} else {
|
||||
RepositoryResult.Error(
|
||||
error ?: ApiError(
|
||||
code = "UNKNOWN_ERROR",
|
||||
message = "Unknown error occurred"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to handle repository results with callbacks
|
||||
*/
|
||||
inline fun <T> RepositoryResult<T>.onSuccess(action: (T) -> Unit): RepositoryResult<T> {
|
||||
if (this is RepositoryResult.Success) {
|
||||
action(data)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <T> RepositoryResult<T>.onError(action: (ApiError) -> Unit): RepositoryResult<T> {
|
||||
if (this is RepositoryResult.Error) {
|
||||
action(error)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <T> RepositoryResult<T>.onLoading(action: (String) -> Unit): RepositoryResult<T> {
|
||||
if (this is RepositoryResult.Loading) {
|
||||
action(message)
|
||||
}
|
||||
return this
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package at.mocode.clients.shared.domain.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ApiResponse<T>(
|
||||
val success: Boolean,
|
||||
val data: T? = null,
|
||||
val error: ApiError? = null,
|
||||
val timestamp: String,
|
||||
val correlationId: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiError(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val details: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HealthResponse(
|
||||
val status: String,
|
||||
val timestamp: String,
|
||||
val service: String,
|
||||
val healthy: Boolean
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
package at.mocode.clients.shared.domain.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class User(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val firstName: String,
|
||||
val lastName: String,
|
||||
val roles: Set<String> = emptySet(),
|
||||
val isActive: Boolean = true
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthToken(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val expiresIn: Long,
|
||||
val tokenType: String = "Bearer"
|
||||
)
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package at.mocode.clients.shared.navigation
|
||||
|
||||
import at.mocode.clients.shared.presentation.store.AppStore
|
||||
|
||||
/**
|
||||
* Deep link handling for the application
|
||||
*/
|
||||
class DeepLinkHandler(
|
||||
private val navigationManager: NavigationManager,
|
||||
private val store: AppStore
|
||||
) {
|
||||
|
||||
/**
|
||||
* Deep link configuration
|
||||
*/
|
||||
data class DeepLinkConfig(
|
||||
val scheme: String = "meldestelle",
|
||||
val host: String = "app",
|
||||
val allowedDomains: Set<String> = setOf("meldestelle.com", "localhost")
|
||||
)
|
||||
|
||||
private val config = DeepLinkConfig()
|
||||
|
||||
/**
|
||||
* Handle a deep link URL
|
||||
*/
|
||||
fun handleDeepLink(url: String): Boolean {
|
||||
return try {
|
||||
val parsedLink = parseDeepLink(url)
|
||||
if (parsedLink != null) {
|
||||
processDeepLink(parsedLink)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Log error in real implementation
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse deep link URL into components
|
||||
*/
|
||||
private fun parseDeepLink(url: String): DeepLink? {
|
||||
return when {
|
||||
url.startsWith("${config.scheme}://") -> parseCustomSchemeLink(url)
|
||||
url.startsWith("https://") || url.startsWith("http://") -> parseWebLink(url)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse custom scheme deep links (e.g., meldestelle://app/dashboard)
|
||||
*/
|
||||
private fun parseCustomSchemeLink(url: String): DeepLink? {
|
||||
val withoutScheme = url.removePrefix("${config.scheme}://")
|
||||
val parts = withoutScheme.split("/")
|
||||
|
||||
if (parts.isEmpty() || parts[0] != config.host) {
|
||||
return null
|
||||
}
|
||||
|
||||
val path = "/" + parts.drop(1).joinToString("/")
|
||||
val route = if (path == "/") Routes.HOME else path
|
||||
|
||||
return DeepLink(
|
||||
type = DeepLinkType.CUSTOM_SCHEME,
|
||||
route = route,
|
||||
params = RouteUtils.parseRouteParams(route),
|
||||
originalUrl = url
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse web deep links (e.g., https://meldestelle.com/dashboard)
|
||||
*/
|
||||
private fun parseWebLink(url: String): DeepLink? {
|
||||
// Simple URL parsing - in real implementation use proper URL parser
|
||||
val urlParts = url.split("/")
|
||||
if (urlParts.size < 3) return null
|
||||
|
||||
val domain = urlParts[2]
|
||||
if (!config.allowedDomains.contains(domain)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val path = "/" + urlParts.drop(3).joinToString("/")
|
||||
val route = if (path == "/" || path.isEmpty()) Routes.HOME else path
|
||||
|
||||
return DeepLink(
|
||||
type = DeepLinkType.WEB_LINK,
|
||||
route = route,
|
||||
params = RouteUtils.parseRouteParams(route),
|
||||
originalUrl = url
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a parsed deep link
|
||||
*/
|
||||
private fun processDeepLink(deepLink: DeepLink) {
|
||||
val authState = store.state.value.auth
|
||||
val cleanRoute = RouteUtils.getCleanRoute(deepLink.route)
|
||||
|
||||
// Check if route requires authentication
|
||||
if (RouteUtils.requiresAuth(cleanRoute)) {
|
||||
if (!authState.isAuthenticated) {
|
||||
// Save the intended route and redirect to log in
|
||||
saveIntendedRoute(deepLink.route)
|
||||
navigationManager.navigateTo(Routes.Auth.LOGIN)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if route requires admin privileges
|
||||
if (RouteUtils.requiresAdmin(cleanRoute)) {
|
||||
val hasAdminRole = authState.user?.roles?.contains("admin") ?: false
|
||||
if (!hasAdminRole) {
|
||||
// Redirect to unauthorized or home
|
||||
navigationManager.navigateTo(Routes.HOME)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to the route
|
||||
navigationManager.navigateTo(deepLink.route)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the intended route for after authentication
|
||||
*/
|
||||
private fun saveIntendedRoute(route: String) {
|
||||
// In real implementation, save to persistent storage
|
||||
// For now; we'll store it in a simple variable
|
||||
intendedRoute = route
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and clear the intended route
|
||||
*/
|
||||
fun getAndClearIntendedRoute(): String? {
|
||||
val route = intendedRoute
|
||||
intendedRoute = null
|
||||
return route
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a pending intended route
|
||||
*/
|
||||
fun hasIntendedRoute(): Boolean = intendedRoute != null
|
||||
|
||||
/**
|
||||
* Generate a deep link for a route
|
||||
*/
|
||||
fun generateDeepLink(route: String, useCustomScheme: Boolean = true): String {
|
||||
return if (useCustomScheme) {
|
||||
"${config.scheme}://${config.host}$route"
|
||||
} else {
|
||||
"https://${config.allowedDomains.first()}$route"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a route is valid for deep linking
|
||||
*/
|
||||
fun isValidDeepLinkRoute(route: String): Boolean {
|
||||
return RouteUtils.isValidRoute(route) &&
|
||||
!route.startsWith("/auth/") && // Auth routes shouldn't be deep linked
|
||||
route != Routes.Auth.LOGIN
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var intendedRoute: String? = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep link data class
|
||||
*/
|
||||
data class DeepLink(
|
||||
val type: DeepLinkType,
|
||||
val route: String,
|
||||
val params: Map<String, String>,
|
||||
val originalUrl: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Types of deep links
|
||||
*/
|
||||
enum class DeepLinkType {
|
||||
CUSTOM_SCHEME, // meldestelle://app/route
|
||||
WEB_LINK // https://meldestelle.com/route
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
package at.mocode.clients.shared.navigation
|
||||
|
||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||
import at.mocode.clients.shared.presentation.store.AppStore
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Navigation manager for handling routing and navigation logic
|
||||
*/
|
||||
class NavigationManager(
|
||||
private val store: AppStore
|
||||
) {
|
||||
|
||||
/**
|
||||
* Current route as a flow
|
||||
*/
|
||||
val currentRoute: Flow<String> = store.state.map { it.navigation.currentRoute }
|
||||
|
||||
/**
|
||||
* Navigation history as a flow
|
||||
*/
|
||||
val navigationHistory: Flow<List<String>> = store.state.map { it.navigation.history }
|
||||
|
||||
/**
|
||||
* Can go back flag as a flow
|
||||
*/
|
||||
val canGoBack: Flow<Boolean> = store.state.map { it.navigation.canGoBack }
|
||||
|
||||
/**
|
||||
* Navigate to a specific route
|
||||
*/
|
||||
fun navigateTo(route: String) {
|
||||
store.dispatch(AppAction.Navigation.NavigateTo(route))
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate back to the previous route
|
||||
*/
|
||||
fun navigateBack() {
|
||||
store.dispatch(AppAction.Navigation.NavigateBack)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace current route without adding to history
|
||||
*/
|
||||
fun replaceRoute(route: String) {
|
||||
store.dispatch(AppAction.Navigation.UpdateHistory(route))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear navigation history and navigate to the route
|
||||
*/
|
||||
fun navigateAndClearHistory(route: String) {
|
||||
// First clear by replacing with the new route
|
||||
store.dispatch(AppAction.Navigation.UpdateHistory(route))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current route value (non-reactive)
|
||||
*/
|
||||
fun getCurrentRoute(): String = store.state.value.navigation.currentRoute
|
||||
|
||||
/**
|
||||
* Check if we can navigate back
|
||||
*/
|
||||
fun canNavigateBack(): Boolean = store.state.value.navigation.canGoBack
|
||||
}
|
||||
|
||||
/**
|
||||
* Route definitions for the application
|
||||
*/
|
||||
object Routes {
|
||||
const val HOME = "/"
|
||||
const val LOGIN = "/login"
|
||||
const val DASHBOARD = "/dashboard"
|
||||
const val PROFILE = "/profile"
|
||||
const val SETTINGS = "/settings"
|
||||
const val PING = "/ping"
|
||||
|
||||
// Auth-related routes
|
||||
object Auth {
|
||||
const val LOGIN = "/auth/login"
|
||||
const val LOGOUT = "/auth/logout"
|
||||
const val REGISTER = "/auth/register"
|
||||
const val FORGOT_PASSWORD = "/auth/forgot-password"
|
||||
}
|
||||
|
||||
// Admin routes
|
||||
object Admin {
|
||||
const val DASHBOARD = "/admin/dashboard"
|
||||
const val USERS = "/admin/users"
|
||||
const val SETTINGS = "/admin/settings"
|
||||
}
|
||||
|
||||
// Feature routes
|
||||
object Features {
|
||||
const val PING = "/features/ping"
|
||||
const val REPORTS = "/features/reports"
|
||||
const val NOTIFICATIONS = "/features/notifications"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route validation and utilities
|
||||
*/
|
||||
object RouteUtils {
|
||||
|
||||
/**
|
||||
* Check if a route requires authentication
|
||||
*/
|
||||
fun requiresAuth(route: String): Boolean {
|
||||
return when {
|
||||
route.startsWith("/auth/") && route != Routes.Auth.LOGIN -> false
|
||||
route == Routes.HOME -> false
|
||||
route == Routes.LOGIN -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route is for admin only
|
||||
*/
|
||||
fun requiresAdmin(route: String): Boolean {
|
||||
return route.startsWith("/admin/")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default route for authenticated users
|
||||
*/
|
||||
fun getDefaultAuthenticatedRoute(): String = Routes.DASHBOARD
|
||||
|
||||
/**
|
||||
* Get the default route for unauthenticated users
|
||||
*/
|
||||
fun getDefaultUnauthenticatedRoute(): String = Routes.LOGIN
|
||||
|
||||
/**
|
||||
* Validate route format
|
||||
*/
|
||||
fun isValidRoute(route: String): Boolean {
|
||||
return route.startsWith("/") && route.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse route parameters (simple implementation)
|
||||
*/
|
||||
fun parseRouteParams(route: String): Map<String, String> {
|
||||
val params = mutableMapOf<String, String>()
|
||||
|
||||
// Simple query parameter parsing
|
||||
if (route.contains("?")) {
|
||||
val parts = route.split("?")
|
||||
if (parts.size == 2) {
|
||||
val queryParams = parts[1].split("&")
|
||||
queryParams.forEach { param ->
|
||||
val keyValue = param.split("=")
|
||||
if (keyValue.size == 2) {
|
||||
params[keyValue[0]] = keyValue[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clean route without parameters
|
||||
*/
|
||||
fun getCleanRoute(route: String): String {
|
||||
return if (route.contains("?")) {
|
||||
route.split("?")[0]
|
||||
} else {
|
||||
route
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package at.mocode.clients.shared.navigation
|
||||
|
||||
import at.mocode.clients.shared.presentation.state.NavigationState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Interface für das Persistieren von Navigation State
|
||||
*/
|
||||
interface NavigationPersistence {
|
||||
suspend fun saveNavigationState(state: NavigationState)
|
||||
fun getNavigationState(): Flow<NavigationState?>
|
||||
suspend fun clearNavigationState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation ohne echte Persistierung (In-Memory)
|
||||
* Platform-spezifische Implementierungen können echte Persistierung bereitstellen
|
||||
*/
|
||||
class DefaultNavigationPersistence : NavigationPersistence {
|
||||
private var currentState: NavigationState? = null
|
||||
|
||||
override suspend fun saveNavigationState(state: NavigationState) {
|
||||
currentState = state
|
||||
}
|
||||
|
||||
override fun getNavigationState(): Flow<NavigationState?> {
|
||||
return kotlinx.coroutines.flow.flowOf(currentState)
|
||||
}
|
||||
|
||||
override suspend fun clearNavigationState() {
|
||||
currentState = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation History Manager mit Persistierung
|
||||
*/
|
||||
class NavigationHistoryManager(
|
||||
private val persistence: NavigationPersistence
|
||||
) {
|
||||
companion object {
|
||||
private const val MAX_HISTORY_SIZE = 50
|
||||
}
|
||||
|
||||
suspend fun saveRoute(route: String, history: List<String>) {
|
||||
val state = NavigationState(
|
||||
currentRoute = route,
|
||||
history = history.takeLast(MAX_HISTORY_SIZE),
|
||||
canGoBack = history.isNotEmpty()
|
||||
)
|
||||
persistence.saveNavigationState(state)
|
||||
}
|
||||
|
||||
fun getPersistedState() = persistence.getNavigationState()
|
||||
|
||||
suspend fun clear() = persistence.clearNavigationState()
|
||||
|
||||
/**
|
||||
* Optimiert die History für bessere Performance
|
||||
*/
|
||||
private fun optimizeHistory(history: List<String>): List<String> {
|
||||
// Entfernt Duplikate in Folge und behält nur die letzten N Einträge
|
||||
return history
|
||||
.fold(emptyList<String>()) { acc, route ->
|
||||
if (acc.lastOrNull() != route) acc + route else acc
|
||||
}
|
||||
.takeLast(MAX_HISTORY_SIZE)
|
||||
}
|
||||
|
||||
suspend fun addToHistory(newRoute: String, currentHistory: List<String>) {
|
||||
val optimizedHistory = optimizeHistory(currentHistory + newRoute)
|
||||
saveRoute(newRoute, optimizedHistory.dropLast(1))
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package at.mocode.clients.shared.network
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object HttpClientConfig {
|
||||
|
||||
fun createClient(
|
||||
baseUrl: String = "http://localhost:8080"
|
||||
): HttpClient = HttpClient {
|
||||
|
||||
// Content negotiation with JSON (based on PingApiClient pattern)
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun createClientWithBaseUrl(baseUrl: String): HttpClient {
|
||||
return createClient(baseUrl)
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package at.mocode.clients.shared.network
|
||||
|
||||
import at.mocode.clients.shared.domain.models.ApiError
|
||||
import io.ktor.client.network.sockets.*
|
||||
import io.ktor.client.plugins.*
|
||||
import kotlinx.io.IOException
|
||||
|
||||
/**
|
||||
* Custom exceptions for network operations
|
||||
*/
|
||||
sealed class NetworkException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
val apiError: ApiError
|
||||
) : Exception(message, cause) {
|
||||
|
||||
class ConnectionException(
|
||||
message: String = "Connection failed",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "CONNECTION_ERROR",
|
||||
message = message,
|
||||
details = mapOf("type" to "network_connectivity")
|
||||
)
|
||||
)
|
||||
|
||||
class TimeoutException(
|
||||
message: String = "Request timed out",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "TIMEOUT_ERROR",
|
||||
message = message,
|
||||
details = mapOf("type" to "request_timeout")
|
||||
)
|
||||
)
|
||||
|
||||
class ServerException(
|
||||
statusCode: Int,
|
||||
message: String = "Server error",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "SERVER_ERROR",
|
||||
message = message,
|
||||
details = mapOf(
|
||||
"type" to "server_error",
|
||||
"status_code" to statusCode.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
class ClientException(
|
||||
statusCode: Int,
|
||||
message: String = "Client error",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "CLIENT_ERROR",
|
||||
message = message,
|
||||
details = mapOf(
|
||||
"type" to "client_error",
|
||||
"status_code" to statusCode.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
class AuthenticationException(
|
||||
message: String = "Authentication failed",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "AUTHENTICATION_ERROR",
|
||||
message = message,
|
||||
details = mapOf("type" to "authentication_failure")
|
||||
)
|
||||
)
|
||||
|
||||
class AuthorizationException(
|
||||
message: String = "Authorization failed",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "AUTHORIZATION_ERROR",
|
||||
message = message,
|
||||
details = mapOf("type" to "authorization_failure")
|
||||
)
|
||||
)
|
||||
|
||||
class UnknownException(
|
||||
message: String = "Unknown error occurred",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "UNKNOWN_ERROR",
|
||||
message = message,
|
||||
details = mapOf("type" to "unknown_error")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to convert various exceptions to NetworkException
|
||||
*/
|
||||
fun Throwable.toNetworkException(): NetworkException {
|
||||
return when (this) {
|
||||
is ConnectTimeoutException -> NetworkException.TimeoutException(
|
||||
message = "Connection timeout: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
is SocketTimeoutException -> NetworkException.TimeoutException(
|
||||
message = "Socket timeout: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
is ResponseException -> when (this.response.status.value) {
|
||||
401 -> NetworkException.AuthenticationException(
|
||||
message = "Authentication required",
|
||||
cause = this
|
||||
)
|
||||
403 -> NetworkException.AuthorizationException(
|
||||
message = "Access forbidden",
|
||||
cause = this
|
||||
)
|
||||
in 400..499 -> NetworkException.ClientException(
|
||||
statusCode = this.response.status.value,
|
||||
message = "Client error: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
in 500..599 -> NetworkException.ServerException(
|
||||
statusCode = this.response.status.value,
|
||||
message = "Server error: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
else -> NetworkException.UnknownException(
|
||||
message = "HTTP error: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
}
|
||||
is IOException -> NetworkException.ConnectionException(
|
||||
message = "Network connection failed: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
is NetworkException -> this
|
||||
else -> NetworkException.UnknownException(
|
||||
message = "Unexpected error: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package at.mocode.clients.shared.network
|
||||
|
||||
import at.mocode.clients.shared.data.repository.RepositoryResult
|
||||
import at.mocode.clients.shared.domain.models.ApiError
|
||||
import kotlinx.coroutines.delay
|
||||
// Using platform-agnostic timestamp handling
|
||||
|
||||
/**
|
||||
* Simple timestamp provider for multiplatform compatibility
|
||||
*/
|
||||
expect fun currentTimeMillis(): Long
|
||||
|
||||
/**
|
||||
* Network utilities for handling retry logic and resilience
|
||||
*/
|
||||
object NetworkUtils {
|
||||
|
||||
/**
|
||||
* Retry configuration for network operations
|
||||
*/
|
||||
data class RetryConfig(
|
||||
val maxAttempts: Int = 3,
|
||||
val initialDelayMs: Long = 1000L,
|
||||
val maxDelayMs: Long = 10000L,
|
||||
val backoffMultiplier: Double = 2.0,
|
||||
val retryableExceptions: Set<String> = setOf(
|
||||
"CONNECTION_ERROR",
|
||||
"TIMEOUT_ERROR",
|
||||
"SERVER_ERROR"
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Execute operation with retry logic
|
||||
*/
|
||||
suspend fun <T> withRetry(
|
||||
config: RetryConfig = RetryConfig(),
|
||||
operation: suspend () -> RepositoryResult<T>
|
||||
): RepositoryResult<T> {
|
||||
var lastError: ApiError? = null
|
||||
var currentDelay = config.initialDelayMs
|
||||
|
||||
repeat(config.maxAttempts) { attempt ->
|
||||
try {
|
||||
val result = operation()
|
||||
|
||||
// Return success immediately
|
||||
if (result.isSuccess()) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Check if the error is retryable
|
||||
val error = result.getErrorOrNull()
|
||||
if (error != null && shouldRetry(error, config)) {
|
||||
lastError = error
|
||||
|
||||
// Don't delay on the last attempt
|
||||
if (attempt < config.maxAttempts - 1) {
|
||||
delay(currentDelay)
|
||||
currentDelay = minOf(
|
||||
(currentDelay * config.backoffMultiplier).toLong(),
|
||||
config.maxDelayMs
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Non-retryable error, return immediately
|
||||
return result
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val networkException = e.toNetworkException()
|
||||
lastError = networkException.apiError
|
||||
|
||||
if (shouldRetry(networkException.apiError, config)) {
|
||||
if (attempt < config.maxAttempts - 1) {
|
||||
delay(currentDelay)
|
||||
currentDelay = minOf(
|
||||
(currentDelay * config.backoffMultiplier).toLong(),
|
||||
config.maxDelayMs
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return RepositoryResult.Error(networkException.apiError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts exhausted, return last error
|
||||
return RepositoryResult.Error(
|
||||
lastError ?: ApiError(
|
||||
code = "MAX_RETRIES_EXCEEDED",
|
||||
message = "Maximum retry attempts exceeded"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should trigger a retry
|
||||
*/
|
||||
private fun shouldRetry(error: ApiError, config: RetryConfig): Boolean {
|
||||
return config.retryableExceptions.contains(error.code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Network connectivity checker (simplified for shared module)
|
||||
*/
|
||||
object ConnectivityChecker {
|
||||
private var isOnline: Boolean = true
|
||||
private var lastCheckMillis: Long = 0L
|
||||
|
||||
fun setOnlineStatus(online: Boolean) {
|
||||
isOnline = online
|
||||
lastCheckMillis = currentTimeMillis()
|
||||
}
|
||||
|
||||
fun isOnline(): Boolean = isOnline
|
||||
|
||||
fun getLastCheckMillis(): Long = lastCheckMillis
|
||||
|
||||
/**
|
||||
* Simple connectivity test by attempting a lightweight operation
|
||||
*/
|
||||
suspend fun checkConnectivity(testOperation: suspend () -> Boolean): Boolean {
|
||||
return try {
|
||||
val result = testOperation()
|
||||
setOnlineStatus(result)
|
||||
result
|
||||
} catch (_: Exception) {
|
||||
setOnlineStatus(false)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker pattern for network operations
|
||||
*/
|
||||
class CircuitBreaker(
|
||||
private val failureThreshold: Int = 5,
|
||||
private val recoveryTimeoutMs: Long = 60000L,
|
||||
private val successThreshold: Int = 3
|
||||
) {
|
||||
private enum class State { CLOSED, OPEN, HALF_OPEN }
|
||||
|
||||
private var state = State.CLOSED
|
||||
private var failureCount = 0
|
||||
private var successCount = 0
|
||||
private var lastFailureTime = 0L
|
||||
|
||||
suspend fun <T> execute(operation: suspend () -> RepositoryResult<T>): RepositoryResult<T> {
|
||||
when (state) {
|
||||
State.OPEN -> {
|
||||
if (currentTimeMillis() - lastFailureTime >= recoveryTimeoutMs) {
|
||||
state = State.HALF_OPEN
|
||||
successCount = 0
|
||||
} else {
|
||||
return RepositoryResult.Error(
|
||||
ApiError(
|
||||
code = "CIRCUIT_BREAKER_OPEN",
|
||||
message = "Circuit breaker is open, requests blocked"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
State.HALF_OPEN -> {
|
||||
// Allow limited requests to test recovery
|
||||
}
|
||||
State.CLOSED -> {
|
||||
// Normal operation
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val result = operation()
|
||||
|
||||
if (result.isSuccess()) {
|
||||
onSuccess()
|
||||
} else {
|
||||
onFailure()
|
||||
}
|
||||
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
onFailure()
|
||||
val networkException = e.toNetworkException()
|
||||
RepositoryResult.Error(networkException.apiError)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSuccess() {
|
||||
failureCount = 0
|
||||
|
||||
when (state) {
|
||||
State.HALF_OPEN -> {
|
||||
successCount++
|
||||
if (successCount >= successThreshold) {
|
||||
state = State.CLOSED
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
state = State.CLOSED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFailure() {
|
||||
failureCount++
|
||||
lastFailureTime = currentTimeMillis()
|
||||
|
||||
if (failureCount >= failureThreshold) {
|
||||
state = State.OPEN
|
||||
}
|
||||
}
|
||||
|
||||
fun getState(): String = state.name
|
||||
fun getFailureCount(): Int = failureCount
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package at.mocode.clients.shared.presentation.actions
|
||||
|
||||
import at.mocode.clients.shared.domain.models.User
|
||||
import at.mocode.clients.shared.domain.models.AuthToken
|
||||
|
||||
sealed class AppAction {
|
||||
// Auth Actions
|
||||
sealed class Auth : AppAction() {
|
||||
data class LoginStart(val username: String, val password: String) : Auth()
|
||||
data class LoginSuccess(val user: User, val token: AuthToken) : Auth()
|
||||
data class LoginFailure(val error: String) : Auth()
|
||||
object Logout : Auth()
|
||||
data class RefreshToken(val newToken: AuthToken) : Auth()
|
||||
}
|
||||
|
||||
// Navigation Actions
|
||||
sealed class Navigation : AppAction() {
|
||||
data class NavigateTo(val route: String) : Navigation()
|
||||
object NavigateBack : Navigation()
|
||||
data class UpdateHistory(val route: String) : Navigation()
|
||||
}
|
||||
|
||||
// UI Actions
|
||||
sealed class UI : AppAction() {
|
||||
object ToggleDarkMode : UI()
|
||||
data class SetLoading(val isLoading: Boolean) : UI()
|
||||
data class ShowNotification(val notification: at.mocode.clients.shared.presentation.state.Notification) : UI()
|
||||
data class DismissNotification(val id: String) : UI()
|
||||
}
|
||||
|
||||
// Network Actions
|
||||
sealed class Network : AppAction() {
|
||||
data class SetOnlineStatus(val isOnline: Boolean) : Network()
|
||||
data class UpdateLastSync(val timestamp: String) : Network()
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package at.mocode.clients.shared.presentation.state
|
||||
|
||||
import at.mocode.clients.shared.domain.models.User
|
||||
import at.mocode.clients.shared.domain.models.AuthToken
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AppState(
|
||||
val auth: AuthState = AuthState(),
|
||||
val navigation: NavigationState = NavigationState(),
|
||||
val ui: UiState = UiState(),
|
||||
val network: NetworkState = NetworkState()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthState(
|
||||
val isAuthenticated: Boolean = false,
|
||||
val user: User? = null,
|
||||
val token: AuthToken? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NavigationState(
|
||||
val currentRoute: String = "/",
|
||||
val history: List<String> = emptyList(),
|
||||
val canGoBack: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UiState(
|
||||
val isDarkMode: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val notifications: List<Notification> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NetworkState(
|
||||
val isOnline: Boolean = true,
|
||||
val lastSync: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Notification(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val type: NotificationType = NotificationType.INFO,
|
||||
val timestamp: String
|
||||
)
|
||||
|
||||
enum class NotificationType {
|
||||
INFO, SUCCESS, WARNING, ERROR
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package at.mocode.clients.shared.presentation.store
|
||||
|
||||
import at.mocode.clients.shared.presentation.state.AppState
|
||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
class AppStore(
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.Main
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val _state = MutableStateFlow(AppState())
|
||||
|
||||
val state: StateFlow<AppState> = _state.asStateFlow()
|
||||
|
||||
fun dispatch(action: AppAction) {
|
||||
scope.launch {
|
||||
val currentState = _state.value
|
||||
val newState = reduce(currentState, action)
|
||||
_state.value = newState
|
||||
|
||||
// Handle side effects
|
||||
handleSideEffect(action, newState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reduce(currentState: AppState, action: AppAction): AppState {
|
||||
return when (action) {
|
||||
is AppAction.Auth -> currentState.copy(
|
||||
auth = reduceAuth(currentState.auth, action)
|
||||
)
|
||||
is AppAction.Navigation -> currentState.copy(
|
||||
navigation = reduceNavigation(currentState.navigation, action)
|
||||
)
|
||||
is AppAction.UI -> currentState.copy(
|
||||
ui = reduceUI(currentState.ui, action)
|
||||
)
|
||||
is AppAction.Network -> currentState.copy(
|
||||
network = reduceNetwork(currentState.network, action)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reduceAuth(currentAuth: at.mocode.clients.shared.presentation.state.AuthState, action: AppAction.Auth): at.mocode.clients.shared.presentation.state.AuthState {
|
||||
return when (action) {
|
||||
is AppAction.Auth.LoginStart -> currentAuth.copy(
|
||||
isLoading = true,
|
||||
error = null
|
||||
)
|
||||
is AppAction.Auth.LoginSuccess -> currentAuth.copy(
|
||||
isAuthenticated = true,
|
||||
user = action.user,
|
||||
token = action.token,
|
||||
isLoading = false,
|
||||
error = null
|
||||
)
|
||||
is AppAction.Auth.LoginFailure -> currentAuth.copy(
|
||||
isAuthenticated = false,
|
||||
user = null,
|
||||
token = null,
|
||||
isLoading = false,
|
||||
error = action.error
|
||||
)
|
||||
is AppAction.Auth.Logout -> at.mocode.clients.shared.presentation.state.AuthState()
|
||||
is AppAction.Auth.RefreshToken -> currentAuth.copy(
|
||||
token = action.newToken
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reduceNavigation(currentNav: at.mocode.clients.shared.presentation.state.NavigationState, action: AppAction.Navigation): at.mocode.clients.shared.presentation.state.NavigationState {
|
||||
return when (action) {
|
||||
is AppAction.Navigation.NavigateTo -> currentNav.copy(
|
||||
currentRoute = action.route,
|
||||
history = currentNav.history + currentNav.currentRoute,
|
||||
canGoBack = true
|
||||
)
|
||||
is AppAction.Navigation.NavigateBack -> {
|
||||
val newHistory = currentNav.history.dropLast(1)
|
||||
currentNav.copy(
|
||||
currentRoute = newHistory.lastOrNull() ?: "/",
|
||||
history = newHistory,
|
||||
canGoBack = newHistory.isNotEmpty()
|
||||
)
|
||||
}
|
||||
is AppAction.Navigation.UpdateHistory -> currentNav.copy(
|
||||
currentRoute = action.route
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reduceUI(currentUI: at.mocode.clients.shared.presentation.state.UiState, action: AppAction.UI): at.mocode.clients.shared.presentation.state.UiState {
|
||||
return when (action) {
|
||||
is AppAction.UI.ToggleDarkMode -> currentUI.copy(
|
||||
isDarkMode = !currentUI.isDarkMode
|
||||
)
|
||||
is AppAction.UI.SetLoading -> currentUI.copy(
|
||||
isLoading = action.isLoading
|
||||
)
|
||||
is AppAction.UI.ShowNotification -> currentUI.copy(
|
||||
notifications = currentUI.notifications + action.notification
|
||||
)
|
||||
is AppAction.UI.DismissNotification -> currentUI.copy(
|
||||
notifications = currentUI.notifications.filter { it.id != action.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reduceNetwork(currentNetwork: at.mocode.clients.shared.presentation.state.NetworkState, action: AppAction.Network): at.mocode.clients.shared.presentation.state.NetworkState {
|
||||
return when (action) {
|
||||
is AppAction.Network.SetOnlineStatus -> currentNetwork.copy(
|
||||
isOnline = action.isOnline
|
||||
)
|
||||
is AppAction.Network.UpdateLastSync -> currentNetwork.copy(
|
||||
lastSync = action.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleSideEffect(action: AppAction, newState: AppState) {
|
||||
when (action) {
|
||||
is AppAction.Auth.LoginSuccess -> {
|
||||
// Auto-save token to local storage
|
||||
// TODO: Implement storage
|
||||
}
|
||||
is AppAction.Auth.Logout -> {
|
||||
// Clear local storage
|
||||
// TODO: Implement storage cleanup
|
||||
}
|
||||
else -> { /* No side effects */ }
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package at.mocode.clients.shared.presentation.store
|
||||
|
||||
import at.mocode.clients.shared.domain.models.User
|
||||
import at.mocode.clients.shared.domain.models.AuthToken
|
||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlin.test.*
|
||||
|
||||
class AppStoreTest {
|
||||
|
||||
@Test
|
||||
fun `store should be created successfully`() {
|
||||
val store = AppStore(Dispatchers.Unconfined)
|
||||
assertNotNull(store)
|
||||
store.cleanup()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auth actions should update state`() {
|
||||
val store = AppStore(Dispatchers.Unconfined)
|
||||
|
||||
// Test login start action
|
||||
store.dispatch(AppAction.Auth.LoginStart("testuser", "password"))
|
||||
|
||||
// Test login success
|
||||
val user = User("1", "test", "test@example.com", "Test", "User")
|
||||
val token = AuthToken("access", "refresh", 3600)
|
||||
store.dispatch(AppAction.Auth.LoginSuccess(user, token))
|
||||
|
||||
// Test logout
|
||||
store.dispatch(AppAction.Auth.Logout)
|
||||
|
||||
store.cleanup()
|
||||
assertTrue(true) // Basic test to verify actions don't throw exceptions
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigation actions should work`() {
|
||||
val store = AppStore(Dispatchers.Unconfined)
|
||||
|
||||
store.dispatch(AppAction.Navigation.NavigateTo("/dashboard"))
|
||||
store.dispatch(AppAction.Navigation.NavigateBack)
|
||||
|
||||
store.cleanup()
|
||||
assertTrue(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ui actions should work`() {
|
||||
val store = AppStore(Dispatchers.Unconfined)
|
||||
|
||||
store.dispatch(AppAction.UI.ToggleDarkMode)
|
||||
store.dispatch(AppAction.UI.SetLoading(true))
|
||||
|
||||
store.cleanup()
|
||||
assertTrue(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `network actions should work`() {
|
||||
val store = AppStore(Dispatchers.Unconfined)
|
||||
|
||||
store.dispatch(AppAction.Network.SetOnlineStatus(false))
|
||||
store.dispatch(AppAction.Network.UpdateLastSync("2024-01-01T12:00:00Z"))
|
||||
|
||||
store.cleanup()
|
||||
assertTrue(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package at.mocode.clients.shared.test
|
||||
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
|
||||
expect fun runBlockingTest(block: suspend () -> Unit)
|
||||
|
||||
abstract class BaseTest {
|
||||
@BeforeTest
|
||||
fun setupTest() {
|
||||
// Set up a common test environment
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardownTest() {
|
||||
// Cleanup test environment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package at.mocode.clients.shared.network
|
||||
|
||||
import kotlin.js.Date
|
||||
|
||||
actual fun currentTimeMillis(): Long = Date.now().toLong()
|
||||
@@ -0,0 +1,10 @@
|
||||
package at.mocode.clients.shared.test
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
actual fun runBlockingTest(block: suspend () -> Unit) {
|
||||
GlobalScope.promise {
|
||||
block()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package at.mocode.clients.shared.network
|
||||
|
||||
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
|
||||
@@ -0,0 +1,9 @@
|
||||
package at.mocode.clients.shared.test
|
||||
|
||||
import kotlinx.coroutines.test.*
|
||||
|
||||
actual fun runBlockingTest(block: suspend () -> Unit) {
|
||||
runTest {
|
||||
block()
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package at.mocode.clients.shared.network
|
||||
|
||||
// WASM implementation using a simple counter-approach
|
||||
// Since we don't have direct access to system time in WASM,
|
||||
// we'll use a monotonic counter for relative timing
|
||||
private var wasmTimeCounter: Long = 0L
|
||||
|
||||
actual fun currentTimeMillis(): Long {
|
||||
wasmTimeCounter += 1
|
||||
return wasmTimeCounter
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package at.mocode.clients.shared.test
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class, ExperimentalWasmJsInterop::class)
|
||||
actual fun runBlockingTest(block: suspend () -> Unit) {
|
||||
// WASM-JS uses the same approach as regular JS
|
||||
GlobalScope.promise {
|
||||
block()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user