feat(core+frontend): integrate mDNS-based network discovery and update UI
- **Network Discovery Service:** - Added platform-specific `DiscoveryModule` with JmDNS-based `JmDnsDiscoveryService` for JVM and no-op implementation for JS. - Implemented service and device discovery using mDNS to enable peer-to-peer synchronization within LAN. - Registered the module in Koin for dependency injection and integrated it with `networkModule`. - **Frontend Integration:** - Enhanced `BewerbViewModel` with intents and actions for starting, stopping, and refreshing network scans. - Introduced polling for discovered services during an active scan. - **UI Additions:** - Added a `NetworkDiscoveryPanel` in `TurnierBewerbeTab` to display discovered services and indicate scan state. - Updated action buttons to include toggle functionality for network scans.
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
package at.mocode.zns.parser
|
||||||
|
|
||||||
|
import at.mocode.core.utils.parser.FixedWidthLineReader
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domänen-Modell für eine ZNS-Nennung (N-Satz).
|
||||||
|
*/
|
||||||
|
data class ZnsNennung(
|
||||||
|
val bewerbNummer: Int,
|
||||||
|
val abteilung: Int,
|
||||||
|
val reiterName: String,
|
||||||
|
val pferdeName: String,
|
||||||
|
val verein: String,
|
||||||
|
val kopfNummer: String,
|
||||||
|
val reiterNummer: String,
|
||||||
|
val pferdeNummer: String,
|
||||||
|
val startWunsch: String? = null // Z.B. "V" für Vorne, "H" für Hinten (falls im N-Satz kodiert)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spezialisierter Parser für N-Sätze aus der n2-XXXXX.dat Datei.
|
||||||
|
* N-Sätze enthalten die konkreten Nennungen pro Bewerb.
|
||||||
|
*/
|
||||||
|
object ZnsNennungParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst eine Zeile aus der n2-XXXXX.dat Datei, sofern es sich um einen N-Satz handelt.
|
||||||
|
* Ein N-Satz beginnt oft mit einem Kennzeichen (z.B. 'N' oder nach einem Bewerbs-Header).
|
||||||
|
*/
|
||||||
|
fun parse(line: String): ZnsNennung? {
|
||||||
|
// Ein valider N-Satz hat typischerweise eine feste Breite
|
||||||
|
if (line.length < 50) return null
|
||||||
|
|
||||||
|
// N-Sätze in n2-Dateien folgen oft direkt auf B-Sätze
|
||||||
|
// Wir prüfen hier auf das typische Format (Starts with 'N' or index markers)
|
||||||
|
// Basierend auf OETO Specs: N-Sätze fangen oft mit 'N' an
|
||||||
|
if (!line.startsWith("N")) return null
|
||||||
|
|
||||||
|
val reader = FixedWidthLineReader(line)
|
||||||
|
|
||||||
|
// Die Offsets sind beispielhaft und müssen an das reale n2-Format angepasst werden
|
||||||
|
// Typischerweise:
|
||||||
|
// N 010 1 Mustermann Max Superpferd 01 001 12345 67890
|
||||||
|
|
||||||
|
val bewerbNummer = reader.getIntOrNull(2, 3) ?: return null
|
||||||
|
val abteilung = reader.getIntOrNull(5, 1) ?: 0
|
||||||
|
val reiterName = reader.getString(6, 20)
|
||||||
|
val pferdeName = reader.getString(26, 20)
|
||||||
|
val verein = reader.getString(46, 20)
|
||||||
|
val kopfNummer = reader.getString(66, 4)
|
||||||
|
val reiterNummer = reader.getString(70, 7)
|
||||||
|
val pferdeNummer = reader.getString(77, 6)
|
||||||
|
|
||||||
|
return ZnsNennung(
|
||||||
|
bewerbNummer = bewerbNummer,
|
||||||
|
abteilung = abteilung,
|
||||||
|
reiterName = reiterName,
|
||||||
|
pferdeName = pferdeName,
|
||||||
|
verein = verein,
|
||||||
|
kopfNummer = kopfNummer,
|
||||||
|
reiterNummer = reiterNummer,
|
||||||
|
pferdeNummer = pferdeNummer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,4 +107,19 @@ class ZnsParserTest {
|
|||||||
assertEquals("CSN-C", bewerb.kategorie)
|
assertEquals("CSN-C", bewerb.kategorie)
|
||||||
assertEquals("2026-04-10", bewerb.datum.toString())
|
assertEquals("2026-04-10", bewerb.datum.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseNennung should extract N-Satz correctly`() {
|
||||||
|
// N(1) + BEWNR(3) + ABT(1) + REITER(20) + PFERD(20) + VEREIN(20) + KOPF(4) + RNR(7) + PNR(6)
|
||||||
|
val line = "N0101Mustermann Max Superpferd 01 Reitclub Musterdorf 001 123456789012"
|
||||||
|
val nennung = ZnsNennungParser.parse(line)
|
||||||
|
|
||||||
|
assertNotNull(nennung)
|
||||||
|
assertEquals(10, nennung.bewerbNummer)
|
||||||
|
assertEquals(1, nennung.abteilung)
|
||||||
|
assertEquals("Mustermann Max", nennung.reiterName)
|
||||||
|
assertEquals("Superpferd 01", nennung.pferdeName)
|
||||||
|
assertEquals("Reitclub Musterdorf", nennung.verein)
|
||||||
|
assertEquals("001", nennung.kopfNummer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ und über definierte Schnittstellen kommunizieren.
|
|||||||
* [x] **Konzept/ADR:** LAN‑Sync (ADR‑0022) und Offline‑First Desktop↔Backend Konzept definiert und verlinkt.
|
* [x] **Konzept/ADR:** LAN‑Sync (ADR‑0022) und Offline‑First Desktop↔Backend Konzept definiert und verlinkt.
|
||||||
* [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). ✓
|
||||||
* [ ] **Discovery:** Implementierung des mDNS-Service für die Geräte-Suche (Phase 7 Übertrag).
|
* [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).
|
* [ ] **Transport:** Aufbau der WebSocket-Infrastruktur für P2P-Sync (Phase 7 Übertrag).
|
||||||
* [ ] **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`.
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ kotlin {
|
|||||||
|
|
||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation("org.jmdns:jmdns:3.5.5")
|
||||||
}
|
}
|
||||||
|
|
||||||
jsMain.dependencies {
|
jsMain.dependencies {
|
||||||
|
|||||||
+4
-1
@@ -7,8 +7,10 @@ import io.ktor.client.plugins.logging.*
|
|||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schnittstelle zur Token-Bereitstellung – entkoppelt core-network von core-auth.
|
* Schnittstelle zur Token-Bereitstellung – entkoppelt core-network von core-auth.
|
||||||
@@ -20,7 +22,8 @@ interface TokenProvider { fun getAccessToken(): String? }
|
|||||||
* - "baseHttpClient": Roh-Client für Auth/Keycloak (kein Token-Header)
|
* - "baseHttpClient": Roh-Client für Auth/Keycloak (kein Token-Header)
|
||||||
* - "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 {
|
val networkModule: Module = module {
|
||||||
|
includes(discoveryModule)
|
||||||
|
|
||||||
// 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")) {
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package at.mocode.frontend.core.network.discovery
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erwartetes Koin-Modul für die Netzwerk-Discovery.
|
||||||
|
* Plattform-spezifische Implementierungen (JVM mit JmDNS, JS/Wasm evtl. No-op)
|
||||||
|
* müssen hier injiziert werden.
|
||||||
|
*/
|
||||||
|
expect val discoveryModule: Module
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package at.mocode.frontend.core.network.discovery
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modell für einen entdeckten Dienst im lokalen Netzwerk.
|
||||||
|
*/
|
||||||
|
data class DiscoveredService(
|
||||||
|
val name: String,
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
val metadata: Map<String, String> = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface für die mDNS-basierte Entdeckung von Meldestelle-Instanzen.
|
||||||
|
* Erlaubt Offline-First Synchronisation im LAN.
|
||||||
|
*/
|
||||||
|
interface NetworkDiscoveryService {
|
||||||
|
/**
|
||||||
|
* Startet das Scannen nach verfügbaren Diensten im Netzwerk.
|
||||||
|
*/
|
||||||
|
fun startDiscovery()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt den Scan-Vorgang.
|
||||||
|
*/
|
||||||
|
fun stopDiscovery()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registriert den eigenen Dienst, damit andere Instanzen ihn finden können.
|
||||||
|
* @param port Der Port, auf dem der lokale WebSocket-Server lauscht.
|
||||||
|
*/
|
||||||
|
fun registerService(port: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Liste der aktuell entdeckten Dienste zurück.
|
||||||
|
*/
|
||||||
|
fun getDiscoveredServices(): List<DiscoveredService>
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package at.mocode.frontend.core.network.discovery
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JS-spezifische Implementierung (vorerst No-op, da mDNS im Browser nicht nativ möglich).
|
||||||
|
*/
|
||||||
|
actual val discoveryModule: Module = module {
|
||||||
|
single<NetworkDiscoveryService> { NoOpDiscoveryService() }
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoOpDiscoveryService : NetworkDiscoveryService {
|
||||||
|
override fun startDiscovery() {}
|
||||||
|
override fun stopDiscovery() {}
|
||||||
|
override fun registerService(port: Int) {}
|
||||||
|
override fun getDiscoveredServices(): List<DiscoveredService> = emptyList()
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package at.mocode.frontend.core.network.discovery
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JVM-spezifische Implementierung des DiscoveryModules.
|
||||||
|
*/
|
||||||
|
actual val discoveryModule: Module = module {
|
||||||
|
single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package at.mocode.frontend.core.network.discovery
|
||||||
|
|
||||||
|
import javax.jmdns.JmDNS
|
||||||
|
import javax.jmdns.ServiceEvent
|
||||||
|
import javax.jmdns.ServiceInfo
|
||||||
|
import javax.jmdns.ServiceListener
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JVM-spezifische Implementierung der Netzwerk-Discovery mittels JmDNS.
|
||||||
|
*/
|
||||||
|
class JmDnsDiscoveryService : NetworkDiscoveryService {
|
||||||
|
|
||||||
|
private var jmdns: JmDNS? = null
|
||||||
|
private val SERVICE_TYPE = "_meldestelle-biest._tcp.local."
|
||||||
|
private val discoveredServicesMap = ConcurrentHashMap<String, DiscoveredService>()
|
||||||
|
|
||||||
|
override fun startDiscovery() {
|
||||||
|
if (jmdns == null) {
|
||||||
|
jmdns = JmDNS.create(InetAddress.getLocalHost())
|
||||||
|
}
|
||||||
|
|
||||||
|
jmdns?.addServiceListener(SERVICE_TYPE, object : ServiceListener {
|
||||||
|
override fun serviceAdded(event: ServiceEvent) {
|
||||||
|
// Bei ServiceAdded fordern wir die Details an
|
||||||
|
jmdns?.requestServiceInfo(event.type, event.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun serviceRemoved(event: ServiceEvent) {
|
||||||
|
discoveredServicesMap.remove(event.name)
|
||||||
|
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
|
||||||
|
println("[Discovery] Service gefunden: ${service.name} @ ${service.host}:${service.port}")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopDiscovery() {
|
||||||
|
jmdns?.close()
|
||||||
|
jmdns = null
|
||||||
|
discoveredServicesMap.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun registerService(port: Int) {
|
||||||
|
val serviceInfo = ServiceInfo.create(
|
||||||
|
SERVICE_TYPE,
|
||||||
|
"Meldestelle-${System.getProperty("user.name")}",
|
||||||
|
port,
|
||||||
|
"Offline-First Sync Node"
|
||||||
|
)
|
||||||
|
jmdns?.registerService(serviceInfo)
|
||||||
|
println("[Discovery] Eigenen Dienst registriert auf Port $port")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDiscoveredServices(): List<DiscoveredService> {
|
||||||
|
return discoveredServicesMap.values.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-1
@@ -1,5 +1,7 @@
|
|||||||
package at.mocode.turnier.feature.presentation
|
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.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
|
||||||
@@ -10,9 +12,9 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
typealias BewerbListItem = Bewerb
|
typealias BewerbListItem = Bewerb
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@ data class BewerbState(
|
|||||||
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(),
|
||||||
|
val discoveredNodes: List<DiscoveredService> = emptyList(),
|
||||||
|
val isScanning: Boolean = false,
|
||||||
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
|
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
|
||||||
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
|
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
|
||||||
)
|
)
|
||||||
@@ -59,12 +63,16 @@ sealed interface BewerbIntent {
|
|||||||
data class ConfirmImport(val turnierId: Long) : BewerbIntent
|
data class ConfirmImport(val turnierId: Long) : BewerbIntent
|
||||||
data object GenerateStartliste : BewerbIntent
|
data object GenerateStartliste : BewerbIntent
|
||||||
data object CloseStartlistePreview : BewerbIntent
|
data object CloseStartlistePreview : BewerbIntent
|
||||||
|
data object StartNetworkScan : BewerbIntent
|
||||||
|
data object StopNetworkScan : BewerbIntent
|
||||||
|
data object RefreshDiscoveredNodes : 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 turnierId: Long,
|
private val turnierId: Long,
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
@@ -114,9 +122,28 @@ class BewerbViewModel(
|
|||||||
}
|
}
|
||||||
is BewerbIntent.GenerateStartliste -> generateStartliste()
|
is BewerbIntent.GenerateStartliste -> generateStartliste()
|
||||||
is BewerbIntent.CloseStartlistePreview -> reduce { it.copy(showStartlistePreview = false) }
|
is BewerbIntent.CloseStartlistePreview -> reduce { it.copy(showStartlistePreview = false) }
|
||||||
|
is BewerbIntent.StartNetworkScan -> startScan()
|
||||||
|
is BewerbIntent.StopNetworkScan -> stopScan()
|
||||||
|
is BewerbIntent.RefreshDiscoveredNodes -> refreshNodes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startScan() {
|
||||||
|
discoveryService?.startDiscovery()
|
||||||
|
_state.update { it.copy(isScanning = true) }
|
||||||
|
refreshNodes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopScan() {
|
||||||
|
discoveryService?.stopDiscovery()
|
||||||
|
_state.update { it.copy(isScanning = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshNodes() {
|
||||||
|
val nodes = discoveryService?.getDiscoveredServices() ?: emptyList()
|
||||||
|
_state.update { it.copy(discoveredNodes = nodes) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun generateStartliste() {
|
private fun generateStartliste() {
|
||||||
val selectedId = _state.value.selectedId ?: return
|
val selectedId = _state.value.selectedId ?: return
|
||||||
reduce { it.copy(isLoading = true) }
|
reduce { it.copy(isLoading = true) }
|
||||||
|
|||||||
+10
-2
@@ -1,5 +1,6 @@
|
|||||||
package at.mocode.turnier.feature.di
|
package at.mocode.turnier.feature.di
|
||||||
|
|
||||||
|
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
|
||||||
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
|
||||||
@@ -24,8 +25,15 @@ val turnierFeatureModule = module {
|
|||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
factory { TurnierViewModel(repo = get()) }
|
factory { TurnierViewModel(repo = get()) }
|
||||||
// BewerbViewModel: repos + turnierId — turnierId wird per parametersOf übergeben
|
// BewerbViewModel: repos + discoveryService + turnierId
|
||||||
factory { (turnierId: Long) -> BewerbViewModel(repo = get(), startlistenRepo = get(), turnierId = turnierId) }
|
factory { (turnierId: Long) ->
|
||||||
|
BewerbViewModel(
|
||||||
|
repo = get(),
|
||||||
|
startlistenRepo = get(),
|
||||||
|
discoveryService = getOrNull<NetworkDiscoveryService>(),
|
||||||
|
turnierId = turnierId
|
||||||
|
)
|
||||||
|
}
|
||||||
// BewerbAnlegenViewModel hat keinen Repository-Parameter (nutzt StoreV2 intern)
|
// BewerbAnlegenViewModel hat keinen Repository-Parameter (nutzt StoreV2 intern)
|
||||||
factory { BewerbAnlegenViewModel() }
|
factory { BewerbAnlegenViewModel() }
|
||||||
// AbteilungViewModel: repo + bewerbId + abteilungsNr — per parametersOf übergeben
|
// AbteilungViewModel: repo + bewerbId + abteilungsNr — per parametersOf übergeben
|
||||||
|
|||||||
+78
-5
@@ -43,6 +43,16 @@ fun BewerbeTabContent(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
// Polling für entdeckte Dienste, wenn Scan aktiv ist
|
||||||
|
LaunchedEffect(state.isScanning) {
|
||||||
|
if (state.isScanning) {
|
||||||
|
while (true) {
|
||||||
|
viewModel.send(BewerbIntent.RefreshDiscoveredNodes)
|
||||||
|
kotlinx.coroutines.delay(2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dialog-ViewModel für "Bewerb anlegen"
|
// Dialog-ViewModel für "Bewerb anlegen"
|
||||||
val bewerbDialogVm = remember { BewerbAnlegenViewModel() }
|
val bewerbDialogVm = remember { BewerbAnlegenViewModel() }
|
||||||
val bewerbDialogState by bewerbDialogVm.state.collectAsState()
|
val bewerbDialogState by bewerbDialogVm.state.collectAsState()
|
||||||
@@ -64,7 +74,12 @@ fun BewerbeTabContent(
|
|||||||
viewModel.send(BewerbIntent.OpenImportDialog)
|
viewModel.send(BewerbIntent.OpenImportDialog)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onGenerateStartliste = { viewModel.send(BewerbIntent.GenerateStartliste) }
|
onGenerateStartliste = { viewModel.send(BewerbIntent.GenerateStartliste) },
|
||||||
|
onToggleScan = {
|
||||||
|
if (state.isScanning) viewModel.send(BewerbIntent.StopNetworkScan)
|
||||||
|
else viewModel.send(BewerbIntent.StartNetworkScan)
|
||||||
|
},
|
||||||
|
isScanning = state.isScanning
|
||||||
)
|
)
|
||||||
VerticalDivider()
|
VerticalDivider()
|
||||||
|
|
||||||
@@ -131,10 +146,20 @@ fun BewerbeTabContent(
|
|||||||
|
|
||||||
// ── Rechtes Detail-Panel ──────────────────────────────────────────────
|
// ── Rechtes Detail-Panel ──────────────────────────────────────────────
|
||||||
val selectedItem = state.list.find { it.id == state.selectedId }
|
val selectedItem = state.list.find { it.id == state.selectedId }
|
||||||
BewerbeDetailPanel(
|
Column(modifier = Modifier.width(340.dp).fillMaxHeight()) {
|
||||||
bewerb = selectedItem?.toUiModel(),
|
BewerbeDetailPanel(
|
||||||
modifier = Modifier.width(340.dp).fillMaxHeight(),
|
bewerb = selectedItem?.toUiModel(),
|
||||||
)
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.isScanning || state.discoveredNodes.isNotEmpty()) {
|
||||||
|
HorizontalDivider()
|
||||||
|
NetworkDiscoveryPanel(
|
||||||
|
nodes = state.discoveredNodes,
|
||||||
|
isScanning = state.isScanning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bewerbDialogState.isOpen) {
|
if (bewerbDialogState.isOpen) {
|
||||||
@@ -298,6 +323,8 @@ private fun BewerbeAktionsSpalte(
|
|||||||
onBewerbEinfuegen: () -> Unit = {},
|
onBewerbEinfuegen: () -> Unit = {},
|
||||||
onZnsImport: () -> Unit = {},
|
onZnsImport: () -> Unit = {},
|
||||||
onGenerateStartliste: () -> Unit = {},
|
onGenerateStartliste: () -> Unit = {},
|
||||||
|
onToggleScan: () -> Unit = {},
|
||||||
|
isScanning: Boolean = false,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.padding(8.dp),
|
modifier = modifier.padding(8.dp),
|
||||||
@@ -314,6 +341,11 @@ private fun BewerbeAktionsSpalte(
|
|||||||
AktionsBtn("Bewerb nach\noben verschieben")
|
AktionsBtn("Bewerb nach\noben verschieben")
|
||||||
AktionsBtn("Bewerb nach\nunten verschieben")
|
AktionsBtn("Bewerb nach\nunten verschieben")
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
AktionsBtn(
|
||||||
|
label = if (isScanning) "Netzwerk-Scan\nStoppen" else "Netzwerk-Scan\nStarten",
|
||||||
|
onClick = onToggleScan
|
||||||
|
)
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
AktionsBtn("Startliste\nGenerieren", onClick = onGenerateStartliste)
|
AktionsBtn("Startliste\nGenerieren", onClick = onGenerateStartliste)
|
||||||
AktionsBtn("Startliste\nBearbeiten")
|
AktionsBtn("Startliste\nBearbeiten")
|
||||||
AktionsBtn("Startliste\nDrucken")
|
AktionsBtn("Startliste\nDrucken")
|
||||||
@@ -322,6 +354,47 @@ private fun BewerbeAktionsSpalte(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NetworkDiscoveryPanel(
|
||||||
|
nodes: List<at.mocode.frontend.core.network.discovery.DiscoveredService>,
|
||||||
|
isScanning: Boolean
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Netzwerk (P2P)", style = MaterialTheme.typography.titleSmall, color = PrimaryBlue)
|
||||||
|
if (isScanning) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 2.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
if (nodes.isEmpty()) {
|
||||||
|
Text("Keine Instanzen gefunden", fontSize = 11.sp, color = Color.Gray)
|
||||||
|
} else {
|
||||||
|
LazyColumn {
|
||||||
|
items(nodes) { node ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp), tint = PrimaryBlue)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
|
Text(node.name, fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Text("${node.host}:${node.port}", fontSize = 10.sp, color = Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AktionsBtn(label: String, onClick: () -> Unit = {}) {
|
private fun AktionsBtn(label: String, onClick: () -> Unit = {}) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
|
|||||||
+1
-1
@@ -128,7 +128,7 @@ 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, 1L)
|
val vm = BewerbViewModel(mockRepo, mockStartlistenRepo, null, 1L)
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
BewerbeTabContent(viewModel = vm, turnierId = 1L)
|
BewerbeTabContent(viewModel = vm, turnierId = 1L)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ keycloakAdminClient = "26.0.7"
|
|||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
bignum = "0.3.10"
|
bignum = "0.3.10"
|
||||||
|
jmdns = "3.5.12"
|
||||||
logback = "1.5.25"
|
logback = "1.5.25"
|
||||||
caffeine = "3.2.3"
|
caffeine = "3.2.3"
|
||||||
reactorKafka = "1.3.23"
|
reactorKafka = "1.3.23"
|
||||||
@@ -260,6 +261,7 @@ reactor-kafka = { module = "io.projectreactor.kafka:reactor-kafka", version.ref
|
|||||||
jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" }
|
jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" }
|
||||||
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" }
|
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" }
|
||||||
jakarta-annotation-api = { module = "jakarta.annotation:jakarta.annotation-api", version.ref = "jakartaAnnotation" }
|
jakarta-annotation-api = { module = "jakarta.annotation:jakarta.annotation-api", version.ref = "jakartaAnnotation" }
|
||||||
|
jmdns = { module = "org.jmdns:jmdns", version.ref = "jmdns" }
|
||||||
|
|
||||||
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiter" }
|
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiter" }
|
||||||
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junitJupiter" }
|
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junitJupiter" }
|
||||||
|
|||||||
Reference in New Issue
Block a user