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:
+44
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+43
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
-31
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+27
@@ -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
-6
@@ -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
|
||||
|
||||
+273
-116
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
-28
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user