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:
parent
f774d686a4
commit
f71bfb292b
524
docs/02_Guides/SQLDelight_Integration_Compose_Multiplatform.md
Normal file
524
docs/02_Guides/SQLDelight_Integration_Compose_Multiplatform.md
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### That’s 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.
|
||||||
167
docs/02_Guides/SQLDelight_Web_Asynchron.md
Normal file
167
docs/02_Guides/SQLDelight_Web_Asynchron.md
Normal 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.
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user