chore(docs, design-system, ping-service): integrate SQLDelight with KMP, refine design-system components, and enhance logging

- Added a comprehensive guide for SQLDelight integration in Kotlin Multiplatform, covering setup for Android, iOS, desktop, and web platforms.
- Introduced `DashboardCard` and `DenseButton` to the design system, focusing on enterprise-grade usability and visual consistency.
- Enhanced `PingViewModel` with structured logging (`LogEntry`) functionality for better debugging and traceability across API calls.
- Updated `AppTheme` with a refined color palette, typography, and shapes to align with enterprise UI standards.
- Extended Koin integration and modularized database setup for smoother dependency injection and code reuse.
This commit is contained in:
Stefan Mogeritsch 2026-01-24 00:39:31 +01:00
parent f774d686a4
commit f71bfb292b
11 changed files with 1287 additions and 247 deletions

View File

@ -0,0 +1,524 @@
# SQLDelight Integration in Compose Multiplatform
This guide shows how to integrate SQLDelight in a Compose Multiplatform project with Koin dependency injection.
## Step 1: Add Dependencies
Add below dependencies In `gradle/libs.versions.toml`:
```toml
[versions]
sqldelight = "2.0.1"
koin = "3.5.3"
[libraries]
sqldelight-driver-sqlite = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
[plugins]
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
```
In `build.gradle.kts` (project level):
```kotlin
plugins {
alias(libs.plugins.sqldelight) apply false
}
```
In `shared/build.gradle.kts`:
```kotlin
plugins {
alias(libs.plugins.sqldelight)
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.koin.core)
implementation(libs.sqldelight.driver.sqlite)
}
androidMain.dependencies {
implementation(libs.koin.android)
implementation(libs.sqldelight.driver.android)
}
iosMain.dependencies {
implementation(libs.sqldelight.driver.native)
}
desktopMain.dependencies {
implementation(libs.sqldelight.driver.sqlite)
}
}
}
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.example.database")
}
}
}
```
##Step 2: Create SQL Schema
**Create directory structure:**
`shared/src/commonMain/sqldelight/com/example/database/`
Create `User.sq` file:
```sql
CREATE TABLE User
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
imageUrl TEXT
);
-- Insert a new user
insertUser
:
INSERT INTO User(name, imageUrl)
VALUES (?, ?);
-- Get all users
getAllUsers
:
SELECT *
FROM User;
-- Get user by ID
getUserById
:
SELECT *
FROM User
WHERE id = ?;
-- Update user
updateUser
:
UPDATE User
SET name = ?,
imageUrl = ?
WHERE id = ?;
-- Delete user
deleteUser
:
DELETE
FROM User
WHERE id = ?;
-- Delete all users
deleteAllUsers
:
DELETE
FROM User;
```
## Step 3: Create Database Driver Interface
In `shared/src/commonMain/kotlin/database/DatabaseDriverFactory.kt`:
```kotlin
package com.example.database
import app.cash.sqldelight.db.SqlDriver
expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
```
## Step 4: Platform-Specific Implementations
### Android —
`shared/src/androidMain/kotlin/database/DatabaseDriverFactory.android.kt`:
```kotlin
package com.example.database
import android.content.Context
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
actual class DatabaseDriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(
schema = AppDatabase.Schema,
context = context,
name = "app.db"
)
}
}
```
### iOS —
`shared/src/iosMain/kotlin/database/DatabaseDriverFactory.ios.kt`:
```kotlin
package com.example.database
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(
schema = AppDatabase.Schema,
name = "app.db"
)
}
}
```
### Desktop —
`shared/src/desktopMain/kotlin/database/DatabaseDriverFactory.desktop.kt`:
```kotlin
package com.example.database
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
AppDatabase.Schema.create(driver)
return driver
}
}
```
## Step 5: Create Repository
In `shared/src/commonMain/kotlin/repository/UserRepository.kt`:
```kotlin
package com.example.repository
import com.example.database.AppDatabase
import com.example.database.User
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
class UserRepository(private val database: AppDatabase) {
private val queries = database.userQueries
suspend fun insertUser(name: String, imageUrl: String?) = withContext(Dispatchers.IO) {
queries.insertUser(name, imageUrl)
}
suspend fun getAllUsers(): List<User> = withContext(Dispatchers.IO) {
queries.getAllUsers().executeAsList()
}
suspend fun getUserById(id: Long): User? = withContext(Dispatchers.IO) {
queries.getUserById(id).executeAsOneOrNull()
}
suspend fun updateUser(id: Long, name: String, imageUrl: String?) = withContext(Dispatchers.IO) {
queries.updateUser(name, imageUrl, id)
}
suspend fun deleteUser(id: Long) = withContext(Dispatchers.IO) {
queries.deleteUser(id)
}
suspend fun deleteAllUsers() = withContext(Dispatchers.IO) {
queries.deleteAllUsers()
}
}
```
## Step 6: Setup Koin Modules
In `shared/src/commonMain/kotlin/di/DatabaseModule.kt`:
```kotlin
package com.example.di
import com.example.database.AppDatabase
import com.example.database.DatabaseDriverFactory
import com.example.repository.UserRepository
import org.koin.dsl.module
val databaseModule = module {
single { DatabaseDriverFactory() }
single { AppDatabase(get<DatabaseDriverFactory>().createDriver()) }
single { UserRepository(get()) }
}
```
### Platform-specific modules
### Android —
`shared/src/androidMain/kotlin/di/PlatformModule.android.kt`:
```kotlin
package com.example.di
import com.example.database.DatabaseDriverFactory
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
actual val platformModule = module {
single { DatabaseDriverFactory(androidContext()) }
}
```
### iOS —
`shared/src/iosMain/kotlin/di/PlatformModule.ios.kt`:
```kotlin
package com.example.di
import com.example.database.DatabaseDriverFactory
import org.koin.dsl.module
actual val platformModule = module {
single { DatabaseDriverFactory() }
}
```
### Desktop —
`shared/src/desktopMain/kotlin/di/PlatformModule.desktop.kt`:
```kotlin
package com.example.di
import com.example.database.DatabaseDriverFactory
import org.koin.dsl.module
actual val platformModule = module {
single { DatabaseDriverFactory() }
}
```
### Common module declaration —
`shared/src/commonMain/kotlin/di/PlatformModule.kt`:
```kotlin
package com.example.di
import org.koin.core.module.Module
expect val platformModule: Module
```
## Step 7: Initialize Koin
In `shared/src/commonMain/kotlin/di/KoinInit.kt`:
```kotlin
package com.example.di
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
appDeclaration()
modules(
platformModule,
databaseModule
)
}
```
## Step 8: Platform Initialization
### Android —
In `MainActivity.kt`:
```kotlin
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initKoin {
androidContext(this@MainActivity)
}
setContent {
App()
}
}
}
```
### iOS —
In `iosApp/iosApp/iOSApp.swift`:
```kotlin
import SwiftUI
import shared
@main
struct iOSApp : App {
init() {
KoinInitKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
### Desktop —
In `desktopApp/src/jvmMain/kotlin/main.kt`:
```kotlin
fun main() {
initKoin()
application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}
}
```
## Step 9: Use in Compose
### Create VieModel —
In `shared/src/commonMain/kotlin/viewmodel/UserViewModel.kt`:
```kotlin
package com.example.viewmodel
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.database.User
import com.example.repository.UserRepository
import kotlinx.coroutines.launch
class UserViewModel(private val userRepository: UserRepository) : ViewModel() {
var users by mutableStateOf<List<User>>(emptyList())
private set
var isLoading by mutableStateOf(false)
private set
init {
loadUsers()
}
fun loadUsers() {
viewModelScope.launch {
isLoading = true
users = userRepository.getAllUsers()
isLoading = false
}
}
fun addUser(name: String, imageUrl: String?) {
viewModelScope.launch {
userRepository.insertUser(name, imageUrl)
loadUsers()
}
}
fun deleteUser(id: Long) {
viewModelScope.launch {
userRepository.deleteUser(id)
loadUsers()
}
}
}
```
Use in Compose Screen:
```kotlin
@Composable
fun UserScreen() {
val userViewModel: UserViewModel = koinInject()
LazyColumn {
items(userViewModel.users) { user ->
UserItem(
user = user,
onDelete = { userViewModel.deleteUser(user.id) }
)
}
}
}
@Composable
fun UserItem(user: User, onDelete: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = user.name,
modifier = Modifier.weight(1f)
)
Button(onClick = onDelete) {
Text("Delete")
}
}
}
```
### Thats It!
You now have SQLDelight fully integrated in your Compose Multiplatform project with:
- Database working on Android, iOS, and Desktop
- Koin dependency injection setup
- Repository pattern for clean architecture
- Ready-to-use User table with CRUD operations
The database will automatically handle platform-specific implementations while sharing the same business logic across
all platforms.

View File

@ -0,0 +1,167 @@
# Architekturstrategien für Asynchrone Persistenz in Kotlin Multiplatform: Eine umfassende Analyse zur Integration von SQLDelight in Web-Umgebungen
## 1. Einleitung und Problemstellung
Die Entwicklung plattformübergreifender Anwendungen mittels Kotlin Multiplatform (KMP) hat in den letzten Jahren einen paradigmatischen Wandel vollzogen. Ein zentraler Bestandteil dieser Architektur ist die Datenpersistenz, für die sich SQLDelight als Industriestandard etabliert hat.
Die Integration der Web-Plattform stellt jedoch eine signifikante architektonische Herausforderung dar. Wie in der Problemstellung korrekt identifiziert, existiert eine fundamentale Diskrepanz zwischen den synchronen I/O-Operationen nativer Plattformen (Android, iOS) und der zwingend asynchronen Natur des Webs. Während native SQLite-Treiber (`AndroidSqliteDriver`, `NativeSqliteDriver`) Datenbankoperationen blockierend ausführen können, erfordert der Browser die Nutzung eines `WebWorkerDriver` und asynchrone Initialisierungsmuster.
Dieser Bericht liefert eine Lösungsarchitektur basierend auf dem "Lazy Async Wrapper"-Muster und Koin.
---
## 2. Theoretisches Fundament: Die Asynchronitäts-Lücke
### 2.1 Native vs. Web-Laufzeitumgebungen
Auf nativen Systemen kann der `SqlDriver` synchron instanziiert werden. Im Browser hingegen nutzt SQLDelight `sql.js` oder `sqlite-wasm` in einem Web Worker. Die Kommunikation erfolgt über Message Passing, was `suspend`-Funktionen für die Initialisierung erzwingt.
### 2.2 Der Paradigmenwechsel mit SQLDelight 2.0
Mit Version 2.0 wurde die Konfiguration `generateAsync` eingeführt:kotlin sqldelight { databases { create("AppDatabase") { packageName.set("com.example.db") generateAsync.set(true) } } }
Setzt man dieses Flag auf `true`, werden alle Datenbankoperationen als `suspend`-Funktionen generiert.[1, 4] Dies ist der erste Schritt zur Vereinheitlichung: Auch native Plattformen nutzen nun (formal) asynchrone Schnittstellen, was den gemeinsamen Code homogenisiert.
---
## 3. Die Lösungsarchitektur: Das "Lazy Async Wrapper"-Muster
Anstatt die Datenbank direkt beim App-Start zu initialisieren (was im Web blockieren oder fehlschlagen würde, wenn der Worker noch nicht bereit ist), kapseln wir den Treiber in einer Wrapper-Klasse.[5, 2]
### 3.1 Definition der Factory
**Datei:** `shared/src/commonMain/kotlin/.../DatabaseDriverFactory.kt`
```kotlin
import app.cash.sqldelight.db.SqlDriver
interface DatabaseDriverFactory {
suspend fun createDriver(): SqlDriver
}
```
### 3.2 Der Database Wrapper
Diese Komponente löst das Problem des Nutzers, indem sie die Initialisierung bis zum ersten Zugriff verzögert und mittels `Mutex` absichert.
**Datei:** `shared/src/commonMain/kotlin/.../DatabaseWrapper.kt`
```kotlin
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class DatabaseWrapper(private val driverFactory: DatabaseDriverFactory) {
private var _database: AppDatabase? = null
private val mutex = Mutex()
suspend fun get(): AppDatabase {
_database?.let { return it }
return mutex.withLock {
_database?: AppDatabase(driverFactory.createDriver()).also { _database = it }
}
}
// Helper für Repositories
suspend operator fun <R> invoke(block: suspend (AppDatabase) -> R): R {
return block(get())
}
}
```
---
## 4. Implementierung der Plattform-Treiber
### 4.1 Web (Kotlin/Wasm & JS)
Hier liegt der Kern der Lösung: Wir warten explizit auf die Schema-Erstellung (`awaitCreate`), bevor wir den Treiber zurückgeben.
**Datei:** `shared/src/jsMain/kotlin/.../WebDatabaseDriverFactory.kt`
```kotlin
import app.cash.sqldelight.driver.worker.WebWorkerDriver
import org.w3c.dom.Worker
class WebDatabaseDriverFactory : DatabaseDriverFactory {
override suspend fun createDriver(): SqlDriver {
val worker = Worker(
js("""new URL("@cashapp/sqldelight-sqljs-worker/sqljs.worker.js", import.meta.url)""")
)
val driver = WebWorkerDriver(worker)
// WICHTIG: Hier wird asynchron gewartet!
AppDatabase.Schema.create(driver).await()
return driver
}
}
```
**Webpack Konfiguration:**
Damit dies funktioniert, muss die `sql-wasm.wasm` Datei korrekt kopiert werden.
```javascript
// webpack.config.d/sqljs.js
const CopyWebpackPlugin = require('copy-webpack-plugin');
config.plugins.push(
new CopyWebpackPlugin({
patterns: [
'../../node_modules/sql.js/dist/sql-wasm.wasm'
]
})
);
```
### 4.2 Android (Synchron)
Für Android geben wir den synchronen Treiber einfach in der `suspend`-Funktion zurück.
```kotlin
class AndroidDatabaseDriverFactory(private val context: Context) : DatabaseDriverFactory {
override suspend fun createDriver(): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}
}
```
---
## 5. Integration mit Koin
Da der `DatabaseWrapper` selbst leichtgewichtig ist (er erstellt die DB noch nicht im Konstruktor), kann er problemlos als `single` in Koin registriert werden.
```kotlin
val appModule = module {
single { DatabaseWrapper(get()) }
single { MyRepository(get()) }
}
```
Das Repository nutzt dann den Wrapper:
```kotlin
class MyRepository(private val dbWrapper: DatabaseWrapper) {
suspend fun getItems() = dbWrapper { db ->
db.itemQueries.selectAll().executeAsList()
}
}
```
## 6. Zusammenfassung
Diese Architektur löst den Konflikt zwischen synchronen und asynchronen Welten durch:
1. **`generateAsync = true`**: Erzwingt `suspend` überall.
2. **Wrapper Pattern**: Kapselt die asynchrone Initialisierung (`await()`) im Web.
3. **Koin Singleton**: Der Wrapper kann sofort injiziert werden, die DB wird erst beim ersten `invoke` geladen.

View File

@ -0,0 +1,44 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.Dimens
/**
* Ein kompakter Button für unsere High-Density UI.
*
* Warum ein eigener Button?
* Der Standard Material3 Button ist sehr hoch (40dp+) und hat viel Padding.
* Das verschwendet Platz in Tabellen oder Toolbars.
* Unser 'DenseButton' ist fix 32dp hoch- und hat weniger Innenabstand.
*/
@Composable
fun DenseButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.primary
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.height(32.dp), // Fixe, kompakte Höhe
shape = MaterialTheme.shapes.small, // Nutzt unsere 4dp Rundung
colors = ButtonDefaults.buttonColors(containerColor = containerColor),
contentPadding = PaddingValues(horizontal = Dimens.SpacingM, vertical = 0.dp) // Wenig Padding
) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium // Kleinere Schrift
)
}
}

View File

@ -0,0 +1,43 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.Dimens
/**
* Eine flache, umrandete Card für Dashboards.
*
* Warum?
* Standard Cards haben oft Schatten (Elevation), was bei vielen Cards unruhig wirkt.
* Im Enterprise-Kontext sind flache Cards mit dünnem Border (1px) oft sauberer.
*/
@Composable
fun DashboardCard(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), // Dünner Rahmen
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) // Kein Schatten
) {
Column(
modifier = Modifier.padding(Dimens.SpacingS) // Kompaktes Padding innen
) {
content()
}
}
}

View File

@ -3,48 +3,104 @@ package at.mocode.frontend.core.designsystem.theme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// --- 1. Farben (Palette) ---
// Wir definieren eine professionelle, kontrastreiche Palette.
// Blau steht für Aktion/Information, Grau für Struktur.
// Define custom colors for the app
private val LightColorScheme = lightColorScheme( private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1976D2), primary = Color(0xFF0052CC), // Enterprise Blue (stark)
onPrimary = Color.White, onPrimary = Color.White,
primaryContainer = Color(0xFFBBDEFB), primaryContainer = Color(0xFFDEEBFF),
onPrimaryContainer = Color(0xFF0D47A1), onPrimaryContainer = Color(0xFF0052CC),
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black, secondary = Color(0xFF2684FF), // Helleres Blau für Akzente
tertiary = Color(0xFF03A9F4), onSecondary = Color.White,
background = Color(0xFFFAFAFA),
surface = Color.White, background = Color(0xFFF4F5F7), // Helles Grau (nicht hartes Weiß)
onBackground = Color(0xFF1C1B1F), surface = Color.White,
onSurface = Color(0xFF1C1B1F) onBackground = Color(0xFF172B4D), // Fast Schwarz (besser lesbar als #000)
onSurface = Color(0xFF172B4D),
error = Color(0xFFDE350B),
onError = Color.White
) )
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF90CAF9), primary = Color(0xFF4C9AFF), // Helleres Blau auf Dunkel
onPrimary = Color(0xFF0D47A1), onPrimary = Color(0xFF091E42),
primaryContainer = Color(0xFF1565C0), primaryContainer = Color(0xFF0052CC),
onPrimaryContainer = Color(0xFFBBDEFB), onPrimaryContainer = Color.White,
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black, secondary = Color(0xFF2684FF),
tertiary = Color(0xFF03A9F4), onSecondary = Color.White,
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E), background = Color(0xFF1E1E1E), // Dunkles Grau (angenehmer als #000)
onBackground = Color(0xFFE0E0E0), surface = Color(0xFF2C2C2C), // Panels heben sich leicht ab
onSurface = Color(0xFFE0E0E0) onBackground = Color(0xFFEBECF0),
onSurface = Color(0xFFEBECF0),
error = Color(0xFFFF5630),
onError = Color.Black
)
// --- 2. Formen (Shapes) ---
// Enterprise Apps nutzen oft weniger Rundung als Consumer Apps (seriöser).
private val AppShapes = Shapes(
small = RoundedCornerShape(Dimens.CornerRadiusS), // Buttons, Inputs
medium = RoundedCornerShape(Dimens.CornerRadiusM), // Cards, Dialogs
large = RoundedCornerShape(Dimens.CornerRadiusM)
)
// --- 3. Typografie ---
// wir setzen auf klare Hierarchie.
private val AppTypography = Typography(
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle( // Standard Text
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelSmall = TextStyle( // Für dichte Tabellen/Labels
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp
)
) )
@Suppress("unused")
@Composable @Composable
fun AppTheme( fun AppTheme(
darkTheme: Boolean = false, // For now, we'll default to light theme darkTheme: Boolean = false, // Kann später via Settings gesteuert werden
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
content = content shapes = AppShapes,
) typography = AppTypography,
content = content
)
} }

View File

@ -0,0 +1,27 @@
package at.mocode.frontend.core.designsystem.theme
import androidx.compose.ui.unit.dp
/**
* Zentrale Definition für Abstände und Größen.
* Warum? Damit wir nicht überall "Magic Numbers" (z.B. 13.dp) haben.
* Wenn wir den Abstand global ändern wollen, machen wir das nur hier.
*/
object Dimens {
// Spacing (Abstände)
val SpacingXS = 4.dp // Sehr eng (für Tabellen, dichte Listen)
val SpacingS = 8.dp // Standard Abstand zwischen Elementen
val SpacingM = 16.dp // Abstand für Sektionen
val SpacingL = 24.dp // Außenabstand für Screens
// Sizes (Größen)
val IconSizeS = 16.dp
val IconSizeM = 24.dp
// Borders
val BorderThin = 1.dp
// Corner Radius (Ecken)
val CornerRadiusS = 4.dp // Leicht abgerundet (Enterprise Look)
val CornerRadiusM = 8.dp
}

View File

@ -6,12 +6,12 @@ import org.w3c.dom.Worker
actual class DatabaseDriverFactory { actual class DatabaseDriverFactory {
actual suspend fun createDriver(): SqlDriver { actual suspend fun createDriver(): SqlDriver {
// Load the worker script. This assumes the worker is bundled correctly by Webpack. // Load the worker script.
// We use a custom worker entry point to support OPFS if needed (as per report). // We use a simple string path instead of `new URL(..., import.meta.url)` to prevent Webpack
// For now, we point to a resource we will create. // from trying to resolve/bundle this file at build time.
val worker = Worker( // The file 'sqlite.worker.js' is copied to the root of the distribution by the Gradle build script.
js("""new URL("sqlite.worker.js", import.meta.url)""") val worker = Worker("sqlite.worker.js")
)
val driver = WebWorkerDriver(worker) val driver = WebWorkerDriver(worker)
// Initialize schema asynchronously // Initialize schema asynchronously

View File

@ -1,140 +1,297 @@
package at.mocode.ping.feature.presentation package at.mocode.ping.feature.presentation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.components.DashboardCard
import at.mocode.frontend.core.designsystem.components.DenseButton
import at.mocode.frontend.core.designsystem.theme.Dimens
// --- Refactored PingScreen using Design System ---
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PingScreen( fun PingScreen(
viewModel: PingViewModel, viewModel: PingViewModel,
onBack: () -> Unit = {} onBack: () -> Unit = {}
) { ) {
val uiState = viewModel.uiState val uiState = viewModel.uiState
val scrollState = rememberScrollState()
Scaffold( // Wir nutzen jetzt das globale Theme (Hintergrund kommt vom Theme)
topBar = {
TopAppBar(
title = { Text("Ping Service") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
}
)
}
) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .background(MaterialTheme.colorScheme.background)
.padding(16.dp) .padding(Dimens.SpacingS) // Globales Spacing
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
if (uiState.isLoading || uiState.isSyncing) { // 1. Header
CircularProgressIndicator() PingHeader(
} onBack = onBack,
isSyncing = uiState.isSyncing,
isLoading = uiState.isLoading
)
if (uiState.errorMessage != null) { Spacer(Modifier.height(Dimens.SpacingS))
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) { // 2. Main Dashboard Area (Split View)
Text( Row(modifier = Modifier.weight(1f)) {
text = "Error", // Left Panel: Controls & Status Grid (60%)
style = MaterialTheme.typography.titleMedium, Column(
color = MaterialTheme.colorScheme.error modifier = Modifier
) .weight(0.6f)
Text(text = uiState.errorMessage) .fillMaxHeight()
Button(onClick = { viewModel.clearError() }) { .padding(end = Dimens.SpacingS)
Text("Clear") ) {
ActionToolbar(viewModel)
Spacer(Modifier.height(Dimens.SpacingS))
StatusGrid(uiState)
} }
}
}
}
if (uiState.lastSyncResult != null) { // Right Panel: Terminal Log (40%)
Card(modifier = Modifier.fillMaxWidth()) { // Hier nutzen wir bewusst einen dunklen "Terminal"-Look, unabhängig vom Theme
Column(modifier = Modifier.padding(16.dp)) { DashboardCard(
Text( modifier = Modifier
text = "Sync Status", .weight(0.4f)
style = MaterialTheme.typography.titleMedium, .fillMaxHeight()
color = MaterialTheme.colorScheme.primary ) {
) LogHeader(onClear = { viewModel.clearLogs() })
Text(text = uiState.lastSyncResult) LogConsole(uiState.logs)
} }
} }
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Spacer(Modifier.height(Dimens.SpacingXS))
Button(onClick = { viewModel.performSimplePing() }) {
Text("Simple Ping")
}
Button(onClick = { viewModel.performEnhancedPing() }) {
Text("Enhanced Ping")
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { // 3. Footer
Button(onClick = { viewModel.performHealthCheck() }) { PingStatusBar(uiState.lastSyncResult)
Text("Health Check") }
} }
Button(onClick = { viewModel.performSecurePing() }) {
Text("Secure Ping") @Composable
} private fun PingHeader(
} onBack: () -> Unit,
isSyncing: Boolean,
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { isLoading: Boolean
Button(onClick = { viewModel.triggerSync() }) { ) {
Text("Sync Now") Row(
} verticalAlignment = Alignment.CenterVertically,
} modifier = Modifier.fillMaxWidth().height(40.dp)
) {
if (uiState.simplePingResponse != null) { IconButton(onClick = onBack) {
Card(modifier = Modifier.fillMaxWidth()) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onBackground)
Column(modifier = Modifier.padding(16.dp)) { }
Text("Simple / Secure Ping Response:", style = MaterialTheme.typography.titleMedium) Text(
Text("Status: ${uiState.simplePingResponse.status}") "PING SERVICE // DASHBOARD",
Text("Service: ${uiState.simplePingResponse.service}") style = MaterialTheme.typography.titleMedium,
Text("Timestamp: ${uiState.simplePingResponse.timestamp}") color = MaterialTheme.colorScheme.onBackground,
} modifier = Modifier.weight(1f).padding(start = Dimens.SpacingS)
} )
}
if (isLoading) {
if (uiState.enhancedPingResponse != null) { StatusBadge("BUSY", Color(0xFFFFA000)) // Amber
Card(modifier = Modifier.fillMaxWidth()) { Spacer(Modifier.width(Dimens.SpacingS))
Column(modifier = Modifier.padding(16.dp)) { }
Text("Enhanced Ping Response:", style = MaterialTheme.typography.titleMedium)
Text("Status: ${uiState.enhancedPingResponse.status}") if (isSyncing) {
Text("Timestamp: ${uiState.enhancedPingResponse.timestamp}") StatusBadge("SYNCING", MaterialTheme.colorScheme.primary)
Text("Circuit Breaker: ${uiState.enhancedPingResponse.circuitBreakerState}") Spacer(Modifier.width(Dimens.SpacingS))
Text("Response Time: ${uiState.enhancedPingResponse.responseTime}ms") CircularProgressIndicator(
} modifier = Modifier.size(16.dp),
} strokeWidth = 2.dp,
} color = MaterialTheme.colorScheme.primary
)
if (uiState.healthResponse != null) { } else {
Card(modifier = Modifier.fillMaxWidth()) { StatusBadge("IDLE", Color(0xFF388E3C)) // Green
Column(modifier = Modifier.padding(16.dp)) { }
Text("Health Response:", style = MaterialTheme.typography.titleMedium) }
Text("Status: ${uiState.healthResponse.status}") }
Text("Healthy: ${uiState.healthResponse.healthy}")
Text("Service: ${uiState.healthResponse.service}") @Composable
} private fun StatusBadge(text: String, color: Color) {
} Surface(
} color = color.copy(alpha = 0.1f),
contentColor = color,
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, color)
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
@Composable
private fun ActionToolbar(viewModel: PingViewModel) {
// Wrap buttons to avoid overflow on small screens
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS)
) {
DenseButton(text = "Simple", onClick = { viewModel.performSimplePing() })
DenseButton(text = "Enhanced", onClick = { viewModel.performEnhancedPing() })
DenseButton(text = "Secure", onClick = { viewModel.performSecurePing() })
DenseButton(text = "Health", onClick = { viewModel.performHealthCheck() })
DenseButton(
text = "Sync",
onClick = { viewModel.triggerSync() },
containerColor = MaterialTheme.colorScheme.secondary
)
}
}
@Composable
private fun StatusGrid(uiState: PingUiState) {
Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
// Row 1
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
DashboardCard(modifier = Modifier.weight(1f)) {
StatusHeader("SIMPLE / SECURE PING")
if (uiState.simplePingResponse != null) {
KeyValueRow("Status", uiState.simplePingResponse.status)
KeyValueRow("Service", uiState.simplePingResponse.service)
KeyValueRow("Time", uiState.simplePingResponse.timestamp)
} else {
EmptyStateText()
}
}
DashboardCard(modifier = Modifier.weight(1f)) {
StatusHeader("HEALTH CHECK")
if (uiState.healthResponse != null) {
KeyValueRow("Status", uiState.healthResponse.status)
KeyValueRow("Healthy", uiState.healthResponse.healthy.toString())
KeyValueRow("Service", uiState.healthResponse.service)
} else {
EmptyStateText()
}
}
}
// Row 2
DashboardCard(modifier = Modifier.fillMaxWidth()) {
StatusHeader("ENHANCED PING (RESILIENCE)")
if (uiState.enhancedPingResponse != null) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
KeyValueRow("Status", uiState.enhancedPingResponse.status)
KeyValueRow("Timestamp", uiState.enhancedPingResponse.timestamp)
}
Column(modifier = Modifier.weight(1f)) {
KeyValueRow("Circuit Breaker", uiState.enhancedPingResponse.circuitBreakerState)
KeyValueRow("Latency", "${uiState.enhancedPingResponse.responseTime}ms")
}
}
} else {
EmptyStateText()
}
}
}
}
@Composable
private fun StatusHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = Dimens.SpacingXS)
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
Spacer(Modifier.height(Dimens.SpacingXS))
}
@Composable
private fun EmptyStateText() {
Text("No Data", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun KeyValueRow(key: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = "$key:",
modifier = Modifier.width(100.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
// --- Log Components (Terminal Style - intentionally distinct) ---
@Composable
private fun LogHeader(onClear: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = Dimens.SpacingXS),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("EVENT LOG", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
TextButton(
onClick = onClear,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(24.dp)
) {
Text("CLEAR", style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun LogConsole(logs: List<LogEntry>) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF1E1E1E)) // Always dark for terminal
.padding(Dimens.SpacingXS),
reverseLayout = false
) {
items(logs) { log ->
val color = if (log.isError) Color(0xFFFF5555) else Color(0xFF55FF55)
Text(
text = "[${log.timestamp}] [${log.source}] ${log.message}",
color = color,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
lineHeight = 14.sp
)
}
}
}
@Composable
private fun PingStatusBar(lastSync: String?) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = lastSync ?: "Ready",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = Dimens.SpacingS, vertical = 2.dp)
)
} }
}
} }

View File

@ -11,9 +11,17 @@ import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse import at.mocode.ping.api.PingResponse
import at.mocode.ping.feature.domain.PingSyncService import at.mocode.ping.feature.domain.PingSyncService
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Clock
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
data class LogEntry(
val timestamp: String,
val source: String,
val message: String,
val isError: Boolean = false
)
data class PingUiState( data class PingUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val simplePingResponse: PingResponse? = null, val simplePingResponse: PingResponse? = null,
@ -21,7 +29,8 @@ data class PingUiState(
val healthResponse: HealthResponse? = null, val healthResponse: HealthResponse? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
val lastSyncResult: String? = null val lastSyncResult: String? = null,
val logs: List<LogEntry> = emptyList()
) )
class PingViewModel( class PingViewModel(
@ -32,98 +41,108 @@ class PingViewModel(
var uiState by mutableStateOf(PingUiState()) var uiState by mutableStateOf(PingUiState())
private set private set
private fun addLog(source: String, message: String, isError: Boolean = false) {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val timeString = "${now.hour.toString().padStart(2, '0')}:${now.minute.toString().padStart(2, '0')}:${now.second.toString().padStart(2, '0')}"
val entry = LogEntry(timeString, source, message, isError)
uiState = uiState.copy(logs = listOf(entry) + uiState.logs) // Prepend for newest first
}
fun performSimplePing() { fun performSimplePing() {
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null) uiState = uiState.copy(isLoading = true)
addLog("SimplePing", "Sending request...")
try { try {
val response = apiClient.simplePing() val response = apiClient.simplePing()
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
simplePingResponse = response simplePingResponse = response
) )
addLog("SimplePing", "Success: ${response.status} from ${response.service}")
} catch (e: Exception) { } catch (e: Exception) {
uiState = uiState.copy( uiState = uiState.copy(isLoading = false)
isLoading = false, addLog("SimplePing", "Failed: ${e.message}", isError = true)
errorMessage = "Simple ping failed: ${e.message}"
)
} }
} }
} }
fun performEnhancedPing(simulate: Boolean = false) { fun performEnhancedPing(simulate: Boolean = false) {
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null) uiState = uiState.copy(isLoading = true)
addLog("EnhancedPing", "Sending request (simulate=$simulate)...")
try { try {
val response = apiClient.enhancedPing(simulate) val response = apiClient.enhancedPing(simulate)
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
enhancedPingResponse = response enhancedPingResponse = response
) )
addLog("EnhancedPing", "Success: CB=${response.circuitBreakerState}, Time=${response.responseTime}ms")
} catch (e: Exception) { } catch (e: Exception) {
uiState = uiState.copy( uiState = uiState.copy(isLoading = false)
isLoading = false, addLog("EnhancedPing", "Failed: ${e.message}", isError = true)
errorMessage = "Enhanced ping failed: ${e.message}"
)
} }
} }
} }
fun performHealthCheck() { fun performHealthCheck() {
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null) uiState = uiState.copy(isLoading = true)
addLog("HealthCheck", "Checking system health...")
try { try {
val response = apiClient.healthCheck() val response = apiClient.healthCheck()
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
healthResponse = response healthResponse = response
) )
addLog("HealthCheck", "Status: ${response.status}, Healthy: ${response.healthy}")
} catch (e: Exception) { } catch (e: Exception) {
uiState = uiState.copy( uiState = uiState.copy(isLoading = false)
isLoading = false, addLog("HealthCheck", "Failed: ${e.message}", isError = true)
errorMessage = "Health check failed: ${e.message}"
)
} }
} }
} }
fun performSecurePing() { fun performSecurePing() {
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null) uiState = uiState.copy(isLoading = true)
addLog("SecurePing", "Sending authenticated request...")
try { try {
val response = apiClient.securePing() val response = apiClient.securePing()
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
simplePingResponse = response simplePingResponse = response
) )
addLog("SecurePing", "Success: Authorized access granted.")
} catch (e: Exception) { } catch (e: Exception) {
uiState = uiState.copy( uiState = uiState.copy(isLoading = false)
isLoading = false, addLog("SecurePing", "Access Denied/Error: ${e.message}", isError = true)
errorMessage = "Secure ping failed: ${e.message}"
)
} }
} }
} }
fun triggerSync() { fun triggerSync() {
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy(isSyncing = true, errorMessage = null) uiState = uiState.copy(isSyncing = true)
addLog("Sync", "Starting delta sync...")
try { try {
syncService.syncPings() syncService.syncPings()
// Use kotlin.time.Clock explicitly to avoid ambiguity and deprecation issues val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val now = kotlin.time.Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
uiState = uiState.copy( uiState = uiState.copy(
isSyncing = false, isSyncing = false,
lastSyncResult = "Sync successful at $now" lastSyncResult = "Sync successful at $now"
) )
addLog("Sync", "Sync completed successfully.")
} catch (e: Exception) { } catch (e: Exception) {
uiState = uiState.copy( uiState = uiState.copy(isSyncing = false)
isSyncing = false, addLog("Sync", "Sync failed: ${e.message}", isError = true)
errorMessage = "Sync failed: ${e.message}"
)
} }
} }
} }
fun clearLogs() {
uiState = uiState.copy(logs = emptyList())
}
fun clearError() { fun clearError() {
uiState = uiState.copy(errorMessage = null) uiState = uiState.copy(errorMessage = null)
} }

View File

@ -13,12 +13,14 @@ import at.mocode.frontend.core.auth.presentation.LoginViewModel
import at.mocode.ping.feature.presentation.PingScreen import at.mocode.ping.feature.presentation.PingScreen
import at.mocode.ping.feature.presentation.PingViewModel import at.mocode.ping.feature.presentation.PingViewModel
import at.mocode.frontend.core.designsystem.components.AppFooter import at.mocode.frontend.core.designsystem.components.AppFooter
import at.mocode.frontend.core.designsystem.theme.AppTheme
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun MainApp() { fun MainApp() {
MaterialTheme { // Wrap the entire app in our centralized AppTheme
AppTheme {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background

View File

@ -24,78 +24,79 @@ import org.w3c.dom.HTMLElement
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
fun main() { fun main() {
console.log("[WebApp] main() entered") console.log("[WebApp] main() entered")
// Initialize DI (Koin) with shared modules + network + local DB modules
// 1. Initialize DI (Koin) with static modules
try { try {
// Updated: Only load the consolidated pingFeatureModule from at.mocode.ping.feature.di
initKoin { modules(networkModule, localDbModule, syncModule, pingFeatureModule, authModule, navigationModule) } initKoin { modules(networkModule, localDbModule, syncModule, pingFeatureModule, authModule, navigationModule) }
console.log("[WebApp] Koin initialized with networkModule + localDbModule + authModule + navigationModule + pingFeatureModule") console.log("[WebApp] Koin initialized with static modules")
} catch (e: dynamic) { } catch (e: dynamic) {
console.warn("[WebApp] Koin initialization warning:", e) console.warn("[WebApp] Koin initialization warning:", e)
} }
// Simple smoke request using DI apiClient
try {
val client = GlobalContext.get().get<HttpClient>(named("apiClient"))
MainScope().launch {
try {
val resp: String = client.get("/api/ping/health").body()
console.log("[WebApp] /api/ping/health → ", resp)
} catch (e: dynamic) {
console.warn("[WebApp] /api/ping/health failed:", e?.message ?: e)
}
}
} catch (e: dynamic) {
console.warn("[WebApp] Unable to resolve apiClient from Koin:", e)
}
// Simple local DB smoke: create DB instance (avoid query calls to keep smoke minimal) // 2. Async Initialization Chain
try { // We must ensure DB is ready and registered in Koin BEFORE we mount the UI.
val provider = GlobalContext.get().get<DatabaseProvider>() val provider = GlobalContext.get().get<DatabaseProvider>()
MainScope().launch {
try { MainScope().launch {
val db = provider.createDatabase()
// Register the created DB instance into Koin so feature repositories can use it.
// This is the central place where we bridge the async DB creation into the DI graph.
// Inject the created DB instance into Koin.
// We register a one-off module that provides this concrete instance.
loadKoinModules(
module {
single<AppDatabase> { db }
}
)
console.log("[WebApp] Local DB created:", jsTypeOf(db))
} catch (e: dynamic) {
console.warn("[WebApp] Local DB smoke failed:", e?.message ?: e)
}
}
} catch (e: dynamic) {
console.warn("[WebApp] Unable to resolve DatabaseProvider from Koin:", e)
}
fun startApp() {
try { try {
console.log("[WebApp] startApp(): readyState=", document.asDynamic().readyState) console.log("[WebApp] Initializing Database...")
val root = document.getElementById("ComposeTarget") as HTMLElement val db = provider.createDatabase()
console.log("[WebApp] ComposeTarget exists? ", (true))
ComposeViewport(root) {
MainApp()
}
// Remove the static loading placeholder if present
(document.querySelector(".loading") as? HTMLElement)?.let { it.parentElement?.removeChild(it) }
console.log("[WebApp] ComposeViewport mounted, loading placeholder removed")
} catch (e: Exception) {
console.error("Failed to start Compose Web app", e)
val fallbackTarget = (document.getElementById("ComposeTarget") ?: document.body) as HTMLElement
fallbackTarget.innerHTML =
"<div style='padding: 50px; text-align: center;'>❌ Failed to load app: ${e.message}</div>"
}
}
// Start immediately if DOM is already parsed, otherwise wait for DOMContentLoaded. // Register the created DB instance into Koin
val state = document.asDynamic().readyState as String? loadKoinModules(
if (state == "interactive" || state == "complete") { module {
console.log("[WebApp] DOM already ready (", state, ") → starting immediately") single<AppDatabase> { db }
startApp() }
} else { )
console.log("[WebApp] Waiting for DOMContentLoaded, current state:", state) console.log("[WebApp] Local DB created and registered in Koin")
document.addEventListener("DOMContentLoaded", { startApp() })
// 3. Start App only after DB is ready
startAppWhenDomReady()
} catch (e: dynamic) {
console.error("[WebApp] CRITICAL: Database initialization failed:", e)
renderFatalError("Database initialization failed: ${e?.message ?: e}")
}
} }
} }
@OptIn(ExperimentalComposeUiApi::class)
fun startAppWhenDomReady() {
val state = document.asDynamic().readyState as String?
if (state == "interactive" || state == "complete") {
mountComposeApp()
} else {
document.addEventListener("DOMContentLoaded", { mountComposeApp() })
}
}
@OptIn(ExperimentalComposeUiApi::class)
fun mountComposeApp() {
try {
console.log("[WebApp] Mounting Compose App...")
val root = document.getElementById("ComposeTarget") as HTMLElement
ComposeViewport(root) {
MainApp()
}
// Remove loading spinner
(document.querySelector(".loading") as? HTMLElement)?.let { it.parentElement?.removeChild(it) }
console.log("[WebApp] App mounted successfully")
} catch (e: Exception) {
console.error("Failed to start Compose Web app", e)
renderFatalError("UI Mount failed: ${e.message}")
}
}
fun renderFatalError(message: String) {
val fallbackTarget = (document.getElementById("ComposeTarget") ?: document.body) as HTMLElement
fallbackTarget.innerHTML = """
<div style='padding: 50px; text-align: center; color: #D32F2F; font-family: sans-serif;'>
<h1>System Error</h1>
<p>The application could not be started.</p>
<pre style='background: #FFEBEE; padding: 10px; border-radius: 4px; text-align: left; display: inline-block;'>$message</pre>
</div>
""".trimIndent()
}