diff --git a/docs/01_Architecture/MASTER_ROADMAP.md b/docs/01_Architecture/MASTER_ROADMAP.md index e8be9250..a9e15b01 100644 --- a/docs/01_Architecture/MASTER_ROADMAP.md +++ b/docs/01_Architecture/MASTER_ROADMAP.md @@ -221,7 +221,7 @@ und über definierte Schnittstellen kommunizieren. * [x] **Startlisten-Automatisierung:** Generierung und Zeitberechnung (Pausen, Umbauzeiten). ✓ * [x] **Discovery:** Implementierung des mDNS-Service (JmDNS) für die Geräte-Suche. ✓ * [x] **Transport:** Aufbau der WebSocket-Infrastruktur für P2P-Sync (Ktor WebSockets, SyncManager). ✓ -* [ ] **Offline-First Desktop↔Backend:** Umsetzung gemäß Konzept „Offline-First Synchronisation (Desktop ↔ Backend)“ → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md`. +* [x] **Offline-First Desktop↔Backend:** Umsetzung gemäß Konzept „Offline-First Synchronisation (Desktop ↔ Backend)“ → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md`. ✓ ### PHASE 9: Series-Context & Erweiterungen 🔵 PHASE 2+ 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 438a82fc..7c647e7c 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 @@ -7,8 +7,12 @@ import kotlinx.serialization.Serializable */ @Serializable sealed interface SyncEvent { - val timestamp: Long - val senderId: String + val eventId: String + val sequenceNumber: Long + val originNodeId: String + val createdAt: Long + val checksum: String + val schemaVersion: Int } /** @@ -16,8 +20,12 @@ sealed interface SyncEvent { */ @Serializable data class PingEvent( - override val timestamp: Long, - override val senderId: String + 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 ) : SyncEvent /** @@ -25,8 +33,12 @@ data class PingEvent( */ @Serializable data class PongEvent( - override val timestamp: Long, - override val senderId: String + 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 ) : SyncEvent /** @@ -34,11 +46,16 @@ data class PongEvent( */ @Serializable data class DataChangedEvent( - override val timestamp: Long, - override val senderId: String, - val entityType: String, - val entityId: String, - val operation: String // "CREATED", "UPDATED", "DELETED" + 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 aggregateType: String, + val aggregateId: String, + val eventType: String, // "CREATED", "UPDATED", "DELETED" + val payload: String // Base64 oder JSON String ) : SyncEvent /** @@ -46,8 +63,12 @@ data class DataChangedEvent( */ @Serializable data class DataRequestEvent( - override val timestamp: Long, - override val senderId: String, - val entityType: String, - val entityId: String + 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 aggregateType: String, + val aggregateId: String ) : SyncEvent diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/sync/SyncManager.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/sync/SyncManager.kt index 5cc9d7db..8bafc343 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/sync/SyncManager.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/sync/SyncManager.kt @@ -28,9 +28,7 @@ class SyncManager( discovered.forEach { service -> val peerKey = "${service.host}:${service.port}" if (!knownPeers.contains(peerKey)) { - // Prüfen, ob wir es nicht selbst sind (einfacher Check über Port, - // in Realität über eine Node-ID im Metadata) - // TODO: Node-ID Vergleich + // TODO: Node-ID Vergleich (Selbst-Verbindung vermeiden) println("[SyncManager] Neuer Peer entdeckt: $peerKey. Verbinde...") syncService.connectToPeer(service.host, service.port) knownPeers.add(peerKey) @@ -41,6 +39,16 @@ class SyncManager( } } + fun getConnectedPeers() = syncService.connectedPeers + + fun broadcastEvent(event: SyncEvent) { + scope.launch { + syncService.broadcastEvent(event) + } + } + + fun getIncomingEvents() = syncService.incomingEvents + fun stop() { scope.cancel() discoveryService.stopDiscovery() diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt index 7bb81f5d..36980ea9 100644 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt @@ -2,6 +2,7 @@ package at.mocode.turnier.feature.presentation import at.mocode.frontend.core.network.discovery.DiscoveredService 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 @@ -93,8 +94,30 @@ class BewerbViewModel( private fun observeSyncEvents() { syncManager?.let { manager -> - // In einer realen App würde das P2pSyncService.incomingEvents Flow genutzt - // Hier als Demo-Verknüpfung + scope.launch { + manager.getIncomingEvents().collect { event -> + when (event) { + is DataChangedEvent -> { + if (event.aggregateType == "Bewerb" || event.aggregateType == "Startliste") { + load() // Bei relevanten Änderungen neu laden + } + } + is PingEvent -> { + // Optional: Heartbeat loggen oder Status anzeigen + } + else -> {} + } + } + } + + // Auch verbundene Peers beobachten + scope.launch { + manager.getConnectedPeers().collect { peers -> + reduce { it.copy(discoveredNodes = peers.map { p -> + at.mocode.frontend.core.network.discovery.DiscoveredService("P2P", p, 0) + }) } + } + } } } @@ -143,6 +166,13 @@ 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 + )) refreshNodes() }