- Frontend-Aufbau für Meldestelle KMP
- Network Layer - Shared Foundation - Service Layer and API Integration - Test-Fix und Development Screen - WASM-Js Test-Implementation - Build-Konfiguration reparieren
This commit is contained in:
@@ -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 {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.composeMultiplatform)
|
|
||||||
alias(libs.plugins.composeCompiler)
|
alias(libs.plugins.composeCompiler)
|
||||||
|
alias(libs.plugins.composeMultiplatform)
|
||||||
|
alias(libs.plugins.kotlinSerialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "at.mocode.clients"
|
|
||||||
version = "1.0.0"
|
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
|
// JVM Target für Desktop
|
||||||
|
jvm()
|
||||||
|
|
||||||
jvmToolchain(21)
|
// JavaScript Target für Web
|
||||||
|
|
||||||
jvm {
|
|
||||||
binaries {
|
|
||||||
executable {
|
|
||||||
mainClass.set("MainKt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
js(IR) {
|
js(IR) {
|
||||||
outputModuleName = "web-app"
|
|
||||||
browser {
|
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 {
|
webpackTask {
|
||||||
mainOutputFileName = "web-app.js"
|
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()
|
binaries.executable()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enableWasm) {
|
|
||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
|
||||||
wasmJs {
|
|
||||||
browser()
|
|
||||||
binaries.executable()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDefaultHierarchyTemplate()
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// Feature modules
|
|
||||||
implementation(project(":clients:ping-feature"))
|
|
||||||
implementation(project(":clients:auth-feature"))
|
|
||||||
// Shared modules
|
// Shared modules
|
||||||
|
implementation(project(":clients:shared"))
|
||||||
implementation(project(":clients:shared:common-ui"))
|
implementation(project(":clients:shared:common-ui"))
|
||||||
implementation(project(":clients:shared:navigation"))
|
implementation(project(":clients:shared:navigation"))
|
||||||
// Compose dependencies
|
implementation(project(":clients:ping-feature"))
|
||||||
|
|
||||||
|
// Compose Multiplatform
|
||||||
implementation(compose.runtime)
|
implementation(compose.runtime)
|
||||||
implementation(compose.foundation)
|
implementation(compose.foundation)
|
||||||
implementation(compose.material3)
|
implementation(compose.material3)
|
||||||
implementation(compose.ui)
|
implementation(compose.ui)
|
||||||
// ViewModel lifecycle
|
implementation(compose.components.resources)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
|
||||||
|
// Coroutines
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
|
// Serialization
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
|
|
||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
implementation(compose.desktop.currentOs)
|
implementation(compose.desktop.currentOs)
|
||||||
implementation(libs.kotlinx.coroutines.swing)
|
implementation(libs.kotlinx.coroutines.swing)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jsMain.dependencies {
|
jsMain.dependencies {
|
||||||
implementation(npm("html-webpack-plugin", "5.6.4"))
|
implementation(compose.html.core)
|
||||||
}
|
|
||||||
if (enableWasm) {
|
|
||||||
wasmJsMain.dependencies {
|
|
||||||
implementation(npm("html-webpack-plugin", "5.6.4"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
commonTest.dependencies {
|
|
||||||
implementation(libs.kotlin.test)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
// Desktop Application Configuration
|
||||||
compilerOptions {
|
compose.desktop {
|
||||||
jvmTarget.set(JvmTarget.JVM_21)
|
application {
|
||||||
freeCompilerArgs.addAll(
|
mainClass = "MainKt"
|
||||||
"-opt-in=kotlin.RequiresOptIn",
|
|
||||||
"-Xskip-metadata-version-check" // Für bleeding-edge Versionen
|
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<Tar> {
|
|
||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<Zip> {
|
|
||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure copy/sync-based distribution tasks exclude duplicates (e.g., index.html from resources and HtmlWebpackPlugin)
|
|
||||||
tasks.withType<Copy> {
|
|
||||||
duplicatesStrategy = DuplicatesStrategy.WARN // Statt EXCLUDE
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<Sync> {
|
|
||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
expect fun isDevelopmentMode(): Boolean
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String>,
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
actual fun isDevelopmentMode(): Boolean =
|
||||||
|
kotlinx.browser.window.location.hostname == "localhost"
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.window.ComposeViewport
|
import androidx.compose.ui.window.ComposeViewport
|
||||||
import at.mocode.clients.app.App
|
|
||||||
import kotlinx.browser.document
|
import kotlinx.browser.document
|
||||||
|
import kotlinx.browser.window
|
||||||
import org.w3c.dom.HTMLElement
|
import org.w3c.dom.HTMLElement
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun main() {
|
fun main() {
|
||||||
|
window.onload = {
|
||||||
|
try {
|
||||||
val root = document.getElementById("ComposeTarget") as HTMLElement
|
val root = document.getElementById("ComposeTarget") as HTMLElement
|
||||||
ComposeViewport(root) {
|
ComposeViewport(root) {
|
||||||
App()
|
MainApp()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
console.error("Failed to start Compose Web app", e)
|
||||||
|
document.getElementById("root")?.innerHTML =
|
||||||
|
"<div style='padding: 50px; text-align: center;'>❌ Failed to load app: ${e.message}</div>"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,39 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
<title>Meldestelle - Web Development</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Meldestelle</title>
|
<style>
|
||||||
<meta name="theme-color" content="#0f172a">
|
body {
|
||||||
<link type="text/css" rel="stylesheet" href="styles.css">
|
margin: 0;
|
||||||
<link rel="manifest" href="manifest.webmanifest">
|
padding: 0;
|
||||||
<link rel="icon" href="icons/icon-192.png" type="image/png">
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
#ComposeTarget {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
#root {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="ComposeTarget"></div>
|
<div id="root">
|
||||||
<script src="web-app.js"></script>
|
<canvas id="ComposeTarget"></canvas>
|
||||||
<script>
|
<div class="loading">🚀 Loading Meldestelle...</div>
|
||||||
if ('serviceWorker' in navigator) {
|
</div>
|
||||||
const isLocalhost = ['localhost', '127.0.0.1', '::1'].includes(location.hostname);
|
<script src="web-app.js"></script>
|
||||||
if (isLocalhost) {
|
|
||||||
navigator.serviceWorker.getRegistrations().then(regs => {
|
|
||||||
for (const reg of regs) reg.unregister();
|
|
||||||
}).catch(console.error);
|
|
||||||
} else {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
navigator.serviceWorker.register('sw.js').catch(console.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
actual fun isDevelopmentMode(): Boolean =
|
||||||
|
System.getProperty("development.mode", "false").toBoolean()
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
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 {
|
fun main() = application {
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
title = "Meldestelle - Desktop Application"
|
title = "Meldestelle - Desktop Development",
|
||||||
|
state = WindowState(width = 1200.dp, height = 800.dp)
|
||||||
) {
|
) {
|
||||||
App()
|
MainApp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
|
alias(libs.plugins.kotlinSerialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
js(IR) {
|
||||||
|
browser()
|
||||||
|
nodejs()
|
||||||
|
}
|
||||||
|
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||||
|
wasmJs {
|
||||||
|
browser()
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// Coroutines für asynchrone Programmierung
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
|
// Serialization für JSON
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
// HTTP Client
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.contentNegotiation)
|
||||||
|
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||||
|
implementation(libs.ktor.client.logging)
|
||||||
|
implementation(libs.ktor.client.auth)
|
||||||
|
|
||||||
|
// DateTime
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
|
}
|
||||||
|
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.kotlin.test)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsMain.dependencies {
|
||||||
|
implementation(libs.ktor.client.js)
|
||||||
|
}
|
||||||
|
|
||||||
|
jvmMain.dependencies {
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,51 @@
|
|||||||
/**
|
|
||||||
* Dieses Modul stellt "dumme", wiederverwendbare UI-Komponenten und das Theme bereit.
|
|
||||||
* Es darf keine Ahnung von irgendeiner Fachlichkeit haben.
|
|
||||||
*/
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.composeMultiplatform)
|
alias(libs.plugins.composeMultiplatform)
|
||||||
alias(libs.plugins.composeCompiler)
|
alias(libs.plugins.composeCompiler)
|
||||||
|
alias(libs.plugins.kotlinSerialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "at.mocode.clients.shared"
|
|
||||||
version = "1.0.0"
|
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
|
|
||||||
|
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
jvm()
|
jvm()
|
||||||
|
js(IR) {
|
||||||
js {
|
browser()
|
||||||
browser {
|
nodejs()
|
||||||
testTask {
|
|
||||||
enabled = false
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableWasm) {
|
|
||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||||
wasmJs {
|
wasmJs {
|
||||||
browser()
|
browser()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
|
// Shared module dependency
|
||||||
|
implementation(project(":clients:shared"))
|
||||||
|
|
||||||
|
// Compose dependencies
|
||||||
implementation(compose.runtime)
|
implementation(compose.runtime)
|
||||||
implementation(compose.foundation)
|
implementation(compose.foundation)
|
||||||
implementation(compose.material3)
|
implementation(compose.material3)
|
||||||
implementation(compose.ui)
|
implementation(compose.ui)
|
||||||
implementation(compose.components.resources)
|
implementation(compose.components.resources)
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
|
// Serialization
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
// DateTime
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
|
||||||
implementation(libs.kotlin.test)
|
jsMain.dependencies {
|
||||||
|
// JS-specific UI dependencies if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
jvmMain.dependencies {
|
||||||
|
// JVM-specific UI dependencies if needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
package at.mocode.clients.shared.commonui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
enum class LoadingSize {
|
||||||
|
SMALL, MEDIUM, LARGE
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingIndicator(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: LoadingSize = LoadingSize.MEDIUM,
|
||||||
|
message: String? = null
|
||||||
|
) {
|
||||||
|
val indicatorSize = when (size) {
|
||||||
|
LoadingSize.SMALL -> 24.dp
|
||||||
|
LoadingSize.MEDIUM -> 32.dp
|
||||||
|
LoadingSize.LARGE -> 48.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
val strokeWidth = when (size) {
|
||||||
|
LoadingSize.SMALL -> 2.dp
|
||||||
|
LoadingSize.MEDIUM -> 3.dp
|
||||||
|
LoadingSize.LARGE -> 4.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(indicatorSize),
|
||||||
|
strokeWidth = strokeWidth
|
||||||
|
)
|
||||||
|
|
||||||
|
if (message != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FullScreenLoading(
|
||||||
|
message: String = "Loading...",
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
LoadingIndicator(
|
||||||
|
size = LoadingSize.LARGE,
|
||||||
|
message = message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InlineLoading(
|
||||||
|
message: String? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
LoadingIndicator(
|
||||||
|
size = LoadingSize.SMALL,
|
||||||
|
message = message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LinearLoadingIndicator(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
message: String? = null
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (message != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
package at.mocode.clients.shared.commonui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
enum class ButtonVariant {
|
||||||
|
PRIMARY, SECONDARY, OUTLINE, TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ButtonSize {
|
||||||
|
SMALL, MEDIUM, LARGE
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MeldestelleButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
variant: ButtonVariant = ButtonVariant.PRIMARY,
|
||||||
|
size: ButtonSize = ButtonSize.MEDIUM,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
isLoading: Boolean = false,
|
||||||
|
fullWidth: Boolean = false
|
||||||
|
) {
|
||||||
|
val buttonModifier = modifier.then(
|
||||||
|
if (fullWidth) Modifier.fillMaxWidth() else Modifier
|
||||||
|
).then(
|
||||||
|
when (size) {
|
||||||
|
ButtonSize.SMALL -> Modifier.height(32.dp)
|
||||||
|
ButtonSize.MEDIUM -> Modifier.height(40.dp)
|
||||||
|
ButtonSize.LARGE -> Modifier.height(48.dp)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
when (variant) {
|
||||||
|
ButtonVariant.PRIMARY -> Button(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = buttonModifier,
|
||||||
|
enabled = enabled && !isLoading
|
||||||
|
) {
|
||||||
|
ButtonContent(text = text, isLoading = isLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonVariant.SECONDARY -> FilledTonalButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = buttonModifier,
|
||||||
|
enabled = enabled && !isLoading
|
||||||
|
) {
|
||||||
|
ButtonContent(text = text, isLoading = isLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonVariant.OUTLINE -> OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = buttonModifier,
|
||||||
|
enabled = enabled && !isLoading
|
||||||
|
) {
|
||||||
|
ButtonContent(text = text, isLoading = isLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonVariant.TEXT -> TextButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = buttonModifier,
|
||||||
|
enabled = enabled && !isLoading
|
||||||
|
) {
|
||||||
|
ButtonContent(text = text, isLoading = isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ButtonContent(
|
||||||
|
text: String,
|
||||||
|
isLoading: Boolean
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.padding(2.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PrimaryButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
isLoading: Boolean = false,
|
||||||
|
fullWidth: Boolean = false
|
||||||
|
) = MeldestelleButton(
|
||||||
|
text = text,
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
variant = ButtonVariant.PRIMARY,
|
||||||
|
enabled = enabled,
|
||||||
|
isLoading = isLoading,
|
||||||
|
fullWidth = fullWidth
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SecondaryButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
isLoading: Boolean = false,
|
||||||
|
fullWidth: Boolean = false
|
||||||
|
) = MeldestelleButton(
|
||||||
|
text = text,
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
variant = ButtonVariant.SECONDARY,
|
||||||
|
enabled = enabled,
|
||||||
|
isLoading = isLoading,
|
||||||
|
fullWidth = fullWidth
|
||||||
|
)
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
package at.mocode.clients.shared.commonui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MeldestelleTextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
label: String? = null,
|
||||||
|
placeholder: String? = null,
|
||||||
|
leadingIcon: ImageVector? = null,
|
||||||
|
trailingIcon: ImageVector? = null,
|
||||||
|
onTrailingIconClick: (() -> Unit)? = null,
|
||||||
|
isError: Boolean = false,
|
||||||
|
errorMessage: String? = null,
|
||||||
|
helperText: String? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
readOnly: Boolean = false,
|
||||||
|
singleLine: Boolean = true,
|
||||||
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
imeAction: ImeAction = ImeAction.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
|
visualTransformation: VisualTransformation = VisualTransformation.None
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = label?.let { { Text(it) } },
|
||||||
|
placeholder = placeholder?.let { { Text(it) } },
|
||||||
|
leadingIcon = leadingIcon?.let { icon ->
|
||||||
|
{ Icon(imageVector = icon, contentDescription = null) }
|
||||||
|
},
|
||||||
|
trailingIcon = if (trailingIcon != null) {
|
||||||
|
{
|
||||||
|
IconButton(
|
||||||
|
onClick = onTrailingIconClick ?: {}
|
||||||
|
) {
|
||||||
|
Icon(imageVector = trailingIcon, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
isError = isError,
|
||||||
|
enabled = enabled,
|
||||||
|
readOnly = readOnly,
|
||||||
|
singleLine = singleLine,
|
||||||
|
maxLines = maxLines,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = keyboardType,
|
||||||
|
imeAction = imeAction
|
||||||
|
),
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
|
visualTransformation = visualTransformation
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error or helper text
|
||||||
|
when {
|
||||||
|
isError && errorMessage != null -> {
|
||||||
|
Text(
|
||||||
|
text = errorMessage,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
helperText != null -> {
|
||||||
|
Text(
|
||||||
|
text = helperText,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MeldestellePasswordField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
label: String = "Password",
|
||||||
|
placeholder: String? = null,
|
||||||
|
isError: Boolean = false,
|
||||||
|
errorMessage: String? = null,
|
||||||
|
helperText: String? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
imeAction: ImeAction = ImeAction.Done,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default
|
||||||
|
) {
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
MeldestelleTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = modifier,
|
||||||
|
label = label,
|
||||||
|
placeholder = placeholder,
|
||||||
|
trailingIcon = if (passwordVisible) {
|
||||||
|
// You would need to import the actual icon from Material Icons
|
||||||
|
null // Placeholder for visibility off icon
|
||||||
|
} else {
|
||||||
|
null // Placeholder for visibility on icon
|
||||||
|
},
|
||||||
|
onTrailingIconClick = { passwordVisible = !passwordVisible },
|
||||||
|
isError = isError,
|
||||||
|
errorMessage = errorMessage,
|
||||||
|
helperText = helperText,
|
||||||
|
enabled = enabled,
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = imeAction,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
|
visualTransformation = if (passwordVisible) {
|
||||||
|
VisualTransformation.None
|
||||||
|
} else {
|
||||||
|
PasswordVisualTransformation()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MeldestelleEmailField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
label: String = "Email",
|
||||||
|
placeholder: String? = null,
|
||||||
|
isError: Boolean = false,
|
||||||
|
errorMessage: String? = null,
|
||||||
|
helperText: String? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
imeAction: ImeAction = ImeAction.Next,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default
|
||||||
|
) {
|
||||||
|
MeldestelleTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = modifier,
|
||||||
|
label = label,
|
||||||
|
placeholder = placeholder,
|
||||||
|
isError = isError,
|
||||||
|
errorMessage = errorMessage,
|
||||||
|
helperText = helperText,
|
||||||
|
enabled = enabled,
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = imeAction,
|
||||||
|
keyboardActions = keyboardActions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form validation utilities
|
||||||
|
*/
|
||||||
|
object FormValidation {
|
||||||
|
fun validateEmail(email: String): String? {
|
||||||
|
return when {
|
||||||
|
email.isEmpty() -> "Email is required"
|
||||||
|
!email.contains("@") -> "Invalid email format"
|
||||||
|
!email.matches(Regex("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$")) -> "Invalid email format"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validatePassword(password: String): String? {
|
||||||
|
return when {
|
||||||
|
password.isEmpty() -> "Password is required"
|
||||||
|
password.length < 6 -> "Password must be at least 6 characters"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateRequired(value: String, fieldName: String): String? {
|
||||||
|
return if (value.isEmpty()) "$fieldName is required" else null
|
||||||
|
}
|
||||||
|
}
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
package at.mocode.clients.shared.commonui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.clients.shared.presentation.state.Notification
|
||||||
|
import at.mocode.clients.shared.presentation.state.NotificationType
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotificationCard(
|
||||||
|
notification: Notification,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val backgroundColor = when (notification.type) {
|
||||||
|
NotificationType.SUCCESS -> Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||||
|
NotificationType.ERROR -> Color(0xFFF44336).copy(alpha = 0.1f)
|
||||||
|
NotificationType.WARNING -> Color(0xFFFF9800).copy(alpha = 0.1f)
|
||||||
|
NotificationType.INFO -> Color(0xFF2196F3).copy(alpha = 0.1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val borderColor = when (notification.type) {
|
||||||
|
NotificationType.SUCCESS -> Color(0xFF4CAF50)
|
||||||
|
NotificationType.ERROR -> Color(0xFFF44336)
|
||||||
|
NotificationType.WARNING -> Color(0xFFFF9800)
|
||||||
|
NotificationType.INFO -> Color(0xFF2196F3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, borderColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = notification.title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
if (notification.message.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = notification.message,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = notification.timestamp,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
) {
|
||||||
|
Text("×", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotificationList(
|
||||||
|
notifications: List<Notification>,
|
||||||
|
onDismissNotification: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
notifications.forEach { notification ->
|
||||||
|
NotificationCard(
|
||||||
|
notification = notification,
|
||||||
|
onDismiss = { onDismissNotification(notification.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SnackbarNotification(
|
||||||
|
notification: Notification,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val backgroundColor = when (notification.type) {
|
||||||
|
NotificationType.SUCCESS -> Color(0xFF4CAF50)
|
||||||
|
NotificationType.ERROR -> Color(0xFFF44336)
|
||||||
|
NotificationType.WARNING -> Color(0xFFFF9800)
|
||||||
|
NotificationType.INFO -> Color(0xFF2196F3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = backgroundColor,
|
||||||
|
contentColor = Color.White,
|
||||||
|
action = {
|
||||||
|
TextButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = notification.title,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
if (notification.message.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = notification.message,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ToastNotification(
|
||||||
|
message: String,
|
||||||
|
type: NotificationType = NotificationType.INFO,
|
||||||
|
visible: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (visible) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(3000) // Auto dismiss after 3 seconds
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
val backgroundColor = when (type) {
|
||||||
|
NotificationType.SUCCESS -> Color(0xFF4CAF50)
|
||||||
|
NotificationType.ERROR -> Color(0xFFF44336)
|
||||||
|
NotificationType.WARNING -> Color(0xFFFF9800)
|
||||||
|
NotificationType.INFO -> Color(0xFF2196F3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier,
|
||||||
|
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
||||||
|
shape = RoundedCornerShape(24.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
package at.mocode.clients.shared.commonui.layout
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.clients.shared.commonui.components.*
|
||||||
|
import at.mocode.clients.shared.commonui.screens.LoginScreenContainer
|
||||||
|
import at.mocode.clients.shared.presentation.state.AppState
|
||||||
|
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MainLayout(
|
||||||
|
appState: AppState,
|
||||||
|
onDispatchAction: (AppAction) -> Unit,
|
||||||
|
onNavigateTo: (String) -> Unit,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var showUserMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Meldestelle",
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
// Notifications
|
||||||
|
if (appState.ui.notifications.isNotEmpty()) {
|
||||||
|
BadgedBox(
|
||||||
|
badge = {
|
||||||
|
Badge(
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
) {
|
||||||
|
Text(appState.ui.notifications.size.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onNavigateTo("/notifications") }
|
||||||
|
) {
|
||||||
|
Text("🔔")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onNavigateTo("/notifications") }
|
||||||
|
) {
|
||||||
|
Text("🔔")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme toggle
|
||||||
|
IconButton(
|
||||||
|
onClick = { onDispatchAction(AppAction.UI.ToggleDarkMode) }
|
||||||
|
) {
|
||||||
|
Text(if (appState.ui.isDarkMode) "☀️" else "🌙")
|
||||||
|
}
|
||||||
|
|
||||||
|
// User menu
|
||||||
|
Box {
|
||||||
|
IconButton(
|
||||||
|
onClick = { showUserMenu = true }
|
||||||
|
) {
|
||||||
|
Text("👤")
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showUserMenu,
|
||||||
|
onDismissRequest = { showUserMenu = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = appState.auth.user?.firstName ?: "User",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = appState.auth.user?.email ?: "",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showUserMenu = false
|
||||||
|
onNavigateTo("/profile")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Settings") },
|
||||||
|
onClick = {
|
||||||
|
showUserMenu = false
|
||||||
|
onNavigateTo("/settings")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Help") },
|
||||||
|
onClick = {
|
||||||
|
showUserMenu = false
|
||||||
|
onNavigateTo("/help")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = "Logout",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showUserMenu = false
|
||||||
|
onDispatchAction(AppAction.Auth.Logout)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (appState.ui.notifications.isNotEmpty()) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${appState.ui.notifications.size} notification(s)",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = { onNavigateTo("/notifications") }
|
||||||
|
) {
|
||||||
|
Text("View All")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
// Loading overlay
|
||||||
|
if (appState.ui.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
FullScreenLoading("Loading...")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthenticatedLayout(
|
||||||
|
appState: AppState,
|
||||||
|
onDispatchAction: (AppAction) -> Unit,
|
||||||
|
onNavigateTo: (String) -> Unit,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (appState.auth.isAuthenticated) {
|
||||||
|
MainLayout(
|
||||||
|
appState = appState,
|
||||||
|
onDispatchAction = onDispatchAction,
|
||||||
|
onNavigateTo = onNavigateTo,
|
||||||
|
content = content,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Show login screen if not authenticated
|
||||||
|
LoginScreenContainer(
|
||||||
|
authState = appState.auth,
|
||||||
|
onDispatchAction = onDispatchAction,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ResponsiveLayout(
|
||||||
|
appState: AppState,
|
||||||
|
onDispatchAction: (AppAction) -> Unit,
|
||||||
|
onNavigateTo: (String) -> Unit,
|
||||||
|
content: @Composable (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
// Simple responsive design - could be enhanced with actual screen size detection
|
||||||
|
val isCompact = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AuthenticatedLayout(
|
||||||
|
appState = appState,
|
||||||
|
onDispatchAction = onDispatchAction,
|
||||||
|
onNavigateTo = onNavigateTo,
|
||||||
|
content = { content(isCompact.value) },
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
package at.mocode.clients.shared.commonui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.clients.shared.commonui.components.*
|
||||||
|
import at.mocode.clients.shared.presentation.state.AppState
|
||||||
|
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DashboardScreen(
|
||||||
|
appState: AppState,
|
||||||
|
onDispatchAction: (AppAction) -> Unit,
|
||||||
|
onNavigateTo: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val user = appState.auth.user
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Welcome Header
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Welcome back, ${user?.firstName ?: "User"}!",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Here's what's happening in your Meldestelle dashboard",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick Actions
|
||||||
|
Text(
|
||||||
|
text = "Quick Actions",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
PrimaryButton(
|
||||||
|
text = "New Report",
|
||||||
|
onClick = { onNavigateTo("/reports/new") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
SecondaryButton(
|
||||||
|
text = "View Reports",
|
||||||
|
onClick = { onNavigateTo("/reports") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics Cards
|
||||||
|
Text(
|
||||||
|
text = "Overview",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
StatisticCard(
|
||||||
|
title = "Total Reports",
|
||||||
|
value = "142",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StatisticCard(
|
||||||
|
title = "Open Issues",
|
||||||
|
value = "23",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
StatisticCard(
|
||||||
|
title = "Resolved",
|
||||||
|
value = "119",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StatisticCard(
|
||||||
|
title = "This Month",
|
||||||
|
value = "18",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Activity
|
||||||
|
Text(
|
||||||
|
text = "Recent Activity",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
ActivityItem(
|
||||||
|
title = "Report #1234 updated",
|
||||||
|
subtitle = "Status changed to 'In Progress'",
|
||||||
|
timestamp = "2 hours ago"
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
ActivityItem(
|
||||||
|
title = "New report submitted",
|
||||||
|
subtitle = "Report #1235 - Urgent priority",
|
||||||
|
timestamp = "4 hours ago"
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
ActivityItem(
|
||||||
|
title = "Report #1230 resolved",
|
||||||
|
subtitle = "Issue successfully closed",
|
||||||
|
timestamp = "1 day ago"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection Status
|
||||||
|
if (!appState.network.isOnline) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "⚠️",
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Offline Mode",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Some features may be limited",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatisticCard(
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActivityItem(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
timestamp: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = timestamp,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
package at.mocode.clients.shared.commonui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.clients.shared.commonui.components.*
|
||||||
|
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||||
|
import at.mocode.clients.shared.presentation.state.AuthState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
authState: AuthState,
|
||||||
|
onLoginClick: (String, String) -> Unit,
|
||||||
|
onNavigateToRegister: () -> Unit = {},
|
||||||
|
onForgotPassword: () -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var usernameError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
val isFormValid = username.isNotBlank() && password.isNotBlank() &&
|
||||||
|
usernameError == null && passwordError == null
|
||||||
|
|
||||||
|
fun validateUsername(value: String) {
|
||||||
|
usernameError = FormValidation.validateRequired(value, "Username")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validatePassword(value: String) {
|
||||||
|
passwordError = FormValidation.validatePassword(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleLogin() {
|
||||||
|
validateUsername(username)
|
||||||
|
validatePassword(password)
|
||||||
|
|
||||||
|
if (isFormValid) {
|
||||||
|
onLoginClick(username.trim(), password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Text(
|
||||||
|
text = "Meldestelle",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Sign in to your account",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Username Field
|
||||||
|
MeldestelleTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = {
|
||||||
|
username = it
|
||||||
|
if (usernameError != null) validateUsername(it)
|
||||||
|
},
|
||||||
|
label = "Username",
|
||||||
|
placeholder = "Enter your username",
|
||||||
|
isError = usernameError != null,
|
||||||
|
errorMessage = usernameError,
|
||||||
|
enabled = !authState.isLoading,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Password Field
|
||||||
|
MeldestellePasswordField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = {
|
||||||
|
password = it
|
||||||
|
if (passwordError != null) validatePassword(it)
|
||||||
|
},
|
||||||
|
label = "Password",
|
||||||
|
placeholder = "Enter your password",
|
||||||
|
isError = passwordError != null,
|
||||||
|
errorMessage = passwordError,
|
||||||
|
enabled = !authState.isLoading,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
if (isFormValid) handleLogin()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error display
|
||||||
|
authState.error?.let { errorMessage ->
|
||||||
|
Text(
|
||||||
|
text = errorMessage,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Login Button
|
||||||
|
PrimaryButton(
|
||||||
|
text = "Sign In",
|
||||||
|
onClick = ::handleLogin,
|
||||||
|
enabled = isFormValid && !authState.isLoading,
|
||||||
|
isLoading = authState.isLoading,
|
||||||
|
fullWidth = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Forgot Password
|
||||||
|
TextButton(
|
||||||
|
onClick = onForgotPassword,
|
||||||
|
enabled = !authState.isLoading
|
||||||
|
) {
|
||||||
|
Text("Forgot Password?")
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Register Link
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Don't have an account?",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = onNavigateToRegister,
|
||||||
|
enabled = !authState.isLoading
|
||||||
|
) {
|
||||||
|
Text("Sign Up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreenContainer(
|
||||||
|
authState: AuthState,
|
||||||
|
onDispatchAction: (AppAction) -> Unit,
|
||||||
|
onNavigateToRegister: () -> Unit = {},
|
||||||
|
onForgotPassword: () -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
LoginScreen(
|
||||||
|
authState = authState,
|
||||||
|
onLoginClick = { username, password ->
|
||||||
|
onDispatchAction(AppAction.Auth.LoginStart(username, password))
|
||||||
|
},
|
||||||
|
onNavigateToRegister = onNavigateToRegister,
|
||||||
|
onForgotPassword = onForgotPassword,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
package at.mocode.clients.shared.data.repository
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.domain.models.User
|
||||||
|
import at.mocode.clients.shared.domain.models.AuthToken
|
||||||
|
import at.mocode.clients.shared.domain.models.ApiResponse
|
||||||
|
import at.mocode.clients.shared.network.HttpClientConfig
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.request.forms.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication repository handling all authentication-related operations
|
||||||
|
* with Keycloak integration.
|
||||||
|
*/
|
||||||
|
class AuthRepository(
|
||||||
|
private val baseUrl: String = "http://localhost:8080",
|
||||||
|
private val keycloakUrl: String = "http://localhost:8180",
|
||||||
|
private val realm: String = "meldestelle",
|
||||||
|
private val clientId: String = "meldestelle-client"
|
||||||
|
) : Repository {
|
||||||
|
|
||||||
|
private val httpClient: HttpClient = HttpClientConfig.createClient(baseUrl)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginRequest(
|
||||||
|
val username: String,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KeycloakTokenResponse(
|
||||||
|
val access_token: String,
|
||||||
|
val refresh_token: String,
|
||||||
|
val expires_in: Long,
|
||||||
|
val token_type: String = "Bearer"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate user with username and password via Keycloak
|
||||||
|
*/
|
||||||
|
suspend fun login(username: String, password: String): RepositoryResult<AuthToken> {
|
||||||
|
return try {
|
||||||
|
val response = httpClient.submitForm(
|
||||||
|
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
|
||||||
|
formParameters = Parameters.build {
|
||||||
|
append("grant_type", "password")
|
||||||
|
append("client_id", clientId)
|
||||||
|
append("username", username)
|
||||||
|
append("password", password)
|
||||||
|
}
|
||||||
|
).body<KeycloakTokenResponse>()
|
||||||
|
|
||||||
|
val authToken = AuthToken(
|
||||||
|
accessToken = response.access_token,
|
||||||
|
refreshToken = response.refresh_token,
|
||||||
|
expiresIn = response.expires_in,
|
||||||
|
tokenType = response.token_type
|
||||||
|
)
|
||||||
|
|
||||||
|
RepositoryResult.Success(authToken)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
RepositoryResult.Error(
|
||||||
|
at.mocode.clients.shared.domain.models.ApiError(
|
||||||
|
code = "LOGIN_FAILED",
|
||||||
|
message = "Login failed: ${e.message}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh authentication token
|
||||||
|
*/
|
||||||
|
suspend fun refreshToken(refreshToken: String): RepositoryResult<AuthToken> {
|
||||||
|
return try {
|
||||||
|
val response = httpClient.submitForm(
|
||||||
|
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
|
||||||
|
formParameters = Parameters.build {
|
||||||
|
append("grant_type", "refresh_token")
|
||||||
|
append("client_id", clientId)
|
||||||
|
append("refresh_token", refreshToken)
|
||||||
|
}
|
||||||
|
).body<KeycloakTokenResponse>()
|
||||||
|
|
||||||
|
val authToken = AuthToken(
|
||||||
|
accessToken = response.access_token,
|
||||||
|
refreshToken = response.refresh_token,
|
||||||
|
expiresIn = response.expires_in,
|
||||||
|
tokenType = response.token_type
|
||||||
|
)
|
||||||
|
|
||||||
|
RepositoryResult.Success(authToken)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
RepositoryResult.Error(
|
||||||
|
at.mocode.clients.shared.domain.models.ApiError(
|
||||||
|
code = "TOKEN_REFRESH_FAILED",
|
||||||
|
message = "Token refresh failed: ${e.message}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user information using access token
|
||||||
|
*/
|
||||||
|
suspend fun getCurrentUser(accessToken: String): RepositoryResult<User> {
|
||||||
|
return try {
|
||||||
|
val response = httpClient.get("$baseUrl/api/auth/me") {
|
||||||
|
header("Authorization", "Bearer $accessToken")
|
||||||
|
}.body<ApiResponse<User>>()
|
||||||
|
|
||||||
|
response.toRepositoryResult()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
RepositoryResult.Error(
|
||||||
|
at.mocode.clients.shared.domain.models.ApiError(
|
||||||
|
code = "USER_INFO_FAILED",
|
||||||
|
message = "Failed to get user info: ${e.message}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout user by invalidating tokens
|
||||||
|
*/
|
||||||
|
suspend fun logout(refreshToken: String): RepositoryResult<Unit> {
|
||||||
|
return try {
|
||||||
|
httpClient.submitForm(
|
||||||
|
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/logout",
|
||||||
|
formParameters = Parameters.build {
|
||||||
|
append("client_id", clientId)
|
||||||
|
append("refresh_token", refreshToken)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
RepositoryResult.Success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
RepositoryResult.Error(
|
||||||
|
at.mocode.clients.shared.domain.models.ApiError(
|
||||||
|
code = "LOGOUT_FAILED",
|
||||||
|
message = "Logout failed: ${e.message}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if token is still valid
|
||||||
|
*/
|
||||||
|
suspend fun validateToken(accessToken: String): RepositoryResult<Boolean> {
|
||||||
|
return try {
|
||||||
|
val response = httpClient.get("$baseUrl/api/auth/validate") {
|
||||||
|
header("Authorization", "Bearer $accessToken")
|
||||||
|
}.body<ApiResponse<Boolean>>()
|
||||||
|
|
||||||
|
response.toRepositoryResult()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
RepositoryResult.Success(false) // Token is invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup resources
|
||||||
|
*/
|
||||||
|
fun close() {
|
||||||
|
httpClient.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package at.mocode.clients.shared.data.repository
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.domain.models.ApiResponse
|
||||||
|
import at.mocode.clients.shared.domain.models.ApiError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base repository interface defining common operations and patterns
|
||||||
|
* for data access across the application.
|
||||||
|
*/
|
||||||
|
interface Repository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result wrapper for repository operations to handle success/error states
|
||||||
|
*/
|
||||||
|
sealed class RepositoryResult<out T> {
|
||||||
|
data class Success<T>(val data: T) : RepositoryResult<T>()
|
||||||
|
data class Error(val error: ApiError) : RepositoryResult<Nothing>()
|
||||||
|
data class Loading(val message: String = "Loading...") : RepositoryResult<Nothing>()
|
||||||
|
|
||||||
|
fun isSuccess(): Boolean = this is Success
|
||||||
|
fun isError(): Boolean = this is Error
|
||||||
|
fun isLoading(): Boolean = this is Loading
|
||||||
|
|
||||||
|
fun getOrNull(): T? = when (this) {
|
||||||
|
is Success -> data
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getErrorOrNull(): ApiError? = when (this) {
|
||||||
|
is Error -> error
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to convert ApiResponse to RepositoryResult
|
||||||
|
*/
|
||||||
|
fun <T> ApiResponse<T>.toRepositoryResult(): RepositoryResult<T> {
|
||||||
|
return if (success && data != null) {
|
||||||
|
RepositoryResult.Success(data)
|
||||||
|
} else {
|
||||||
|
RepositoryResult.Error(
|
||||||
|
error ?: ApiError(
|
||||||
|
code = "UNKNOWN_ERROR",
|
||||||
|
message = "Unknown error occurred"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to handle repository results with callbacks
|
||||||
|
*/
|
||||||
|
inline fun <T> RepositoryResult<T>.onSuccess(action: (T) -> Unit): RepositoryResult<T> {
|
||||||
|
if (this is RepositoryResult.Success) {
|
||||||
|
action(data)
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> RepositoryResult<T>.onError(action: (ApiError) -> Unit): RepositoryResult<T> {
|
||||||
|
if (this is RepositoryResult.Error) {
|
||||||
|
action(error)
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> RepositoryResult<T>.onLoading(action: (String) -> Unit): RepositoryResult<T> {
|
||||||
|
if (this is RepositoryResult.Loading) {
|
||||||
|
action(message)
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package at.mocode.clients.shared.domain.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApiResponse<T>(
|
||||||
|
val success: Boolean,
|
||||||
|
val data: T? = null,
|
||||||
|
val error: ApiError? = null,
|
||||||
|
val timestamp: String,
|
||||||
|
val correlationId: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApiError(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
val details: Map<String, String> = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HealthResponse(
|
||||||
|
val status: String,
|
||||||
|
val timestamp: String,
|
||||||
|
val service: String,
|
||||||
|
val healthy: Boolean
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package at.mocode.clients.shared.domain.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class User(
|
||||||
|
val id: String,
|
||||||
|
val username: String,
|
||||||
|
val email: String,
|
||||||
|
val firstName: String,
|
||||||
|
val lastName: String,
|
||||||
|
val roles: Set<String> = emptySet(),
|
||||||
|
val isActive: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthToken(
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String,
|
||||||
|
val expiresIn: Long,
|
||||||
|
val tokenType: String = "Bearer"
|
||||||
|
)
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
package at.mocode.clients.shared.navigation
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.presentation.store.AppStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep link handling for the application
|
||||||
|
*/
|
||||||
|
class DeepLinkHandler(
|
||||||
|
private val navigationManager: NavigationManager,
|
||||||
|
private val store: AppStore
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep link configuration
|
||||||
|
*/
|
||||||
|
data class DeepLinkConfig(
|
||||||
|
val scheme: String = "meldestelle",
|
||||||
|
val host: String = "app",
|
||||||
|
val allowedDomains: Set<String> = setOf("meldestelle.com", "localhost")
|
||||||
|
)
|
||||||
|
|
||||||
|
private val config = DeepLinkConfig()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a deep link URL
|
||||||
|
*/
|
||||||
|
fun handleDeepLink(url: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val parsedLink = parseDeepLink(url)
|
||||||
|
if (parsedLink != null) {
|
||||||
|
processDeepLink(parsedLink)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error in real implementation
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse deep link URL into components
|
||||||
|
*/
|
||||||
|
private fun parseDeepLink(url: String): DeepLink? {
|
||||||
|
return when {
|
||||||
|
url.startsWith("${config.scheme}://") -> parseCustomSchemeLink(url)
|
||||||
|
url.startsWith("https://") || url.startsWith("http://") -> parseWebLink(url)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse custom scheme deep links (e.g., meldestelle://app/dashboard)
|
||||||
|
*/
|
||||||
|
private fun parseCustomSchemeLink(url: String): DeepLink? {
|
||||||
|
val withoutScheme = url.removePrefix("${config.scheme}://")
|
||||||
|
val parts = withoutScheme.split("/")
|
||||||
|
|
||||||
|
if (parts.isEmpty() || parts[0] != config.host) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val path = "/" + parts.drop(1).joinToString("/")
|
||||||
|
val route = if (path == "/") Routes.HOME else path
|
||||||
|
|
||||||
|
return DeepLink(
|
||||||
|
type = DeepLinkType.CUSTOM_SCHEME,
|
||||||
|
route = route,
|
||||||
|
params = RouteUtils.parseRouteParams(route),
|
||||||
|
originalUrl = url
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse web deep links (e.g., https://meldestelle.com/dashboard)
|
||||||
|
*/
|
||||||
|
private fun parseWebLink(url: String): DeepLink? {
|
||||||
|
// Simple URL parsing - in real implementation use proper URL parser
|
||||||
|
val urlParts = url.split("/")
|
||||||
|
if (urlParts.size < 3) return null
|
||||||
|
|
||||||
|
val domain = urlParts[2]
|
||||||
|
if (!config.allowedDomains.contains(domain)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val path = "/" + urlParts.drop(3).joinToString("/")
|
||||||
|
val route = if (path == "/" || path.isEmpty()) Routes.HOME else path
|
||||||
|
|
||||||
|
return DeepLink(
|
||||||
|
type = DeepLinkType.WEB_LINK,
|
||||||
|
route = route,
|
||||||
|
params = RouteUtils.parseRouteParams(route),
|
||||||
|
originalUrl = url
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a parsed deep link
|
||||||
|
*/
|
||||||
|
private fun processDeepLink(deepLink: DeepLink) {
|
||||||
|
val authState = store.state.value.auth
|
||||||
|
val cleanRoute = RouteUtils.getCleanRoute(deepLink.route)
|
||||||
|
|
||||||
|
// Check if route requires authentication
|
||||||
|
if (RouteUtils.requiresAuth(cleanRoute)) {
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
// Save the intended route and redirect to log in
|
||||||
|
saveIntendedRoute(deepLink.route)
|
||||||
|
navigationManager.navigateTo(Routes.Auth.LOGIN)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if route requires admin privileges
|
||||||
|
if (RouteUtils.requiresAdmin(cleanRoute)) {
|
||||||
|
val hasAdminRole = authState.user?.roles?.contains("admin") ?: false
|
||||||
|
if (!hasAdminRole) {
|
||||||
|
// Redirect to unauthorized or home
|
||||||
|
navigationManager.navigateTo(Routes.HOME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the route
|
||||||
|
navigationManager.navigateTo(deepLink.route)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the intended route for after authentication
|
||||||
|
*/
|
||||||
|
private fun saveIntendedRoute(route: String) {
|
||||||
|
// In real implementation, save to persistent storage
|
||||||
|
// For now; we'll store it in a simple variable
|
||||||
|
intendedRoute = route
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get and clear the intended route
|
||||||
|
*/
|
||||||
|
fun getAndClearIntendedRoute(): String? {
|
||||||
|
val route = intendedRoute
|
||||||
|
intendedRoute = null
|
||||||
|
return route
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there's a pending intended route
|
||||||
|
*/
|
||||||
|
fun hasIntendedRoute(): Boolean = intendedRoute != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a deep link for a route
|
||||||
|
*/
|
||||||
|
fun generateDeepLink(route: String, useCustomScheme: Boolean = true): String {
|
||||||
|
return if (useCustomScheme) {
|
||||||
|
"${config.scheme}://${config.host}$route"
|
||||||
|
} else {
|
||||||
|
"https://${config.allowedDomains.first()}$route"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a route is valid for deep linking
|
||||||
|
*/
|
||||||
|
fun isValidDeepLinkRoute(route: String): Boolean {
|
||||||
|
return RouteUtils.isValidRoute(route) &&
|
||||||
|
!route.startsWith("/auth/") && // Auth routes shouldn't be deep linked
|
||||||
|
route != Routes.Auth.LOGIN
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private var intendedRoute: String? = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep link data class
|
||||||
|
*/
|
||||||
|
data class DeepLink(
|
||||||
|
val type: DeepLinkType,
|
||||||
|
val route: String,
|
||||||
|
val params: Map<String, String>,
|
||||||
|
val originalUrl: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types of deep links
|
||||||
|
*/
|
||||||
|
enum class DeepLinkType {
|
||||||
|
CUSTOM_SCHEME, // meldestelle://app/route
|
||||||
|
WEB_LINK // https://meldestelle.com/route
|
||||||
|
}
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
package at.mocode.clients.shared.navigation
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||||
|
import at.mocode.clients.shared.presentation.store.AppStore
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation manager for handling routing and navigation logic
|
||||||
|
*/
|
||||||
|
class NavigationManager(
|
||||||
|
private val store: AppStore
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current route as a flow
|
||||||
|
*/
|
||||||
|
val currentRoute: Flow<String> = store.state.map { it.navigation.currentRoute }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation history as a flow
|
||||||
|
*/
|
||||||
|
val navigationHistory: Flow<List<String>> = store.state.map { it.navigation.history }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can go back flag as a flow
|
||||||
|
*/
|
||||||
|
val canGoBack: Flow<Boolean> = store.state.map { it.navigation.canGoBack }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a specific route
|
||||||
|
*/
|
||||||
|
fun navigateTo(route: String) {
|
||||||
|
store.dispatch(AppAction.Navigation.NavigateTo(route))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate back to the previous route
|
||||||
|
*/
|
||||||
|
fun navigateBack() {
|
||||||
|
store.dispatch(AppAction.Navigation.NavigateBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace current route without adding to history
|
||||||
|
*/
|
||||||
|
fun replaceRoute(route: String) {
|
||||||
|
store.dispatch(AppAction.Navigation.UpdateHistory(route))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear navigation history and navigate to the route
|
||||||
|
*/
|
||||||
|
fun navigateAndClearHistory(route: String) {
|
||||||
|
// First clear by replacing with the new route
|
||||||
|
store.dispatch(AppAction.Navigation.UpdateHistory(route))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current route value (non-reactive)
|
||||||
|
*/
|
||||||
|
fun getCurrentRoute(): String = store.state.value.navigation.currentRoute
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we can navigate back
|
||||||
|
*/
|
||||||
|
fun canNavigateBack(): Boolean = store.state.value.navigation.canGoBack
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route definitions for the application
|
||||||
|
*/
|
||||||
|
object Routes {
|
||||||
|
const val HOME = "/"
|
||||||
|
const val LOGIN = "/login"
|
||||||
|
const val DASHBOARD = "/dashboard"
|
||||||
|
const val PROFILE = "/profile"
|
||||||
|
const val SETTINGS = "/settings"
|
||||||
|
const val PING = "/ping"
|
||||||
|
|
||||||
|
// Auth-related routes
|
||||||
|
object Auth {
|
||||||
|
const val LOGIN = "/auth/login"
|
||||||
|
const val LOGOUT = "/auth/logout"
|
||||||
|
const val REGISTER = "/auth/register"
|
||||||
|
const val FORGOT_PASSWORD = "/auth/forgot-password"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
object Admin {
|
||||||
|
const val DASHBOARD = "/admin/dashboard"
|
||||||
|
const val USERS = "/admin/users"
|
||||||
|
const val SETTINGS = "/admin/settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature routes
|
||||||
|
object Features {
|
||||||
|
const val PING = "/features/ping"
|
||||||
|
const val REPORTS = "/features/reports"
|
||||||
|
const val NOTIFICATIONS = "/features/notifications"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route validation and utilities
|
||||||
|
*/
|
||||||
|
object RouteUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a route requires authentication
|
||||||
|
*/
|
||||||
|
fun requiresAuth(route: String): Boolean {
|
||||||
|
return when {
|
||||||
|
route.startsWith("/auth/") && route != Routes.Auth.LOGIN -> false
|
||||||
|
route == Routes.HOME -> false
|
||||||
|
route == Routes.LOGIN -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a route is for admin only
|
||||||
|
*/
|
||||||
|
fun requiresAdmin(route: String): Boolean {
|
||||||
|
return route.startsWith("/admin/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default route for authenticated users
|
||||||
|
*/
|
||||||
|
fun getDefaultAuthenticatedRoute(): String = Routes.DASHBOARD
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default route for unauthenticated users
|
||||||
|
*/
|
||||||
|
fun getDefaultUnauthenticatedRoute(): String = Routes.LOGIN
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate route format
|
||||||
|
*/
|
||||||
|
fun isValidRoute(route: String): Boolean {
|
||||||
|
return route.startsWith("/") && route.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse route parameters (simple implementation)
|
||||||
|
*/
|
||||||
|
fun parseRouteParams(route: String): Map<String, String> {
|
||||||
|
val params = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
// Simple query parameter parsing
|
||||||
|
if (route.contains("?")) {
|
||||||
|
val parts = route.split("?")
|
||||||
|
if (parts.size == 2) {
|
||||||
|
val queryParams = parts[1].split("&")
|
||||||
|
queryParams.forEach { param ->
|
||||||
|
val keyValue = param.split("=")
|
||||||
|
if (keyValue.size == 2) {
|
||||||
|
params[keyValue[0]] = keyValue[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get clean route without parameters
|
||||||
|
*/
|
||||||
|
fun getCleanRoute(route: String): String {
|
||||||
|
return if (route.contains("?")) {
|
||||||
|
route.split("?")[0]
|
||||||
|
} else {
|
||||||
|
route
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package at.mocode.clients.shared.navigation
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.presentation.state.NavigationState
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface für das Persistieren von Navigation State
|
||||||
|
*/
|
||||||
|
interface NavigationPersistence {
|
||||||
|
suspend fun saveNavigationState(state: NavigationState)
|
||||||
|
fun getNavigationState(): Flow<NavigationState?>
|
||||||
|
suspend fun clearNavigationState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation ohne echte Persistierung (In-Memory)
|
||||||
|
* Platform-spezifische Implementierungen können echte Persistierung bereitstellen
|
||||||
|
*/
|
||||||
|
class DefaultNavigationPersistence : NavigationPersistence {
|
||||||
|
private var currentState: NavigationState? = null
|
||||||
|
|
||||||
|
override suspend fun saveNavigationState(state: NavigationState) {
|
||||||
|
currentState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getNavigationState(): Flow<NavigationState?> {
|
||||||
|
return kotlinx.coroutines.flow.flowOf(currentState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearNavigationState() {
|
||||||
|
currentState = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation History Manager mit Persistierung
|
||||||
|
*/
|
||||||
|
class NavigationHistoryManager(
|
||||||
|
private val persistence: NavigationPersistence
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val MAX_HISTORY_SIZE = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveRoute(route: String, history: List<String>) {
|
||||||
|
val state = NavigationState(
|
||||||
|
currentRoute = route,
|
||||||
|
history = history.takeLast(MAX_HISTORY_SIZE),
|
||||||
|
canGoBack = history.isNotEmpty()
|
||||||
|
)
|
||||||
|
persistence.saveNavigationState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPersistedState() = persistence.getNavigationState()
|
||||||
|
|
||||||
|
suspend fun clear() = persistence.clearNavigationState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimiert die History für bessere Performance
|
||||||
|
*/
|
||||||
|
private fun optimizeHistory(history: List<String>): List<String> {
|
||||||
|
// Entfernt Duplikate in Folge und behält nur die letzten N Einträge
|
||||||
|
return history
|
||||||
|
.fold(emptyList<String>()) { acc, route ->
|
||||||
|
if (acc.lastOrNull() != route) acc + route else acc
|
||||||
|
}
|
||||||
|
.takeLast(MAX_HISTORY_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addToHistory(newRoute: String, currentHistory: List<String>) {
|
||||||
|
val optimizedHistory = optimizeHistory(currentHistory + newRoute)
|
||||||
|
saveRoute(newRoute, optimizedHistory.dropLast(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package at.mocode.clients.shared.network
|
||||||
|
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
object HttpClientConfig {
|
||||||
|
|
||||||
|
fun createClient(
|
||||||
|
baseUrl: String = "http://localhost:8080"
|
||||||
|
): HttpClient = HttpClient {
|
||||||
|
|
||||||
|
// Content negotiation with JSON (based on PingApiClient pattern)
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json {
|
||||||
|
prettyPrint = true
|
||||||
|
isLenient = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createClientWithBaseUrl(baseUrl: String): HttpClient {
|
||||||
|
return createClient(baseUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
package at.mocode.clients.shared.network
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.domain.models.ApiError
|
||||||
|
import io.ktor.client.network.sockets.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import kotlinx.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom exceptions for network operations
|
||||||
|
*/
|
||||||
|
sealed class NetworkException(
|
||||||
|
message: String,
|
||||||
|
cause: Throwable? = null,
|
||||||
|
val apiError: ApiError
|
||||||
|
) : Exception(message, cause) {
|
||||||
|
|
||||||
|
class ConnectionException(
|
||||||
|
message: String = "Connection failed",
|
||||||
|
cause: Throwable? = null
|
||||||
|
) : NetworkException(
|
||||||
|
message = message,
|
||||||
|
cause = cause,
|
||||||
|
apiError = ApiError(
|
||||||
|
code = "CONNECTION_ERROR",
|
||||||
|
message = message,
|
||||||
|
details = mapOf("type" to "network_connectivity")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class TimeoutException(
|
||||||
|
message: String = "Request timed out",
|
||||||
|
cause: Throwable? = null
|
||||||
|
) : NetworkException(
|
||||||
|
message = message,
|
||||||
|
cause = cause,
|
||||||
|
apiError = ApiError(
|
||||||
|
code = "TIMEOUT_ERROR",
|
||||||
|
message = message,
|
||||||
|
details = mapOf("type" to "request_timeout")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class ServerException(
|
||||||
|
statusCode: Int,
|
||||||
|
message: String = "Server error",
|
||||||
|
cause: Throwable? = null
|
||||||
|
) : NetworkException(
|
||||||
|
message = message,
|
||||||
|
cause = cause,
|
||||||
|
apiError = ApiError(
|
||||||
|
code = "SERVER_ERROR",
|
||||||
|
message = message,
|
||||||
|
details = mapOf(
|
||||||
|
"type" to "server_error",
|
||||||
|
"status_code" to statusCode.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class ClientException(
|
||||||
|
statusCode: Int,
|
||||||
|
message: String = "Client error",
|
||||||
|
cause: Throwable? = null
|
||||||
|
) : NetworkException(
|
||||||
|
message = message,
|
||||||
|
cause = cause,
|
||||||
|
apiError = ApiError(
|
||||||
|
code = "CLIENT_ERROR",
|
||||||
|
message = message,
|
||||||
|
details = mapOf(
|
||||||
|
"type" to "client_error",
|
||||||
|
"status_code" to statusCode.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class AuthenticationException(
|
||||||
|
message: String = "Authentication failed",
|
||||||
|
cause: Throwable? = null
|
||||||
|
) : NetworkException(
|
||||||
|
message = message,
|
||||||
|
cause = cause,
|
||||||
|
apiError = ApiError(
|
||||||
|
code = "AUTHENTICATION_ERROR",
|
||||||
|
message = message,
|
||||||
|
details = mapOf("type" to "authentication_failure")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class AuthorizationException(
|
||||||
|
message: String = "Authorization failed",
|
||||||
|
cause: Throwable? = null
|
||||||
|
) : NetworkException(
|
||||||
|
message = message,
|
||||||
|
cause = cause,
|
||||||
|
apiError = ApiError(
|
||||||
|
code = "AUTHORIZATION_ERROR",
|
||||||
|
message = message,
|
||||||
|
details = mapOf("type" to "authorization_failure")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class UnknownException(
|
||||||
|
message: String = "Unknown error occurred",
|
||||||
|
cause: Throwable? = null
|
||||||
|
) : NetworkException(
|
||||||
|
message = message,
|
||||||
|
cause = cause,
|
||||||
|
apiError = ApiError(
|
||||||
|
code = "UNKNOWN_ERROR",
|
||||||
|
message = message,
|
||||||
|
details = mapOf("type" to "unknown_error")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to convert various exceptions to NetworkException
|
||||||
|
*/
|
||||||
|
fun Throwable.toNetworkException(): NetworkException {
|
||||||
|
return when (this) {
|
||||||
|
is ConnectTimeoutException -> NetworkException.TimeoutException(
|
||||||
|
message = "Connection timeout: ${this.message}",
|
||||||
|
cause = this
|
||||||
|
)
|
||||||
|
is SocketTimeoutException -> NetworkException.TimeoutException(
|
||||||
|
message = "Socket timeout: ${this.message}",
|
||||||
|
cause = this
|
||||||
|
)
|
||||||
|
is ResponseException -> when (this.response.status.value) {
|
||||||
|
401 -> NetworkException.AuthenticationException(
|
||||||
|
message = "Authentication required",
|
||||||
|
cause = this
|
||||||
|
)
|
||||||
|
403 -> NetworkException.AuthorizationException(
|
||||||
|
message = "Access forbidden",
|
||||||
|
cause = this
|
||||||
|
)
|
||||||
|
in 400..499 -> NetworkException.ClientException(
|
||||||
|
statusCode = this.response.status.value,
|
||||||
|
message = "Client error: ${this.message}",
|
||||||
|
cause = this
|
||||||
|
)
|
||||||
|
in 500..599 -> NetworkException.ServerException(
|
||||||
|
statusCode = this.response.status.value,
|
||||||
|
message = "Server error: ${this.message}",
|
||||||
|
cause = this
|
||||||
|
)
|
||||||
|
else -> NetworkException.UnknownException(
|
||||||
|
message = "HTTP error: ${this.message}",
|
||||||
|
cause = this
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is IOException -> NetworkException.ConnectionException(
|
||||||
|
message = "Network connection failed: ${this.message}",
|
||||||
|
cause = this
|
||||||
|
)
|
||||||
|
is NetworkException -> this
|
||||||
|
else -> NetworkException.UnknownException(
|
||||||
|
message = "Unexpected error: ${this.message}",
|
||||||
|
cause = this
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
package at.mocode.clients.shared.network
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.data.repository.RepositoryResult
|
||||||
|
import at.mocode.clients.shared.domain.models.ApiError
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
// Using platform-agnostic timestamp handling
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple timestamp provider for multiplatform compatibility
|
||||||
|
*/
|
||||||
|
expect fun currentTimeMillis(): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network utilities for handling retry logic and resilience
|
||||||
|
*/
|
||||||
|
object NetworkUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry configuration for network operations
|
||||||
|
*/
|
||||||
|
data class RetryConfig(
|
||||||
|
val maxAttempts: Int = 3,
|
||||||
|
val initialDelayMs: Long = 1000L,
|
||||||
|
val maxDelayMs: Long = 10000L,
|
||||||
|
val backoffMultiplier: Double = 2.0,
|
||||||
|
val retryableExceptions: Set<String> = setOf(
|
||||||
|
"CONNECTION_ERROR",
|
||||||
|
"TIMEOUT_ERROR",
|
||||||
|
"SERVER_ERROR"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute operation with retry logic
|
||||||
|
*/
|
||||||
|
suspend fun <T> withRetry(
|
||||||
|
config: RetryConfig = RetryConfig(),
|
||||||
|
operation: suspend () -> RepositoryResult<T>
|
||||||
|
): RepositoryResult<T> {
|
||||||
|
var lastError: ApiError? = null
|
||||||
|
var currentDelay = config.initialDelayMs
|
||||||
|
|
||||||
|
repeat(config.maxAttempts) { attempt ->
|
||||||
|
try {
|
||||||
|
val result = operation()
|
||||||
|
|
||||||
|
// Return success immediately
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the error is retryable
|
||||||
|
val error = result.getErrorOrNull()
|
||||||
|
if (error != null && shouldRetry(error, config)) {
|
||||||
|
lastError = error
|
||||||
|
|
||||||
|
// Don't delay on the last attempt
|
||||||
|
if (attempt < config.maxAttempts - 1) {
|
||||||
|
delay(currentDelay)
|
||||||
|
currentDelay = minOf(
|
||||||
|
(currentDelay * config.backoffMultiplier).toLong(),
|
||||||
|
config.maxDelayMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-retryable error, return immediately
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val networkException = e.toNetworkException()
|
||||||
|
lastError = networkException.apiError
|
||||||
|
|
||||||
|
if (shouldRetry(networkException.apiError, config)) {
|
||||||
|
if (attempt < config.maxAttempts - 1) {
|
||||||
|
delay(currentDelay)
|
||||||
|
currentDelay = minOf(
|
||||||
|
(currentDelay * config.backoffMultiplier).toLong(),
|
||||||
|
config.maxDelayMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return RepositoryResult.Error(networkException.apiError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All attempts exhausted, return last error
|
||||||
|
return RepositoryResult.Error(
|
||||||
|
lastError ?: ApiError(
|
||||||
|
code = "MAX_RETRIES_EXCEEDED",
|
||||||
|
message = "Maximum retry attempts exceeded"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error should trigger a retry
|
||||||
|
*/
|
||||||
|
private fun shouldRetry(error: ApiError, config: RetryConfig): Boolean {
|
||||||
|
return config.retryableExceptions.contains(error.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network connectivity checker (simplified for shared module)
|
||||||
|
*/
|
||||||
|
object ConnectivityChecker {
|
||||||
|
private var isOnline: Boolean = true
|
||||||
|
private var lastCheckMillis: Long = 0L
|
||||||
|
|
||||||
|
fun setOnlineStatus(online: Boolean) {
|
||||||
|
isOnline = online
|
||||||
|
lastCheckMillis = currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isOnline(): Boolean = isOnline
|
||||||
|
|
||||||
|
fun getLastCheckMillis(): Long = lastCheckMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple connectivity test by attempting a lightweight operation
|
||||||
|
*/
|
||||||
|
suspend fun checkConnectivity(testOperation: suspend () -> Boolean): Boolean {
|
||||||
|
return try {
|
||||||
|
val result = testOperation()
|
||||||
|
setOnlineStatus(result)
|
||||||
|
result
|
||||||
|
} catch (_: Exception) {
|
||||||
|
setOnlineStatus(false)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit breaker pattern for network operations
|
||||||
|
*/
|
||||||
|
class CircuitBreaker(
|
||||||
|
private val failureThreshold: Int = 5,
|
||||||
|
private val recoveryTimeoutMs: Long = 60000L,
|
||||||
|
private val successThreshold: Int = 3
|
||||||
|
) {
|
||||||
|
private enum class State { CLOSED, OPEN, HALF_OPEN }
|
||||||
|
|
||||||
|
private var state = State.CLOSED
|
||||||
|
private var failureCount = 0
|
||||||
|
private var successCount = 0
|
||||||
|
private var lastFailureTime = 0L
|
||||||
|
|
||||||
|
suspend fun <T> execute(operation: suspend () -> RepositoryResult<T>): RepositoryResult<T> {
|
||||||
|
when (state) {
|
||||||
|
State.OPEN -> {
|
||||||
|
if (currentTimeMillis() - lastFailureTime >= recoveryTimeoutMs) {
|
||||||
|
state = State.HALF_OPEN
|
||||||
|
successCount = 0
|
||||||
|
} else {
|
||||||
|
return RepositoryResult.Error(
|
||||||
|
ApiError(
|
||||||
|
code = "CIRCUIT_BREAKER_OPEN",
|
||||||
|
message = "Circuit breaker is open, requests blocked"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State.HALF_OPEN -> {
|
||||||
|
// Allow limited requests to test recovery
|
||||||
|
}
|
||||||
|
State.CLOSED -> {
|
||||||
|
// Normal operation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val result = operation()
|
||||||
|
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
onSuccess()
|
||||||
|
} else {
|
||||||
|
onFailure()
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onFailure()
|
||||||
|
val networkException = e.toNetworkException()
|
||||||
|
RepositoryResult.Error(networkException.apiError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSuccess() {
|
||||||
|
failureCount = 0
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
State.HALF_OPEN -> {
|
||||||
|
successCount++
|
||||||
|
if (successCount >= successThreshold) {
|
||||||
|
state = State.CLOSED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
state = State.CLOSED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onFailure() {
|
||||||
|
failureCount++
|
||||||
|
lastFailureTime = currentTimeMillis()
|
||||||
|
|
||||||
|
if (failureCount >= failureThreshold) {
|
||||||
|
state = State.OPEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getState(): String = state.name
|
||||||
|
fun getFailureCount(): Int = failureCount
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package at.mocode.clients.shared.presentation.actions
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.domain.models.User
|
||||||
|
import at.mocode.clients.shared.domain.models.AuthToken
|
||||||
|
|
||||||
|
sealed class AppAction {
|
||||||
|
// Auth Actions
|
||||||
|
sealed class Auth : AppAction() {
|
||||||
|
data class LoginStart(val username: String, val password: String) : Auth()
|
||||||
|
data class LoginSuccess(val user: User, val token: AuthToken) : Auth()
|
||||||
|
data class LoginFailure(val error: String) : Auth()
|
||||||
|
object Logout : Auth()
|
||||||
|
data class RefreshToken(val newToken: AuthToken) : Auth()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation Actions
|
||||||
|
sealed class Navigation : AppAction() {
|
||||||
|
data class NavigateTo(val route: String) : Navigation()
|
||||||
|
object NavigateBack : Navigation()
|
||||||
|
data class UpdateHistory(val route: String) : Navigation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI Actions
|
||||||
|
sealed class UI : AppAction() {
|
||||||
|
object ToggleDarkMode : UI()
|
||||||
|
data class SetLoading(val isLoading: Boolean) : UI()
|
||||||
|
data class ShowNotification(val notification: at.mocode.clients.shared.presentation.state.Notification) : UI()
|
||||||
|
data class DismissNotification(val id: String) : UI()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network Actions
|
||||||
|
sealed class Network : AppAction() {
|
||||||
|
data class SetOnlineStatus(val isOnline: Boolean) : Network()
|
||||||
|
data class UpdateLastSync(val timestamp: String) : Network()
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package at.mocode.clients.shared.presentation.state
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.domain.models.User
|
||||||
|
import at.mocode.clients.shared.domain.models.AuthToken
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppState(
|
||||||
|
val auth: AuthState = AuthState(),
|
||||||
|
val navigation: NavigationState = NavigationState(),
|
||||||
|
val ui: UiState = UiState(),
|
||||||
|
val network: NetworkState = NetworkState()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthState(
|
||||||
|
val isAuthenticated: Boolean = false,
|
||||||
|
val user: User? = null,
|
||||||
|
val token: AuthToken? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NavigationState(
|
||||||
|
val currentRoute: String = "/",
|
||||||
|
val history: List<String> = emptyList(),
|
||||||
|
val canGoBack: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UiState(
|
||||||
|
val isDarkMode: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val notifications: List<Notification> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NetworkState(
|
||||||
|
val isOnline: Boolean = true,
|
||||||
|
val lastSync: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Notification(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val message: String,
|
||||||
|
val type: NotificationType = NotificationType.INFO,
|
||||||
|
val timestamp: String
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class NotificationType {
|
||||||
|
INFO, SUCCESS, WARNING, ERROR
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
package at.mocode.clients.shared.presentation.store
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.presentation.state.AppState
|
||||||
|
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
|
||||||
|
class AppStore(
|
||||||
|
private val dispatcher: CoroutineDispatcher = Dispatchers.Main
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||||
|
private val _state = MutableStateFlow(AppState())
|
||||||
|
|
||||||
|
val state: StateFlow<AppState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun dispatch(action: AppAction) {
|
||||||
|
scope.launch {
|
||||||
|
val currentState = _state.value
|
||||||
|
val newState = reduce(currentState, action)
|
||||||
|
_state.value = newState
|
||||||
|
|
||||||
|
// Handle side effects
|
||||||
|
handleSideEffect(action, newState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reduce(currentState: AppState, action: AppAction): AppState {
|
||||||
|
return when (action) {
|
||||||
|
is AppAction.Auth -> currentState.copy(
|
||||||
|
auth = reduceAuth(currentState.auth, action)
|
||||||
|
)
|
||||||
|
is AppAction.Navigation -> currentState.copy(
|
||||||
|
navigation = reduceNavigation(currentState.navigation, action)
|
||||||
|
)
|
||||||
|
is AppAction.UI -> currentState.copy(
|
||||||
|
ui = reduceUI(currentState.ui, action)
|
||||||
|
)
|
||||||
|
is AppAction.Network -> currentState.copy(
|
||||||
|
network = reduceNetwork(currentState.network, action)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reduceAuth(currentAuth: at.mocode.clients.shared.presentation.state.AuthState, action: AppAction.Auth): at.mocode.clients.shared.presentation.state.AuthState {
|
||||||
|
return when (action) {
|
||||||
|
is AppAction.Auth.LoginStart -> currentAuth.copy(
|
||||||
|
isLoading = true,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
is AppAction.Auth.LoginSuccess -> currentAuth.copy(
|
||||||
|
isAuthenticated = true,
|
||||||
|
user = action.user,
|
||||||
|
token = action.token,
|
||||||
|
isLoading = false,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
is AppAction.Auth.LoginFailure -> currentAuth.copy(
|
||||||
|
isAuthenticated = false,
|
||||||
|
user = null,
|
||||||
|
token = null,
|
||||||
|
isLoading = false,
|
||||||
|
error = action.error
|
||||||
|
)
|
||||||
|
is AppAction.Auth.Logout -> at.mocode.clients.shared.presentation.state.AuthState()
|
||||||
|
is AppAction.Auth.RefreshToken -> currentAuth.copy(
|
||||||
|
token = action.newToken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reduceNavigation(currentNav: at.mocode.clients.shared.presentation.state.NavigationState, action: AppAction.Navigation): at.mocode.clients.shared.presentation.state.NavigationState {
|
||||||
|
return when (action) {
|
||||||
|
is AppAction.Navigation.NavigateTo -> currentNav.copy(
|
||||||
|
currentRoute = action.route,
|
||||||
|
history = currentNav.history + currentNav.currentRoute,
|
||||||
|
canGoBack = true
|
||||||
|
)
|
||||||
|
is AppAction.Navigation.NavigateBack -> {
|
||||||
|
val newHistory = currentNav.history.dropLast(1)
|
||||||
|
currentNav.copy(
|
||||||
|
currentRoute = newHistory.lastOrNull() ?: "/",
|
||||||
|
history = newHistory,
|
||||||
|
canGoBack = newHistory.isNotEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is AppAction.Navigation.UpdateHistory -> currentNav.copy(
|
||||||
|
currentRoute = action.route
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reduceUI(currentUI: at.mocode.clients.shared.presentation.state.UiState, action: AppAction.UI): at.mocode.clients.shared.presentation.state.UiState {
|
||||||
|
return when (action) {
|
||||||
|
is AppAction.UI.ToggleDarkMode -> currentUI.copy(
|
||||||
|
isDarkMode = !currentUI.isDarkMode
|
||||||
|
)
|
||||||
|
is AppAction.UI.SetLoading -> currentUI.copy(
|
||||||
|
isLoading = action.isLoading
|
||||||
|
)
|
||||||
|
is AppAction.UI.ShowNotification -> currentUI.copy(
|
||||||
|
notifications = currentUI.notifications + action.notification
|
||||||
|
)
|
||||||
|
is AppAction.UI.DismissNotification -> currentUI.copy(
|
||||||
|
notifications = currentUI.notifications.filter { it.id != action.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reduceNetwork(currentNetwork: at.mocode.clients.shared.presentation.state.NetworkState, action: AppAction.Network): at.mocode.clients.shared.presentation.state.NetworkState {
|
||||||
|
return when (action) {
|
||||||
|
is AppAction.Network.SetOnlineStatus -> currentNetwork.copy(
|
||||||
|
isOnline = action.isOnline
|
||||||
|
)
|
||||||
|
is AppAction.Network.UpdateLastSync -> currentNetwork.copy(
|
||||||
|
lastSync = action.timestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSideEffect(action: AppAction, newState: AppState) {
|
||||||
|
when (action) {
|
||||||
|
is AppAction.Auth.LoginSuccess -> {
|
||||||
|
// Auto-save token to local storage
|
||||||
|
// TODO: Implement storage
|
||||||
|
}
|
||||||
|
is AppAction.Auth.Logout -> {
|
||||||
|
// Clear local storage
|
||||||
|
// TODO: Implement storage cleanup
|
||||||
|
}
|
||||||
|
else -> { /* No side effects */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cleanup() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package at.mocode.clients.shared.presentation.store
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.domain.models.User
|
||||||
|
import at.mocode.clients.shared.domain.models.AuthToken
|
||||||
|
import at.mocode.clients.shared.presentation.actions.AppAction
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlin.test.*
|
||||||
|
|
||||||
|
class AppStoreTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `store should be created successfully`() {
|
||||||
|
val store = AppStore(Dispatchers.Unconfined)
|
||||||
|
assertNotNull(store)
|
||||||
|
store.cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `auth actions should update state`() {
|
||||||
|
val store = AppStore(Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
// Test login start action
|
||||||
|
store.dispatch(AppAction.Auth.LoginStart("testuser", "password"))
|
||||||
|
|
||||||
|
// Test login success
|
||||||
|
val user = User("1", "test", "test@example.com", "Test", "User")
|
||||||
|
val token = AuthToken("access", "refresh", 3600)
|
||||||
|
store.dispatch(AppAction.Auth.LoginSuccess(user, token))
|
||||||
|
|
||||||
|
// Test logout
|
||||||
|
store.dispatch(AppAction.Auth.Logout)
|
||||||
|
|
||||||
|
store.cleanup()
|
||||||
|
assertTrue(true) // Basic test to verify actions don't throw exceptions
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigation actions should work`() {
|
||||||
|
val store = AppStore(Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
store.dispatch(AppAction.Navigation.NavigateTo("/dashboard"))
|
||||||
|
store.dispatch(AppAction.Navigation.NavigateBack)
|
||||||
|
|
||||||
|
store.cleanup()
|
||||||
|
assertTrue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ui actions should work`() {
|
||||||
|
val store = AppStore(Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
store.dispatch(AppAction.UI.ToggleDarkMode)
|
||||||
|
store.dispatch(AppAction.UI.SetLoading(true))
|
||||||
|
|
||||||
|
store.cleanup()
|
||||||
|
assertTrue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `network actions should work`() {
|
||||||
|
val store = AppStore(Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
store.dispatch(AppAction.Network.SetOnlineStatus(false))
|
||||||
|
store.dispatch(AppAction.Network.UpdateLastSync("2024-01-01T12:00:00Z"))
|
||||||
|
|
||||||
|
store.cleanup()
|
||||||
|
assertTrue(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package at.mocode.clients.shared.test
|
||||||
|
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
|
||||||
|
expect fun runBlockingTest(block: suspend () -> Unit)
|
||||||
|
|
||||||
|
abstract class BaseTest {
|
||||||
|
@BeforeTest
|
||||||
|
fun setupTest() {
|
||||||
|
// Set up a common test environment
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardownTest() {
|
||||||
|
// Cleanup test environment
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package at.mocode.clients.shared.network
|
||||||
|
|
||||||
|
import kotlin.js.Date
|
||||||
|
|
||||||
|
actual fun currentTimeMillis(): Long = Date.now().toLong()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package at.mocode.clients.shared.test
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
actual fun runBlockingTest(block: suspend () -> Unit) {
|
||||||
|
GlobalScope.promise {
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package at.mocode.clients.shared.network
|
||||||
|
|
||||||
|
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package at.mocode.clients.shared.test
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.*
|
||||||
|
|
||||||
|
actual fun runBlockingTest(block: suspend () -> Unit) {
|
||||||
|
runTest {
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package at.mocode.clients.shared.network
|
||||||
|
|
||||||
|
// WASM implementation using a simple counter-approach
|
||||||
|
// Since we don't have direct access to system time in WASM,
|
||||||
|
// we'll use a monotonic counter for relative timing
|
||||||
|
private var wasmTimeCounter: Long = 0L
|
||||||
|
|
||||||
|
actual fun currentTimeMillis(): Long {
|
||||||
|
wasmTimeCounter += 1
|
||||||
|
return wasmTimeCounter
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package at.mocode.clients.shared.test
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class, ExperimentalWasmJsInterop::class)
|
||||||
|
actual fun runBlockingTest(block: suspend () -> Unit) {
|
||||||
|
// WASM-JS uses the same approach as regular JS
|
||||||
|
GlobalScope.promise {
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-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-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-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 (Versions from spring-boot-dependencies BOM) ---
|
||||||
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
|
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
|
||||||
|
|||||||
+8
-294
@@ -219,11 +219,6 @@
|
|||||||
"@types/qs" "*"
|
"@types/qs" "*"
|
||||||
"@types/serve-static" "*"
|
"@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@*":
|
"@types/http-errors@*":
|
||||||
version "2.0.5"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472"
|
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"
|
fast-deep-equal "^3.1.3"
|
||||||
multicast-dns "^7.2.5"
|
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:
|
brace-expansion@^1.1.7:
|
||||||
version "1.1.12"
|
version "1.1.12"
|
||||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843"
|
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"
|
call-bind-apply-helpers "^1.0.2"
|
||||||
get-intrinsic "^1.3.0"
|
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:
|
camelcase@^6.0.0:
|
||||||
version "6.3.0"
|
version "6.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
|
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"
|
resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b"
|
||||||
integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==
|
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:
|
cliui@^7.0.2:
|
||||||
version "7.0.4"
|
version "7.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
|
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"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
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:
|
compressible@~2.0.18:
|
||||||
version "2.0.18"
|
version "2.0.18"
|
||||||
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
|
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"
|
shebang-command "^2.0.0"
|
||||||
which "^2.0.1"
|
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:
|
custom-event@~1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
|
resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
|
||||||
@@ -1008,13 +943,6 @@ dns-packet@^5.2.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@leichtgewicht/ip-codec" "^2.0.1"
|
"@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:
|
dom-serialize@^2.2.1:
|
||||||
version "2.2.1"
|
version "2.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
|
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"
|
extend "^3.0.0"
|
||||||
void-elements "^2.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:
|
dunder-proto@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
|
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"
|
punycode "^1.4.1"
|
||||||
safe-regex-test "^1.1.0"
|
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:
|
envinfo@^7.14.0:
|
||||||
version "7.14.0"
|
version "7.14.0"
|
||||||
resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.14.0.tgz#26dac5db54418f2a4c1159153a0b2ae980838aae"
|
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"
|
readable-stream "^2.0.1"
|
||||||
wbuf "^1.1.0"
|
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:
|
http-deceiver@^1.2.7:
|
||||||
version "1.2.7"
|
version "1.2.7"
|
||||||
resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
|
resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
|
||||||
@@ -1635,11 +1486,6 @@ iconv-lite@^0.6.3:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer ">= 2.1.2 < 3.0.0"
|
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:
|
import-local@^3.0.2:
|
||||||
version "3.2.0"
|
version "3.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260"
|
resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260"
|
||||||
@@ -1934,7 +1780,7 @@ locate-path@^6.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate "^5.0.0"
|
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"
|
version "4.17.21"
|
||||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||||
@@ -1958,13 +1804,6 @@ log4js@^6.4.1:
|
|||||||
rfdc "^1.3.0"
|
rfdc "^1.3.0"
|
||||||
streamroller "^3.1.5"
|
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:
|
lru-cache@^10.2.0:
|
||||||
version "10.4.3"
|
version "10.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
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"
|
dns-packet "^5.2.2"
|
||||||
thunky "^1.0.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:
|
negotiator@0.6.3:
|
||||||
version "0.6.3"
|
version "0.6.3"
|
||||||
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
|
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"
|
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
|
||||||
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
|
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:
|
node-forge@^1:
|
||||||
version "1.3.1"
|
version "1.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3"
|
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"
|
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
||||||
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
|
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:
|
object-assign@^4:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
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"
|
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==
|
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:
|
parseurl@~1.3.2, parseurl@~1.3.3:
|
||||||
version "1.3.3"
|
version "1.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||||
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
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:
|
path-exists@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||||
@@ -2348,64 +2151,6 @@ pkg-dir@^4.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
find-up "^4.0.0"
|
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:
|
process-nextick-args@~2.0.0:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
|
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:
|
dependencies:
|
||||||
resolve "^1.20.0"
|
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:
|
require-directory@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
|
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"
|
"@types/node-forge" "^1.3.0"
|
||||||
node-forge "^1"
|
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:
|
send@0.19.0:
|
||||||
version "0.19.0"
|
version "0.19.0"
|
||||||
resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8"
|
resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8"
|
||||||
@@ -2791,7 +2515,7 @@ sockjs@^0.3.24:
|
|||||||
uuid "^8.3.2"
|
uuid "^8.3.2"
|
||||||
websocket-driver "^0.7.4"
|
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"
|
version "1.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
||||||
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
||||||
@@ -2804,7 +2528,7 @@ source-map-loader@5.0.0:
|
|||||||
iconv-lite "^0.6.3"
|
iconv-lite "^0.6.3"
|
||||||
source-map-js "^1.0.2"
|
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"
|
version "0.5.21"
|
||||||
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
|
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
|
||||||
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
|
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
|
||||||
@@ -2812,7 +2536,7 @@ source-map-support@~0.5.20:
|
|||||||
buffer-from "^1.0.0"
|
buffer-from "^1.0.0"
|
||||||
source-map "^0.6.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"
|
version "0.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
||||||
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
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"
|
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
||||||
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
|
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:
|
supports-color@^7.1.0:
|
||||||
version "7.2.0"
|
version "7.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
|
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"
|
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
|
||||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
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"
|
version "2.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.3.tgz#4b67b635b2d97578a06a2713d2f04800c237e99b"
|
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.3.tgz#4b67b635b2d97578a06a2713d2f04800c237e99b"
|
||||||
integrity sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==
|
integrity sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==
|
||||||
@@ -2950,7 +2669,7 @@ terser-webpack-plugin@^5.3.11:
|
|||||||
serialize-javascript "^6.0.2"
|
serialize-javascript "^6.0.2"
|
||||||
terser "^5.31.1"
|
terser "^5.31.1"
|
||||||
|
|
||||||
terser@^5.10.0, terser@^5.31.1:
|
terser@^5.31.1:
|
||||||
version "5.44.0"
|
version "5.44.0"
|
||||||
resolved "https://registry.yarnpkg.com/terser/-/terser-5.44.0.tgz#ebefb8e5b8579d93111bfdfc39d2cf63879f4a82"
|
resolved "https://registry.yarnpkg.com/terser/-/terser-5.44.0.tgz#ebefb8e5b8579d93111bfdfc39d2cf63879f4a82"
|
||||||
integrity sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==
|
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"
|
resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.1.0.tgz#ab29129169dc46004414f5a9d4a3c6e89f13e8a4"
|
||||||
integrity sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==
|
integrity sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==
|
||||||
|
|
||||||
tslib@^2.0.0, tslib@^2.0.3:
|
tslib@^2.0.0:
|
||||||
version "2.8.1"
|
version "2.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||||
@@ -3033,16 +2752,11 @@ update-browserslist-db@^1.1.3:
|
|||||||
escalade "^3.2.0"
|
escalade "^3.2.0"
|
||||||
picocolors "^1.1.1"
|
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"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
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:
|
utils-merge@1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||||
|
|||||||
Reference in New Issue
Block a user