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:
2026-01-24 00:39:31 +01:00
parent f774d686a4
commit f71bfb292b
11 changed files with 1287 additions and 247 deletions
@@ -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.darkColorScheme
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.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(
primary = Color(0xFF1976D2),
onPrimary = Color.White,
primaryContainer = Color(0xFFBBDEFB),
onPrimaryContainer = Color(0xFF0D47A1),
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black,
tertiary = Color(0xFF03A9F4),
background = Color(0xFFFAFAFA),
surface = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F)
primary = Color(0xFF0052CC), // Enterprise Blue (stark)
onPrimary = Color.White,
primaryContainer = Color(0xFFDEEBFF),
onPrimaryContainer = Color(0xFF0052CC),
secondary = Color(0xFF2684FF), // Helleres Blau für Akzente
onSecondary = Color.White,
background = Color(0xFFF4F5F7), // Helles Grau (nicht hartes Weiß)
surface = Color.White,
onBackground = Color(0xFF172B4D), // Fast Schwarz (besser lesbar als #000)
onSurface = Color(0xFF172B4D),
error = Color(0xFFDE350B),
onError = Color.White
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF90CAF9),
onPrimary = Color(0xFF0D47A1),
primaryContainer = Color(0xFF1565C0),
onPrimaryContainer = Color(0xFFBBDEFB),
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black,
tertiary = Color(0xFF03A9F4),
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E),
onBackground = Color(0xFFE0E0E0),
onSurface = Color(0xFFE0E0E0)
primary = Color(0xFF4C9AFF), // Helleres Blau auf Dunkel
onPrimary = Color(0xFF091E42),
primaryContainer = Color(0xFF0052CC),
onPrimaryContainer = Color.White,
secondary = Color(0xFF2684FF),
onSecondary = Color.White,
background = Color(0xFF1E1E1E), // Dunkles Grau (angenehmer als #000)
surface = Color(0xFF2C2C2C), // Panels heben sich leicht ab
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
fun AppTheme(
darkTheme: Boolean = false, // For now, we'll default to light theme
content: @Composable () -> Unit
darkTheme: Boolean = false, // Kann später via Settings gesteuert werden
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
content = content
)
MaterialTheme(
colorScheme = colorScheme,
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 suspend fun createDriver(): SqlDriver {
// Load the worker script. This assumes the worker is bundled correctly by Webpack.
// We use a custom worker entry point to support OPFS if needed (as per report).
// For now, we point to a resource we will create.
val worker = Worker(
js("""new URL("sqlite.worker.js", import.meta.url)""")
)
// Load the worker script.
// We use a simple string path instead of `new URL(..., import.meta.url)` to prevent Webpack
// from trying to resolve/bundle this file at build time.
// The file 'sqlite.worker.js' is copied to the root of the distribution by the Gradle build script.
val worker = Worker("sqlite.worker.js")
val driver = WebWorkerDriver(worker)
// Initialize schema asynchronously
@@ -1,140 +1,297 @@
package at.mocode.ping.feature.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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.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
fun PingScreen(
viewModel: PingViewModel,
onBack: () -> Unit = {}
viewModel: PingViewModel,
onBack: () -> Unit = {}
) {
val uiState = viewModel.uiState
val scrollState = rememberScrollState()
val uiState = viewModel.uiState
Scaffold(
topBar = {
TopAppBar(
title = { Text("Ping Service") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
}
)
}
) { paddingValues ->
// Wir nutzen jetzt das globale Theme (Hintergrund kommt vom Theme)
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(Dimens.SpacingS) // Globales Spacing
) {
if (uiState.isLoading || uiState.isSyncing) {
CircularProgressIndicator()
}
// 1. Header
PingHeader(
onBack = onBack,
isSyncing = uiState.isSyncing,
isLoading = uiState.isLoading
)
if (uiState.errorMessage != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Error",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Text(text = uiState.errorMessage)
Button(onClick = { viewModel.clearError() }) {
Text("Clear")
Spacer(Modifier.height(Dimens.SpacingS))
// 2. Main Dashboard Area (Split View)
Row(modifier = Modifier.weight(1f)) {
// Left Panel: Controls & Status Grid (60%)
Column(
modifier = Modifier
.weight(0.6f)
.fillMaxHeight()
.padding(end = Dimens.SpacingS)
) {
ActionToolbar(viewModel)
Spacer(Modifier.height(Dimens.SpacingS))
StatusGrid(uiState)
}
}
}
}
if (uiState.lastSyncResult != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Sync Status",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Text(text = uiState.lastSyncResult)
}
// Right Panel: Terminal Log (40%)
// Hier nutzen wir bewusst einen dunklen "Terminal"-Look, unabhängig vom Theme
DashboardCard(
modifier = Modifier
.weight(0.4f)
.fillMaxHeight()
) {
LogHeader(onClear = { viewModel.clearLogs() })
LogConsole(uiState.logs)
}
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.performSimplePing() }) {
Text("Simple Ping")
}
Button(onClick = { viewModel.performEnhancedPing() }) {
Text("Enhanced Ping")
}
}
Spacer(Modifier.height(Dimens.SpacingXS))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.performHealthCheck() }) {
Text("Health Check")
}
Button(onClick = { viewModel.performSecurePing() }) {
Text("Secure Ping")
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.triggerSync() }) {
Text("Sync Now")
}
}
if (uiState.simplePingResponse != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Simple / Secure Ping Response:", style = MaterialTheme.typography.titleMedium)
Text("Status: ${uiState.simplePingResponse.status}")
Text("Service: ${uiState.simplePingResponse.service}")
Text("Timestamp: ${uiState.simplePingResponse.timestamp}")
}
}
}
if (uiState.enhancedPingResponse != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Enhanced Ping Response:", style = MaterialTheme.typography.titleMedium)
Text("Status: ${uiState.enhancedPingResponse.status}")
Text("Timestamp: ${uiState.enhancedPingResponse.timestamp}")
Text("Circuit Breaker: ${uiState.enhancedPingResponse.circuitBreakerState}")
Text("Response Time: ${uiState.enhancedPingResponse.responseTime}ms")
}
}
}
if (uiState.healthResponse != null) {
Card(modifier = Modifier.fillMaxWidth()) {
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}")
}
}
}
// 3. Footer
PingStatusBar(uiState.lastSyncResult)
}
}
@Composable
private fun PingHeader(
onBack: () -> Unit,
isSyncing: Boolean,
isLoading: Boolean
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().height(40.dp)
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onBackground)
}
Text(
"PING SERVICE // DASHBOARD",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f).padding(start = Dimens.SpacingS)
)
if (isLoading) {
StatusBadge("BUSY", Color(0xFFFFA000)) // Amber
Spacer(Modifier.width(Dimens.SpacingS))
}
if (isSyncing) {
StatusBadge("SYNCING", MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(Dimens.SpacingS))
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
StatusBadge("IDLE", Color(0xFF388E3C)) // Green
}
}
}
@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.feature.domain.PingSyncService
import kotlinx.coroutines.launch
import kotlin.time.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
data class LogEntry(
val timestamp: String,
val source: String,
val message: String,
val isError: Boolean = false
)
data class PingUiState(
val isLoading: Boolean = false,
val simplePingResponse: PingResponse? = null,
@@ -21,7 +29,8 @@ data class PingUiState(
val healthResponse: HealthResponse? = null,
val errorMessage: String? = null,
val isSyncing: Boolean = false,
val lastSyncResult: String? = null
val lastSyncResult: String? = null,
val logs: List<LogEntry> = emptyList()
)
class PingViewModel(
@@ -32,98 +41,108 @@ class PingViewModel(
var uiState by mutableStateOf(PingUiState())
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() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
uiState = uiState.copy(isLoading = true)
addLog("SimplePing", "Sending request...")
try {
val response = apiClient.simplePing()
uiState = uiState.copy(
isLoading = false,
simplePingResponse = response
)
addLog("SimplePing", "Success: ${response.status} from ${response.service}")
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
errorMessage = "Simple ping failed: ${e.message}"
)
uiState = uiState.copy(isLoading = false)
addLog("SimplePing", "Failed: ${e.message}", isError = true)
}
}
}
fun performEnhancedPing(simulate: Boolean = false) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
uiState = uiState.copy(isLoading = true)
addLog("EnhancedPing", "Sending request (simulate=$simulate)...")
try {
val response = apiClient.enhancedPing(simulate)
uiState = uiState.copy(
isLoading = false,
enhancedPingResponse = response
)
addLog("EnhancedPing", "Success: CB=${response.circuitBreakerState}, Time=${response.responseTime}ms")
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
errorMessage = "Enhanced ping failed: ${e.message}"
)
uiState = uiState.copy(isLoading = false)
addLog("EnhancedPing", "Failed: ${e.message}", isError = true)
}
}
}
fun performHealthCheck() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
uiState = uiState.copy(isLoading = true)
addLog("HealthCheck", "Checking system health...")
try {
val response = apiClient.healthCheck()
uiState = uiState.copy(
isLoading = false,
healthResponse = response
)
addLog("HealthCheck", "Status: ${response.status}, Healthy: ${response.healthy}")
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
errorMessage = "Health check failed: ${e.message}"
)
uiState = uiState.copy(isLoading = false)
addLog("HealthCheck", "Failed: ${e.message}", isError = true)
}
}
}
fun performSecurePing() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
uiState = uiState.copy(isLoading = true)
addLog("SecurePing", "Sending authenticated request...")
try {
val response = apiClient.securePing()
uiState = uiState.copy(
isLoading = false,
simplePingResponse = response
)
addLog("SecurePing", "Success: Authorized access granted.")
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
errorMessage = "Secure ping failed: ${e.message}"
)
uiState = uiState.copy(isLoading = false)
addLog("SecurePing", "Access Denied/Error: ${e.message}", isError = true)
}
}
}
fun triggerSync() {
viewModelScope.launch {
uiState = uiState.copy(isSyncing = true, errorMessage = null)
uiState = uiState.copy(isSyncing = true)
addLog("Sync", "Starting delta sync...")
try {
syncService.syncPings()
// Use kotlin.time.Clock explicitly to avoid ambiguity and deprecation issues
val now = kotlin.time.Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
uiState = uiState.copy(
isSyncing = false,
lastSyncResult = "Sync successful at $now"
)
addLog("Sync", "Sync completed successfully.")
} catch (e: Exception) {
uiState = uiState.copy(
isSyncing = false,
errorMessage = "Sync failed: ${e.message}"
)
uiState = uiState.copy(isSyncing = false)
addLog("Sync", "Sync failed: ${e.message}", isError = true)
}
}
}
fun clearLogs() {
uiState = uiState.copy(logs = emptyList())
}
fun clearError() {
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.PingViewModel
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.viewmodel.koinViewModel
@Composable
fun MainApp() {
MaterialTheme {
// Wrap the entire app in our centralized AppTheme
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
@@ -24,78 +24,79 @@ import org.w3c.dom.HTMLElement
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
console.log("[WebApp] main() entered")
// Initialize DI (Koin) with shared modules + network + local DB modules
// 1. Initialize DI (Koin) with static modules
try {
// Updated: Only load the consolidated pingFeatureModule from at.mocode.ping.feature.di
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) {
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)
try {
val provider = GlobalContext.get().get<DatabaseProvider>()
MainScope().launch {
try {
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() {
// 2. Async Initialization Chain
// We must ensure DB is ready and registered in Koin BEFORE we mount the UI.
val provider = GlobalContext.get().get<DatabaseProvider>()
MainScope().launch {
try {
console.log("[WebApp] startApp(): readyState=", document.asDynamic().readyState)
val root = document.getElementById("ComposeTarget") as HTMLElement
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>"
}
}
console.log("[WebApp] Initializing Database...")
val db = provider.createDatabase()
// Start immediately if DOM is already parsed, otherwise wait for DOMContentLoaded.
val state = document.asDynamic().readyState as String?
if (state == "interactive" || state == "complete") {
console.log("[WebApp] DOM already ready (", state, ") → starting immediately")
startApp()
} else {
console.log("[WebApp] Waiting for DOMContentLoaded, current state:", state)
document.addEventListener("DOMContentLoaded", { startApp() })
// Register the created DB instance into Koin
loadKoinModules(
module {
single<AppDatabase> { db }
}
)
console.log("[WebApp] Local DB created and registered in Koin")
// 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()
}