diff --git a/.gitea/workflows/feature-build.yml b/.gitea/workflows/feature-build.yml new file mode 100644 index 00000000..f22af702 --- /dev/null +++ b/.gitea/workflows/feature-build.yml @@ -0,0 +1,44 @@ +name: Feature Build — Windows MSI +on: + push: + branches: [ "feature/*" ] # Reagiert auf alle Feature-Branches + +jobs: + package-windows: + name: 📦 Windows .msi Packaging + # Nur ausführen, wenn Desktop-CI explizit aktiviert ist + if: ${{ vars.DESKTOP_CI_ENABLED == 'true' }} + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK 21 (Temurin) + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + .gradle + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Windows .msi bauen + env: + _JAVA_OPTIONS: -Djava.awt.headless=true + run: | + ./gradlew :frontend:shells:meldestelle-desktop:packageMsi --stacktrace --no-daemon + + - name: .msi Artefakt hochladen + uses: actions/upload-artifact@v4 + with: + name: meldestelle-windows-feature-build + path: frontend/shells/meldestelle-desktop/build/compose/binaries/main/msi/*.msi + if-no-files-found: warn diff --git a/docs/01_Architecture/adr/0027-netzwerk-discovery-interface-binding.md b/docs/01_Architecture/adr/0027-netzwerk-discovery-interface-binding.md index 4e42a027..929810ac 100644 --- a/docs/01_Architecture/adr/0027-netzwerk-discovery-interface-binding.md +++ b/docs/01_Architecture/adr/0027-netzwerk-discovery-interface-binding.md @@ -1,20 +1,33 @@ # ADR-0027: Netzwerk-Discovery & Interface-Binding ## Status -In Prüfung (Wartet auf PoC) + +Akzeptiert & Implementiert (05.05.2026) ## Kontext -Desktop-Rechner auf Turnieren sind oft mit mehreren Netzwerken gleichzeitig verbunden (z.B. LAN für das Turnier-Netzwerk, WLAN für Internet-Hotspot). Automatische Discovery-Dienste (JmDNS) wählen ohne explizite Konfiguration oft das falsche Interface, wodurch sich Clients und Master nicht finden. + +Desktop-Rechner auf Turnieren sind oft mit mehreren Netzwerken gleichzeitig verbunden (z.B. LAN für das +Turnier-Netzwerk, WLAN für Internet-Hotspot). Automatische Discovery-Dienste (mDNS) wählen ohne explizite Konfiguration +oft ein Interface, das für die anderen Teilnehmer nicht erreichbar ist (Bridging-Probleme zwischen LAN und WLAN). Zudem +blockieren einige Router Multicast-Pakete zwischen WLAN und LAN-Segmenten. ## Entscheidung -Wir führen ein explizites Netzwerk-Management für die Initialisierung ein. -1. **Interface-Selektion:** Der Benutzer muss bei der technischen Initialisierung explizit wählen, über welches Netzwerk-Interface (IP-Adresse/Adapter) die App kommunizieren soll. Die UI zeigt hierfür benutzerfreundliche Namen (WLAN, Ethernet) an. -2. **Geführte Discovery:** Sobald ein Interface gewählt ist, startet ein "Radar-Modus". Dieser scannt aktiv via JmDNS nach vorhandenen Master-Geräten. -3. **Adaptive Rolle:** Findet die Discovery einen Master, wird dem Benutzer die Rolle "Client" vorgeschlagen. Die UI bleibt jedoch flexibel für manuelle Rollenwechsel. -4. **Fokus-Management:** Nach Auswahl der Rolle wird der Fokus automatisch in das erste relevante Eingabefeld (Gerätename) gesetzt, um einen reibungslosen Workflow zu ermöglichen. +Wir führen ein robustes, mehrstufiges Netzwerk-Management für die Initialisierung ein: + +1. **Multi-Interface Broadcast:** Der Master registriert seinen Dienst proaktiv auf **allen** verfügbaren + Netzwerk-Interfaces (IPv4). Dies erhöht die Chance massiv, dass Clients in verschiedenen Segmenten (WLAN/LAN) den + Master finden. +2. **Interface-Selektion:** Der Benutzer kann weiterhin ein bevorzugtes Interface wählen. Die Master-Info-Card zeigt die + Erreichbarkeit transparent an. +3. **Manueller IP-Fallback:** Wenn mDNS fehlschlägt, kann die IP des Masters manuell eingegeben werden. Dies ist der " + Ultima-Ratio"-Weg für restriktive Netzwerke. +4. **Konnektivitäts-Check (Chat & Self-Test):** Nach dem Handshake wird ein Modal geöffnet, das einen automatischen + Ping-Pong Test durchführt und einen Test-Chat bietet. Dies verifiziert die reale Datenübertragung (Serialisierung, + WebSockets) noch vor Abschluss des Setups. ## Konsequenzen -- Verhindert "Geistersuchen" im falschen Netzwerk. -- Erhöht die Benutzerfreundlichkeit durch automatische Vorschläge. -- Erfordert Zugriff auf System-Netzwerk-APIs in der Desktop-Shell. + +- Deutlich höhere Stabilität in heterogenen Netzwerkumgebungen. +- Transparenteres Feedback für den Anwender bei Verbindungsproblemen. +- Der Chat dient als "Connectivity-Proof" für das Support-Personal vor Ort. diff --git a/docs/99_Journal/2026-05-05_Build_Packaging_Notes.md b/docs/99_Journal/2026-05-05_Build_Packaging_Notes.md new file mode 100644 index 00000000..c486f476 --- /dev/null +++ b/docs/99_Journal/2026-05-05_Build_Packaging_Notes.md @@ -0,0 +1,20 @@ +### Journal: 05.05.2026 - Build & Packaging Issues + +**Status:** Verifiziert + +**Problem:** +Der User versuchte einen Windows-Installer (.msi) auf einem Linux-System zu bauen. Gradle meldete 'BUILD SUCCESSFUL', +aber es wurde kein Artefakt erzeugt. + +**Ursache:** +Das Compose Multiplatform Plugin kann native Installer (MSI, DMG, DEB) nur auf dem jeweiligen Ziel-Betriebssystem bauen. +Auf Linux wird der Task zwar angeboten, führt aber zu keinem Ergebnis, da die nativen Windows-Tools (WiX Toolset) +fehlen. + +**Lösung/Workaround:** + +1. Für Linux wurde erfolgreich ein Paket erzeugt: . +2. Für Windows (.msi) muss der Build zwingend auf einer Windows-Maschine ausgeführt werden. +3. Empfehlung: Einrichtung einer Cross-Platform CI/CD Pipeline. + +**Badge:** 🏗️ [Lead Architect] & 🐧 [DevOps Engineer] diff --git a/docs/99_Journal/2026-05-05_Connectivity_Fix_Chat_Modal.md b/docs/99_Journal/2026-05-05_Connectivity_Fix_Chat_Modal.md new file mode 100644 index 00000000..d6f31ff4 --- /dev/null +++ b/docs/99_Journal/2026-05-05_Connectivity_Fix_Chat_Modal.md @@ -0,0 +1,42 @@ +# Journal-Eintrag: 05.05.2026 - Connectivity-Fix & Code-Qualität + +## Kontext + +Nach einem fehlgeschlagenen Hardware-Test am 30.04.2026 wurde die Netzwerk-Konnektivität zwischen LAN und WLAN als +kritische Schwachstelle identifiziert. Zudem gab es erhebliche Mängel in der Code-Qualität (JVM-Leaks in KMP, +Syntax-Fehler). + +## Durchgeführte Arbeiten + +### 1. 🧹 Code-Sanierung (Clean Code & KMP) + +- **ViewModel Fix:** Sämtliche `java.*` und `System.*` Referenzen aus `commonMain` entfernt. +- **Zeitstempel:** Nutzung der idiomatischen `kotlin.time.Clock` (Kotlin 2.3.20) statt `System.currentTimeMillis()`. +- **Compose UI:** Behebung von Syntax-Fehlern in `DeviceInitializationScreen.kt` (LazyColumn Iteration und Imports). +- **Typsicherheit:** Explizite Typisierung in UI-Komponenten zur Vermeidung von Destrukturierungsfehlern. + +### 2. 📡 Netzwerk-Stabilität + +- **Multi-Interface Discovery:** Der `JmDnsDiscoveryService` registriert Dienste nun auf allen verfügbaren + IPv4-Interfaces gleichzeitig. Dies löst das Problem, dass Master-Geräte in LAN/WLAN-Mischumgebungen nicht gefunden + werden. +- **Manueller Fallback:** Einführung eines IP-Eingabefelds im Setup-Wizard für den Fall, dass mDNS durch Router + blockiert wird. +- **Master-Info-Card:** Anzeige der eigenen IP-Adresse auf dem Host-Gerät zur Erleichterung der manuellen Verbindung. + +### 3. 💬 Interaktiver Connectivity-Check + +- **Chat-Modal:** Implementierung eines Pop-ups nach dem Handshake. +- **Self-Test:** Automatischer Ping-Pong Test beim Öffnen des Modals zur Verifizierung der WebSocket-Verbindung. +- **Test-Chat:** Ermöglicht den manuellen Austausch von Nachrichten als definitiven Beweis für eine stabile + Datenverbindung. + +## Status: Verifiziert & Bereit für Hardware-Test + +Alle identifizierten Kompilierungsfehler (einschließlich Koin-Modul Typkonflikte) wurden behoben. Der Code folgt den +KMP-Standards für Kotlin 2.3.20. Die Architektur entspricht nun ADR-0027. + +**🏗️ [Lead Architect]** +**👷 [Backend Developer]** +**🎨 [Frontend Expert]** +**🧹 [Curator]** diff --git a/docs/99_Journal/2026-05-05_Curator_Fruehjahrsputz_Docs.md b/docs/99_Journal/2026-05-05_Curator_Fruehjahrsputz_Docs.md new file mode 100644 index 00000000..f1333ea1 --- /dev/null +++ b/docs/99_Journal/2026-05-05_Curator_Fruehjahrsputz_Docs.md @@ -0,0 +1,54 @@ +--- +type: Journal +status: COMPLETED +owner: Curator +date: 2026-05-05 +--- + +# 🧹 [Curator] Journal: Frühjahrsputz in der Dokumentation + +**Datum:** 5. Mai 2026 + +## 🎯 Zielsetzung + +Bereinigung der Dokumentationsstruktur (`docs/`), Archivierung veralteter Chat-Verläufe und Berichte, sowie +Konsolidierung von Assets und Event-Daten zur Wiederherstellung der Übersichtlichkeit. + +## 🛠️ Durchgeführte Maßnahmen + +### 1. Radikale Archivierung + +- **`docs/temp/`**: Alle Dateien (viele veraltete Blueprints und Chat-Logs) wurden nach `docs/_archive/temp_2026-05-05/` + verschoben. +- **`docs/90_Reports/`**: Berichte, die älter als April 2026 sind, wurden in den Unterordner `_archive/` verschoben. +- **`docs/99_Journal/`**: Das Journal war mit über 100 Einträgen überladen. Alle Einträge aus dem April 2026 (und + früher) wurden nach `docs/99_Journal/_archive/` verschoben. Das Hauptverzeichnis ist nun bereit für die Einträge im + Mai. + +### 2. Strukturierung & Konsolidierung + +- **Screenshots**: Das lose Verzeichnis `docs/ScreenShots/` wurde nach `docs/80_Assets/Screenshots/` verschoben, um es + in die Asset-Hierarchie einzugliedern. +- **Event-Daten**: Spezifische Turnierdaten (`Neumarkt2026`, `St-Poetlen-Hart-2026`) wurden unter + `docs/03_Domain/Events/` gruppiert. +- **Architektur-Bereinigung**: Das Verzeichnis `docs/01_Architecture/` wurde restrukturiert. Veraltete Roadmaps wurden + archiviert, und aktive Dokumente wurden in Unterkategorien (`Concepts/`, `Specifications/`, `Roadmaps/`, + `Checklists/`) sortiert. +- **Redundanz-Eliminierung**: Veraltete/redundante Ordner (`docs/Bin/`, `docs/BilderSuDo/`) und das fälschlich angelegte + Verzeichnis `mocode/` wurden entfernt. Ein verirrtes Journal-Fragment wurde ins Archiv überführt. + +### 3. Integritäts-Check + +- Die `MASTER_ROADMAP.md` wurde als zentrales Steuerungsdokument verifiziert und alle internen Links auf die neue + Struktur angepasst. +- Die Agenten-Richtlinien in `AGENTS.md` bleiben die Basis für alle weiteren Sessions. + +## 📊 Status Quo + +- **Dokumentations-Health:** ✅ GRÜN (Aufgeräumt) +- **Archiv-Integrität:** ✅ Vorhanden +- **Nächste Schritte:** Fokus zurück auf die fachliche Implementierung der Turnier-Hierarchie (Meilenstein 1 der + Roadmap). + +--- +*Unterzeichnet vom Curator am 05.05.2026* diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/sync/SyncEvent.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/sync/SyncEvent.kt index 7c647e7c..d0b99ef5 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/sync/SyncEvent.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/sync/SyncEvent.kt @@ -72,3 +72,18 @@ data class DataRequestEvent( val aggregateType: String, val aggregateId: String ) : SyncEvent + +/** + * Chat-Event für den Connectivity-Test und einfache Kommunikation. + */ +@Serializable +data class ChatMessageEvent( + override val eventId: String, + override val sequenceNumber: Long, + override val originNodeId: String, + override val createdAt: Long, + override val checksum: String = "", + override val schemaVersion: Int = 1, + val senderName: String, + val message: String +) : SyncEvent diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt index 497387b3..108c19d4 100644 --- a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.net.InetAddress +import java.net.NetworkInterface import java.util.concurrent.ConcurrentHashMap import javax.jmdns.JmDNS import javax.jmdns.ServiceEvent @@ -15,7 +16,7 @@ import javax.jmdns.ServiceListener */ class JmDnsDiscoveryService : NetworkDiscoveryService { - private var jmdns: JmDNS? = null + private val jmdnsInstances = mutableListOf() private val SERVICE_TYPE = "_meldestelle._tcp.local." private val discoveredServicesMap = ConcurrentHashMap() @@ -23,69 +24,123 @@ class JmDnsDiscoveryService : NetworkDiscoveryService { override val discoveredServices: StateFlow> = _discoveredServices.asStateFlow() override fun startDiscovery(preferredIp: String?) { - if (jmdns == null) { - val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost() - println("[Discovery] Starte Discovery gebunden an: $addr") - jmdns = JmDNS.create(addr) + if (jmdnsInstances.isNotEmpty()) return + + val addresses = getRelevantAddresses(preferredIp) + if (addresses.isEmpty()) { + println("[Discovery] Keine validen Netzwerk-Interfaces gefunden für Discovery.") + return } - jmdns?.addServiceListener(SERVICE_TYPE, object : ServiceListener { - override fun serviceAdded(event: ServiceEvent) { - // Bei ServiceAdded fordern wir die Details an - jmdns?.requestServiceInfo(event.type, event.name) - } + addresses.forEach { addr -> + try { + println("[Discovery] Starte Discovery gebunden an: $addr") + val jmdns = JmDNS.create(addr) + jmdnsInstances.add(jmdns) - override fun serviceRemoved(event: ServiceEvent) { - discoveredServicesMap.remove(event.name) - _discoveredServices.value = discoveredServicesMap.values.toList() - println("[Discovery] Service entfernt: ${event.name}") - } + jmdns.addServiceListener(SERVICE_TYPE, object : ServiceListener { + override fun serviceAdded(event: ServiceEvent) { + jmdns.requestServiceInfo(event.type, event.name) + } - override fun serviceResolved(event: ServiceEvent) { - val info = event.info - val service = DiscoveredService( - name = event.name, - host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown", - port = info.port, - metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) } - ) - discoveredServicesMap[event.name] = service - _discoveredServices.value = discoveredServicesMap.values.toList() - println("[Discovery] Service gefunden: ${service.name} @ ${service.host}:${service.port}") + override fun serviceRemoved(event: ServiceEvent) { + discoveredServicesMap.remove(event.name) + _discoveredServices.value = discoveredServicesMap.values.toList() + println("[Discovery] Service entfernt: ${event.name}") + } + + override fun serviceResolved(event: ServiceEvent) { + val info = event.info + val service = DiscoveredService( + name = event.name, + host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown", + port = info.port, + metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) } + ) + discoveredServicesMap[event.name] = service + _discoveredServices.value = discoveredServicesMap.values.toList() + println("[Discovery] Service gefunden: ${service.name} @ ${service.host}:${service.port}") + } + }) + } catch (e: Exception) { + println("[Discovery] Fehler beim Starten von JmDNS auf $addr: ${e.message}") } - }) + } } override fun stopDiscovery() { - jmdns?.close() - jmdns = null + jmdnsInstances.forEach { it.close() } + jmdnsInstances.clear() discoveredServicesMap.clear() _discoveredServices.value = emptyList() } override fun registerService(port: Int, preferredIp: String?, deviceName: String?) { - if (jmdns == null) { - val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost() - println("[Discovery] Registriere Dienst gebunden an: $addr") - jmdns = JmDNS.create(addr) + if (jmdnsInstances.isEmpty()) { + val addresses = getRelevantAddresses(preferredIp) + addresses.forEach { addr -> + try { + println("[Discovery] Erstelle JmDNS Instanz für Registrierung auf: $addr") + jmdnsInstances.add(JmDNS.create(addr)) + } catch (e: Exception) { + println("[Discovery] Fehler beim Erstellen von JmDNS auf $addr: ${e.message}") + } + } } - // Wir nutzen den übergebenen Namen, den vom System gesetzten oder einen sprechenden Default - val name = deviceName ?: System.getProperty("meldestelle.device.name") ?: "Meldestelle-${System.getProperty("user.name")}" + val name = deviceName ?: try { + java.net.InetAddress.getLocalHost().hostName + } catch (e: Exception) { + "Meldestelle-Device" + } + "-${System.getProperty("user.name", "unknown")}" - val serviceInfo = ServiceInfo.create( - SERVICE_TYPE, - name, - port, - 0, 0, // weight, priority - mapOf( - "version" to "1.0.0", - "type" to "master", - "nodeId" to name + jmdnsInstances.forEach { jmdns -> + val serviceInfo = ServiceInfo.create( + SERVICE_TYPE, + name, + port, + 0, 0, // weight, priority + mapOf( + "version" to "1.0.0", + "type" to "master", + "nodeId" to name + ) ) - ) - jmdns?.registerService(serviceInfo) - println("[Discovery] Eigenen Dienst '$name' registriert auf Port $port") + try { + jmdns.registerService(serviceInfo) + println("[Discovery] Dienst '$name' auf ${jmdns.inetAddress} registriert (Port $port)") + } catch (e: Exception) { + println("[Discovery] Fehler bei Registrierung auf ${jmdns.inetAddress}: ${e.message}") + } + } + } + + private fun getRelevantAddresses(preferredIp: String?): List { + if (preferredIp != null) { + return listOf(InetAddress.getByName(preferredIp)) + } + + val addresses = mutableListOf() + try { + val interfaces = NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val iface = interfaces.nextElement() + if (iface.isLoopback || !iface.isUp || iface.isVirtual) continue + + val inetAddresses = iface.inetAddresses + while (inetAddresses.hasMoreElements()) { + val addr = inetAddresses.nextElement() + // Nur IPv4 für maximale Kompatibilität in lokalen Netzen (ÖTO/FEI Standardumgebungen) + if (addr is java.net.Inet4Address) { + addresses.add(addr) + } + } + } + } catch (e: Exception) { + println("[Discovery] Fehler beim Auflisten der Interfaces: ${e.message}") + } + + return if (addresses.isEmpty()) listOf(InetAddress.getLocalHost()) else addresses } override fun getDiscoveredServices(): List { diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/di/DeviceInitializationModule.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/di/DeviceInitializationModule.kt index 8fe59ae5..00602e2e 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/di/DeviceInitializationModule.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/di/DeviceInitializationModule.kt @@ -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()) } } diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt index ec049e49..022d0fc3 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt @@ -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 diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt index 23705f0d..8f9fbff8 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt @@ -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 = emptyList() +) + +data class ChatMessage( + val sender: String, + val text: String, + val timestamp: Long, + val isSystem: Boolean = false ) enum class ConnectionStatus { diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt index 0bfd3f71..13006143 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt @@ -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 = _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) }