- 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:
2025-10-06 22:54:32 +02:00
parent 389e612e88
commit d462f98e05
42 changed files with 3064 additions and 741 deletions
@@ -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()
}
}
@@ -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
}
@@ -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"
)
@@ -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
}
@@ -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
}
}
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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
)
}
}
@@ -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
}
}
@@ -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()
}
}
@@ -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
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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()
}
}