diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsEnumDropdown.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsEnumDropdown.kt index bd14a13c..ddffc919 100644 --- a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsEnumDropdown.kt +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsEnumDropdown.kt @@ -1,10 +1,11 @@ package at.mocode.frontend.core.designsystem.components -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.HelpOutline import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.* import androidx.compose.ui.unit.dp @@ -31,6 +32,7 @@ fun > MsEnumDropdown( onOptionSelected: (T) -> Unit, modifier: Modifier = Modifier, optionLabel: (T) -> String = { it.name }, + helpDescription: String? = null, enabled: Boolean = true, isError: Boolean = false, errorMessage: String? = null @@ -46,7 +48,44 @@ fun > MsEnumDropdown( value = selectedOption?.let { optionLabel(it) } ?: "", onValueChange = {}, readOnly = true, - label = { Text(label, style = MaterialTheme.typography.bodySmall) }, + label = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(label, style = MaterialTheme.typography.bodySmall) + if (helpDescription != null) { + var showHelp by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { showHelp = !showHelp }, + modifier = Modifier.size(16.dp) + ) { + Icon( + imageVector = Icons.Default.HelpOutline, + contentDescription = "Hilfe", + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) + ) + } + if (showHelp) { + @OptIn(ExperimentalMaterial3Api::class) + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text(helpDescription) + } + }, + state = rememberTooltipState(isPersistent = true) + ) { + // Tooltip wird durch Klick auf das Icon getriggert + } + } + } + } + } + }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), modifier = Modifier diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.kt index a63f33d8..4aef9ee1 100644 --- a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.kt +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.kt @@ -13,6 +13,7 @@ expect fun MsFilePicker( onFileSelected: (String) -> Unit, fileExtensions: List = emptyList(), directoryOnly: Boolean = false, + helpDescription: String? = null, enabled: Boolean = true, modifier: Modifier = Modifier ) diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsStringDropdown.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsStringDropdown.kt index bef0101b..2aea4fd8 100644 --- a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsStringDropdown.kt +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsStringDropdown.kt @@ -1,10 +1,11 @@ package at.mocode.frontend.core.designsystem.components -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.HelpOutline import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.* import androidx.compose.ui.unit.dp @@ -21,6 +22,7 @@ fun MsStringDropdown( onOptionSelected: (String) -> Unit, modifier: Modifier = Modifier, placeholder: String = "", + helpDescription: String? = null, enabled: Boolean = true, isError: Boolean = false, errorMessage: String? = null @@ -36,7 +38,44 @@ fun MsStringDropdown( value = selectedOption, onValueChange = {}, readOnly = true, - label = { Text(label, style = MaterialTheme.typography.bodySmall) }, + label = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(label, style = MaterialTheme.typography.bodySmall) + if (helpDescription != null) { + var showHelp by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { showHelp = !showHelp }, + modifier = Modifier.size(16.dp) + ) { + Icon( + imageVector = Icons.Default.HelpOutline, + contentDescription = "Hilfe", + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) + ) + } + if (showHelp) { + @OptIn(ExperimentalMaterial3Api::class) + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text(helpDescription) + } + }, + state = rememberTooltipState(isPersistent = true) + ) { + // Tooltip wird durch Klick auf das Icon getriggert + } + } + } + } + } + }, placeholder = { Text(placeholder, style = MaterialTheme.typography.bodySmall) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsTextField.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsTextField.kt index 02e05899..2cc478c2 100644 --- a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsTextField.kt +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsTextField.kt @@ -3,6 +3,8 @@ package at.mocode.frontend.core.designsystem.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.HelpOutline import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -27,6 +29,7 @@ fun MsTextField( isError: Boolean = false, errorMessage: String? = null, helperText: String? = null, + helpDescription: String? = null, enabled: Boolean = true, readOnly: Boolean = false, singleLine: Boolean = true, @@ -41,12 +44,47 @@ fun MsTextField( Column(modifier = modifier) { if (label != null) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 4.dp, start = 4.dp) - ) + Row( + modifier = Modifier.padding(bottom = 4.dp, start = 4.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + if (helpDescription != null) { + var showHelp by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { showHelp = !showHelp }, + modifier = Modifier.size(16.dp) + ) { + Icon( + imageVector = Icons.Default.HelpOutline, + contentDescription = "Hilfe", + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) + ) + } + if (showHelp) { + @OptIn(ExperimentalMaterial3Api::class) + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text(helpDescription) + } + }, + state = rememberTooltipState(isPersistent = true) + ) { + // Tooltip wird durch Klick auf das Icon getriggert + } + } + } + } + } } OutlinedTextField( diff --git a/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt b/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt index d587dc59..51152c2c 100644 --- a/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt +++ b/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt @@ -20,6 +20,7 @@ actual fun MsFilePicker( onFileSelected: (String) -> Unit, fileExtensions: List, directoryOnly: Boolean, + helpDescription: String?, enabled: Boolean, modifier: Modifier ) { @@ -32,6 +33,7 @@ actual fun MsFilePicker( onValueChange = { }, readOnly = true, label = label, + helpDescription = helpDescription, placeholder = if (directoryOnly) "Verzeichnis wählen..." else "Datei wählen...", modifier = Modifier.weight(1f), enabled = enabled, diff --git a/frontend/core/design-system/src/wasmJsMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.wasmJs.kt b/frontend/core/design-system/src/wasmJsMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.wasmJs.kt index a3be190a..3132e2f5 100644 --- a/frontend/core/design-system/src/wasmJsMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.wasmJs.kt +++ b/frontend/core/design-system/src/wasmJsMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.wasmJs.kt @@ -10,6 +10,7 @@ actual fun MsFilePicker( onFileSelected: (String) -> Unit, fileExtensions: List, directoryOnly: Boolean, + helpDescription: String?, enabled: Boolean, modifier: Modifier ) { diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/domain/model/DeviceInitializationSettings.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/domain/model/DeviceInitializationSettings.kt index 5eeeedfa..9c0b5a2e 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/domain/model/DeviceInitializationSettings.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/domain/model/DeviceInitializationSettings.kt @@ -27,6 +27,7 @@ data class ExpectedClient( @Serializable data class DeviceInitializationSettings( val deviceName: String = "", + val networkInterface: String = "", val sharedKey: String = "", val backupPath: String = "", val networkRole: NetworkRole = NetworkRole.CLIENT, diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt index 813a42f9..92867b3d 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt @@ -2,6 +2,8 @@ package at.mocode.frontend.features.device.initialization.presentation +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -10,6 +12,7 @@ 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.material.icons.filled.Edit +import androidx.compose.material.icons.filled.NetworkCheck import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -19,12 +22,65 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1 import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2 import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator +@Composable +private fun DiscoveryRadar() { + val infiniteTransition = rememberInfiniteTransition() + val radius by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 40f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + val alpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + val color = MaterialTheme.colorScheme.primary + + Box( + modifier = Modifier.size(80.dp), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle( + color = color, + radius = radius.dp.toPx(), + center = Offset(size.width / 2, size.height / 2), + style = Stroke(width = 2.dp.toPx()), + alpha = alpha + ) + drawCircle( + color = color, + radius = (radius * 0.5f).dp.toPx(), + center = Offset(size.width / 2, size.height / 2), + style = Stroke(width = 1.dp.toPx()), + alpha = alpha * 0.5f + ) + } + Icon( + imageVector = Icons.Default.NetworkCheck, + contentDescription = null, + tint = color, + modifier = Modifier.size(24.dp) + ) + } +} + @Composable fun DeviceInitializationScreen( viewModel: DeviceInitializationViewModel @@ -67,6 +123,20 @@ fun DeviceInitializationScreen( style = MaterialTheme.typography.bodySmall ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + DiscoveryRadar() + Spacer(modifier = Modifier.width(16.dp)) + Text( + "Suche nach Geräten im Netzwerk...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + NetworkRoleSelector( selectedRole = uiState.settings.networkRole, onRoleSelected = { diff --git a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/data/local/DeviceInitializationSettingsManager.jvm.kt b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/data/local/DeviceInitializationSettingsManager.jvm.kt index 0d692eb0..7cffe3b4 100644 --- a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/data/local/DeviceInitializationSettingsManager.jvm.kt +++ b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/data/local/DeviceInitializationSettingsManager.jvm.kt @@ -8,7 +8,17 @@ import kotlinx.serialization.json.Json import java.io.File actual object DeviceInitializationSettingsManager { - private val settingsFile = File("settings.json") + private val settingsFile: File by lazy { + val os = System.getProperty("os.name").lowercase() + val appName = "Meldestelle" + val baseDir = when { + os.contains("win") -> File(System.getenv("APPDATA"), appName) + os.contains("mac") -> File(System.getProperty("user.home"), "Library/Application Support/$appName") + else -> File(System.getProperty("user.home"), ".config/$appName") + } + if (!baseDir.exists()) baseDir.mkdirs() + File(baseDir, "settings.json") + } private val json = Json { prettyPrint = true; ignoreUnknownKeys = true } actual fun saveSettings(settings: DeviceInitializationSettings) { diff --git a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt index 6d39e497..b861833b 100644 --- a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt +++ b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt @@ -34,6 +34,7 @@ import at.mocode.frontend.core.designsystem.components.MsStringDropdown import at.mocode.frontend.core.designsystem.components.MsTextField import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole +import java.net.NetworkInterface import javax.print.PrintServiceLookup @Composable @@ -57,6 +58,7 @@ actual fun DeviceInitializationConfig( value = settings.deviceName, onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } }, label = "Gerätename", + helpDescription = "Ein eindeutiger Name für diesen PC (z.B. 'Richter-Springplatz'). Dies hilft dem Master, die Datenquellen zuzuordnen.", placeholder = "z.B. Meldestelle-PC-1", isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName), errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.", @@ -66,11 +68,28 @@ actual fun DeviceInitializationConfig( enabled = !uiState.isLocked ) + val interfaces = remember { + NetworkInterface.getNetworkInterfaces().toList() + .filter { it.isUp && !it.isLoopback && it.inetAddresses.hasMoreElements() } + .map { "${it.displayName} (${it.inetAddresses.nextElement().hostAddress})" } + } + + MsStringDropdown( + label = "Netzwerk-Interface", + helpDescription = "Wähle das Netzwerk-Interface aus, über das die App kommunizieren soll (z.B. LAN für das Turnier-Netzwerk).", + options = interfaces, + selectedOption = settings.networkInterface, + onOptionSelected = { viewModel.updateSettings { s -> s.copy(networkInterface = it) } }, + placeholder = "Interface wählen...", + enabled = !uiState.isLocked + ) + var passwordVisible by remember { mutableStateOf(false) } MsTextField( value = settings.sharedKey, onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } }, label = "Sicherheitsschlüssel (Sync-Key)", + helpDescription = "Das 'Turnier-Passwort'. Nur Geräte mit exakt diesem Schlüssel können Daten austauschen. Wichtig für die Verschlüsselung (DSGVO)!", placeholder = "Mindestens 8 Zeichen", isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey), errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.", @@ -87,6 +106,7 @@ actual fun DeviceInitializationConfig( MsFilePicker( label = "Backup-Verzeichnis (Pfad)", + helpDescription = "Wähle hier deinen USB-Stick oder einen lokalen Ordner aus. Die App speichert hier laufend Sicherheitskopien für den Notfall (Plan-USB).", selectedPath = settings.backupPath, onFileSelected = { selectedPath -> viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) } @@ -102,6 +122,7 @@ actual fun DeviceInitializationConfig( MsStringDropdown( label = "Standard-Drucker", + helpDescription = "Der Drucker, der standardmäßig für Protokolle und Listen verwendet wird. Kann später jederzeit geändert werden.", options = printers, selectedOption = settings.defaultPrinter, onOptionSelected = { viewModel.updateSettings { s -> s.copy(defaultPrinter = it) } },