refactor(desktop, core): Onboarding zu DeviceInitialization umbenannt, Navigation und Screens angepasst

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-18 11:10:01 +02:00
parent 315517f03f
commit 7bbb991e69
24 changed files with 742 additions and 222 deletions
@@ -0,0 +1,102 @@
package at.mocode.ping.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.Dimens
/**
* Eine modulare Gruppe von Test-Buttons für die Konnektivitäts-Diagnose.
* Plug-and-Play fähig für Ping-Screen oder Sidebar.
*/
@Composable
fun PingActionGroup(
viewModel: PingViewModel,
modifier: Modifier = Modifier
) {
val uiState = viewModel.uiState
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
Text(
text = "DIAGNOSE-TESTS",
style = MaterialTheme.typography.labelSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
modifier = Modifier.padding(bottom = Dimens.SpacingXS)
)
// Grid-ähnliches Layout für die Buttons
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
PingTestButton(
text = "Simple Ping",
icon = Icons.Default.NetworkCheck,
onClick = { viewModel.performSimplePing() },
isLoading = uiState.isLoading,
modifier = Modifier.weight(1f)
)
PingTestButton(
text = "Secure Ping",
icon = Icons.Default.Lock,
onClick = { viewModel.performSecurePing() },
isLoading = uiState.isLoading,
modifier = Modifier.weight(1f)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
PingTestButton(
text = "Health Check",
icon = Icons.Default.HealthAndSafety,
onClick = { viewModel.performHealthCheck() },
isLoading = uiState.isLoading,
modifier = Modifier.weight(1f)
)
PingTestButton(
text = "Delta Sync",
icon = Icons.Default.Sync,
onClick = { viewModel.triggerSync() },
isLoading = uiState.isSyncing,
modifier = Modifier.weight(1f)
)
}
// Zusätzlicher Button für Enhanced Ping (Circuit Breaker Test)
OutlinedButton(
onClick = { viewModel.performEnhancedPing() },
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isLoading
) {
Text("Enhanced Ping (Simulation)", fontSize = 12.sp)
}
}
}
@Composable
private fun PingTestButton(
text: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
isLoading: Boolean,
modifier: Modifier = Modifier
) {
Button(
onClick = onClick,
modifier = modifier.height(48.dp),
enabled = !isLoading,
contentPadding = PaddingValues(horizontal = Dimens.SpacingS)
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(Dimens.SpacingXS))
Text(text, fontSize = 12.sp, maxLines = 1)
}
}
@@ -2,8 +2,6 @@ package at.mocode.ping.feature.presentation
import androidx.compose.foundation.background
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.*
@@ -11,21 +9,22 @@ 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.ButtonSize
import at.mocode.frontend.core.designsystem.components.MsButton
import at.mocode.frontend.core.auth.presentation.AuthStatusCard
import at.mocode.frontend.core.auth.presentation.LoginViewModel
import at.mocode.frontend.core.designsystem.components.MsCard
import at.mocode.frontend.core.designsystem.theme.Dimens
import org.koin.compose.koinInject
@Composable
fun PingScreen(
viewModel: PingViewModel,
onBack: () -> Unit = {}
onBack: () -> Unit = {},
onNavigateToLogin: () -> Unit = {}
) {
val uiState = viewModel.uiState
val authViewModel: LoginViewModel = koinInject()
// Wir nutzen jetzt das globale Theme (Hintergrund kommt vom Theme)
Column(
@@ -43,7 +42,15 @@ fun PingScreen(
Spacer(Modifier.height(Dimens.SpacingS))
// 2. Main Dashboard Area (Split View)
// 2. Auth Status Area (Plug-and-Play)
AuthStatusCard(
viewModel = authViewModel,
onLoginClick = onNavigateToLogin
)
Spacer(Modifier.height(Dimens.SpacingS))
// 3. Main Dashboard Area (Split View)
Row(modifier = Modifier.weight(1f)) {
// Left Panel: Controls & Status Grid (60%)
Column(
@@ -52,26 +59,24 @@ fun PingScreen(
.fillMaxHeight()
.padding(end = Dimens.SpacingS)
) {
ActionToolbar(viewModel)
PingActionGroup(viewModel)
Spacer(Modifier.height(Dimens.SpacingS))
StatusGrid(uiState)
}
// Right Panel: Terminal Log (40%)
// Hier nutzen wir bewusst einen dunklen "Terminal"-Look, unabhängig vom Theme
MsCard(
TerminalConsole(
logs = uiState.logs,
onClear = { viewModel.clearLogs() },
modifier = Modifier
.weight(0.4f)
.fillMaxHeight()
) {
LogHeader(onClear = { viewModel.clearLogs() })
LogConsole(uiState.logs)
}
)
}
Spacer(Modifier.height(Dimens.SpacingXS))
// 3. Footer
// 4. Footer
PingStatusBar(uiState.lastSyncResult)
}
}
@@ -90,7 +95,7 @@ private fun PingHeader(
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onBackground)
}
Text(
"PING SERVICE // DASHBOARD",
"KONNEKTIVITÄTS-DIAGNOSE // DASHBOARD",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f).padding(start = Dimens.SpacingS)
@@ -131,27 +136,6 @@ private fun StatusBadge(text: String, color: Color) {
}
}
@Composable
private fun ActionToolbar(viewModel: PingViewModel) {
// Wrap buttons to avoid overflow on small screens
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingXS)
) {
MsButton(text = "Simple", size = ButtonSize.SMALL, onClick = { viewModel.performSimplePing() })
MsButton(text = "Enhanced", size = ButtonSize.SMALL, onClick = { viewModel.performEnhancedPing() })
MsButton(text = "Secure", size = ButtonSize.SMALL, onClick = { viewModel.performSecurePing() })
MsButton(text = "Health", size = ButtonSize.SMALL, onClick = { viewModel.performHealthCheck() })
MsButton(
text = "Sync",
size = ButtonSize.SMALL,
onClick = { viewModel.triggerSync() }
)
}
}
@Composable
private fun StatusGrid(uiState: PingUiState) {
Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
@@ -237,50 +221,6 @@ private fun KeyValueRow(key: String, value: String) {
}
}
// --- 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(
@@ -0,0 +1,66 @@
package at.mocode.ping.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.theme.Dimens
/**
* Eine universelle Terminal-Konsole zur Anzeige von Log-Einträgen.
* Plug-and-Play ist fähig für verschiedene Features (Ping, Sync, Auth-Logs).
*/
@Composable
fun TerminalConsole(
logs: List<LogEntry>,
modifier: Modifier = Modifier,
onClear: () -> Unit = {}
) {
Column(modifier = modifier) {
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)
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF1E1E1E)) // Terminallook (Dunkel)
.padding(Dimens.SpacingXS)
) {
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
)
}
}
}
}
@@ -1,11 +1,13 @@
package at.mocode.turnier.feature.presentation
import at.mocode.frontend.core.network.discovery.DiscoveredService
import at.mocode.frontend.core.network.sync.DataChangedEvent
import at.mocode.frontend.core.network.sync.PingEvent
import at.mocode.frontend.core.network.sync.SyncManager
import at.mocode.frontend.core.network.sync.*
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.BewerbRepository
import at.mocode.turnier.feature.domain.StartlistenRepository
import at.mocode.turnier.feature.domain.model.StartlistenZeile
import at.mocode.zns.parser.ZnsBewerb
import at.mocode.zns.parser.ZnsBewerbParser
import at.mocode.zns.parser.ZnsNennung
@@ -17,7 +19,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import at.mocode.turnier.feature.domain.model.StartlistenZeile
typealias BewerbListItem = Bewerb
@@ -112,9 +113,11 @@ class BewerbViewModel(
load() // Bei relevanten Änderungen neu laden
}
}
is PingEvent -> {
// Optional: Heartbeat loggen oder Status anzeigen
}
else -> {}
}
}
@@ -123,9 +126,11 @@ class BewerbViewModel(
// Auch verbundene Peers beobachten
scope.launch {
manager.getConnectedPeers().collect { peers ->
reduce { it.copy(discoveredNodes = peers.map { p ->
DiscoveredService("P2P", p, 0)
}) }
reduce {
it.copy(discoveredNodes = peers.map { p ->
DiscoveredService("P2P", p, 0)
})
}
}
}
}
@@ -138,38 +143,46 @@ class BewerbViewModel(
is BewerbIntent.Select -> {
reduce { it.copy(selectedId = intent.id) }
if (intent.id != null) {
loadErgebnisse()
loadErgebnisse()
}
}
is BewerbIntent.ClearError -> reduce { it.copy(errorMessage = null) }
is BewerbIntent.OpenDialog -> {
dialogVm.send(BewerbAnlegenIntent.Open)
syncDialogState()
}
is BewerbIntent.CloseDialog -> {
dialogVm.send(BewerbAnlegenIntent.Close)
syncDialogState()
}
is BewerbIntent.SetBewerbsTyp -> {
dialogVm.send(BewerbAnlegenIntent.SetBewerbsTyp(intent.typ))
syncDialogState()
}
is BewerbIntent.SetAbteilungsTyp -> {
dialogVm.send(BewerbAnlegenIntent.SetAbteilungsTyp(intent.typ))
syncDialogState()
}
is BewerbIntent.OpenImportDialog -> _state.value = _state.value.copy(showImportDialog = true)
is BewerbIntent.CloseImportDialog -> _state.value = _state.value.copy(showImportDialog = false, importPreview = emptyList(), nennungenPreview = emptyList())
is BewerbIntent.CloseImportDialog -> _state.value =
_state.value.copy(showImportDialog = false, importPreview = emptyList(), nennungenPreview = emptyList())
is BewerbIntent.ProcessImportFile -> {
val bewerbe = intent.lines.mapNotNull { ZnsBewerbParser.parse(it) }
val nennungen = intent.lines.mapNotNull { ZnsNennungParser.parse(it) }
_state.value = _state.value.copy(importPreview = bewerbe, nennungenPreview = nennungen)
}
is BewerbIntent.ConfirmImport -> {
confirmImport()
}
is BewerbIntent.GenerateStartliste -> generateStartliste()
is BewerbIntent.CloseStartlistePreview -> reduce { it.copy(showStartlistePreview = false) }
is BewerbIntent.StartNetworkScan -> startScan()
@@ -183,38 +196,41 @@ class BewerbViewModel(
is BewerbIntent.OpenErgebnisEdit -> {
val bewerbId = state.value.selectedId?.toString() ?: ""
reduce {
it.copy(
selectedZeile = intent.zeile,
editingErgebnis = at.mocode.turnier.feature.domain.Ergebnis(
nennungId = intent.zeile.nennungId,
bewerbId = bewerbId
)
it.copy(
selectedZeile = intent.zeile,
editingErgebnis = at.mocode.turnier.feature.domain.Ergebnis(
nennungId = intent.zeile.nennungId,
bewerbId = bewerbId
)
)
}
}
is BewerbIntent.CloseErgebnisEdit -> reduce { it.copy(editingErgebnis = null, selectedZeile = null) }
is BewerbIntent.SaveErgebnis -> {
scope.launch {
ergebnisRepo.save(intent.ergebnis).onSuccess {
reduce { it.copy(editingErgebnis = null, selectedZeile = null) }
loadErgebnisse()
}
ergebnisRepo.save(intent.ergebnis).onSuccess {
reduce { it.copy(editingErgebnis = null, selectedZeile = null) }
loadErgebnisse()
}
}
}
is BewerbIntent.CalculatePlatzierung -> {
val selectedId = state.value.selectedId ?: return@send
val selectedId = state.value.selectedId ?: return
scope.launch {
ergebnisRepo.calculatePlatzierung(selectedId.toString()).onSuccess {
loadErgebnisse()
}
}
}
is BewerbIntent.ExportErgebnislistePdf -> {
val selectedId = state.value.selectedId ?: return@send
val selectedId = state.value.selectedId ?: return
scope.launch {
ergebnisRepo.exportPdf(selectedId.toString()).onSuccess { bytes ->
// In einer echten Desktop-App würde man hier einen File-Saver öffnen
// Für den MVP loggen wir nur den Erfolg.
// In einer echten Desktop-App würde man hier einen File-Saver öffnen.
// Für den MVP loggen wir nur den Erfolg ein.
println("PDF Export erfolgreich: ${bytes.size} bytes")
}
}
@@ -225,9 +241,9 @@ class BewerbViewModel(
private fun loadErgebnisse() {
val bewerbId = state.value.selectedId ?: return
scope.launch {
ergebnisRepo.getForBewerb(bewerbId.toString()).onSuccess { list ->
reduce { it.copy(ergebnisse = list) }
}
ergebnisRepo.getForBewerb(bewerbId.toString()).onSuccess { list ->
reduce { it.copy(ergebnisse = list) }
}
}
}
@@ -248,7 +264,12 @@ class BewerbViewModel(
repo.getAuditLog(id).onSuccess { log ->
_state.update { it.copy(auditLog = log, isAuditLoading = false) }
}.onFailure { t ->
_state.update { it.copy(isAuditLoading = false, errorMessage = "Audit-Log konnte nicht geladen werden: ${t.message}") }
_state.update {
it.copy(
isAuditLoading = false,
errorMessage = "Audit-Log konnte nicht geladen werden: ${t.message}"
)
}
}
}
}
@@ -256,7 +277,7 @@ class BewerbViewModel(
private fun updateZeitplan(id: Long, beginn: String?) {
scope.launch {
repo.updateZeitplan(id, null, beginn, null).onSuccess {
load() // Neu laden um Konsistenz zu prüfen
load() // Neu laden, um Konsistenz zu prüfen
}
}
}
@@ -264,13 +285,15 @@ class BewerbViewModel(
private fun startScan() {
syncManager?.start(8080)
_state.update { it.copy(isScanning = true) }
// Nach dem Start des Servers ein Ping-Event broadcasten um Präsenz zu zeigen
syncManager?.broadcastEvent(PingEvent(
eventId = turnierId.toString(),
sequenceNumber = 0,
originNodeId = "Client-${(1000..9999).random()}",
createdAt = 0 // In commonMain ohne Clock-Lib erst mal 0
))
// Nach dem Start des Servers ein ConnectivityCheck-Event Broadcasting, um Präsenz zu zeigen
syncManager?.broadcastEvent(
PingEvent(
eventId = turnierId.toString(),
sequenceNumber = 0,
originNodeId = "Client-${(1000..9999).random()}",
createdAt = 0 // In commonMain ohne Clock-Lib erst mal 0
)
)
refreshNodes()
}
@@ -318,7 +341,12 @@ class BewerbViewModel(
reduce { it.copy(showImportDialog = false, importPreview = emptyList()) }
load()
} else {
reduce { it.copy(isLoading = false, errorMessage = "Import fehlgeschlagen: ${result.exceptionOrNull()?.message}") }
reduce {
it.copy(
isLoading = false,
errorMessage = "Import fehlgeschlagen: ${result.exceptionOrNull()?.message}"
)
}
}
}
}
@@ -348,9 +376,9 @@ class BewerbViewModel(
val q = query.trim()
return list.filter {
it.name.contains(q, ignoreCase = true) ||
it.sparte.contains(q, ignoreCase = true) ||
it.klasse.contains(q, ignoreCase = true) ||
it.tag.contains(q, ignoreCase = true)
it.sparte.contains(q, ignoreCase = true) ||
it.klasse.contains(q, ignoreCase = true) ||
it.tag.contains(q, ignoreCase = true)
}
}
@@ -26,13 +26,13 @@ private val NennSelectedBg = Color(0xFFEFF6FF)
* NENNUNGEN-Tab gemäß Vision_03.
*
* Layout: 2-spaltig
* - Links (flex): Pferd+Reiter-Suche + Nennungs-Tabelle
* - Rechts (360dp): Verkauf/Buchungen + Bewerbsübersicht
* - Links (flex): Pferd+Reiter-Suche + Nennungs-Tabelle
* - Rechts (360dp): Verkauf/Buchungen + Bewerbsübersicht
*/
@Composable
fun NennungenTabContent(
viewModel: TurnierNennungViewModel,
onAbrechnungClick: () -> Unit = {}
viewModel: TurnierNennungViewModel,
onAbrechnungClick: () -> Unit = {}
) {
val state by viewModel.state.collectAsState()
@@ -55,7 +55,7 @@ fun NennungenTabContent(
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Suche + Tabelle ─────────────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
NennungenSuchePanel(viewModel, state)
NennungenSuchePanel(viewModel)
HorizontalDivider()
NennungenTabelle(viewModel, state)
}
@@ -77,7 +77,7 @@ fun NennungenTabContent(
}
@Composable
private fun NennungenSuchePanel(viewModel: TurnierNennungViewModel, state: NennungenState) {
private fun NennungenSuchePanel(viewModel: TurnierNennungViewModel) {
var pferdQuery by remember { mutableStateOf("") }
var reiterQuery by remember { mutableStateOf("") }
@@ -146,7 +146,7 @@ private fun NennungenTabelle(viewModel: TurnierNennungViewModel, state: Nennunge
Text("Keine Nennungen vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(8.dp))
Text(
"Suchen Sie nach Pferd und Reiter, um eine Nennung hinzuzufügen.",
"Suchen Sie nach Pferd und Reiter, um eine EntryManagement hinzuzufügen.",
fontSize = 12.sp,
color = Color(0xFF9CA3AF)
)
@@ -287,5 +287,3 @@ private data class NennungUiModel(
val bewerb: String,
val status: String,
)
private fun sampleNennungen(): List<NennungUiModel> = emptyList()