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:
+28
-1
@@ -1,5 +1,7 @@
|
||||
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.BewerbRepository
|
||||
import at.mocode.turnier.feature.domain.StartlistenRepository
|
||||
@@ -10,9 +12,9 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
typealias BewerbListItem = Bewerb
|
||||
|
||||
@@ -36,6 +38,8 @@ data class BewerbState(
|
||||
val showImportDialog: Boolean = false,
|
||||
val showStartlistePreview: Boolean = false,
|
||||
val currentStartliste: List<StartlistenZeile> = emptyList(),
|
||||
val discoveredNodes: List<DiscoveredService> = emptyList(),
|
||||
val isScanning: Boolean = false,
|
||||
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
|
||||
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
|
||||
)
|
||||
@@ -59,12 +63,16 @@ sealed interface BewerbIntent {
|
||||
data class ConfirmImport(val turnierId: Long) : BewerbIntent
|
||||
data object GenerateStartliste : BewerbIntent
|
||||
data object CloseStartlistePreview : BewerbIntent
|
||||
data object StartNetworkScan : BewerbIntent
|
||||
data object StopNetworkScan : BewerbIntent
|
||||
data object RefreshDiscoveredNodes : BewerbIntent
|
||||
}
|
||||
|
||||
|
||||
class BewerbViewModel(
|
||||
private val repo: BewerbRepository,
|
||||
private val startlistenRepo: StartlistenRepository,
|
||||
private val discoveryService: NetworkDiscoveryService? = null,
|
||||
private val turnierId: Long,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -114,9 +122,28 @@ class BewerbViewModel(
|
||||
}
|
||||
is BewerbIntent.GenerateStartliste -> generateStartliste()
|
||||
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() {
|
||||
val selectedId = _state.value.selectedId ?: return
|
||||
reduce { it.copy(isLoading = true) }
|
||||
|
||||
+10
-2
@@ -1,5 +1,6 @@
|
||||
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.DefaultBewerbRepository
|
||||
import at.mocode.turnier.feature.data.remote.DefaultStartlistenRepository
|
||||
@@ -24,8 +25,15 @@ val turnierFeatureModule = module {
|
||||
|
||||
// ViewModels
|
||||
factory { TurnierViewModel(repo = get()) }
|
||||
// BewerbViewModel: repos + turnierId — turnierId wird per parametersOf übergeben
|
||||
factory { (turnierId: Long) -> BewerbViewModel(repo = get(), startlistenRepo = get(), turnierId = turnierId) }
|
||||
// BewerbViewModel: repos + discoveryService + turnierId
|
||||
factory { (turnierId: Long) ->
|
||||
BewerbViewModel(
|
||||
repo = get(),
|
||||
startlistenRepo = get(),
|
||||
discoveryService = getOrNull<NetworkDiscoveryService>(),
|
||||
turnierId = turnierId
|
||||
)
|
||||
}
|
||||
// BewerbAnlegenViewModel hat keinen Repository-Parameter (nutzt StoreV2 intern)
|
||||
factory { BewerbAnlegenViewModel() }
|
||||
// AbteilungViewModel: repo + bewerbId + abteilungsNr — per parametersOf übergeben
|
||||
|
||||
+78
-5
@@ -43,6 +43,16 @@ fun BewerbeTabContent(
|
||||
) {
|
||||
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"
|
||||
val bewerbDialogVm = remember { BewerbAnlegenViewModel() }
|
||||
val bewerbDialogState by bewerbDialogVm.state.collectAsState()
|
||||
@@ -64,7 +74,12 @@ fun BewerbeTabContent(
|
||||
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()
|
||||
|
||||
@@ -131,10 +146,20 @@ fun BewerbeTabContent(
|
||||
|
||||
// ── Rechtes Detail-Panel ──────────────────────────────────────────────
|
||||
val selectedItem = state.list.find { it.id == state.selectedId }
|
||||
BewerbeDetailPanel(
|
||||
bewerb = selectedItem?.toUiModel(),
|
||||
modifier = Modifier.width(340.dp).fillMaxHeight(),
|
||||
)
|
||||
Column(modifier = Modifier.width(340.dp).fillMaxHeight()) {
|
||||
BewerbeDetailPanel(
|
||||
bewerb = selectedItem?.toUiModel(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
if (state.isScanning || state.discoveredNodes.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
NetworkDiscoveryPanel(
|
||||
nodes = state.discoveredNodes,
|
||||
isScanning = state.isScanning
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bewerbDialogState.isOpen) {
|
||||
@@ -298,6 +323,8 @@ private fun BewerbeAktionsSpalte(
|
||||
onBewerbEinfuegen: () -> Unit = {},
|
||||
onZnsImport: () -> Unit = {},
|
||||
onGenerateStartliste: () -> Unit = {},
|
||||
onToggleScan: () -> Unit = {},
|
||||
isScanning: Boolean = false,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(8.dp),
|
||||
@@ -314,6 +341,11 @@ private fun BewerbeAktionsSpalte(
|
||||
AktionsBtn("Bewerb nach\noben verschieben")
|
||||
AktionsBtn("Bewerb nach\nunten verschieben")
|
||||
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\nBearbeiten")
|
||||
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
|
||||
private fun AktionsBtn(label: String, onClick: () -> Unit = {}) {
|
||||
OutlinedButton(
|
||||
|
||||
Reference in New Issue
Block a user