JS-spezifische Module und Dateien entfernt, Multiplattform-Targets korrigiert

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-18 14:16:22 +02:00
parent 7bbb991e69
commit e91b10daa3
169 changed files with 2128 additions and 824 deletions
@@ -0,0 +1,10 @@
package at.mocode.frontend.features.deviceinitialization.di
import at.mocode.frontend.features.deviceinitialization.presentation.DeviceInitializationViewModel
import org.koin.dsl.module
val deviceInitializationModule = module {
factory { (onComplete: (at.mocode.frontend.features.deviceinitialization.domain.DeviceInitializationSettings) -> Unit) ->
DeviceInitializationViewModel(get(), onComplete)
}
}
@@ -0,0 +1,35 @@
package at.mocode.frontend.features.deviceinitialization.domain
import kotlinx.serialization.Serializable
@Serializable
enum class NetworkRole {
MASTER,
CLIENT,
RICHTER,
ZEITNEHMER,
STALLMEISTER,
ANZEIGE,
PARCOURS_CHEF
}
@Serializable
data class ExpectedClient(
val name: String,
val role: NetworkRole,
val isOnline: Boolean = false,
val isSynchronized: Boolean = true
)
@Serializable
data class DeviceInitializationSettings(
val deviceName: String = "",
val sharedKey: String = "",
val backupPath: String = "",
val networkRole: NetworkRole = NetworkRole.CLIENT,
val expectedClients: List<ExpectedClient> = emptyList(),
val syncInterval: Int = 30, // in Minuten
val defaultPrinter: String = ""
) {
val isConfigured: Boolean get() = deviceName.isNotBlank() && sharedKey.isNotBlank()
}
@@ -0,0 +1,7 @@
package at.mocode.frontend.features.deviceinitialization.domain
expect object DeviceInitializationSettingsManager {
fun saveSettings(settings: DeviceInitializationSettings)
fun loadSettings(): DeviceInitializationSettings?
fun isConfigured(): Boolean
}
@@ -0,0 +1,44 @@
package at.mocode.frontend.features.deviceinitialization.domain
/**
* Validierungslogik für den Geräte-Initialisierungs-Wizard.
*/
object DeviceInitializationValidator {
/** Mindestlänge für den Gerätenamen. */
const val MIN_NAME_LENGTH = 3
/** Mindestlänge für den Sicherheitsschlüssel. */
const val MIN_KEY_LENGTH = 8
/** Gibt `true` zurück, wenn der Gerätename gültig ist. */
fun isNameValid(name: String): Boolean = name.trim().length >= MIN_NAME_LENGTH
/** Gibt `true` zurück, wenn der Sicherheitsschlüssel gültig ist. */
fun isKeyValid(key: String): Boolean = key.trim().length >= MIN_KEY_LENGTH
/** Gibt `true` zurück, wenn der Backup-Pfad gültig ist. */
fun isBackupPathValid(path: String): Boolean = path.isNotBlank()
/** Gibt `true` zurück, wenn das Sync-Intervall gültig ist. */
fun isSyncIntervalValid(interval: Int): Boolean = interval in 1..60
/**
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind.
*/
fun canContinue(settings: DeviceInitializationSettings): Boolean {
val basicValid = isNameValid(settings.deviceName) &&
isKeyValid(settings.sharedKey) &&
(if (settings.networkRole == NetworkRole.MASTER) isBackupPathValid(settings.backupPath) else true) &&
isSyncIntervalValid(settings.syncInterval)
if (!basicValid) return false
// Falls Master, müssen alle erwarteten Clients einen Namen haben
if (settings.networkRole == NetworkRole.MASTER) {
return settings.expectedClients.all { it.name.trim().isNotEmpty() }
}
return true
}
}
@@ -0,0 +1,107 @@
package at.mocode.frontend.features.deviceinitialization.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.features.deviceinitialization.domain.DeviceInitializationValidator
@Composable
fun DeviceInitializationScreen(
viewModel: DeviceInitializationViewModel
) {
val uiState by viewModel.uiState.collectAsState()
// Automatische Discovery starten, wenn wir auf Schritt 0 sind
LaunchedEffect(uiState.currentStep) {
if (uiState.currentStep == 0) viewModel.startDiscovery()
}
Surface(color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"Willkommen bei der Meldestelle",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
Text(
if (uiState.currentStep == 0) "Schritt 1: Netzwerk-Rolle festlegen" else "Schritt 2: Rollenspezifische Konfiguration",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (uiState.currentStep == 0) {
// PHASE 1: NETZWERK-ROLLE
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("🌐 Netzwerk-Rolle wählen", style = MaterialTheme.typography.titleMedium)
Text(
"Wähle aus, ob dieses Gerät als Master (zentrale Datenbank) oder als Client fungiert.",
style = MaterialTheme.typography.bodySmall
)
NetworkRoleSelector(
selectedRole = uiState.settings.networkRole,
onRoleSelected = { viewModel.setNetworkRole(it) }
)
Button(
onClick = { viewModel.nextStep() },
modifier = Modifier.align(Alignment.End)
) {
Text("Weiter")
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
}
}
}
} else {
// PHASE 2: ROLLENSPEZIFISCH (JVM spezifische Implementierung folgt)
DeviceInitializationConfig(
uiState = uiState,
viewModel = viewModel
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = { viewModel.previousStep() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
Spacer(Modifier.width(8.dp))
Text("Zurück zur Rollenauswahl")
}
Button(
onClick = { viewModel.completeInitialization() },
enabled = DeviceInitializationValidator.canContinue(uiState.settings)
) {
Text("Konfiguration abschließen")
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp))
}
}
}
}
}
}
@Composable
expect fun DeviceInitializationConfig(
uiState: DeviceInitializationUiState,
viewModel: DeviceInitializationViewModel
)
@@ -0,0 +1,12 @@
package at.mocode.frontend.features.deviceinitialization.presentation
import at.mocode.frontend.core.network.discovery.DiscoveredService
import at.mocode.frontend.features.deviceinitialization.domain.DeviceInitializationSettings
data class DeviceInitializationUiState(
val currentStep: Int = 0,
val settings: DeviceInitializationSettings = DeviceInitializationSettings(),
val discoveredMasters: List<DiscoveredService> = emptyList(),
val isProcessing: Boolean = false,
val error: String? = null
)
@@ -0,0 +1,66 @@
package at.mocode.frontend.features.deviceinitialization.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.features.deviceinitialization.domain.DeviceInitializationSettings
import at.mocode.frontend.features.deviceinitialization.domain.ExpectedClient
import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class DeviceInitializationViewModel(
private val discoveryService: NetworkDiscoveryService,
private val onInitializationComplete: (DeviceInitializationSettings) -> Unit
) : ViewModel() {
private val _uiState = MutableStateFlow(DeviceInitializationUiState())
val uiState: StateFlow<DeviceInitializationUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
discoveryService.discoveredServices.collect { services ->
_uiState.update { it.copy(discoveredMasters = services) }
}
}
}
fun startDiscovery() {
discoveryService.startDiscovery()
}
fun nextStep() {
_uiState.update { it.copy(currentStep = it.currentStep + 1) }
}
fun previousStep() {
_uiState.update { it.copy(currentStep = (it.currentStep - 1).coerceAtLeast(0)) }
}
fun updateSettings(update: (DeviceInitializationSettings) -> DeviceInitializationSettings) {
_uiState.update { it.copy(settings = update(it.settings)) }
}
fun setNetworkRole(role: NetworkRole) {
updateSettings { it.copy(networkRole = role) }
}
fun addExpectedClient(name: String, role: NetworkRole) {
updateSettings {
it.copy(expectedClients = it.expectedClients + ExpectedClient(name, role))
}
}
fun removeExpectedClient(index: Int) {
updateSettings {
val newList = it.expectedClients.toMutableList().apply { removeAt(index) }
it.copy(expectedClients = newList)
}
}
fun completeInitialization() {
onInitializationComplete(_uiState.value.settings)
}
}
@@ -0,0 +1,63 @@
package at.mocode.frontend.features.deviceinitialization.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
@Composable
fun NetworkRoleSelector(
selectedRole: NetworkRole,
onRoleSelected: (NetworkRole) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
NetworkRoleCard(
title = "Master (Host)",
description = "Verwaltet die zentrale Datenbank und koordiniert den Sync.",
isSelected = selectedRole == NetworkRole.MASTER,
onClick = { onRoleSelected(NetworkRole.MASTER) }
)
NetworkRoleCard(
title = "Client",
description = "Verbindet sich mit einem Master-Gerät im lokalen Netzwerk.",
isSelected = selectedRole == NetworkRole.CLIENT,
onClick = { onRoleSelected(NetworkRole.CLIENT) }
)
}
}
@Composable
private fun NetworkRoleCard(
title: String,
description: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.medium,
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth()
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = isSelected,
onClick = onClick
)
Column {
Text(title, style = MaterialTheme.typography.labelLarge)
Text(
description,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
@@ -0,0 +1,34 @@
package at.mocode.frontend.features.deviceinitialization.domain
import kotlinx.serialization.json.Json
import java.io.File
actual object DeviceInitializationSettingsManager {
private val settingsFile = File("settings.json")
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
actual fun saveSettings(settings: DeviceInitializationSettings) {
try {
val content = json.encodeToString(settings)
settingsFile.writeText(content)
} catch (e: Exception) {
println("Fehler beim Speichern der Einstellungen: ${e.message}")
}
}
actual fun loadSettings(): DeviceInitializationSettings? {
if (!settingsFile.exists()) return null
return try {
val content = settingsFile.readText()
json.decodeFromString<DeviceInitializationSettings>(content)
} catch (e: Exception) {
println("Fehler beim Laden der Einstellungen: ${e.message}")
null
}
}
actual fun isConfigured(): Boolean {
val settings = loadSettings() ?: return false
return DeviceInitializationValidator.canContinue(settings)
}
}
@@ -0,0 +1,268 @@
package at.mocode.frontend.features.deviceinitialization.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsEnumDropdown
import at.mocode.frontend.features.deviceinitialization.domain.DeviceInitializationValidator
import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
import java.io.File
import javax.swing.JFileChooser
import javax.swing.UIManager
@Composable
actual fun DeviceInitializationConfig(
uiState: DeviceInitializationUiState,
viewModel: DeviceInitializationViewModel
) {
val settings = uiState.settings
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("⚙️ Geräte-Konfiguration (${settings.networkRole})", style = MaterialTheme.typography.titleMedium)
MsSettingsField(
value = settings.deviceName,
onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } },
label = "Gerätename",
placeholder = "z.B. Meldestelle-PC-1",
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich."
)
var passwordVisible by remember { mutableStateOf(false) }
MsSettingsField(
value = settings.sharedKey,
onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } },
label = "Sicherheitsschlüssel (Sync-Key)",
placeholder = "Mindestens 8 Zeichen",
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
errorText = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (passwordVisible) "Verbergen" else "Anzeigen"
)
}
}
)
if (settings.networkRole == NetworkRole.MASTER) {
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
Text("👥 Erwartete Clients (Richter, Zeitnehmer, etc.)", style = MaterialTheme.typography.titleSmall)
Text(
"Definiere, welche Geräte sich mit diesem Master synchronisieren dürfen.",
style = MaterialTheme.typography.bodySmall
)
Spacer(Modifier.height(8.dp))
settings.expectedClients.forEachIndexed { index, client ->
ListItem(
headlineContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
client.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
SuggestionChip(
onClick = {},
label = { Text(client.role.name) },
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
labelColor = MaterialTheme.colorScheme.onSecondaryContainer
)
)
}
},
supportingContent = {
Text(
if (client.isOnline) "Verbunden" else "Offline",
style = MaterialTheme.typography.labelSmall,
color = if (client.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
},
trailingContent = {
IconButton(onClick = { viewModel.removeExpectedClient(index) }) {
Icon(
Icons.Default.Delete,
contentDescription = "Löschen",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
},
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
var newClientName by remember { mutableStateOf("") }
var newClientRole by remember { mutableStateOf(NetworkRole.RICHTER) }
var showAddClient by remember { mutableStateOf(false) }
if (showAddClient) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = newClientName,
onValueChange = { newClientName = it },
label = { Text("Gerätename des Clients") },
modifier = Modifier.weight(1f)
)
MsEnumDropdown(
label = "Rolle",
options = NetworkRole.entries.filter { it != NetworkRole.MASTER }.toTypedArray(),
selectedOption = newClientRole,
onOptionSelected = { newClientRole = it },
modifier = Modifier.weight(0.5f)
)
Button(
onClick = {
if (newClientName.isNotBlank()) {
viewModel.addExpectedClient(newClientName, newClientRole)
newClientName = ""
showAddClient = false
}
},
enabled = newClientName.isNotBlank()
) {
Icon(Icons.Default.Add, null)
}
}
} else {
TextButton(onClick = { showAddClient = true }) {
Icon(Icons.Default.Add, null)
Spacer(Modifier.width(8.dp))
Text("Client hinzufügen")
}
}
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
OutlinedTextField(
value = settings.backupPath,
onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } },
label = { Text("Backup-Verzeichnis (Pfad)") },
placeholder = { Text("/pfad/zu/den/backups") },
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
IconButton(onClick = {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
val chooser = JFileChooser().apply {
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
dialogTitle = "Backup-Verzeichnis wählen"
if (settings.backupPath.isNotEmpty()) {
val currentDir = File(settings.backupPath)
if (currentDir.exists()) currentDirectory = currentDir
}
}
val result = chooser.showOpenDialog(null)
if (result == JFileChooser.APPROVE_OPTION) {
viewModel.updateSettings { s -> s.copy(backupPath = chooser.selectedFile.absolutePath) }
}
} catch (e: Exception) {
println("[Error] Fehler beim Öffnen des Verzeichnis-Wählers: ${e.message}")
}
}) {
Icon(Icons.Outlined.FolderOpen, contentDescription = "Verzeichnis wählen")
}
},
isError = settings.backupPath.isNotEmpty() && !DeviceInitializationValidator.isBackupPathValid(settings.backupPath)
)
Text("Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.labelMedium)
Slider(
value = settings.syncInterval.toFloat(),
onValueChange = { viewModel.updateSettings { s -> s.copy(syncInterval = it.toInt()) } },
valueRange = 1f..60f,
steps = 59
)
} else {
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
Text("🔍 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleSmall)
if (uiState.discoveredMasters.isEmpty()) {
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Text("Suche nach Master...", modifier = Modifier.padding(start = 40.dp))
}
}
uiState.discoveredMasters.forEach { service ->
ListItem(
headlineContent = { Text(service.name) },
supportingContent = { Text("${service.host}:${service.port}") },
trailingContent = {
Button(onClick = {
viewModel.updateSettings { s -> s.copy(sharedKey = service.metadata["key"] ?: s.sharedKey) }
}) {
Text("Verbinden")
}
},
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
)
}
Text(
"Hinweis: Als Client wird dieses Gerät automatisch versuchen, den Master im Netzwerk zu finden.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun MsSettingsField(
value: String,
onValueChange: (String) -> Unit,
label: String,
placeholder: String,
isError: Boolean,
errorText: String,
visualTransformation: VisualTransformation = VisualTransformation.None,
trailingIcon: @Composable (() -> Unit)? = null
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = { Text(placeholder) },
modifier = Modifier.fillMaxWidth(),
isError = isError,
visualTransformation = visualTransformation,
trailingIcon = trailingIcon,
supportingText = {
if (isError) {
Text(errorText)
}
}
)
}
@@ -0,0 +1,11 @@
package at.mocode.frontend.features.deviceinitialization.domain
actual object DeviceInitializationSettingsManager {
actual fun saveSettings(settings: DeviceInitializationSettings) {
// Nicht implementiert für WasmJS
}
actual fun loadSettings(): DeviceInitializationSettings? = null
actual fun isConfigured(): Boolean = false
}
@@ -0,0 +1,12 @@
package at.mocode.frontend.features.deviceinitialization.presentation
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
actual fun DeviceInitializationConfig(
uiState: DeviceInitializationUiState,
viewModel: DeviceInitializationViewModel
) {
Text("Konfiguration für Web (WasmJS) ist nicht implementiert.")
}