From d462f98e05cc57f414fd5f7324953cf00de4304c Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Mon, 6 Oct 2025 22:54:32 +0200 Subject: [PATCH] =?UTF-8?q?-=20Frontend-Aufbau=20f=C3=BCr=20Meldestelle=20?= =?UTF-8?q?KMP=20-=20Network=20Layer=20-=20Shared=20Foundation=20-=20Servi?= =?UTF-8?q?ce=20Layer=20and=20API=20Integration=20-=20Test-Fix=20und=20Dev?= =?UTF-8?q?elopment=20Screen=20-=20WASM-Js=20Test-Implementation=20-=20Bui?= =?UTF-8?q?ld-Konfiguration=20reparieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clients/app/build.gradle.kts | 128 ++------ .../src/commonMain/kotlin/DevelopmentMode.kt | 1 + clients/app/src/commonMain/kotlin/MainApp.kt | 95 ++++++ .../kotlin/at/mocode/clients/app/App.kt | 65 ---- .../at/mocode/clients/app/LandingScreen.kt | 232 -------------- .../src/jsMain/kotlin/DevelopmentMode.js.kt | 2 + clients/app/src/jsMain/kotlin/main.kt | 16 +- clients/app/src/jsMain/resources/index.html | 53 +-- .../src/jvmMain/kotlin/DevelopmentMode.jvm.kt | 2 + clients/app/src/jvmMain/kotlin/main.kt | 8 +- clients/shared/build.gradle.kts | 51 +++ clients/shared/common-ui/build.gradle.kts | 51 +-- .../commonui/components/LoadingIndicator.kt | 109 +++++++ .../commonui/components/MeldestelleButton.kt | 125 ++++++++ .../components/MeldestelleTextField.kt | 192 +++++++++++ .../commonui/components/NotificationCard.kt | 179 +++++++++++ .../shared/commonui/layout/MainLayout.kt | 232 ++++++++++++++ .../commonui/screens/DashboardScreen.kt | 250 +++++++++++++++ .../shared/commonui/screens/LoginScreen.kt | 198 ++++++++++++ .../shared/data/repository/AuthRepository.kt | 171 ++++++++++ .../shared/data/repository/Repository.kt | 73 +++++ .../shared/domain/models/ApiResponse.kt | 27 ++ .../clients/shared/domain/models/User.kt | 22 ++ .../shared/navigation/DeepLinkHandler.kt | 194 +++++++++++ .../shared/navigation/NavigationManager.kt | 179 +++++++++++ .../navigation/NavigationPersistence.kt | 74 +++++ .../shared/network/HttpClientConfig.kt | 27 ++ .../shared/network/NetworkException.kt | 164 ++++++++++ .../clients/shared/network/NetworkUtils.kt | 217 +++++++++++++ .../shared/presentation/actions/AppAction.kt | 36 +++ .../shared/presentation/state/AppState.kt | 55 ++++ .../shared/presentation/store/AppStore.kt | 137 ++++++++ .../shared/presentation/store/AppStoreTest.kt | 69 ++++ .../mocode/clients/shared/test/TestUtils.kt | 18 ++ .../clients/shared/network/NetworkUtilsJs.kt | 5 + .../mocode/clients/shared/test/TestUtilsJs.kt | 10 + .../clients/shared/network/NetworkUtilsJvm.kt | 3 + .../clients/shared/test/TestUtilsJvm.kt | 9 + .../shared/network/NetworkUtilsWasm.kt | 11 + .../clients/shared/test/TestUtilsWasm.kt | 11 + gradle/libs.versions.toml | 2 + kotlin-js-store/yarn.lock | 302 +----------------- 42 files changed, 3064 insertions(+), 741 deletions(-) create mode 100644 clients/app/src/commonMain/kotlin/DevelopmentMode.kt create mode 100644 clients/app/src/commonMain/kotlin/MainApp.kt delete mode 100644 clients/app/src/commonMain/kotlin/at/mocode/clients/app/App.kt delete mode 100644 clients/app/src/commonMain/kotlin/at/mocode/clients/app/LandingScreen.kt create mode 100644 clients/app/src/jsMain/kotlin/DevelopmentMode.js.kt create mode 100644 clients/app/src/jvmMain/kotlin/DevelopmentMode.jvm.kt create mode 100644 clients/shared/build.gradle.kts create mode 100644 clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/LoadingIndicator.kt create mode 100644 clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/MeldestelleButton.kt create mode 100644 clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/MeldestelleTextField.kt create mode 100644 clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/NotificationCard.kt create mode 100644 clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/layout/MainLayout.kt create mode 100644 clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/screens/DashboardScreen.kt create mode 100644 clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/screens/LoginScreen.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/data/repository/AuthRepository.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/data/repository/Repository.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/domain/models/ApiResponse.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/domain/models/User.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/DeepLinkHandler.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/NavigationManager.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/NavigationPersistence.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/HttpClientConfig.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/NetworkException.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/NetworkUtils.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/actions/AppAction.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/state/AppState.kt create mode 100644 clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/store/AppStore.kt create mode 100644 clients/shared/src/commonTest/kotlin/at/mocode/clients/shared/presentation/store/AppStoreTest.kt create mode 100644 clients/shared/src/commonTest/kotlin/at/mocode/clients/shared/test/TestUtils.kt create mode 100644 clients/shared/src/jsMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsJs.kt create mode 100644 clients/shared/src/jsTest/kotlin/at/mocode/clients/shared/test/TestUtilsJs.kt create mode 100644 clients/shared/src/jvmMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsJvm.kt create mode 100644 clients/shared/src/jvmTest/kotlin/at/mocode/clients/shared/test/TestUtilsJvm.kt create mode 100644 clients/shared/src/wasmJsMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsWasm.kt create mode 100644 clients/shared/src/wasmJsTest/kotlin/at/mocode/clients/shared/test/TestUtilsWasm.kt diff --git a/clients/app/build.gradle.kts b/clients/app/build.gradle.kts index a9c95547..3fad892c 100644 --- a/clients/app/build.gradle.kts +++ b/clients/app/build.gradle.kts @@ -1,135 +1,69 @@ -@file:OptIn(ExperimentalKotlinGradlePluginApi::class) +import org.jetbrains.compose.desktop.application.dsl.TargetFormat -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -/** - * Dieses Modul ist der "Host". Es kennt alle Features und die Shared-Module und - * setzt sie zu einer lauffähigen Anwendung zusammen. - */ plugins { alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.kotlinSerialization) } -group = "at.mocode.clients" -version = "1.0.0" - kotlin { - val enableWasm = providers.gradleProperty("enableWasm").orNull == "true" + // JVM Target für Desktop + jvm() - jvmToolchain(21) - - jvm { - binaries { - executable { - mainClass.set("MainKt") - } - } - } + // JavaScript Target für Web js(IR) { - outputModuleName = "web-app" browser { - commonWebpackConfig { - cssSupport { enabled = true } - // Webpack-Mode abhängig von Build-Typ - mode = if (project.hasProperty("production")) - KotlinWebpackConfig.Mode.PRODUCTION - else - KotlinWebpackConfig.Mode.DEVELOPMENT - } webpackTask { mainOutputFileName = "web-app.js" - output.libraryTarget = "commonjs2" - } - // Development Server konfigurieren - runTask { - mainOutputFileName.set("web-app.js") - } - // Browser-Tests komplett deaktivieren (Configuration Cache kompatibel) - testTask { - //enabled = false - - useKarma { - useChromeHeadless() - environment("CHROME_BIN", "/usr/bin/google-chrome-stable") - } } } binaries.executable() } - if (enableWasm) { - @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) - wasmJs { - browser() - binaries.executable() - } - } - - applyDefaultHierarchyTemplate() - sourceSets { commonMain.dependencies { - // Feature modules - implementation(project(":clients:ping-feature")) - implementation(project(":clients:auth-feature")) // Shared modules + implementation(project(":clients:shared")) implementation(project(":clients:shared:common-ui")) implementation(project(":clients:shared:navigation")) - // Compose dependencies + implementation(project(":clients:ping-feature")) + + // Compose Multiplatform implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) - // ViewModel lifecycle - implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(compose.components.resources) + + // Coroutines + implementation(libs.kotlinx.coroutines.core) + + // Serialization + implementation(libs.kotlinx.serialization.json) } + jvmMain.dependencies { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutines.swing) - implementation(libs.kotlinx.coroutines.core) } + jsMain.dependencies { - implementation(npm("html-webpack-plugin", "5.6.4")) - } - if (enableWasm) { - wasmJsMain.dependencies { - implementation(npm("html-webpack-plugin", "5.6.4")) - } - } - commonTest.dependencies { - implementation(libs.kotlin.test) + implementation(compose.html.core) } } } -tasks.withType { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_21) - freeCompilerArgs.addAll( - "-opt-in=kotlin.RequiresOptIn", - "-Xskip-metadata-version-check" // Für bleeding-edge Versionen - ) +// Desktop Application Configuration +compose.desktop { + application { + mainClass = "MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "Meldestelle" + packageVersion = "1.0.0" + description = "Meldestelle Development App" + } } } - -// Configure duplicate handling strategy for distribution tasks -tasks.withType { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE -} - -tasks.withType { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE -} - -// Ensure copy/sync-based distribution tasks exclude duplicates (e.g., index.html from resources and HtmlWebpackPlugin) -tasks.withType { - duplicatesStrategy = DuplicatesStrategy.WARN // Statt EXCLUDE -} - -tasks.withType { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE -} diff --git a/clients/app/src/commonMain/kotlin/DevelopmentMode.kt b/clients/app/src/commonMain/kotlin/DevelopmentMode.kt new file mode 100644 index 00000000..39687079 --- /dev/null +++ b/clients/app/src/commonMain/kotlin/DevelopmentMode.kt @@ -0,0 +1 @@ +expect fun isDevelopmentMode(): Boolean diff --git a/clients/app/src/commonMain/kotlin/MainApp.kt b/clients/app/src/commonMain/kotlin/MainApp.kt new file mode 100644 index 00000000..cefe1dfe --- /dev/null +++ b/clients/app/src/commonMain/kotlin/MainApp.kt @@ -0,0 +1,95 @@ +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.foundation.layout.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun MainApp() { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + DevelopmentScreen() + } + } +} + +@Composable +fun DevelopmentScreen() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + "🚀 Meldestelle Development Mode", + style = MaterialTheme.typography.headlineMedium + ) + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "🌐 Backend Connectivity", + style = MaterialTheme.typography.titleMedium + ) + + var testStatus by remember { mutableStateOf("Not tested") } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button(onClick = { testStatus = "Testing Gateway..." }) { + Text("Test Gateway") + } + Button(onClick = { testStatus = "Testing Ping Service..." }) { + Text("Test Ping Service") + } + } + + Text("Status: $testStatus") + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "🏓 Ping Service Tests", + style = MaterialTheme.typography.titleMedium + ) + + var isDarkMode by remember { mutableStateOf(false) } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button(onClick = { /* TODO: Health Check */ }) { + Text("Health Check") + } + Button(onClick = { /* TODO: Ping Normal */ }) { + Text("Ping Normal") + } + Button(onClick = { isDarkMode = !isDarkMode }) { + Text("Toggle Dark Mode") + } + } + + Text("Dark Mode: ${if(isDarkMode) "🌙 Enabled" else "☀️ Disabled"}") + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "✅ System Status", + style = MaterialTheme.typography.titleMedium + ) + Text("Frontend: 🟢 Running") + Text("Backend: ⚠️ Testing needed") + Text("Build: ✅ Successful") + } + } + } +} diff --git a/clients/app/src/commonMain/kotlin/at/mocode/clients/app/App.kt b/clients/app/src/commonMain/kotlin/at/mocode/clients/app/App.kt deleted file mode 100644 index ed5cfd60..00000000 --- a/clients/app/src/commonMain/kotlin/at/mocode/clients/app/App.kt +++ /dev/null @@ -1,65 +0,0 @@ -package at.mocode.clients.app - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import at.mocode.clients.shared.commonui.components.AppHeader -import at.mocode.clients.shared.commonui.components.AppScaffold -import at.mocode.clients.shared.commonui.theme.AppTheme -import at.mocode.clients.shared.navigation.AppScreen -import at.mocode.clients.pingfeature.PingScreen -import at.mocode.clients.pingfeature.PingViewModel -import at.mocode.clients.authfeature.LoginScreen -import at.mocode.clients.authfeature.AuthTokenManager -import androidx.compose.runtime.collectAsState - -@Composable -fun App() { - var currentScreen: AppScreen by remember { mutableStateOf(AppScreen.Home) } - // Create a single PingViewModel instance for the lifetime of the App composition. - val pingViewModel: PingViewModel = remember { PingViewModel() } - // Create a single AuthTokenManager instance for the lifetime of the App composition. - val authTokenManager: AuthTokenManager = remember { AuthTokenManager() } - // Observe authentication state - val authState by authTokenManager.authState.collectAsState() - - AppTheme { - AppScaffold( - header = { - AppHeader( - title = "Meldestelle", - onNavigateToPing = { currentScreen = AppScreen.Ping }, - onNavigateToLogin = { currentScreen = AppScreen.Login }, - onLogout = { - authTokenManager.clearToken() - currentScreen = AppScreen.Home - }, - isAuthenticated = authState.isAuthenticated, - username = authState.username, - userPermissions = authState.permissions.map { it.name } - ) - }, - { paddingValues -> - Box(modifier = Modifier.padding(paddingValues)) { - when (currentScreen) { - is AppScreen.Home -> { - LandingScreen(authTokenManager = authTokenManager) - } - - is AppScreen.Login -> { - LoginScreen( - authTokenManager = authTokenManager, - onLoginSuccess = { currentScreen = AppScreen.Home } - ) - } - - is AppScreen.Ping -> { - PingScreen(viewModel = pingViewModel) - } - } - } - } - ) - } -} diff --git a/clients/app/src/commonMain/kotlin/at/mocode/clients/app/LandingScreen.kt b/clients/app/src/commonMain/kotlin/at/mocode/clients/app/LandingScreen.kt deleted file mode 100644 index 035a6e29..00000000 --- a/clients/app/src/commonMain/kotlin/at/mocode/clients/app/LandingScreen.kt +++ /dev/null @@ -1,232 +0,0 @@ -package at.mocode.clients.app - -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import at.mocode.clients.authfeature.AuthTokenManager -import at.mocode.clients.authfeature.Permission - -@Composable -fun LandingScreen( - authTokenManager: AuthTokenManager? = null -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top - ) { - Text( - text = "Willkommen bei Meldestelle", - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Eine moderne, skalierbare Frontend-Architektur", - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Medium - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "Diese Anwendung demonstriert eine \"Shell + Feature-Module\"-Architektur " + - "basierend auf Kotlin Multiplatform. Sie spiegelt die DDD-Struktur des Backends " + - "wider und ist als native Desktop-Anwendung (JVM) und Web-Anwendung (JS/Wasm) lauffähig.", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.2 - ) - - Spacer(modifier = Modifier.height(20.dp)) - - Text( - text = "🚀 Technologien:", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - TechItem("Kotlin Multiplatform") - TechItem("Jetpack Compose Multiplatform") - TechItem("Material Design 3") - TechItem("Ktor Client") - TechItem("Domain-Driven Design") - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = "Verwenden Sie das Ping Service Menü oben, um die API-Funktionalität zu testen.", - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - // Permission-based UI demonstration - authTokenManager?.let { tokenManager -> - val authState by tokenManager.authState.collectAsState() - - if (authState.isAuthenticated && authState.permissions.isNotEmpty()) { - Spacer(modifier = Modifier.height(32.dp)) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer - ) - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "🔐 Verfügbare Funktionen", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Admin features (visible only to users with delete permissions) - if (tokenManager.isAdmin()) { - PermissionCard( - title = "👑 Administrator-Bereich", - description = "Vollzugriff auf alle System-Funktionen", - permissions = listOf("Alle Berechtigungen", "System-Verwaltung", "Benutzer-Management"), - backgroundColor = MaterialTheme.colorScheme.errorContainer, - textColor = MaterialTheme.colorScheme.onErrorContainer - ) - } - - // Management features (visible to users with create/update permissions) - if (tokenManager.canCreate() || tokenManager.canUpdate()) { - PermissionCard( - title = "✏️ Verwaltung", - description = "Erstellen und bearbeiten von Daten", - permissions = buildList { - if (tokenManager.hasPermission(Permission.PERSON_CREATE)) add("Personen erstellen") - if (tokenManager.hasPermission(Permission.PERSON_UPDATE)) add("Personen bearbeiten") - if (tokenManager.hasPermission(Permission.VEREIN_CREATE)) add("Vereine erstellen") - if (tokenManager.hasPermission(Permission.VEREIN_UPDATE)) add("Vereine bearbeiten") - if (tokenManager.hasPermission(Permission.PFERD_CREATE)) add("Pferde erstellen") - if (tokenManager.hasPermission(Permission.PFERD_UPDATE)) add("Pferde bearbeiten") - if (tokenManager.hasPermission(Permission.VERANSTALTUNG_CREATE)) add("Veranstaltungen erstellen") - if (tokenManager.hasPermission(Permission.VERANSTALTUNG_UPDATE)) add("Veranstaltungen bearbeiten") - }, - backgroundColor = MaterialTheme.colorScheme.primaryContainer, - textColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - // Read-only features (visible to all authenticated users) - if (tokenManager.canRead()) { - PermissionCard( - title = "👁️ Ansicht", - description = "Nur-Lese-Zugriff auf Daten", - permissions = buildList { - if (tokenManager.hasPermission(Permission.PERSON_READ)) add("Personen anzeigen") - if (tokenManager.hasPermission(Permission.VEREIN_READ)) add("Vereine anzeigen") - if (tokenManager.hasPermission(Permission.PFERD_READ)) add("Pferde anzeigen") - if (tokenManager.hasPermission(Permission.VERANSTALTUNG_READ)) add("Veranstaltungen anzeigen") - }, - backgroundColor = MaterialTheme.colorScheme.surfaceVariant, - textColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - } -} - -@Composable -private fun TechItem(text: String) { - Text( - text = "• $text", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 2.dp) - ) -} - -@Composable -private fun PermissionCard( - title: String, - description: String, - permissions: List, - backgroundColor: androidx.compose.ui.graphics.Color, - textColor: androidx.compose.ui.graphics.Color -) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - colors = CardDefaults.cardColors( - containerColor = backgroundColor - ) - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = textColor - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = textColor - ) - - if (permissions.isNotEmpty()) { - Spacer(modifier = Modifier.height(12.dp)) - - permissions.forEach { permission -> - Text( - text = "✓ $permission", - style = MaterialTheme.typography.bodySmall, - color = textColor, - modifier = Modifier.padding(vertical = 2.dp) - ) - } - } - } - } -} diff --git a/clients/app/src/jsMain/kotlin/DevelopmentMode.js.kt b/clients/app/src/jsMain/kotlin/DevelopmentMode.js.kt new file mode 100644 index 00000000..75f2a318 --- /dev/null +++ b/clients/app/src/jsMain/kotlin/DevelopmentMode.js.kt @@ -0,0 +1,2 @@ +actual fun isDevelopmentMode(): Boolean = + kotlinx.browser.window.location.hostname == "localhost" diff --git a/clients/app/src/jsMain/kotlin/main.kt b/clients/app/src/jsMain/kotlin/main.kt index c2baff13..3be845ed 100644 --- a/clients/app/src/jsMain/kotlin/main.kt +++ b/clients/app/src/jsMain/kotlin/main.kt @@ -1,13 +1,21 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport -import at.mocode.clients.app.App import kotlinx.browser.document +import kotlinx.browser.window import org.w3c.dom.HTMLElement @OptIn(ExperimentalComposeUiApi::class) fun main() { - val root = document.getElementById("ComposeTarget") as HTMLElement - ComposeViewport(root) { - App() + window.onload = { + try { + val root = document.getElementById("ComposeTarget") as HTMLElement + ComposeViewport(root) { + MainApp() + } + } catch (e: Exception) { + console.error("Failed to start Compose Web app", e) + document.getElementById("root")?.innerHTML = + "
❌ Failed to load app: ${e.message}
" + } } } diff --git a/clients/app/src/jsMain/resources/index.html b/clients/app/src/jsMain/resources/index.html index f3fd0ed4..aa53fc16 100644 --- a/clients/app/src/jsMain/resources/index.html +++ b/clients/app/src/jsMain/resources/index.html @@ -1,30 +1,39 @@ - + + Meldestelle - Web Development - Meldestelle - - - - + -
- - +
+ +
🚀 Loading Meldestelle...
+
+ diff --git a/clients/app/src/jvmMain/kotlin/DevelopmentMode.jvm.kt b/clients/app/src/jvmMain/kotlin/DevelopmentMode.jvm.kt new file mode 100644 index 00000000..fa368369 --- /dev/null +++ b/clients/app/src/jvmMain/kotlin/DevelopmentMode.jvm.kt @@ -0,0 +1,2 @@ +actual fun isDevelopmentMode(): Boolean = + System.getProperty("development.mode", "false").toBoolean() diff --git a/clients/app/src/jvmMain/kotlin/main.kt b/clients/app/src/jvmMain/kotlin/main.kt index c05afa54..f5c75eda 100644 --- a/clients/app/src/jvmMain/kotlin/main.kt +++ b/clients/app/src/jvmMain/kotlin/main.kt @@ -1,12 +1,14 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import at.mocode.clients.app.App +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.unit.dp fun main() = application { Window( onCloseRequest = ::exitApplication, - title = "Meldestelle - Desktop Application" + title = "Meldestelle - Desktop Development", + state = WindowState(width = 1200.dp, height = 800.dp) ) { - App() + MainApp() } } diff --git a/clients/shared/build.gradle.kts b/clients/shared/build.gradle.kts new file mode 100644 index 00000000..88a27c93 --- /dev/null +++ b/clients/shared/build.gradle.kts @@ -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) + } + } +} diff --git a/clients/shared/common-ui/build.gradle.kts b/clients/shared/common-ui/build.gradle.kts index 5ff0e0bd..bc9e494f 100644 --- a/clients/shared/common-ui/build.gradle.kts +++ b/clients/shared/common-ui/build.gradle.kts @@ -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 } } } diff --git a/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/LoadingIndicator.kt b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/LoadingIndicator.kt new file mode 100644 index 00000000..fc715ec7 --- /dev/null +++ b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/LoadingIndicator.kt @@ -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 + ) + } + } +} diff --git a/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/MeldestelleButton.kt b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/MeldestelleButton.kt new file mode 100644 index 00000000..477bb306 --- /dev/null +++ b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/MeldestelleButton.kt @@ -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 +) diff --git a/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/MeldestelleTextField.kt b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/MeldestelleTextField.kt new file mode 100644 index 00000000..83160bca --- /dev/null +++ b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/MeldestelleTextField.kt @@ -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 + } +} diff --git a/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/NotificationCard.kt b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/NotificationCard.kt new file mode 100644 index 00000000..e207e638 --- /dev/null +++ b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/NotificationCard.kt @@ -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, + 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 + ) + } + } +} diff --git a/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/layout/MainLayout.kt b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/layout/MainLayout.kt new file mode 100644 index 00000000..9865faae --- /dev/null +++ b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/layout/MainLayout.kt @@ -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 + ) +} diff --git a/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/screens/DashboardScreen.kt b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/screens/DashboardScreen.kt new file mode 100644 index 00000000..902c180f --- /dev/null +++ b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/screens/DashboardScreen.kt @@ -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) + ) + } +} diff --git a/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/screens/LoginScreen.kt b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/screens/LoginScreen.kt new file mode 100644 index 00000000..790f0347 --- /dev/null +++ b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/screens/LoginScreen.kt @@ -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(null) } + var passwordError by remember { mutableStateOf(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 + ) +} diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/data/repository/AuthRepository.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/data/repository/AuthRepository.kt new file mode 100644 index 00000000..e27de814 --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/data/repository/AuthRepository.kt @@ -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 { + 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() + + 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 { + 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() + + 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 { + return try { + val response = httpClient.get("$baseUrl/api/auth/me") { + header("Authorization", "Bearer $accessToken") + }.body>() + + 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 { + 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 { + return try { + val response = httpClient.get("$baseUrl/api/auth/validate") { + header("Authorization", "Bearer $accessToken") + }.body>() + + response.toRepositoryResult() + } catch (e: Exception) { + RepositoryResult.Success(false) // Token is invalid + } + } + + /** + * Cleanup resources + */ + fun close() { + httpClient.close() + } +} diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/data/repository/Repository.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/data/repository/Repository.kt new file mode 100644 index 00000000..dc401775 --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/data/repository/Repository.kt @@ -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 { + data class Success(val data: T) : RepositoryResult() + data class Error(val error: ApiError) : RepositoryResult() + data class Loading(val message: String = "Loading...") : RepositoryResult() + + 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 ApiResponse.toRepositoryResult(): RepositoryResult { + 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 RepositoryResult.onSuccess(action: (T) -> Unit): RepositoryResult { + if (this is RepositoryResult.Success) { + action(data) + } + return this +} + +inline fun RepositoryResult.onError(action: (ApiError) -> Unit): RepositoryResult { + if (this is RepositoryResult.Error) { + action(error) + } + return this +} + +inline fun RepositoryResult.onLoading(action: (String) -> Unit): RepositoryResult { + if (this is RepositoryResult.Loading) { + action(message) + } + return this +} diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/domain/models/ApiResponse.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/domain/models/ApiResponse.kt new file mode 100644 index 00000000..3654282b --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/domain/models/ApiResponse.kt @@ -0,0 +1,27 @@ +package at.mocode.clients.shared.domain.models + +import kotlinx.serialization.Serializable + +@Serializable +data class ApiResponse( + 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 = emptyMap() +) + +@Serializable +data class HealthResponse( + val status: String, + val timestamp: String, + val service: String, + val healthy: Boolean +) diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/domain/models/User.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/domain/models/User.kt new file mode 100644 index 00000000..6bcfe493 --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/domain/models/User.kt @@ -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 = emptySet(), + val isActive: Boolean = true +) + +@Serializable +data class AuthToken( + val accessToken: String, + val refreshToken: String, + val expiresIn: Long, + val tokenType: String = "Bearer" +) diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/DeepLinkHandler.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/DeepLinkHandler.kt new file mode 100644 index 00000000..642e2bb2 --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/DeepLinkHandler.kt @@ -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 = 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, + val originalUrl: String +) + +/** + * Types of deep links + */ +enum class DeepLinkType { + CUSTOM_SCHEME, // meldestelle://app/route + WEB_LINK // https://meldestelle.com/route +} diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/NavigationManager.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/NavigationManager.kt new file mode 100644 index 00000000..1faea4ea --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/NavigationManager.kt @@ -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 = store.state.map { it.navigation.currentRoute } + + /** + * Navigation history as a flow + */ + val navigationHistory: Flow> = store.state.map { it.navigation.history } + + /** + * Can go back flag as a flow + */ + val canGoBack: Flow = 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 { + val params = mutableMapOf() + + // 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 + } + } +} diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/NavigationPersistence.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/NavigationPersistence.kt new file mode 100644 index 00000000..0ba9926c --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/navigation/NavigationPersistence.kt @@ -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 + 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 { + 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) { + 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): List { + // Entfernt Duplikate in Folge und behält nur die letzten N Einträge + return history + .fold(emptyList()) { acc, route -> + if (acc.lastOrNull() != route) acc + route else acc + } + .takeLast(MAX_HISTORY_SIZE) + } + + suspend fun addToHistory(newRoute: String, currentHistory: List) { + val optimizedHistory = optimizeHistory(currentHistory + newRoute) + saveRoute(newRoute, optimizedHistory.dropLast(1)) + } +} diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/HttpClientConfig.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/HttpClientConfig.kt new file mode 100644 index 00000000..9a0c9c68 --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/HttpClientConfig.kt @@ -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) + } +} diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/NetworkException.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/NetworkException.kt new file mode 100644 index 00000000..0cd3d3fb --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/NetworkException.kt @@ -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 + ) + } +} diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/NetworkUtils.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/NetworkUtils.kt new file mode 100644 index 00000000..770a9f77 --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/network/NetworkUtils.kt @@ -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 = setOf( + "CONNECTION_ERROR", + "TIMEOUT_ERROR", + "SERVER_ERROR" + ) + ) + + /** + * Execute operation with retry logic + */ + suspend fun withRetry( + config: RetryConfig = RetryConfig(), + operation: suspend () -> RepositoryResult + ): RepositoryResult { + 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 execute(operation: suspend () -> RepositoryResult): RepositoryResult { + 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 + } +} diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/actions/AppAction.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/actions/AppAction.kt new file mode 100644 index 00000000..0fe522fa --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/actions/AppAction.kt @@ -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() + } +} diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/state/AppState.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/state/AppState.kt new file mode 100644 index 00000000..5974e3a8 --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/state/AppState.kt @@ -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 = emptyList(), + val canGoBack: Boolean = false +) + +@Serializable +data class UiState( + val isDarkMode: Boolean = false, + val isLoading: Boolean = false, + val notifications: List = 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 +} diff --git a/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/store/AppStore.kt b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/store/AppStore.kt new file mode 100644 index 00000000..fd30f24f --- /dev/null +++ b/clients/shared/src/commonMain/kotlin/at/mocode/clients/shared/presentation/store/AppStore.kt @@ -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 = _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() + } +} diff --git a/clients/shared/src/commonTest/kotlin/at/mocode/clients/shared/presentation/store/AppStoreTest.kt b/clients/shared/src/commonTest/kotlin/at/mocode/clients/shared/presentation/store/AppStoreTest.kt new file mode 100644 index 00000000..032825ad --- /dev/null +++ b/clients/shared/src/commonTest/kotlin/at/mocode/clients/shared/presentation/store/AppStoreTest.kt @@ -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) + } +} diff --git a/clients/shared/src/commonTest/kotlin/at/mocode/clients/shared/test/TestUtils.kt b/clients/shared/src/commonTest/kotlin/at/mocode/clients/shared/test/TestUtils.kt new file mode 100644 index 00000000..af4868f0 --- /dev/null +++ b/clients/shared/src/commonTest/kotlin/at/mocode/clients/shared/test/TestUtils.kt @@ -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 + } +} diff --git a/clients/shared/src/jsMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsJs.kt b/clients/shared/src/jsMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsJs.kt new file mode 100644 index 00000000..32e55ff7 --- /dev/null +++ b/clients/shared/src/jsMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsJs.kt @@ -0,0 +1,5 @@ +package at.mocode.clients.shared.network + +import kotlin.js.Date + +actual fun currentTimeMillis(): Long = Date.now().toLong() diff --git a/clients/shared/src/jsTest/kotlin/at/mocode/clients/shared/test/TestUtilsJs.kt b/clients/shared/src/jsTest/kotlin/at/mocode/clients/shared/test/TestUtilsJs.kt new file mode 100644 index 00000000..2ee61d31 --- /dev/null +++ b/clients/shared/src/jsTest/kotlin/at/mocode/clients/shared/test/TestUtilsJs.kt @@ -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() + } +} diff --git a/clients/shared/src/jvmMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsJvm.kt b/clients/shared/src/jvmMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsJvm.kt new file mode 100644 index 00000000..bc437617 --- /dev/null +++ b/clients/shared/src/jvmMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsJvm.kt @@ -0,0 +1,3 @@ +package at.mocode.clients.shared.network + +actual fun currentTimeMillis(): Long = System.currentTimeMillis() diff --git a/clients/shared/src/jvmTest/kotlin/at/mocode/clients/shared/test/TestUtilsJvm.kt b/clients/shared/src/jvmTest/kotlin/at/mocode/clients/shared/test/TestUtilsJvm.kt new file mode 100644 index 00000000..68ad4a43 --- /dev/null +++ b/clients/shared/src/jvmTest/kotlin/at/mocode/clients/shared/test/TestUtilsJvm.kt @@ -0,0 +1,9 @@ +package at.mocode.clients.shared.test + +import kotlinx.coroutines.test.* + +actual fun runBlockingTest(block: suspend () -> Unit) { + runTest { + block() + } +} diff --git a/clients/shared/src/wasmJsMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsWasm.kt b/clients/shared/src/wasmJsMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsWasm.kt new file mode 100644 index 00000000..1234bb3e --- /dev/null +++ b/clients/shared/src/wasmJsMain/kotlin/at/mocode/clients/shared/network/NetworkUtilsWasm.kt @@ -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 +} diff --git a/clients/shared/src/wasmJsTest/kotlin/at/mocode/clients/shared/test/TestUtilsWasm.kt b/clients/shared/src/wasmJsTest/kotlin/at/mocode/clients/shared/test/TestUtilsWasm.kt new file mode 100644 index 00000000..ba4863e5 --- /dev/null +++ b/clients/shared/src/wasmJsTest/kotlin/at/mocode/clients/shared/test/TestUtilsWasm.kt @@ -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() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9838a109..91d7ab74 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -112,6 +112,8 @@ ktor-client-cio = { module = "io.ktor:ktor-client-cio-jvm", version.ref = "ktor" ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } # --- Spring Boot (Versions from spring-boot-dependencies BOM) --- spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index 2305f5e5..9646727e 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -219,11 +219,6 @@ "@types/qs" "*" "@types/serve-static" "*" -"@types/html-minifier-terser@^6.0.0": - version "6.1.0" - resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" - integrity sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg== - "@types/http-errors@*": version "2.0.5" resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472" @@ -592,11 +587,6 @@ bonjour-service@^1.2.1: fast-deep-equal "^3.1.3" multicast-dns "^7.2.5" -boolbase@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== - brace-expansion@^1.1.7: version "1.1.12" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" @@ -667,14 +657,6 @@ call-bound@^1.0.2, call-bound@^1.0.3: call-bind-apply-helpers "^1.0.2" get-intrinsic "^1.3.0" -camel-case@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a" - integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== - dependencies: - pascal-case "^3.1.2" - tslib "^2.0.3" - camelcase@^6.0.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" @@ -720,13 +702,6 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b" integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ== -clean-css@^5.2.2: - version "5.3.3" - resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd" - integrity sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg== - dependencies: - source-map "~0.6.0" - cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" @@ -781,11 +756,6 @@ commander@^2.20.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^8.3.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" - integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== - compressible@~2.0.18: version "2.0.18" resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" @@ -875,41 +845,6 @@ cross-spawn@^7.0.3, cross-spawn@^7.0.6: shebang-command "^2.0.0" which "^2.0.1" -css-loader@7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-7.1.2.tgz#64671541c6efe06b0e22e750503106bdd86880f8" - integrity sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA== - dependencies: - icss-utils "^5.1.0" - postcss "^8.4.33" - postcss-modules-extract-imports "^3.1.0" - postcss-modules-local-by-default "^4.0.5" - postcss-modules-scope "^3.2.0" - postcss-modules-values "^4.0.0" - postcss-value-parser "^4.2.0" - semver "^7.5.4" - -css-select@^4.1.3: - version "4.3.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" - integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== - dependencies: - boolbase "^1.0.0" - css-what "^6.0.1" - domhandler "^4.3.1" - domutils "^2.8.0" - nth-check "^2.0.1" - -css-what@^6.0.1: - version "6.2.2" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" - integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== - -cssesc@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" - integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== - custom-event@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" @@ -1008,13 +943,6 @@ dns-packet@^5.2.2: dependencies: "@leichtgewicht/ip-codec" "^2.0.1" -dom-converter@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" - integrity sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA== - dependencies: - utila "~0.4" - dom-serialize@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b" @@ -1025,44 +953,6 @@ dom-serialize@^2.2.1: extend "^3.0.0" void-elements "^2.0.0" -dom-serializer@^1.0.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" - integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.0" - entities "^2.0.0" - -domelementtype@^2.0.1, domelementtype@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" - integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== - -domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" - integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== - dependencies: - domelementtype "^2.2.0" - -domutils@^2.5.2, domutils@^2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" - integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== - dependencies: - dom-serializer "^1.0.1" - domelementtype "^2.2.0" - domhandler "^4.2.0" - -dot-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" - integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" @@ -1145,11 +1035,6 @@ ent@~2.2.0: punycode "^1.4.1" safe-regex-test "^1.1.0" -entities@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" - integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== - envinfo@^7.14.0: version "7.14.0" resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.14.0.tgz#26dac5db54418f2a4c1159153a0b2ae980838aae" @@ -1531,40 +1416,6 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" -html-minifier-terser@^6.0.2: - version "6.1.0" - resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#bfc818934cc07918f6b3669f5774ecdfd48f32ab" - integrity sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw== - dependencies: - camel-case "^4.1.2" - clean-css "^5.2.2" - commander "^8.3.0" - he "^1.2.0" - param-case "^3.0.4" - relateurl "^0.2.7" - terser "^5.10.0" - -html-webpack-plugin@5.6.4: - version "5.6.4" - resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.4.tgz#d8cb0f7edff7745ae7d6cccb0bff592e9f7f7959" - integrity sha512-V/PZeWsqhfpE27nKeX9EO2sbR+D17A+tLf6qU+ht66jdUsN0QLKJN27Z+1+gHrVMKgndBahes0PU6rRihDgHTw== - dependencies: - "@types/html-minifier-terser" "^6.0.0" - html-minifier-terser "^6.0.2" - lodash "^4.17.21" - pretty-error "^4.0.0" - tapable "^2.0.0" - -htmlparser2@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" - integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.0.0" - domutils "^2.5.2" - entities "^2.0.0" - http-deceiver@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" @@ -1635,11 +1486,6 @@ iconv-lite@^0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -icss-utils@^5.0.0, icss-utils@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" - integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== - import-local@^3.0.2: version "3.2.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" @@ -1934,7 +1780,7 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: +lodash@^4.17.15, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -1958,13 +1804,6 @@ log4js@^6.4.1: rfdc "^1.3.0" streamroller "^3.1.5" -lower-case@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" - integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== - dependencies: - tslib "^2.0.3" - lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" @@ -2129,11 +1968,6 @@ multicast-dns@^7.2.5: dns-packet "^5.2.2" thunky "^1.0.2" -nanoid@^3.3.11: - version "3.3.11" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" - integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== - negotiator@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" @@ -2149,14 +1983,6 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== -no-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" - integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== - dependencies: - lower-case "^2.0.2" - tslib "^2.0.3" - node-forge@^1: version "1.3.1" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" @@ -2172,13 +1998,6 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -nth-check@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" - integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== - dependencies: - boolbase "^1.0.0" - object-assign@^4: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -2277,27 +2096,11 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== -param-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" - integrity sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A== - dependencies: - dot-case "^3.0.4" - tslib "^2.0.3" - parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== -pascal-case@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb" - integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -2348,64 +2151,6 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -postcss-modules-extract-imports@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz#b4497cb85a9c0c4b5aabeb759bb25e8d89f15002" - integrity sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q== - -postcss-modules-local-by-default@^4.0.5: - version "4.2.0" - resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz#d150f43837831dae25e4085596e84f6f5d6ec368" - integrity sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw== - dependencies: - icss-utils "^5.0.0" - postcss-selector-parser "^7.0.0" - postcss-value-parser "^4.1.0" - -postcss-modules-scope@^3.2.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz#1bbccddcb398f1d7a511e0a2d1d047718af4078c" - integrity sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA== - dependencies: - postcss-selector-parser "^7.0.0" - -postcss-modules-values@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c" - integrity sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ== - dependencies: - icss-utils "^5.0.0" - -postcss-selector-parser@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz#4d6af97eba65d73bc4d84bcb343e865d7dd16262" - integrity sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - -postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" - integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== - -postcss@^8.4.33: - version "8.5.6" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" - integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== - dependencies: - nanoid "^3.3.11" - picocolors "^1.1.1" - source-map-js "^1.2.1" - -pretty-error@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-4.0.0.tgz#90a703f46dd7234adb46d0f84823e9d1cb8f10d6" - integrity sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw== - dependencies: - lodash "^4.17.20" - renderkid "^3.0.0" - process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -2499,22 +2244,6 @@ rechoir@^0.8.0: dependencies: resolve "^1.20.0" -relateurl@^0.2.7: - version "0.2.7" - resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" - integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog== - -renderkid@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a" - integrity sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg== - dependencies: - css-select "^4.1.3" - dom-converter "^0.2.0" - htmlparser2 "^6.1.0" - lodash "^4.17.21" - strip-ansi "^6.0.1" - require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -2620,11 +2349,6 @@ selfsigned@^2.4.1: "@types/node-forge" "^1.3.0" node-forge "^1" -semver@^7.5.4: - version "7.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== - send@0.19.0: version "0.19.0" resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" @@ -2791,7 +2515,7 @@ sockjs@^0.3.24: uuid "^8.3.2" websocket-driver "^0.7.4" -source-map-js@^1.0.2, source-map-js@^1.2.1: +source-map-js@^1.0.2: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== @@ -2804,7 +2528,7 @@ source-map-loader@5.0.0: iconv-lite "^0.6.3" source-map-js "^1.0.2" -source-map-support@~0.5.20: +source-map-support@0.5.21, source-map-support@~0.5.20: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -2812,7 +2536,7 @@ source-map-support@~0.5.20: buffer-from "^1.0.0" source-map "^0.6.0" -source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0: +source-map@^0.6.0, source-map@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -2910,11 +2634,6 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -style-loader@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-4.0.0.tgz#0ea96e468f43c69600011e0589cb05c44f3b17a5" - integrity sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA== - supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -2934,7 +2653,7 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0: +tapable@^2.1.1, tapable@^2.2.0: version "2.2.3" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.3.tgz#4b67b635b2d97578a06a2713d2f04800c237e99b" integrity sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg== @@ -2950,7 +2669,7 @@ terser-webpack-plugin@^5.3.11: serialize-javascript "^6.0.2" terser "^5.31.1" -terser@^5.10.0, terser@^5.31.1: +terser@^5.31.1: version "5.44.0" resolved "https://registry.yarnpkg.com/terser/-/terser-5.44.0.tgz#ebefb8e5b8579d93111bfdfc39d2cf63879f4a82" integrity sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w== @@ -2992,7 +2711,7 @@ tree-dump@^1.0.3: resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.1.0.tgz#ab29129169dc46004414f5a9d4a3c6e89f13e8a4" integrity sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA== -tslib@^2.0.0, tslib@^2.0.3: +tslib@^2.0.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -3033,16 +2752,11 @@ update-browserslist-db@^1.1.3: escalade "^3.2.0" picocolors "^1.1.1" -util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -utila@~0.4: - version "0.4.0" - resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" - integrity sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA== - utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"