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