feat(device-initialization, core): Unterstützung für Hilfe-Tooltips, Netzwerk-Interface-Auswahl & Discovery-Radar ergänzt

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-29 12:45:44 +02:00
parent d0edfa2538
commit 8ecc9fbe52
10 changed files with 237 additions and 15 deletions
@@ -1,10 +1,11 @@
package at.mocode.frontend.core.designsystem.components package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -31,6 +32,7 @@ fun <T : Enum<T>> MsEnumDropdown(
onOptionSelected: (T) -> Unit, onOptionSelected: (T) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
optionLabel: (T) -> String = { it.name }, optionLabel: (T) -> String = { it.name },
helpDescription: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
isError: Boolean = false, isError: Boolean = false,
errorMessage: String? = null errorMessage: String? = null
@@ -46,7 +48,44 @@ fun <T : Enum<T>> MsEnumDropdown(
value = selectedOption?.let { optionLabel(it) } ?: "", value = selectedOption?.let { optionLabel(it) } ?: "",
onValueChange = {}, onValueChange = {},
readOnly = true, 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) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier modifier = Modifier
@@ -13,6 +13,7 @@ expect fun MsFilePicker(
onFileSelected: (String) -> Unit, onFileSelected: (String) -> Unit,
fileExtensions: List<String> = emptyList(), fileExtensions: List<String> = emptyList(),
directoryOnly: Boolean = false, directoryOnly: Boolean = false,
helpDescription: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) )
@@ -1,10 +1,11 @@
package at.mocode.frontend.core.designsystem.components package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -21,6 +22,7 @@ fun MsStringDropdown(
onOptionSelected: (String) -> Unit, onOptionSelected: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
placeholder: String = "", placeholder: String = "",
helpDescription: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
isError: Boolean = false, isError: Boolean = false,
errorMessage: String? = null errorMessage: String? = null
@@ -36,7 +38,44 @@ fun MsStringDropdown(
value = selectedOption, value = selectedOption,
onValueChange = {}, onValueChange = {},
readOnly = true, 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) }, placeholder = { Text(placeholder, style = MaterialTheme.typography.bodySmall) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
@@ -3,6 +3,8 @@ package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -27,6 +29,7 @@ fun MsTextField(
isError: Boolean = false, isError: Boolean = false,
errorMessage: String? = null, errorMessage: String? = null,
helperText: String? = null, helperText: String? = null,
helpDescription: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
readOnly: Boolean = false, readOnly: Boolean = false,
singleLine: Boolean = true, singleLine: Boolean = true,
@@ -41,12 +44,47 @@ fun MsTextField(
Column(modifier = modifier) { Column(modifier = modifier) {
if (label != null) { if (label != null) {
Row(
modifier = Modifier.padding(bottom = 4.dp, start = 4.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
modifier = Modifier.padding(bottom = 4.dp, start = 4.dp)
) )
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( OutlinedTextField(
@@ -20,6 +20,7 @@ actual fun MsFilePicker(
onFileSelected: (String) -> Unit, onFileSelected: (String) -> Unit,
fileExtensions: List<String>, fileExtensions: List<String>,
directoryOnly: Boolean, directoryOnly: Boolean,
helpDescription: String?,
enabled: Boolean, enabled: Boolean,
modifier: Modifier modifier: Modifier
) { ) {
@@ -32,6 +33,7 @@ actual fun MsFilePicker(
onValueChange = { }, onValueChange = { },
readOnly = true, readOnly = true,
label = label, label = label,
helpDescription = helpDescription,
placeholder = if (directoryOnly) "Verzeichnis wählen..." else "Datei wählen...", placeholder = if (directoryOnly) "Verzeichnis wählen..." else "Datei wählen...",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
enabled = enabled, enabled = enabled,
@@ -10,6 +10,7 @@ actual fun MsFilePicker(
onFileSelected: (String) -> Unit, onFileSelected: (String) -> Unit,
fileExtensions: List<String>, fileExtensions: List<String>,
directoryOnly: Boolean, directoryOnly: Boolean,
helpDescription: String?,
enabled: Boolean, enabled: Boolean,
modifier: Modifier modifier: Modifier
) { ) {
@@ -27,6 +27,7 @@ data class ExpectedClient(
@Serializable @Serializable
data class DeviceInitializationSettings( data class DeviceInitializationSettings(
val deviceName: String = "", val deviceName: String = "",
val networkInterface: String = "",
val sharedKey: String = "", val sharedKey: String = "",
val backupPath: String = "", val backupPath: String = "",
val networkRole: NetworkRole = NetworkRole.CLIENT, val networkRole: NetworkRole = NetworkRole.CLIENT,
@@ -2,6 +2,8 @@
package at.mocode.frontend.features.device.initialization.presentation 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.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2 import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.focusRequester 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.input.key.*
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator 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 @Composable
fun DeviceInitializationScreen( fun DeviceInitializationScreen(
viewModel: DeviceInitializationViewModel viewModel: DeviceInitializationViewModel
@@ -67,6 +123,20 @@ fun DeviceInitializationScreen(
style = MaterialTheme.typography.bodySmall 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( NetworkRoleSelector(
selectedRole = uiState.settings.networkRole, selectedRole = uiState.settings.networkRole,
onRoleSelected = { onRoleSelected = {
@@ -8,7 +8,17 @@ import kotlinx.serialization.json.Json
import java.io.File import java.io.File
actual object DeviceInitializationSettingsManager { 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 } private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
actual fun saveSettings(settings: DeviceInitializationSettings) { actual fun saveSettings(settings: DeviceInitializationSettings) {
@@ -34,6 +34,7 @@ import at.mocode.frontend.core.designsystem.components.MsStringDropdown
import at.mocode.frontend.core.designsystem.components.MsTextField 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.DeviceInitializationValidator
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
import java.net.NetworkInterface
import javax.print.PrintServiceLookup import javax.print.PrintServiceLookup
@Composable @Composable
@@ -57,6 +58,7 @@ actual fun DeviceInitializationConfig(
value = settings.deviceName, value = settings.deviceName,
onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } }, onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } },
label = "Gerätename", 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", placeholder = "z.B. Meldestelle-PC-1",
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName), isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.", errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
@@ -66,11 +68,28 @@ actual fun DeviceInitializationConfig(
enabled = !uiState.isLocked 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) } var passwordVisible by remember { mutableStateOf(false) }
MsTextField( MsTextField(
value = settings.sharedKey, value = settings.sharedKey,
onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } }, onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } },
label = "Sicherheitsschlüssel (Sync-Key)", 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", placeholder = "Mindestens 8 Zeichen",
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey), isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.", errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
@@ -87,6 +106,7 @@ actual fun DeviceInitializationConfig(
MsFilePicker( MsFilePicker(
label = "Backup-Verzeichnis (Pfad)", 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, selectedPath = settings.backupPath,
onFileSelected = { selectedPath -> onFileSelected = { selectedPath ->
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) } viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
@@ -102,6 +122,7 @@ actual fun DeviceInitializationConfig(
MsStringDropdown( MsStringDropdown(
label = "Standard-Drucker", 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, options = printers,
selectedOption = settings.defaultPrinter, selectedOption = settings.defaultPrinter,
onOptionSelected = { viewModel.updateSettings { s -> s.copy(defaultPrinter = it) } }, onOptionSelected = { viewModel.updateSettings { s -> s.copy(defaultPrinter = it) } },