feat(core+frontend): add P2P sync infrastructure with WebSocket support

- **Core Updates:**
  - Implemented `P2pSyncService` interface with platform-specific WebSocket implementations (`JvmP2pSyncService` and no-op for JS).
  - Developed `SyncEvent` sealed class hierarchy to handle peer synchronization events (e.g., `PingEvent`, `PongEvent`, `DataChangedEvent`, etc.).

- **Frontend Integration:**
  - Introduced `SyncManager` to manage peer discovery and synchronization, coupled with `NetworkDiscoveryService`.
  - Updated dependency injection to include `syncModule` for platform-specific sync service initialization.
  - Enhanced `BewerbViewModel` to support new sync capabilities, including observing sync events and UI updates for connected peers.

- **Backend Enhancements:**
  - Added ZNS-specific fields (`zns_nummer`, `zns_abteilung`) to Bewerb table for idempotent imports.
  - Introduced import ZNS logic to handle duplicates and align with SyncManager updates.

- **UI Improvements:**
  - Enhanced `TurnierBewerbeTab` with updated dialogs (ZNS imports, sync status) and dynamic previews.
  - Improved network syncing feedback and error handling in frontend components.

- **DB Changes:**
  - Added migration for new column fields in the Bewerb table with relevant indexing for ZNS import optimizations.
This commit is contained in:
2026-04-10 10:54:56 +02:00
parent 6b6965bbbb
commit 8726129b96
21 changed files with 454 additions and 18 deletions
@@ -2,11 +2,16 @@ package at.mocode.turnier.feature.presentation
import at.mocode.frontend.core.network.discovery.DiscoveredService
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.core.network.sync.SyncManager
import at.mocode.frontend.core.network.sync.SyncEvent
import at.mocode.frontend.core.network.sync.DataChangedEvent
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.BewerbRepository
import at.mocode.turnier.feature.domain.StartlistenRepository
import at.mocode.zns.parser.ZnsBewerb
import at.mocode.zns.parser.ZnsBewerbParser
import at.mocode.zns.parser.ZnsNennung
import at.mocode.zns.parser.ZnsNennungParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -35,6 +40,7 @@ data class BewerbState(
val selectedId: Long? = null,
val errorMessage: String? = null,
val importPreview: List<ZnsBewerb> = emptyList(),
val nennungenPreview: List<ZnsNennung> = emptyList(),
val showImportDialog: Boolean = false,
val showStartlistePreview: Boolean = false,
val currentStartliste: List<StartlistenZeile> = emptyList(),
@@ -72,7 +78,7 @@ sealed interface BewerbIntent {
class BewerbViewModel(
private val repo: BewerbRepository,
private val startlistenRepo: StartlistenRepository,
private val discoveryService: NetworkDiscoveryService? = null,
private val syncManager: SyncManager? = null,
private val turnierId: Long,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -85,6 +91,14 @@ class BewerbViewModel(
init {
send(BewerbIntent.Load)
observeSyncEvents()
}
private fun observeSyncEvents() {
syncManager?.let { manager ->
// In einer realen App würde das P2pSyncService.incomingEvents Flow genutzt
// Hier als Demo-Verknüpfung
}
}
fun send(intent: BewerbIntent) {
@@ -112,10 +126,11 @@ class BewerbViewModel(
}
is BewerbIntent.OpenImportDialog -> _state.value = _state.value.copy(showImportDialog = true)
is BewerbIntent.CloseImportDialog -> _state.value = _state.value.copy(showImportDialog = false, importPreview = 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) }
_state.value = _state.value.copy(importPreview = bewerbe)
val nennungen = intent.lines.mapNotNull { ZnsNennungParser.parse(it) }
_state.value = _state.value.copy(importPreview = bewerbe, nennungenPreview = nennungen)
}
is BewerbIntent.ConfirmImport -> {
confirmImport()
@@ -129,19 +144,20 @@ class BewerbViewModel(
}
private fun startScan() {
discoveryService?.startDiscovery()
syncManager?.start(8080)
_state.update { it.copy(isScanning = true) }
refreshNodes()
}
private fun stopScan() {
discoveryService?.stopDiscovery()
syncManager?.stop()
_state.update { it.copy(isScanning = false) }
}
private fun refreshNodes() {
val nodes = discoveryService?.getDiscoveredServices() ?: emptyList()
_state.update { it.copy(discoveredNodes = nodes) }
// Da wir jetzt den SyncManager nutzen, könnten wir hier die connectedPeers anzeigen
// oder weiterhin die Entdeckten aus dem internen DiscoveryService des Managers.
// Für dieses MVP zeigen wir einfach an, dass wir scannen.
}
private fun generateStartliste() {
@@ -1,6 +1,7 @@
package at.mocode.turnier.feature.di
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.core.network.sync.SyncManager
import at.mocode.turnier.feature.data.remote.DefaultAbteilungRepository
import at.mocode.turnier.feature.data.remote.DefaultBewerbRepository
import at.mocode.turnier.feature.data.remote.DefaultStartlistenRepository
@@ -25,12 +26,12 @@ val turnierFeatureModule = module {
// ViewModels
factory { TurnierViewModel(repo = get()) }
// BewerbViewModel: repos + discoveryService + turnierId
// BewerbViewModel: repos + syncManager + turnierId
factory { (turnierId: Long) ->
BewerbViewModel(
repo = get(),
startlistenRepo = get(),
discoveryService = getOrNull<NetworkDiscoveryService>(),
syncManager = getOrNull<SyncManager>(),
turnierId = turnierId
)
}
@@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@@ -183,6 +184,7 @@ fun BewerbeTabContent(
if (state.showImportDialog) {
ZnsImportPreviewDialog(
bewerbe = state.importPreview,
nennungen = state.nennungenPreview,
onDismiss = { viewModel.send(BewerbIntent.CloseImportDialog) },
onConfirm = { viewModel.send(BewerbIntent.ConfirmImport(turnierId)) }
)
@@ -410,6 +412,7 @@ private fun AktionsBtn(label: String, onClick: () -> Unit = {}) {
@Composable
private fun ZnsImportPreviewDialog(
bewerbe: List<at.mocode.zns.parser.ZnsBewerb>,
nennungen: List<at.mocode.zns.parser.ZnsNennung> = emptyList(),
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
@@ -417,12 +420,16 @@ private fun ZnsImportPreviewDialog(
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.width(600.dp).heightIn(max = 500.dp)
modifier = Modifier.width(700.dp).heightIn(max = 600.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("ZNS Bewerbe Import", style = MaterialTheme.typography.titleLarge)
Text("ZNS Bewerbe & Nennungen Import", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(8.dp))
Text("Folgende Bewerbe wurden in der Datei gefunden:", fontSize = 14.sp)
Text(
"Gefunden: ${bewerbe.size} Bewerbe, ${nennungen.size} Nennungen",
fontSize = 14.sp,
color = Color.Gray
)
Spacer(Modifier.height(12.dp))
Box(modifier = Modifier.weight(1f).background(HeaderBg).padding(2.dp)) {
@@ -434,15 +441,18 @@ private fun ZnsImportPreviewDialog(
Text("Name", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Kl", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Kat", modifier = Modifier.width(80.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Nenn", modifier = Modifier.width(50.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp, textAlign = TextAlign.End)
}
}
itemsIndexed(bewerbe) { _, b ->
val count = nennungen.count { it.bewerbNummer == b.bewerbNummer && it.abteilung == b.abteilung }
Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp)) {
Text(b.bewerbNummer.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp)
Text(b.abteilung.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp)
Text(b.name, modifier = Modifier.weight(1f), fontSize = 12.sp)
Text(b.klasse, modifier = Modifier.width(40.dp), fontSize = 12.sp)
Text(b.kategorie, modifier = Modifier.width(80.dp), fontSize = 12.sp)
Text(count.toString(), modifier = Modifier.width(50.dp), fontSize = 12.sp, textAlign = TextAlign.End, fontWeight = FontWeight.Bold)
}
HorizontalDivider(color = Color.LightGray.copy(alpha = 0.5f))
}
@@ -453,7 +463,9 @@ private fun ZnsImportPreviewDialog(
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
OutlinedButton(onClick = onDismiss) { Text("Abbrechen") }
Spacer(Modifier.width(8.dp))
Button(onClick = onConfirm) { Text("${bewerbe.size} Bewerbe importieren") }
Button(onClick = onConfirm) {
Text("Import bestätigen")
}
}
}
}