feat: verbesserte Netzwerkfähigkeit und Chat-Test integriert

- **Discovery:** Unterstützung für Multi-Interface-Broadcast und manuelle IP-Eingabe.
- **UI:** Chat-Test für Verbindungsprüfung hinzugefügt.
- **ViewModel:** Datenübertragungslogik (Ping/Pong, Chat) implementiert.
- **Workflow:** Windows-MSI-Build als separaten Job hinzugefügt.

Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
This commit is contained in:
2026-05-05 23:18:25 +02:00
parent 15222b5453
commit c317147ca4
11 changed files with 648 additions and 102 deletions
@@ -6,5 +6,5 @@ import at.mocode.frontend.features.device.initialization.presentation.DeviceInit
import org.koin.dsl.module
val deviceInitializationModule = module {
factory { DeviceInitializationViewModel(get(), { deviceName -> get { org.koin.core.parameter.parametersOf(deviceName) } }) }
factory { DeviceInitializationViewModel(get(), get()) }
}
@@ -3,33 +3,36 @@
package at.mocode.frontend.features.device.initialization.presentation
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator
import at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
@Composable
private fun DiscoveryRadar(
@@ -85,7 +88,8 @@ fun DeviceInitializationScreen(
) {
val uiState by viewModel.uiState.collectAsState()
val focusManager = LocalFocusManager.current
val (roleSelectorFocus, deviceNameFocus) = remember { FocusRequester.createRefs() }
val roleSelectorFocus = remember { FocusRequester() }
val deviceNameFocus = remember { FocusRequester() }
// Automatische Discovery starten
LaunchedEffect(Unit) {
@@ -140,7 +144,7 @@ fun DeviceInitializationScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.entries.forEach { theme ->
AppThemeSetting.entries.forEach { theme ->
val selected = uiState.settings.appTheme == theme
FilterChip(
selected = selected,
@@ -148,9 +152,9 @@ fun DeviceInitializationScreen(
label = {
Text(
when (theme) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> "System"
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> "Hell"
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> "Dunkel"
AppThemeSetting.SYSTEM -> "System"
AppThemeSetting.LIGHT -> "Hell"
AppThemeSetting.DARK -> "Dunkel"
},
style = MaterialTheme.typography.labelSmall
)
@@ -184,6 +188,38 @@ fun DeviceInitializationScreen(
val hasDiscoveries = uiState.discoveredMasters.isNotEmpty()
val selectedInterface = uiState.settings.networkInterface
// MASTER INFO CARD (Eigene IP)
if (role == NetworkRole.MASTER) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.3f
)
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f))
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(Icons.Default.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Column {
Text(
"Master-Information",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
"Dieses Gerät ist erreichbar unter: ${selectedInterface.ifBlank { "Alle Interfaces" }}",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
LaunchedEffect(selectedInterface, role) {
if (selectedInterface.isNotEmpty()) {
viewModel.startDiscovery()
@@ -209,8 +245,8 @@ fun DeviceInitializationScreen(
Spacer(modifier = Modifier.width(8.dp))
Text(
text = when {
role == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.MASTER && hasDiscoveries -> "Aktive Clients im Netzwerk gefunden"
role == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.MASTER -> "Suche nach verfügbaren Clients..."
role == NetworkRole.MASTER && hasDiscoveries -> "Aktive Clients im Netzwerk gefunden"
role == NetworkRole.MASTER -> "Suche nach verfügbaren Clients..."
hasDiscoveries -> "Master im Netzwerk gefunden"
else -> "Suche nach Master-Geräten..."
},
@@ -237,9 +273,38 @@ fun DeviceInitializationScreen(
)
// MASTER-AUSWAHL FÜR CLIENTS
if (uiState.settings.networkRole == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.CLIENT && !uiState.isLocked) {
if (uiState.settings.networkRole == NetworkRole.CLIENT && !uiState.isLocked) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("📋 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("📋 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleMedium)
TextButton(onClick = { viewModel.startDiscovery() }) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("Neu suchen")
}
}
// Manuelle IP Eingabe Fallback
OutlinedTextField(
value = uiState.manualIp,
onValueChange = { viewModel.updateManualIp(it) },
label = { Text("Master IP manuell eingeben (Fallback)") },
placeholder = { Text("z.B. 10.0.0.15") },
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
if (uiState.manualIp.isNotBlank()) {
IconButton(onClick = { viewModel.selectManualIp() }) {
Icon(Icons.Default.Add, contentDescription = "Hinzufügen")
}
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
if (uiState.discoveredMasters.isEmpty()) {
Surface(
@@ -366,11 +431,147 @@ fun DeviceInitializationScreen(
Text("Konfiguration finalisieren")
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp).size(18.dp))
}
if (uiState.connectionStatus == ConnectionStatus.CONNECTED) {
OutlinedButton(
onClick = { viewModel.openChatModal() },
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = MaterialTheme.shapes.medium
) {
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Verbindung testen (Chat & Self-Test)")
}
}
}
}
}
}
}
if (uiState.showChatModal) {
ChatTestModal(
uiState = uiState,
onDismiss = { viewModel.closeChatModal() },
onSendMessage = { viewModel.sendChatMessage(it) }
)
}
}
@Composable
fun ChatTestModal(
uiState: DeviceInitializationUiState,
onDismiss: () -> Unit,
onSendMessage: (String) -> Unit
) {
var messageText by remember { mutableStateOf("") }
val scrollState = rememberLazyListState()
LaunchedEffect(uiState.chatMessages.size) {
if (uiState.chatMessages.isNotEmpty()) {
scrollState.animateScrollToItem(uiState.chatMessages.size - 1)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.NetworkCheck, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text("Konnektivitäts-Check & Chat")
}
},
text = {
Column(modifier = Modifier.fillMaxWidth().height(400.dp)) {
Text(
"Teste hier die reale Datenübertragung. Der automatische Self-Test schickt einen Ping und wartet auf ein Pong.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
.padding(8.dp)
) {
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(uiState.chatMessages) { msg ->
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (msg.isSystem) Alignment.CenterHorizontally else if (msg.sender == "Ich") Alignment.End else Alignment.Start
) {
Surface(
color = if (msg.isSystem) MaterialTheme.colorScheme.secondaryContainer else if (msg.sender == "Ich") MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
contentColor = if (msg.isSystem) MaterialTheme.colorScheme.onSecondaryContainer else if (msg.sender == "Ich") MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
shape = MaterialTheme.shapes.small,
border = if (!msg.isSystem && msg.sender != "Ich") BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant
) else null
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
if (!msg.isSystem) {
Text(msg.sender, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
}
Text(
msg.text,
style = if (msg.isSystem) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = messageText,
onValueChange = { messageText = it },
placeholder = { Text("Nachricht senden...") },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (messageText.isNotBlank()) {
onSendMessage(messageText)
messageText = ""
}
})
)
IconButton(
onClick = {
if (messageText.isNotBlank()) {
onSendMessage(messageText)
messageText = ""
}
},
enabled = messageText.isNotBlank()
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Senden")
}
}
}
},
confirmButton = {
Button(onClick = onDismiss) {
Text("Test beenden")
}
}
)
}
@Composable
@@ -14,7 +14,17 @@ data class DeviceInitializationUiState(
val error: String? = null,
val isLocked: Boolean = false,
val showRoleChangeWarning: Boolean = false,
val pendingRole: at.mocode.frontend.features.device.initialization.domain.model.NetworkRole? = null
val pendingRole: at.mocode.frontend.features.device.initialization.domain.model.NetworkRole? = null,
val manualIp: String = "",
val showChatModal: Boolean = false,
val chatMessages: List<ChatMessage> = emptyList()
)
data class ChatMessage(
val sender: String,
val text: String,
val timestamp: Long,
val isSystem: Boolean = false
)
enum class ConnectionStatus {
@@ -5,9 +5,9 @@ package at.mocode.frontend.features.device.initialization.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.network.backup.BackupService
import at.mocode.frontend.core.network.discovery.DiscoveredService
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.core.network.sync.*
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
@@ -18,7 +18,7 @@ import kotlin.time.Duration.Companion.milliseconds
class DeviceInitializationViewModel(
private val discoveryService: NetworkDiscoveryService,
private val backupServiceProvider: (String) -> BackupService
private val syncService: P2pSyncService
) : ViewModel() {
private val _uiState = MutableStateFlow(DeviceInitializationUiState())
val uiState: StateFlow<DeviceInitializationUiState> = _uiState.asStateFlow()
@@ -46,7 +46,7 @@ class DeviceInitializationViewModel(
_uiState.update {
it.copy(
discoveredMasters = services,
connectionStatus = if (services.isEmpty() && it.settings.networkRole != NetworkRole.MASTER) {
connectionStatus = if (services.isEmpty() && it.settings.networkRole != NetworkRole.MASTER && it.manualIp.isBlank()) {
ConnectionStatus.SEARCHING
} else {
it.connectionStatus
@@ -55,6 +55,110 @@ class DeviceInitializationViewModel(
}
}
}
viewModelScope.launch {
syncService.incomingEvents.collect { event ->
handleIncomingEvent(event)
}
}
}
private fun handleIncomingEvent(event: SyncEvent) {
when (event) {
is PingEvent -> {
viewModelScope.launch {
syncService.broadcastEvent(
PongEvent(
eventId = "pong-${Clock.System.now().toEpochMilliseconds()}",
sequenceNumber = 0,
originNodeId = uiState.value.settings.deviceName,
createdAt = Clock.System.now().toEpochMilliseconds()
)
)
}
}
is PongEvent -> {
addChatMessage("System", "Handshake erfolgreich (Pong empfangen)!", isSystem = true)
}
is ChatMessageEvent -> {
addChatMessage(event.senderName, event.message)
}
else -> {}
}
}
fun updateManualIp(ip: String) {
_uiState.update { it.copy(manualIp = ip) }
}
fun selectManualIp() {
val ip = uiState.value.manualIp.trim()
if (ip.isNotBlank()) {
val service = DiscoveredService(
name = "Manuelle IP ($ip)",
host = ip,
port = 8080,
metadata = mapOf("type" to "master", "manual" to "true")
)
println("[DeviceInit] Manueller Host hinzugefügt: $ip")
selectMaster(service)
}
}
private fun addChatMessage(sender: String, text: String, isSystem: Boolean = false) {
_uiState.update {
it.copy(
chatMessages = it.chatMessages + ChatMessage(
sender = sender,
text = text,
timestamp = Clock.System.now().toEpochMilliseconds(),
isSystem = isSystem
)
)
}
}
fun sendChatMessage(message: String) {
if (message.isBlank()) return
addChatMessage("Ich", message)
viewModelScope.launch {
syncService.broadcastEvent(
ChatMessageEvent(
eventId = "chat-${Clock.System.now().toEpochMilliseconds()}",
sequenceNumber = 0,
originNodeId = uiState.value.settings.deviceName,
createdAt = Clock.System.now().toEpochMilliseconds(),
senderName = uiState.value.settings.deviceName,
message = message
)
)
}
}
fun openChatModal() {
_uiState.update { it.copy(showChatModal = true, chatMessages = emptyList()) }
startSelfTest()
}
fun closeChatModal() {
_uiState.update { it.copy(showChatModal = false) }
}
private fun startSelfTest() {
addChatMessage("System", "Starte automatischen Self-Test...", isSystem = true)
viewModelScope.launch {
syncService.broadcastEvent(
PingEvent(
eventId = "ping-${Clock.System.now().toEpochMilliseconds()}",
sequenceNumber = 0,
originNodeId = uiState.value.settings.deviceName,
createdAt = Clock.System.now().toEpochMilliseconds()
)
)
}
}
fun selectMaster(master: DiscoveredService) {
@@ -78,8 +182,15 @@ class DeviceInitializationViewModel(
if (key == "1234") { // Demo-Key
_uiState.update { it.copy(connectionStatus = ConnectionStatus.CONNECTED) }
println("[DeviceInit] Verbindung erfolgreich hergestellt!")
syncService.startServer(8080)
syncService.connectToPeer(master.host, 8080)
} else {
_uiState.update { it.copy(connectionStatus = ConnectionStatus.FAILED, error = "Sicherheitsschlüssel ungültig!") }
_uiState.update {
it.copy(
connectionStatus = ConnectionStatus.FAILED,
error = "Sicherheitsschlüssel ungültig!"
)
}
println("[DeviceInit] Verbindung fehlgeschlagen: Falscher Key.")
}
}
@@ -100,6 +211,7 @@ class DeviceInitializationViewModel(
// Falls wir ein Master sind, registrieren wir uns auch direkt, damit andere uns finden
if (uiState.value.settings.networkRole == NetworkRole.MASTER) {
discoveryService.registerService(8080, ip, uiState.value.settings.deviceName)
syncService.startServer(8080)
}
}
@@ -150,26 +262,6 @@ class DeviceInitializationViewModel(
}
}
fun testUsbBackup() {
val settings = uiState.value.settings
if (settings.backupPath.isBlank() || settings.sharedKey.isBlank()) {
println("[DeviceInit] Backup-Pfad oder Shared Key fehlt.")
return
}
viewModelScope.launch {
val service = backupServiceProvider(settings.deviceName)
val testData = "PoC Testdaten - ${settings.deviceName} - ${Clock.System.now()}"
val result = service.exportDelta(testData, settings.backupPath, settings.sharedKey)
if (result.isSuccess) {
println("[DeviceInit] USB-Backup Test erfolgreich.")
} else {
println("[DeviceInit] USB-Backup Test fehlgeschlagen: ${result.exceptionOrNull()?.message}")
}
}
}
fun completeInitialization() {
println("[DeviceInit] Konfiguration wird finalisiert...")
_uiState.update { it.copy(isLocked = true) }