feat(desktop-onboarding): neue Onboarding-UI implementiert, Backup- und Rollenmanagement hinzugefügt
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Failing after 3m10s
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 6m37s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 5m59s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Failing after 3m10s
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 6m37s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 5m59s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
- Einbindung eines komplett überarbeiteten Onboarding-Screens mit validierten Eingaben für Gerätename, Sicherheitsschlüssel und Backup-Pfad. - `SettingsManager` eingeführt zur Speicherung der Onboarding-Daten in `settings.json`. - Navigation verbessert: Onboarding-Workflow startet, wenn Konfiguration fehlt; neues "Setup"-Icon in der Navigationsleiste hinzugefügt. - Backend: Geräte-API und `DeviceSecurityFilter` für Authentifizierung per Sicherheitsschlüssel implementiert. Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"geraetName": "Meldestelle",
|
||||
"sharedKey": "Meldestelle",
|
||||
"backupPath": "/home/stefan/WsMeldestelle/Meldestelle/meldestelle/docs/temp",
|
||||
"networkRole": "MASTER",
|
||||
"syncInterval": 20
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import at.mocode.desktop.navigation.DesktopNavigationPort
|
||||
import at.mocode.desktop.screens.layout.DesktopMainLayout
|
||||
import at.mocode.desktop.screens.onboarding.SettingsManager
|
||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||
import at.mocode.frontend.core.auth.presentation.LoginScreen
|
||||
import at.mocode.frontend.core.auth.presentation.LoginViewModel
|
||||
@@ -34,6 +35,13 @@ fun DesktopApp() {
|
||||
val currentScreen by nav.currentScreen.collectAsState()
|
||||
val loginViewModel: LoginViewModel = koinViewModel()
|
||||
|
||||
// Onboarding-Check beim Start
|
||||
LaunchedEffect(Unit) {
|
||||
if (!SettingsManager.isConfigured()) {
|
||||
nav.navigateToScreen(AppScreen.Onboarding)
|
||||
}
|
||||
}
|
||||
|
||||
val authState by authTokenManager.authState.collectAsState()
|
||||
|
||||
// Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt
|
||||
|
||||
+57
-44
@@ -4,11 +4,11 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.*
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -16,6 +16,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.desktop.screens.onboarding.OnboardingSettings
|
||||
import at.mocode.desktop.screens.onboarding.SettingsManager
|
||||
import at.mocode.frontend.core.designsystem.theme.AppColors
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
import at.mocode.frontend.core.navigation.AppScreen
|
||||
@@ -60,9 +62,8 @@ fun DesktopMainLayout(
|
||||
onBack: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
// Onboarding-Eingaben zwischen Navigationswechseln behalten
|
||||
var obGeraet by rememberSaveable { mutableStateOf("") }
|
||||
var obKey by rememberSaveable { mutableStateOf("") }
|
||||
// Onboarding-Daten (On-the-fly geladen oder Default)
|
||||
var onboardingSettings by remember { mutableStateOf(SettingsManager.loadSettings() ?: OnboardingSettings()) }
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
// Navigation Rail (Modernere Seitenleiste)
|
||||
@@ -84,10 +85,8 @@ fun DesktopMainLayout(
|
||||
currentScreen = currentScreen,
|
||||
onNavigate = onNavigate,
|
||||
onBack = onBack,
|
||||
obGeraet = obGeraet,
|
||||
obKey = obKey,
|
||||
onObGeraetChange = { obGeraet = it },
|
||||
onObKeyChange = { obKey = it },
|
||||
onSettingsChange = { onboardingSettings = it },
|
||||
settings = onboardingSettings,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -151,15 +150,25 @@ private fun DesktopNavRail(
|
||||
)
|
||||
|
||||
NavRailItem(
|
||||
icon = Icons.Default.Settings,
|
||||
label = "Tools",
|
||||
icon = Icons.Default.WifiTethering,
|
||||
label = "Sync",
|
||||
selected = currentScreen is AppScreen.Ping,
|
||||
onClick = { onNavigate(AppScreen.Ping) }
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
NavRailItem(
|
||||
icon = Icons.Default.AppRegistration,
|
||||
label = "Setup",
|
||||
selected = currentScreen is AppScreen.Onboarding,
|
||||
onClick = { onNavigate(AppScreen.Onboarding) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NavRailItem(
|
||||
icon: ImageVector,
|
||||
@@ -170,23 +179,35 @@ private fun NavRailItem(
|
||||
val tint = if (selected) MaterialTheme.colorScheme.primary else AppColors.NavigationContent
|
||||
val background = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else Color.Transparent
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = background
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
positioning = TooltipAnchorPosition.Right
|
||||
),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(label)
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = background
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(Dimens.IconSizeM)
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(Dimens.IconSizeM)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,28 +496,20 @@ private fun DesktopContentArea(
|
||||
currentScreen: AppScreen,
|
||||
onNavigate: (AppScreen) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
obGeraet: String,
|
||||
obKey: String,
|
||||
onObGeraetChange: (String) -> Unit,
|
||||
onObKeyChange: (String) -> Unit,
|
||||
settings: OnboardingSettings,
|
||||
onSettingsChange: (OnboardingSettings) -> Unit,
|
||||
) {
|
||||
when (currentScreen) {
|
||||
// Onboarding ohne Login
|
||||
// Onboarding (Geräte-Setup)
|
||||
is AppScreen.Onboarding -> {
|
||||
val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject()
|
||||
at.mocode.frontend.core.designsystem.theme.AppTheme {
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
at.mocode.desktop.v2.OnboardingScreen(
|
||||
geraetName = obGeraet,
|
||||
secureKey = obKey,
|
||||
onGeraetNameChange = onObGeraetChange,
|
||||
onSecureKeyChange = onObKeyChange,
|
||||
) { _, _ ->
|
||||
authTokenManager.setToken("dummy.jwt.token")
|
||||
onNavigate(AppScreen.VeranstaltungVerwaltung)
|
||||
}
|
||||
at.mocode.desktop.v2.OnboardingScreen(
|
||||
settings = settings,
|
||||
onSettingsChange = onSettingsChange,
|
||||
onContinue = { finalSettings ->
|
||||
SettingsManager.saveSettings(finalSettings)
|
||||
onNavigate(AppScreen.VeranstaltungVerwaltung)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Haupt-Zentrale: Veranstaltung-Verwaltung
|
||||
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
package at.mocode.desktop.screens.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
enum class ZnsStatus { NONE, LOCAL, SYNCED }
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
initialName: String = "",
|
||||
initialKey: String = "",
|
||||
initialZns: ZnsStatus = ZnsStatus.NONE,
|
||||
onZnsSync: () -> Unit = {},
|
||||
onZnsUsb: () -> Unit = {},
|
||||
onContinue: (geraetName: String, sharedKey: String, znsStatus: ZnsStatus) -> Unit,
|
||||
) {
|
||||
var geraetName by rememberSaveable { mutableStateOf(initialName) }
|
||||
var sharedKey by rememberSaveable { mutableStateOf(initialKey) }
|
||||
var znsStatus by rememberSaveable { mutableStateOf(initialZns) }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
|
||||
val nameValid = OnboardingValidator.isNameValid(geraetName)
|
||||
val keyValid = OnboardingValidator.isKeyValid(sharedKey)
|
||||
val canContinue = OnboardingValidator.canContinue(geraetName, sharedKey)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("Onboarding", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
Card {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Gerätename (Pflicht)", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
value = geraetName,
|
||||
onValueChange = { geraetName = it },
|
||||
placeholder = { Text("z. B. Meldestelle") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
isError = !nameValid && geraetName.isNotBlank()
|
||||
)
|
||||
|
||||
Text("Sicherheitsschlüssel (Pflicht)", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
value = sharedKey,
|
||||
onValueChange = { sharedKey = it },
|
||||
placeholder = { Text("z. B. Neumarkt2026") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
isError = !keyValid && sharedKey.isNotBlank(),
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
val label = if (showPassword) "Verbergen" else "Anzeigen"
|
||||
TextButton(onClick = { showPassword = !showPassword }) {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Text("ZNS-Daten (optional)", style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AssistChip(onClick = {
|
||||
znsStatus = ZnsStatus.SYNCED
|
||||
onZnsSync()
|
||||
}, label = { Text("Aktualisieren") })
|
||||
AssistChip(onClick = {
|
||||
znsStatus = ZnsStatus.LOCAL
|
||||
onZnsUsb()
|
||||
}, label = { Text("USB-Import") })
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Status: $znsStatus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = { onContinue(geraetName.trim(), sharedKey.trim(), znsStatus) }, enabled = canContinue) {
|
||||
Text("Weiter zu den Veranstaltungen")
|
||||
}
|
||||
if (!canContinue) {
|
||||
Text("Bitte Gerätename (min. 3) und Schlüssel (min. 8) angeben.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package at.mocode.desktop.screens.onboarding
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class NetworkRole {
|
||||
MASTER,
|
||||
CLIENT
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class OnboardingSettings(
|
||||
val geraetName: String = "",
|
||||
val sharedKey: String = "",
|
||||
val backupPath: String = "",
|
||||
val networkRole: NetworkRole = NetworkRole.CLIENT,
|
||||
val syncInterval: Int = 30, // in Minuten
|
||||
val defaultPrinter: String = ""
|
||||
)
|
||||
+16
-1
@@ -7,6 +7,8 @@ package at.mocode.desktop.screens.onboarding
|
||||
* Regeln gemäß Onboarding-Spezifikation:
|
||||
* - Gerätename: mindestens 3 Zeichen (nach trim)
|
||||
* - Sicherheitsschlüssel: mindestens 8 Zeichen (nach trim)
|
||||
* - Backup-Pfad: darf nicht leer sein und muss existieren (Prüfung optional hier)
|
||||
* - Sync-Intervall: zwischen 1 und 60 Minuten
|
||||
*/
|
||||
object OnboardingValidator {
|
||||
|
||||
@@ -16,15 +18,28 @@ object OnboardingValidator {
|
||||
/** Mindestlänge für den Sicherheitsschlüssel. */
|
||||
const val MIN_KEY_LENGTH = 8
|
||||
|
||||
/** Standard-Sync-Intervall in Minuten. */
|
||||
const val DEFAULT_SYNC_INTERVAL = 30
|
||||
|
||||
/** 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 und
|
||||
* der „Weiter"-Button aktiviert werden darf.
|
||||
*/
|
||||
fun canContinue(name: String, key: String): Boolean = isNameValid(name) && isKeyValid(key)
|
||||
fun canContinue(settings: OnboardingSettings): Boolean =
|
||||
isNameValid(settings.geraetName) &&
|
||||
isKeyValid(settings.sharedKey) &&
|
||||
isBackupPathValid(settings.backupPath) &&
|
||||
isSyncIntervalValid(settings.syncInterval)
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package at.mocode.desktop.screens.onboarding
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
object SettingsManager {
|
||||
private val settingsFile = File("settings.json")
|
||||
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
|
||||
|
||||
fun saveSettings(settings: OnboardingSettings) {
|
||||
try {
|
||||
val content = json.encodeToString(settings)
|
||||
settingsFile.writeText(content)
|
||||
} catch (e: Exception) {
|
||||
println("Fehler beim Speichern der Einstellungen: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSettings(): OnboardingSettings? {
|
||||
if (!settingsFile.exists()) return null
|
||||
return try {
|
||||
val content = settingsFile.readText()
|
||||
json.decodeFromString<OnboardingSettings>(content)
|
||||
} catch (e: Exception) {
|
||||
println("Fehler beim Laden der Einstellungen: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun isConfigured(): Boolean {
|
||||
val settings = loadSettings() ?: return false
|
||||
return OnboardingValidator.canContinue(settings)
|
||||
}
|
||||
}
|
||||
+323
-123
@@ -5,136 +5,242 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.desktop.screens.onboarding.NetworkRole
|
||||
import at.mocode.desktop.screens.onboarding.OnboardingSettings
|
||||
import at.mocode.desktop.screens.onboarding.OnboardingValidator
|
||||
import at.mocode.frontend.core.designsystem.components.MsTextField
|
||||
import javax.print.PrintServiceLookup
|
||||
import javax.swing.JFileChooser
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
geraetName: String,
|
||||
secureKey: String,
|
||||
onGeraetNameChange: (String) -> Unit,
|
||||
onSecureKeyChange: (String) -> Unit,
|
||||
onContinue: (String, String) -> Unit,
|
||||
settings: OnboardingSettings,
|
||||
onSettingsChange: (OnboardingSettings) -> Unit,
|
||||
onContinue: (OnboardingSettings) -> Unit,
|
||||
) {
|
||||
DesktopThemeV2 {
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("Onboarding", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
"Willkommen beim Meldestelle-Biest",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
"Bitte konfiguriere deine lokale Instanz (Geburtsurkunde).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
var showPw by remember { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val frName = remember { FocusRequester() }
|
||||
val frKey = remember { FocusRequester() }
|
||||
val frBtn = remember { FocusRequester() }
|
||||
|
||||
MsTextField(
|
||||
value = geraetName,
|
||||
onValueChange = { onGeraetNameChange(it) },
|
||||
label = "Gerätename (Pflicht)",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(frName)
|
||||
.onKeyEvent { e ->
|
||||
if (e.type == KeyEventType.KeyUp) {
|
||||
when (e.key) {
|
||||
Key.Tab, Key.Enter -> {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
}
|
||||
,
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||
)
|
||||
MsTextField(
|
||||
value = secureKey,
|
||||
onValueChange = { onSecureKeyChange(it) },
|
||||
label = "Sicherheitsschlüssel (Pflicht)",
|
||||
trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
onTrailingIconClick = { showPw = !showPw },
|
||||
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(frKey)
|
||||
.onKeyEvent { e ->
|
||||
if (e.type == KeyEventType.KeyUp) {
|
||||
when (e.key) {
|
||||
Key.Tab -> {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
true
|
||||
}
|
||||
Key.Enter -> {
|
||||
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
|
||||
onContinue(geraetName, secureKey)
|
||||
} else {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
}
|
||||
,
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
|
||||
onContinue(geraetName, secureKey)
|
||||
} else {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
}
|
||||
})
|
||||
)
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
val enabled = geraetName.trim().length >= 3 && secureKey.trim().length >= 8
|
||||
Button(
|
||||
onClick = { onContinue(geraetName, secureKey) },
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.focusRequester(frBtn)
|
||||
.onKeyEvent { e ->
|
||||
if (e.type == KeyEventType.KeyUp && (e.key == Key.Enter)) {
|
||||
if (enabled) onContinue(geraetName, secureKey)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
Text("Zu den Veranstaltungen")
|
||||
MsTextField(
|
||||
value = settings.geraetName,
|
||||
onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
|
||||
label = "Gerätename (Pflicht)",
|
||||
placeholder = "z. B. Meldestelle-PC-1",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||
)
|
||||
|
||||
MsTextField(
|
||||
value = settings.sharedKey,
|
||||
onValueChange = { onSettingsChange(settings.copy(sharedKey = it)) },
|
||||
label = "Sicherheitsschlüssel (Pflicht)",
|
||||
placeholder = "Shared Secret für Netzwerk-Sync",
|
||||
trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
onTrailingIconClick = { showPw = !showPw },
|
||||
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("⚙️ Lokale Einstellungen", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
MsTextField(
|
||||
value = settings.backupPath,
|
||||
onValueChange = { onSettingsChange(settings.copy(backupPath = it)) },
|
||||
label = "💾 Datenbank-Sicherungspfad (Backup)",
|
||||
placeholder = "Pfad zum Backup-Verzeichnis",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
trailingIcon = Icons.Default.FolderOpen,
|
||||
onTrailingIconClick = {
|
||||
val chooser = JFileChooser()
|
||||
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
chooser.dialogTitle = "Backup-Verzeichnis auswählen"
|
||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||
onSettingsChange(settings.copy(backupPath = chooser.selectedFile.absolutePath))
|
||||
}
|
||||
},
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||
)
|
||||
|
||||
Text("🌐 Netzwerk-Rolle", style = MaterialTheme.typography.labelLarge)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = settings.networkRole == NetworkRole.MASTER,
|
||||
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) }
|
||||
)
|
||||
Text(
|
||||
"Master (Hostet lokale DB)",
|
||||
modifier = Modifier.clickable { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = settings.networkRole == NetworkRole.CLIENT,
|
||||
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) }
|
||||
)
|
||||
Text(
|
||||
"Client",
|
||||
modifier = Modifier.clickable { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) })
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Text("📡 Sync-Intervall: ${settings.syncInterval} Minuten", style = MaterialTheme.typography.labelLarge)
|
||||
Slider(
|
||||
value = settings.syncInterval.toFloat(),
|
||||
onValueChange = { onSettingsChange(settings.copy(syncInterval = it.toInt())) },
|
||||
valueRange = 1f..60f,
|
||||
steps = 59,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
var showPrinterDialog by remember { mutableStateOf(false) }
|
||||
val availablePrinters = remember {
|
||||
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }
|
||||
}
|
||||
|
||||
MsTextField(
|
||||
value = settings.defaultPrinter,
|
||||
onValueChange = { onSettingsChange(settings.copy(defaultPrinter = it)) },
|
||||
label = "🖨️ Standard-Drucker",
|
||||
placeholder = "Name des Standard-Druckers",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
trailingIcon = Icons.Default.Print,
|
||||
onTrailingIconClick = { showPrinterDialog = true },
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
if (OnboardingValidator.canContinue(settings)) onContinue(settings)
|
||||
})
|
||||
)
|
||||
|
||||
if (showPrinterDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showPrinterDialog = false },
|
||||
title = { Text("Drucker auswählen") },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
if (availablePrinters.isEmpty()) {
|
||||
Text("Keine Drucker gefunden", style = MaterialTheme.typography.bodyMedium)
|
||||
} else {
|
||||
availablePrinters.forEach { printer ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onSettingsChange(settings.copy(defaultPrinter = printer))
|
||||
showPrinterDialog = false
|
||||
}
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = settings.defaultPrinter == printer,
|
||||
onClick = null
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(printer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showPrinterDialog = false }) {
|
||||
Text("Schließen")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val canContinue = OnboardingValidator.canContinue(settings)
|
||||
Button(
|
||||
onClick = { onContinue(settings) },
|
||||
enabled = canContinue,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("Konfiguration speichern & starten")
|
||||
}
|
||||
|
||||
if (!canContinue) {
|
||||
Text(
|
||||
"Bitte alle Pflichtfelder korrekt ausfüllen (Name min. 3, Key min. 8, Backup-Pfad gesetzt).",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
if (!enabled) Text("Mind. 3 Zeichen für Namen und 8 Zeichen für Schlüssel", color = Color(0xFFB00020))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OnboardingScreenPreview() {
|
||||
var settings by remember { mutableStateOf(OnboardingSettings()) }
|
||||
OnboardingScreen(
|
||||
settings = settings,
|
||||
onSettingsChange = { settings = it },
|
||||
onContinue = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PferdProfilV2(id: Long, onBack: () -> Unit) {
|
||||
DesktopThemeV2 {
|
||||
val pferd = remember(id) { StoreV2.pferde.firstOrNull { it.id == id } }
|
||||
if (pferd == null) { Text("Pferd nicht gefunden"); return@DesktopThemeV2 }
|
||||
if (pferd == null) {
|
||||
Text("Pferd nicht gefunden"); return@DesktopThemeV2
|
||||
}
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
||||
@@ -144,13 +250,17 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) {
|
||||
var editOpen by remember { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.size(56.dp).background(Color(0xFF374151), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).background(Color(0xFF374151), shape = MaterialTheme.shapes.small),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(pferd.name.take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(pferd.name, style = MaterialTheme.typography.titleMedium)
|
||||
val l2 = listOfNotNull(pferd.oepsNummer?.let { "OEPS: $it" }, pferd.feiId?.let { "FEI: $it" }).joinToString(" · ")
|
||||
val l2 =
|
||||
listOfNotNull(pferd.oepsNummer?.let { "OEPS: $it" }, pferd.feiId?.let { "FEI: $it" }).joinToString(" · ")
|
||||
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||
val l3 = listOfNotNull(pferd.geburtsdatum?.let { "geb. $it" }, pferd.farbe).joinToString(" · ")
|
||||
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
|
||||
@@ -203,7 +313,9 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) {
|
||||
fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
|
||||
DesktopThemeV2 {
|
||||
val r = remember(id) { StoreV2.reiter.firstOrNull { it.id == id } }
|
||||
if (r == null) { Text("Reiter nicht gefunden"); return@DesktopThemeV2 }
|
||||
if (r == null) {
|
||||
Text("Reiter nicht gefunden"); return@DesktopThemeV2
|
||||
}
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
||||
@@ -213,14 +325,22 @@ fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
|
||||
var editOpen by remember { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.size(56.dp).background(Color(0xFF4B5563), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
|
||||
val initials = (r.vorname + " " + r.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("")
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).background(Color(0xFF4B5563), shape = MaterialTheme.shapes.small),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val initials =
|
||||
(r.vorname + " " + r.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2)
|
||||
.joinToString("")
|
||||
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("${r.vorname} ${r.nachname}", style = MaterialTheme.typography.titleMedium)
|
||||
val l2 = listOfNotNull(r.oepsNummer?.let { "OEPS: $it" }, r.feiId?.let { "FEI: $it" }, r.lizenzKlasse.takeIf { it.isNotBlank() } ).joinToString(" · ")
|
||||
val l2 = listOfNotNull(
|
||||
r.oepsNummer?.let { "OEPS: $it" },
|
||||
r.feiId?.let { "FEI: $it" },
|
||||
r.lizenzKlasse.takeIf { it.isNotBlank() }).joinToString(" · ")
|
||||
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||
r.verein?.let { Text(it, color = Color(0xFF6B7280)) }
|
||||
}
|
||||
@@ -277,7 +397,9 @@ fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
|
||||
fun VereinProfilV2(id: Long, onBack: () -> Unit) {
|
||||
DesktopThemeV2 {
|
||||
val v = remember(id) { StoreV2.vereine.firstOrNull { it.id == id } }
|
||||
if (v == null) { Text("Verein nicht gefunden"); return@DesktopThemeV2 }
|
||||
if (v == null) {
|
||||
Text("Verein nicht gefunden"); return@DesktopThemeV2
|
||||
}
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
||||
@@ -287,13 +409,17 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
|
||||
var editOpen by remember { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text((v.kurzname ?: v.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(v.name, style = MaterialTheme.typography.titleMedium)
|
||||
val l2 = listOfNotNull("OEPS: ${v.oepsNummer}", v.ort, v.plz, v.strasse).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
val l2 = listOfNotNull("OEPS: ${v.oepsNummer}", v.ort, v.plz, v.strasse).filter { it.isNotBlank() }
|
||||
.joinToString(" · ")
|
||||
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||
val l3 = listOfNotNull(v.email, v.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
|
||||
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
|
||||
@@ -340,7 +466,12 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
|
||||
OutlinedTextField(ort, { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(plz, { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
||||
}
|
||||
OutlinedTextField(strasse, { strasse = it }, label = { Text("Straße / Adresse") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(
|
||||
strasse,
|
||||
{ strasse = it },
|
||||
label = { Text("Straße / Adresse") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(tel, { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
|
||||
@@ -357,7 +488,9 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
|
||||
fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
|
||||
DesktopThemeV2 {
|
||||
val f = remember(id) { StoreV2.funktionaere.firstOrNull { it.id == id } }
|
||||
if (f == null) { Text("Funktionär nicht gefunden"); return@DesktopThemeV2 }
|
||||
if (f == null) {
|
||||
Text("Funktionär nicht gefunden"); return@DesktopThemeV2
|
||||
}
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
||||
@@ -367,14 +500,21 @@ fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
|
||||
var editOpen by remember { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.size(56.dp).background(Color(0xFF111827), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
|
||||
val initials = (f.vorname + " " + f.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("")
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).background(Color(0xFF111827), shape = MaterialTheme.shapes.small),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val initials =
|
||||
(f.vorname + " " + f.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2)
|
||||
.joinToString("")
|
||||
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("${f.vorname} ${f.nachname}", style = MaterialTheme.typography.titleMedium)
|
||||
val l2 = listOfNotNull(f.richterNummer?.let { "Nr. $it" }, f.richterQualifikation?.let { "Qual.: $it" }).joinToString(" · ")
|
||||
val l2 = listOfNotNull(
|
||||
f.richterNummer?.let { "Nr. $it" },
|
||||
f.richterQualifikation?.let { "Qual.: $it" }).joinToString(" · ")
|
||||
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||
f.email?.let { Text(it, color = Color(0xFF6B7280)) }
|
||||
}
|
||||
@@ -411,7 +551,12 @@ fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(num, { num = it }, label = { Text("Nummer") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(qual, { qual = it }, label = { Text("Qualifikation") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(
|
||||
qual,
|
||||
{ qual = it },
|
||||
label = { Text("Qualifikation") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
@@ -500,12 +645,21 @@ fun VeranstalterDetailV2(
|
||||
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text((verein.kurzname ?: verein.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
(verein.kurzname ?: verein.name).take(2).uppercase(),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(verein.name, style = MaterialTheme.typography.titleMedium)
|
||||
val line2 = listOfNotNull("OEPS: ${verein.oepsNummer}", verein.ort, verein.plz, verein.strasse).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
val line2 = listOfNotNull(
|
||||
"OEPS: ${verein.oepsNummer}",
|
||||
verein.ort,
|
||||
verein.plz,
|
||||
verein.strasse
|
||||
).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
if (line2.isNotBlank()) Text(line2, color = Color(0xFF6B7280))
|
||||
val line3 = listOfNotNull(verein.email, verein.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
|
||||
if (line3.isNotBlank()) Text(line3, color = Color(0xFF6B7280))
|
||||
@@ -545,19 +699,59 @@ fun VeranstalterDetailV2(
|
||||
title = { Text("Veranstalter bearbeiten") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = oeps, onValueChange = { oeps = it }, label = { Text("OEPS-Nummer") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(value = logo, onValueChange = { logo = it }, label = { Text("Logo-URL") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(
|
||||
value = oeps,
|
||||
onValueChange = { oeps = it },
|
||||
label = { Text("OEPS-Nummer") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = logo,
|
||||
onValueChange = { logo = it },
|
||||
label = { Text("Logo-URL") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = ort, onValueChange = { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(value = plz, onValueChange = { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(
|
||||
value = ort,
|
||||
onValueChange = { ort = it },
|
||||
label = { Text("Ort") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = plz,
|
||||
onValueChange = { plz = it },
|
||||
label = { Text("PLZ") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
OutlinedTextField(value = strasse, onValueChange = { strasse = it }, label = { Text("Straße / Adresse") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(
|
||||
value = strasse,
|
||||
onValueChange = { strasse = it },
|
||||
label = { Text("Straße / Adresse") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(value = tel, onValueChange = { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("E-Mail") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tel,
|
||||
onValueChange = { tel = it },
|
||||
label = { Text("Telefon") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,9 +774,9 @@ fun VeranstalterDetailV2(
|
||||
val q = search.trim()
|
||||
if (q.isEmpty()) events else events.filter {
|
||||
it.titel.contains(q, ignoreCase = true) ||
|
||||
it.status.contains(q, ignoreCase = true) ||
|
||||
it.datumVon.contains(q, ignoreCase = true) ||
|
||||
(it.datumBis?.contains(q, ignoreCase = true) == true)
|
||||
it.status.contains(q, ignoreCase = true) ||
|
||||
it.datumVon.contains(q, ignoreCase = true) ||
|
||||
(it.datumBis?.contains(q, ignoreCase = true) == true)
|
||||
}
|
||||
}
|
||||
if (filtered.isEmpty()) Text("Keine passenden Veranstaltungen gefunden.", color = Color(0xFF6B7280))
|
||||
@@ -613,7 +807,13 @@ fun VeranstalterDetailV2(
|
||||
text = { Text("Diese Aktion entfernt die Veranstaltung und alle zugehörigen Turniere im Prototypen.") }
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { confirm = true }) { Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626)) }
|
||||
IconButton(onClick = { confirm = true }) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Löschen",
|
||||
tint = Color(0xFFDC2626)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
-13
@@ -82,37 +82,54 @@ class OnboardingValidatorTest {
|
||||
|
||||
@Test
|
||||
fun `B2 canContinue false wenn beide Felder leer`() {
|
||||
assertFalse(OnboardingValidator.canContinue("", ""))
|
||||
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "", sharedKey = "")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `B2 canContinue false wenn nur Name gültig`() {
|
||||
assertFalse(OnboardingValidator.canContinue("Meldestelle", "kurz"))
|
||||
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "Meldestelle", sharedKey = "kurz")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `B2 canContinue false wenn nur Schlüssel gültig`() {
|
||||
assertFalse(OnboardingValidator.canContinue("AB", "Neumarkt2026"))
|
||||
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "AB", sharedKey = "Neumarkt2026")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `B2 canContinue true wenn beide Felder gültig`() {
|
||||
assertTrue(OnboardingValidator.canContinue("Meldestelle", "Neumarkt2026"))
|
||||
// Beachte: backupPath muss für true auch gesetzt sein
|
||||
assertTrue(
|
||||
OnboardingValidator.canContinue(
|
||||
OnboardingSettings(
|
||||
geraetName = "Meldestelle",
|
||||
sharedKey = "Neumarkt2026",
|
||||
backupPath = "/tmp"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `B2 canContinue false bei Grenzfall Name 2 Zeichen und gültigem Schlüssel`() {
|
||||
assertFalse(OnboardingValidator.canContinue("AB", "12345678"))
|
||||
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "AB", sharedKey = "12345678")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `B2 canContinue false bei gültigem Namen und Grenzfall Schlüssel 7 Zeichen`() {
|
||||
assertFalse(OnboardingValidator.canContinue("Meldestelle", "1234567"))
|
||||
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "Meldestelle", sharedKey = "1234567")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `B2 canContinue true bei exakten Mindestlängen`() {
|
||||
assertTrue(OnboardingValidator.canContinue("ABC", "12345678"))
|
||||
assertTrue(
|
||||
OnboardingValidator.canContinue(
|
||||
OnboardingSettings(
|
||||
geraetName = "ABC",
|
||||
sharedKey = "12345678",
|
||||
backupPath = "/tmp"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Doppelklick-Schutz (Submit-Guard) ──────────────────────────────────────
|
||||
@@ -120,10 +137,9 @@ class OnboardingValidatorTest {
|
||||
@Test
|
||||
fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() {
|
||||
// Simuliert schnelles Doppelklick: canContinue darf sich nicht ändern
|
||||
val name = "Meldestelle"
|
||||
val key = "Neumarkt2026"
|
||||
val first = OnboardingValidator.canContinue(name, key)
|
||||
val second = OnboardingValidator.canContinue(name, key)
|
||||
val settings = OnboardingSettings(geraetName = "Meldestelle", sharedKey = "Neumarkt2026", backupPath = "/tmp")
|
||||
val first = OnboardingValidator.canContinue(settings)
|
||||
val second = OnboardingValidator.canContinue(settings)
|
||||
assertTrue(first)
|
||||
assertTrue(second)
|
||||
}
|
||||
@@ -147,7 +163,13 @@ class OnboardingValidatorTest {
|
||||
"Sicherheitsschlüssel muss nach Zurück-Navigation noch gültig sein (rememberSaveable-Fix)"
|
||||
)
|
||||
assertTrue(
|
||||
OnboardingValidator.canContinue(wiederhergestellterName, wiederhergestellterKey),
|
||||
OnboardingValidator.canContinue(
|
||||
OnboardingSettings(
|
||||
geraetName = wiederhergestellterName,
|
||||
sharedKey = wiederhergestellterKey,
|
||||
backupPath = "/tmp"
|
||||
)
|
||||
),
|
||||
"Weiter-Button muss nach Zurück-Navigation aktiviert bleiben"
|
||||
)
|
||||
}
|
||||
@@ -160,7 +182,7 @@ class OnboardingValidatorTest {
|
||||
val nameNachReset = ""
|
||||
val keyNachReset = ""
|
||||
assertFalse(
|
||||
OnboardingValidator.canContinue(nameNachReset, keyNachReset),
|
||||
OnboardingValidator.canContinue(OnboardingSettings(geraetName = nameNachReset, sharedKey = keyNachReset)),
|
||||
"Nach Abbrechen darf der Weiter-Button nicht aktiviert sein"
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user