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:
+3
@@ -36,6 +36,9 @@ data class Bewerb(
|
|||||||
// Finanzen
|
// Finanzen
|
||||||
val startgeldCent: Long? = null,
|
val startgeldCent: Long? = null,
|
||||||
val geldpreisAusbezahlt: Boolean = false,
|
val geldpreisAusbezahlt: Boolean = false,
|
||||||
|
// ZNS-Integration
|
||||||
|
val znsNummer: Int? = null,
|
||||||
|
val znsAbteilung: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface BewerbRepository {
|
interface BewerbRepository {
|
||||||
|
|||||||
+10
-1
@@ -72,7 +72,10 @@ class BewerbRepositoryImpl : BewerbRepository {
|
|||||||
stechenGeplant = row[BewerbTable.stechenGeplant],
|
stechenGeplant = row[BewerbTable.stechenGeplant],
|
||||||
// Finanzen
|
// Finanzen
|
||||||
startgeldCent = row[BewerbTable.startgeldCent],
|
startgeldCent = row[BewerbTable.startgeldCent],
|
||||||
geldpreisAusbezahlt = row[BewerbTable.geldpreisAusbezahlt]
|
geldpreisAusbezahlt = row[BewerbTable.geldpreisAusbezahlt],
|
||||||
|
// ZNS-Integration
|
||||||
|
znsNummer = row[BewerbTable.znsNummer],
|
||||||
|
znsAbteilung = row[BewerbTable.znsAbteilung]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +107,9 @@ class BewerbRepositoryImpl : BewerbRepository {
|
|||||||
// Finanzen
|
// Finanzen
|
||||||
s[BewerbTable.startgeldCent] = b.startgeldCent
|
s[BewerbTable.startgeldCent] = b.startgeldCent
|
||||||
s[BewerbTable.geldpreisAusbezahlt] = b.geldpreisAusbezahlt
|
s[BewerbTable.geldpreisAusbezahlt] = b.geldpreisAusbezahlt
|
||||||
|
// ZNS-Integration
|
||||||
|
s[BewerbTable.znsNummer] = b.znsNummer
|
||||||
|
s[BewerbTable.znsAbteilung] = b.znsAbteilung
|
||||||
s[BewerbTable.createdAt] = now
|
s[BewerbTable.createdAt] = now
|
||||||
s[BewerbTable.updatedAt] = now
|
s[BewerbTable.updatedAt] = now
|
||||||
}
|
}
|
||||||
@@ -148,6 +154,9 @@ class BewerbRepositoryImpl : BewerbRepository {
|
|||||||
// Finanzen
|
// Finanzen
|
||||||
s[BewerbTable.startgeldCent] = b.startgeldCent
|
s[BewerbTable.startgeldCent] = b.startgeldCent
|
||||||
s[BewerbTable.geldpreisAusbezahlt] = b.geldpreisAusbezahlt
|
s[BewerbTable.geldpreisAusbezahlt] = b.geldpreisAusbezahlt
|
||||||
|
// ZNS-Integration
|
||||||
|
s[BewerbTable.znsNummer] = b.znsNummer
|
||||||
|
s[BewerbTable.znsAbteilung] = b.znsAbteilung
|
||||||
s[BewerbTable.updatedAt] = now
|
s[BewerbTable.updatedAt] = now
|
||||||
}
|
}
|
||||||
persistRichterEinsaetze(b.id, b.richterEinsaetze)
|
persistRichterEinsaetze(b.id, b.richterEinsaetze)
|
||||||
|
|||||||
+44
@@ -57,6 +57,50 @@ class BewerbService(
|
|||||||
|
|
||||||
suspend fun list(turnierId: Uuid, klasse: String?, q: String?): List<Bewerb> = repo.findByTurnierId(turnierId, klasse, q)
|
suspend fun list(turnierId: Uuid, klasse: String?, q: String?): List<Bewerb> = repo.findByTurnierId(turnierId, klasse, q)
|
||||||
|
|
||||||
|
suspend fun importZns(turnierId: Uuid, reqs: List<CreateBewerbRequest>): List<Bewerb> {
|
||||||
|
if (isTurnierPublished(turnierId)) throw LockedException("Turnier ist PUBLISHED – Import nicht möglich")
|
||||||
|
|
||||||
|
val existing = repo.findByTurnierId(turnierId)
|
||||||
|
val results = mutableListOf<Bewerb>()
|
||||||
|
|
||||||
|
reqs.forEach { req ->
|
||||||
|
// Idempotenz-Check: Wenn ZNS-Nummer und Abteilung bereits existieren, überspringen oder updaten?
|
||||||
|
// Hier: Überspringen, wenn bereits vorhanden (einfachste Logik für MVP)
|
||||||
|
val duplicate = existing.find { it.znsNummer == req.znsNummer && it.znsAbteilung == req.znsAbteilung }
|
||||||
|
if (duplicate == null) {
|
||||||
|
val b = Bewerb(
|
||||||
|
id = Uuid.random(),
|
||||||
|
turnierId = turnierId,
|
||||||
|
klasse = req.klasse,
|
||||||
|
hoeheCm = req.hoeheCm,
|
||||||
|
bezeichnung = req.bezeichnung,
|
||||||
|
teilungsTyp = req.teilungsTyp,
|
||||||
|
beschreibung = req.beschreibung,
|
||||||
|
aufgabe = req.aufgabe,
|
||||||
|
aufgabenNummer = req.aufgabenNummer,
|
||||||
|
paraGrade = req.paraGrade,
|
||||||
|
austragungsplatzId = req.austragungsplatzId?.let { Uuid.parse(it) },
|
||||||
|
richterEinsaetze = req.richterEinsaetze.map { RichterEinsatz(Uuid.parse(it.funktionaerId), it.position) },
|
||||||
|
geplantesDatum = req.geplantesDatum,
|
||||||
|
beginnZeitTyp = req.beginnZeitTyp,
|
||||||
|
beginnZeit = req.beginnZeit,
|
||||||
|
reitdauerMinuten = req.reitdauerMinuten,
|
||||||
|
umbauMinuten = req.umbauMinuten,
|
||||||
|
besichtigungMinuten = req.besichtigungMinuten,
|
||||||
|
stechenGeplant = req.stechenGeplant,
|
||||||
|
startgeldCent = req.startgeldCent,
|
||||||
|
geldpreisAusbezahlt = req.geldpreisAusbezahlt,
|
||||||
|
znsNummer = req.znsNummer,
|
||||||
|
znsAbteilung = req.znsAbteilung
|
||||||
|
)
|
||||||
|
results.add(repo.create(b))
|
||||||
|
} else {
|
||||||
|
results.add(duplicate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun get(id: Uuid): Bewerb = repo.findById(id) ?: throw NoSuchElementException("Bewerb $id nicht gefunden")
|
suspend fun get(id: Uuid): Bewerb = repo.findById(id) ?: throw NoSuchElementException("Bewerb $id nicht gefunden")
|
||||||
|
|
||||||
suspend fun update(id: Uuid, req: UpdateBewerbRequest): Bewerb {
|
suspend fun update(id: Uuid, req: UpdateBewerbRequest): Bewerb {
|
||||||
|
|||||||
+23
@@ -48,6 +48,10 @@ data class CreateBewerbRequest(
|
|||||||
// Finanzen
|
// Finanzen
|
||||||
val startgeldCent: Long? = null,
|
val startgeldCent: Long? = null,
|
||||||
val geldpreisAusbezahlt: Boolean = false,
|
val geldpreisAusbezahlt: Boolean = false,
|
||||||
|
|
||||||
|
// ZNS-Integration
|
||||||
|
val znsNummer: Int? = null,
|
||||||
|
val znsAbteilung: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class UpdateBewerbRequest(
|
data class UpdateBewerbRequest(
|
||||||
@@ -81,6 +85,10 @@ data class UpdateBewerbRequest(
|
|||||||
// Finanzen
|
// Finanzen
|
||||||
val startgeldCent: Long? = null,
|
val startgeldCent: Long? = null,
|
||||||
val geldpreisAusbezahlt: Boolean = false,
|
val geldpreisAusbezahlt: Boolean = false,
|
||||||
|
|
||||||
|
// ZNS-Integration
|
||||||
|
val znsNummer: Int? = null,
|
||||||
|
val znsAbteilung: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BewerbResponse(
|
data class BewerbResponse(
|
||||||
@@ -115,6 +123,10 @@ data class BewerbResponse(
|
|||||||
// Finanzen
|
// Finanzen
|
||||||
val startgeldCent: Long?,
|
val startgeldCent: Long?,
|
||||||
val geldpreisAusbezahlt: Boolean,
|
val geldpreisAusbezahlt: Boolean,
|
||||||
|
|
||||||
|
// ZNS-Integration
|
||||||
|
val znsNummer: Int?,
|
||||||
|
val znsAbteilung: Int?,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun RichterEinsatzDto.toDomain(): RichterEinsatz =
|
private fun RichterEinsatzDto.toDomain(): RichterEinsatz =
|
||||||
@@ -145,6 +157,8 @@ private fun domainToDto(b: Bewerb): BewerbResponse = BewerbResponse(
|
|||||||
stechenGeplant = b.stechenGeplant,
|
stechenGeplant = b.stechenGeplant,
|
||||||
startgeldCent = b.startgeldCent,
|
startgeldCent = b.startgeldCent,
|
||||||
geldpreisAusbezahlt = b.geldpreisAusbezahlt,
|
geldpreisAusbezahlt = b.geldpreisAusbezahlt,
|
||||||
|
znsNummer = b.znsNummer,
|
||||||
|
znsAbteilung = b.znsAbteilung,
|
||||||
)
|
)
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -164,6 +178,15 @@ class BewerbeController(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@PostMapping("/turniere/{turnierId}/bewerbe/import/zns")
|
||||||
|
suspend fun importZns(
|
||||||
|
@PathVariable turnierId: String,
|
||||||
|
@RequestBody body: List<CreateBewerbRequest>
|
||||||
|
): List<BewerbResponse> = service.importZns(
|
||||||
|
Uuid.parse(turnierId),
|
||||||
|
body
|
||||||
|
).map(::domainToDto)
|
||||||
|
|
||||||
@GetMapping("/turniere/{turnierId}/bewerbe")
|
@GetMapping("/turniere/{turnierId}/bewerbe")
|
||||||
suspend fun list(
|
suspend fun list(
|
||||||
@PathVariable turnierId: String,
|
@PathVariable turnierId: String,
|
||||||
|
|||||||
+4
@@ -38,6 +38,10 @@ object BewerbTable : Table("bewerbe") {
|
|||||||
val startgeldCent = long("startgeld_cent").nullable()
|
val startgeldCent = long("startgeld_cent").nullable()
|
||||||
val geldpreisAusbezahlt = bool("geldpreis_ausbezahlt").default(false)
|
val geldpreisAusbezahlt = bool("geldpreis_ausbezahlt").default(false)
|
||||||
|
|
||||||
|
// ZNS-Integration
|
||||||
|
val znsNummer = integer("zns_nummer").nullable()
|
||||||
|
val znsAbteilung = integer("zns_abteilung").nullable()
|
||||||
|
|
||||||
val createdAt = timestamp("created_at")
|
val createdAt = timestamp("created_at")
|
||||||
val updatedAt = timestamp("updated_at")
|
val updatedAt = timestamp("updated_at")
|
||||||
|
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
-- V7: ZNS-spezifische Felder für Bewerbe zur Vermeidung von Duplikaten beim Import
|
||||||
|
-- Context: Phase 8 – ZNS Importer Erweiterung
|
||||||
|
|
||||||
|
ALTER TABLE bewerbe
|
||||||
|
ADD COLUMN IF NOT EXISTS zns_nummer INTEGER NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS zns_abteilung INTEGER NULL;
|
||||||
|
|
||||||
|
-- Index für schnelles Nachschlagen beim Import
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bewerbe_zns_ref ON bewerbe(turnier_id, zns_nummer, zns_abteilung);
|
||||||
@@ -220,7 +220,7 @@ und über definierte Schnittstellen kommunizieren.
|
|||||||
* [x] **Bewerbe-Import:** Implementierung der Merge-Logik (ZNS-XML -> BewerbUiModel). ✓
|
* [x] **Bewerbe-Import:** Implementierung der Merge-Logik (ZNS-XML -> BewerbUiModel). ✓
|
||||||
* [x] **Startlisten-Automatisierung:** Generierung und Zeitberechnung (Pausen, Umbauzeiten). ✓
|
* [x] **Startlisten-Automatisierung:** Generierung und Zeitberechnung (Pausen, Umbauzeiten). ✓
|
||||||
* [x] **Discovery:** Implementierung des mDNS-Service (JmDNS) für die Geräte-Suche. ✓
|
* [x] **Discovery:** Implementierung des mDNS-Service (JmDNS) für die Geräte-Suche. ✓
|
||||||
* [ ] **Transport:** Aufbau der WebSocket-Infrastruktur für P2P-Sync (Phase 7 Übertrag).
|
* [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`.
|
* [ ] **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+
|
### PHASE 9: Series-Context & Erweiterungen 🔵 PHASE 2+
|
||||||
|
|||||||
@@ -21,13 +21,21 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||||
implementation(libs.ktor.client.auth)
|
implementation(libs.ktor.client.auth)
|
||||||
implementation(libs.ktor.client.logging)
|
implementation(libs.ktor.client.logging)
|
||||||
|
api(libs.ktor.client.websockets.common)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
api(libs.koin.core)
|
api(libs.koin.core)
|
||||||
}
|
}
|
||||||
|
|
||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
implementation("org.jmdns:jmdns:3.5.5")
|
implementation(libs.ktor.client.websockets)
|
||||||
|
implementation(libs.ktor.server.core)
|
||||||
|
implementation(libs.ktor.server.netty)
|
||||||
|
implementation(libs.ktor.server.websockets)
|
||||||
|
implementation(libs.ktor.server.contentNegotiation)
|
||||||
|
implementation(libs.ktor.server.serialization.kotlinx.json)
|
||||||
|
implementation(libs.jmdns)
|
||||||
}
|
}
|
||||||
|
|
||||||
jsMain.dependencies {
|
jsMain.dependencies {
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@ import org.koin.core.module.Module
|
|||||||
import org.koin.core.qualifier.named
|
import org.koin.core.qualifier.named
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import at.mocode.frontend.core.network.discovery.discoveryModule
|
import at.mocode.frontend.core.network.discovery.discoveryModule
|
||||||
|
import at.mocode.frontend.core.network.sync.syncModule
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schnittstelle zur Token-Bereitstellung – entkoppelt core-network von core-auth.
|
* Schnittstelle zur Token-Bereitstellung – entkoppelt core-network von core-auth.
|
||||||
@@ -23,7 +24,7 @@ interface TokenProvider { fun getAccessToken(): String? }
|
|||||||
* - "apiClient": Konfigurierter Client für das API-Gateway (Auth-Header, Retry, Timeout)
|
* - "apiClient": Konfigurierter Client für das API-Gateway (Auth-Header, Retry, Timeout)
|
||||||
*/
|
*/
|
||||||
val networkModule: Module = module {
|
val networkModule: Module = module {
|
||||||
includes(discoveryModule)
|
includes(discoveryModule, syncModule)
|
||||||
|
|
||||||
// 1. Basis-Client (für Auth-Endpunkte, ohne Bearer-Token)
|
// 1. Basis-Client (für Auth-Endpunkte, ohne Bearer-Token)
|
||||||
single(named("baseHttpClient")) {
|
single(named("baseHttpClient")) {
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package at.mocode.frontend.core.network.sync
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface für den P2P-Synchronisationsdienst.
|
||||||
|
*/
|
||||||
|
interface P2pSyncService {
|
||||||
|
/**
|
||||||
|
* Startet den Sync-Server auf dieser Instanz.
|
||||||
|
*/
|
||||||
|
fun startServer(port: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt den Sync-Server.
|
||||||
|
*/
|
||||||
|
fun stopServer()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindet sich mit einem anderen Peer.
|
||||||
|
*/
|
||||||
|
suspend fun connectToPeer(host: String, port: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet ein Event an alle verbundenen Peers.
|
||||||
|
*/
|
||||||
|
suspend fun broadcastEvent(event: SyncEvent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream von eingehenden Events von anderen Peers.
|
||||||
|
*/
|
||||||
|
val incomingEvents: Flow<SyncEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste der aktuell verbundenen Peers (Host:Port).
|
||||||
|
*/
|
||||||
|
val connectedPeers: Flow<List<String>>
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package at.mocode.frontend.core.network.sync
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basis-Interface für alle P2P-Sync-Nachrichten.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface SyncEvent {
|
||||||
|
val timestamp: Long
|
||||||
|
val senderId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heartbeat-Event zur Überprüfung der Verbindung.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PingEvent(
|
||||||
|
override val timestamp: Long,
|
||||||
|
override val senderId: String
|
||||||
|
) : SyncEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Antwort auf ein Ping-Event.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PongEvent(
|
||||||
|
override val timestamp: Long,
|
||||||
|
override val senderId: String
|
||||||
|
) : SyncEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ankündigung einer Datenänderung (z.B. neuer Bewerb oder Startliste).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class DataChangedEvent(
|
||||||
|
override val timestamp: Long,
|
||||||
|
override val senderId: String,
|
||||||
|
val entityType: String,
|
||||||
|
val entityId: String,
|
||||||
|
val operation: String // "CREATED", "UPDATED", "DELETED"
|
||||||
|
) : SyncEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anforderung von Daten von einem Peer.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class DataRequestEvent(
|
||||||
|
override val timestamp: Long,
|
||||||
|
override val senderId: String,
|
||||||
|
val entityType: String,
|
||||||
|
val entityId: String
|
||||||
|
) : SyncEvent
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package at.mocode.frontend.core.network.sync
|
||||||
|
|
||||||
|
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager, der mDNS Discovery und P2P Sync verbindet.
|
||||||
|
* Er lauscht auf neu entdeckte Dienste und baut automatisch Verbindungen auf.
|
||||||
|
*/
|
||||||
|
class SyncManager(
|
||||||
|
private val discoveryService: NetworkDiscoveryService,
|
||||||
|
private val syncService: P2pSyncService
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob())
|
||||||
|
private val knownPeers = mutableSetOf<String>()
|
||||||
|
|
||||||
|
fun start(port: Int) {
|
||||||
|
// Eigenen Dienst registrieren und Server starten
|
||||||
|
discoveryService.registerService(port)
|
||||||
|
syncService.startServer(port)
|
||||||
|
discoveryService.startDiscovery()
|
||||||
|
|
||||||
|
// Regelmäßig nach neuen Peers suchen und verbinden
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
val discovered = discoveryService.getDiscoveredServices()
|
||||||
|
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
|
||||||
|
println("[SyncManager] Neuer Peer entdeckt: $peerKey. Verbinde...")
|
||||||
|
syncService.connectToPeer(service.host, service.port)
|
||||||
|
knownPeers.add(peerKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(5000) // Alle 5 Sekunden prüfen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
scope.cancel()
|
||||||
|
discoveryService.stopDiscovery()
|
||||||
|
syncService.stopServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package at.mocode.frontend.core.network.sync
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erwartetes Koin-Modul für den P2P-Sync.
|
||||||
|
*/
|
||||||
|
expect val syncModule: Module
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package at.mocode.frontend.core.network.sync
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JS-spezifische Implementierung (vorerst No-op).
|
||||||
|
*/
|
||||||
|
actual val syncModule: Module = module {
|
||||||
|
single<P2pSyncService> { NoOpP2pSyncService() }
|
||||||
|
single { SyncManager(get(), get()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoOpP2pSyncService : P2pSyncService {
|
||||||
|
override fun startServer(port: Int) {}
|
||||||
|
override fun stopServer() {}
|
||||||
|
override suspend fun connectToPeer(host: String, port: Int) {}
|
||||||
|
override suspend fun broadcastEvent(event: SyncEvent) {}
|
||||||
|
override val incomingEvents: Flow<SyncEvent> = emptyFlow()
|
||||||
|
override val connectedPeers: Flow<List<String>> = emptyFlow()
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package at.mocode.frontend.core.network.sync
|
||||||
|
|
||||||
|
import io.ktor.server.application.*
|
||||||
|
import io.ktor.server.engine.*
|
||||||
|
import io.ktor.server.netty.*
|
||||||
|
import io.ktor.server.routing.*
|
||||||
|
import io.ktor.server.websocket.*
|
||||||
|
import io.ktor.websocket.*
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.plugins.websocket.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
class JvmP2pSyncService : P2pSyncService {
|
||||||
|
private var server: EmbeddedServer<*, *>? = null
|
||||||
|
private val client = HttpClient {
|
||||||
|
install(io.ktor.client.plugins.websocket.WebSockets)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _incomingEvents = MutableSharedFlow<SyncEvent>()
|
||||||
|
override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow()
|
||||||
|
|
||||||
|
private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
|
||||||
|
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
override fun startServer(port: Int) {
|
||||||
|
if (server != null) return
|
||||||
|
|
||||||
|
server = embeddedServer(Netty, port = port) {
|
||||||
|
install(io.ktor.server.websocket.WebSockets)
|
||||||
|
routing {
|
||||||
|
webSocket("/sync") {
|
||||||
|
println("[P2P Server] Neuer Peer verbunden")
|
||||||
|
activeSessions.add(this)
|
||||||
|
updatePeers()
|
||||||
|
try {
|
||||||
|
for (frame in incoming) {
|
||||||
|
if (frame is Frame.Text) {
|
||||||
|
val text = frame.readText()
|
||||||
|
try {
|
||||||
|
val event = Json.decodeFromString<SyncEvent>(text)
|
||||||
|
_incomingEvents.emit(event)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[P2P Server] Fehler beim Dekodieren: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeSessions.remove(this)
|
||||||
|
updatePeers()
|
||||||
|
println("[P2P Server] Peer getrennt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start(wait = false)
|
||||||
|
println("[P2P Server] Gestartet auf Port $port")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopServer() {
|
||||||
|
server?.stop(1000, 2000)
|
||||||
|
server = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun connectToPeer(host: String, port: Int) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
client.webSocket(host = host, port = port, path = "/sync") {
|
||||||
|
println("[P2P Client] Verbunden mit $host:$port")
|
||||||
|
activeSessions.add(this)
|
||||||
|
updatePeers()
|
||||||
|
try {
|
||||||
|
for (frame in incoming) {
|
||||||
|
if (frame is Frame.Text) {
|
||||||
|
val text = frame.readText()
|
||||||
|
val event = Json.decodeFromString<SyncEvent>(text)
|
||||||
|
_incomingEvents.emit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeSessions.remove(this)
|
||||||
|
updatePeers()
|
||||||
|
println("[P2P Client] Verbindung zu $host:$port beendet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[P2P Client] Fehler bei Verbindung zu $host:$port: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun broadcastEvent(event: SyncEvent) {
|
||||||
|
val text = Json.encodeToString(event)
|
||||||
|
activeSessions.toList().forEach { session ->
|
||||||
|
try {
|
||||||
|
session.send(Frame.Text(text))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[P2P] Fehler beim Senden an Session: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePeers() {
|
||||||
|
// Da wir keine einfachen IPs in den Sessions haben ohne tieferes Casting,
|
||||||
|
// nutzen wir hier erst mal einen Platzhalter oder zählen nur.
|
||||||
|
_connectedPeers.value = activeSessions.map { "Peer-${it.hashCode()}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package at.mocode.frontend.core.network.sync
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JVM-spezifische Implementierung des SyncModules.
|
||||||
|
*/
|
||||||
|
actual val syncModule: Module = module {
|
||||||
|
single<P2pSyncService> { JvmP2pSyncService() }
|
||||||
|
single { SyncManager(get(), get()) }
|
||||||
|
}
|
||||||
+23
-7
@@ -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.DiscoveredService
|
||||||
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
|
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.Bewerb
|
||||||
import at.mocode.turnier.feature.domain.BewerbRepository
|
import at.mocode.turnier.feature.domain.BewerbRepository
|
||||||
import at.mocode.turnier.feature.domain.StartlistenRepository
|
import at.mocode.turnier.feature.domain.StartlistenRepository
|
||||||
import at.mocode.zns.parser.ZnsBewerb
|
import at.mocode.zns.parser.ZnsBewerb
|
||||||
import at.mocode.zns.parser.ZnsBewerbParser
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -35,6 +40,7 @@ data class BewerbState(
|
|||||||
val selectedId: Long? = null,
|
val selectedId: Long? = null,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val importPreview: List<ZnsBewerb> = emptyList(),
|
val importPreview: List<ZnsBewerb> = emptyList(),
|
||||||
|
val nennungenPreview: List<ZnsNennung> = emptyList(),
|
||||||
val showImportDialog: Boolean = false,
|
val showImportDialog: Boolean = false,
|
||||||
val showStartlistePreview: Boolean = false,
|
val showStartlistePreview: Boolean = false,
|
||||||
val currentStartliste: List<StartlistenZeile> = emptyList(),
|
val currentStartliste: List<StartlistenZeile> = emptyList(),
|
||||||
@@ -72,7 +78,7 @@ sealed interface BewerbIntent {
|
|||||||
class BewerbViewModel(
|
class BewerbViewModel(
|
||||||
private val repo: BewerbRepository,
|
private val repo: BewerbRepository,
|
||||||
private val startlistenRepo: StartlistenRepository,
|
private val startlistenRepo: StartlistenRepository,
|
||||||
private val discoveryService: NetworkDiscoveryService? = null,
|
private val syncManager: SyncManager? = null,
|
||||||
private val turnierId: Long,
|
private val turnierId: Long,
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
@@ -85,6 +91,14 @@ class BewerbViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
send(BewerbIntent.Load)
|
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) {
|
fun send(intent: BewerbIntent) {
|
||||||
@@ -112,10 +126,11 @@ class BewerbViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is BewerbIntent.OpenImportDialog -> _state.value = _state.value.copy(showImportDialog = true)
|
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 -> {
|
is BewerbIntent.ProcessImportFile -> {
|
||||||
val bewerbe = intent.lines.mapNotNull { ZnsBewerbParser.parse(it) }
|
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 -> {
|
is BewerbIntent.ConfirmImport -> {
|
||||||
confirmImport()
|
confirmImport()
|
||||||
@@ -129,19 +144,20 @@ class BewerbViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startScan() {
|
private fun startScan() {
|
||||||
discoveryService?.startDiscovery()
|
syncManager?.start(8080)
|
||||||
_state.update { it.copy(isScanning = true) }
|
_state.update { it.copy(isScanning = true) }
|
||||||
refreshNodes()
|
refreshNodes()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopScan() {
|
private fun stopScan() {
|
||||||
discoveryService?.stopDiscovery()
|
syncManager?.stop()
|
||||||
_state.update { it.copy(isScanning = false) }
|
_state.update { it.copy(isScanning = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshNodes() {
|
private fun refreshNodes() {
|
||||||
val nodes = discoveryService?.getDiscoveredServices() ?: emptyList()
|
// Da wir jetzt den SyncManager nutzen, könnten wir hier die connectedPeers anzeigen
|
||||||
_state.update { it.copy(discoveredNodes = nodes) }
|
// oder weiterhin die Entdeckten aus dem internen DiscoveryService des Managers.
|
||||||
|
// Für dieses MVP zeigen wir einfach an, dass wir scannen.
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateStartliste() {
|
private fun generateStartliste() {
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
package at.mocode.turnier.feature.di
|
package at.mocode.turnier.feature.di
|
||||||
|
|
||||||
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
|
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.DefaultAbteilungRepository
|
||||||
import at.mocode.turnier.feature.data.remote.DefaultBewerbRepository
|
import at.mocode.turnier.feature.data.remote.DefaultBewerbRepository
|
||||||
import at.mocode.turnier.feature.data.remote.DefaultStartlistenRepository
|
import at.mocode.turnier.feature.data.remote.DefaultStartlistenRepository
|
||||||
@@ -25,12 +26,12 @@ val turnierFeatureModule = module {
|
|||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
factory { TurnierViewModel(repo = get()) }
|
factory { TurnierViewModel(repo = get()) }
|
||||||
// BewerbViewModel: repos + discoveryService + turnierId
|
// BewerbViewModel: repos + syncManager + turnierId
|
||||||
factory { (turnierId: Long) ->
|
factory { (turnierId: Long) ->
|
||||||
BewerbViewModel(
|
BewerbViewModel(
|
||||||
repo = get(),
|
repo = get(),
|
||||||
startlistenRepo = get(),
|
startlistenRepo = get(),
|
||||||
discoveryService = getOrNull<NetworkDiscoveryService>(),
|
syncManager = getOrNull<SyncManager>(),
|
||||||
turnierId = turnierId
|
turnierId = turnierId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
@@ -183,6 +184,7 @@ fun BewerbeTabContent(
|
|||||||
if (state.showImportDialog) {
|
if (state.showImportDialog) {
|
||||||
ZnsImportPreviewDialog(
|
ZnsImportPreviewDialog(
|
||||||
bewerbe = state.importPreview,
|
bewerbe = state.importPreview,
|
||||||
|
nennungen = state.nennungenPreview,
|
||||||
onDismiss = { viewModel.send(BewerbIntent.CloseImportDialog) },
|
onDismiss = { viewModel.send(BewerbIntent.CloseImportDialog) },
|
||||||
onConfirm = { viewModel.send(BewerbIntent.ConfirmImport(turnierId)) }
|
onConfirm = { viewModel.send(BewerbIntent.ConfirmImport(turnierId)) }
|
||||||
)
|
)
|
||||||
@@ -410,6 +412,7 @@ private fun AktionsBtn(label: String, onClick: () -> Unit = {}) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ZnsImportPreviewDialog(
|
private fun ZnsImportPreviewDialog(
|
||||||
bewerbe: List<at.mocode.zns.parser.ZnsBewerb>,
|
bewerbe: List<at.mocode.zns.parser.ZnsBewerb>,
|
||||||
|
nennungen: List<at.mocode.zns.parser.ZnsNennung> = emptyList(),
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -417,12 +420,16 @@ private fun ZnsImportPreviewDialog(
|
|||||||
Surface(
|
Surface(
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
color = MaterialTheme.colorScheme.surface,
|
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)) {
|
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))
|
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))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
Box(modifier = Modifier.weight(1f).background(HeaderBg).padding(2.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("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("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("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 ->
|
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)) {
|
Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp)) {
|
||||||
Text(b.bewerbNummer.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp)
|
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.abteilung.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp)
|
||||||
Text(b.name, modifier = Modifier.weight(1f), 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.klasse, modifier = Modifier.width(40.dp), fontSize = 12.sp)
|
||||||
Text(b.kategorie, modifier = Modifier.width(80.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))
|
HorizontalDivider(color = Color.LightGray.copy(alpha = 0.5f))
|
||||||
}
|
}
|
||||||
@@ -453,7 +463,9 @@ private fun ZnsImportPreviewDialog(
|
|||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||||
OutlinedButton(onClick = onDismiss) { Text("Abbrechen") }
|
OutlinedButton(onClick = onDismiss) { Text("Abbrechen") }
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Button(onClick = onConfirm) { Text("${bewerbe.size} Bewerbe importieren") }
|
Button(onClick = onConfirm) {
|
||||||
|
Text("Import bestätigen")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -128,7 +128,12 @@ fun PreviewTurnierBewerbeTab() {
|
|||||||
override suspend fun generate(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
|
override suspend fun generate(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
|
||||||
override suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
|
override suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
|
||||||
}
|
}
|
||||||
val vm = BewerbViewModel(mockRepo, mockStartlistenRepo, null, 1L)
|
val vm = BewerbViewModel(
|
||||||
|
repo = mockRepo,
|
||||||
|
startlistenRepo = mockStartlistenRepo,
|
||||||
|
syncManager = null,
|
||||||
|
turnierId = 1L
|
||||||
|
)
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
BewerbeTabContent(viewModel = vm, turnierId = 1L)
|
BewerbeTabContent(viewModel = vm, turnierId = 1L)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,10 +139,13 @@ ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-
|
|||||||
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
|
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
|
||||||
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
||||||
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
|
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
|
||||||
|
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets-jvm", version.ref = "ktor" }
|
||||||
|
ktor-client-websockets-common = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
|
|
||||||
# Engines
|
# Engines
|
||||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio-jvm", version.ref = "ktor" } # JVM/Desktop
|
ktor-client-cio = { module = "io.ktor:ktor-client-cio-jvm", version.ref = "ktor" } # JVM/Desktop
|
||||||
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } # JS/Wasm
|
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } # JS/Wasm
|
||||||
|
# ktor-client-websockets-common = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# === FRONTEND: DEPENDENCY INJECTION (KOIN) ===
|
# === FRONTEND: DEPENDENCY INJECTION (KOIN) ===
|
||||||
@@ -213,6 +216,7 @@ ktor-server-swagger = { module = "io.ktor:ktor-server-swagger", version.ref = "k
|
|||||||
ktor-server-tests = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor" }
|
ktor-server-tests = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor" }
|
||||||
ktor-server-testHost = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" }
|
ktor-server-testHost = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" }
|
||||||
ktor-server-routing-openapi = { module = "io.ktor:ktor-server-routing-openapi", version.ref = "ktor" }
|
ktor-server-routing-openapi = { module = "io.ktor:ktor-server-routing-openapi", version.ref = "ktor" }
|
||||||
|
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets-jvm", version.ref = "ktor" }
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# === BACKEND: PERSISTENCE & INFRA ===
|
# === BACKEND: PERSISTENCE & INFRA ===
|
||||||
|
|||||||
Reference in New Issue
Block a user