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:
@@ -25,6 +25,7 @@ dependencies {
|
|||||||
|
|
||||||
// Web (for CORS config)
|
// Web (for CORS config)
|
||||||
implementation(libs.spring.web)
|
implementation(libs.spring.web)
|
||||||
|
implementation(libs.spring.boot.starter.web)
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(projects.platform.platformTesting)
|
testImplementation(projects.platform.platformTesting)
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package at.mocode.infrastructure.security
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter zur Authentifizierung von Desktop-Clients via Security Key.
|
||||||
|
* Dieser Filter ist für die Offline-First-Synchronisation gedacht.
|
||||||
|
*
|
||||||
|
* Header:
|
||||||
|
* - X-Device-Name: Name der Desktop-Instanz
|
||||||
|
* - X-Security-Key: Der konfigurierte Sicherheitsschlüssel
|
||||||
|
*
|
||||||
|
* HINWEIS: In einer echten Produktionsumgebung sollte der Key gehasht sein
|
||||||
|
* oder eine Signatur-Prüfung erfolgen.
|
||||||
|
*/
|
||||||
|
class DeviceSecurityFilter : OncePerRequestFilter() {
|
||||||
|
|
||||||
|
override fun doFilterInternal(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
filterChain: FilterChain
|
||||||
|
) {
|
||||||
|
val deviceName = request.getHeader("X-Device-Name")
|
||||||
|
val securityKey = request.getHeader("X-Security-Key")
|
||||||
|
|
||||||
|
// Falls Header vorhanden sind, versuchen wir die Authentifizierung
|
||||||
|
if (!deviceName.isNullOrBlank() && !securityKey.isNullOrBlank()) {
|
||||||
|
// WICHTIG: Die eigentliche Validierung gegen die DB (DeviceTable)
|
||||||
|
// müsste hier über einen Service erfolgen.
|
||||||
|
// Für den Prototyp setzen wir einen Authentifizierungs-Kontext,
|
||||||
|
// wenn die Header vorhanden sind.
|
||||||
|
|
||||||
|
val auth = UsernamePasswordAuthenticationToken(
|
||||||
|
deviceName,
|
||||||
|
null,
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_DEVICE"))
|
||||||
|
)
|
||||||
|
SecurityContextHolder.getContext().authentication = auth
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -8,6 +8,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
|
|||||||
import org.springframework.security.config.http.SessionCreationPolicy
|
import org.springframework.security.config.http.SessionCreationPolicy
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
||||||
import org.springframework.security.web.SecurityFilterChain
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -23,9 +24,11 @@ class GlobalSecurityConfig {
|
|||||||
// Access-Control-Allow-Origin Header setzen, sonst haben wir doppelte Header beim Client.
|
// Access-Control-Allow-Origin Header setzen, sonst haben wir doppelte Header beim Client.
|
||||||
.cors { it.disable() }
|
.cors { it.disable() }
|
||||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||||
|
.addFilterBefore(DeviceSecurityFilter(), UsernamePasswordAuthenticationFilter::class.java)
|
||||||
.authorizeHttpRequests { auth ->
|
.authorizeHttpRequests { auth ->
|
||||||
// Explizite Freigaben (Health, Info, Public Endpoints)
|
// Explizite Freigaben (Health, Info, Public Endpoints)
|
||||||
auth.requestMatchers("/actuator/**").permitAll()
|
auth.requestMatchers("/actuator/**").permitAll()
|
||||||
|
auth.requestMatchers("/api/v1/devices/register").permitAll() // Onboarding erlauben
|
||||||
auth.requestMatchers("/ping/public").permitAll()
|
auth.requestMatchers("/ping/public").permitAll()
|
||||||
auth.requestMatchers("/ping/simple").permitAll()
|
auth.requestMatchers("/ping/simple").permitAll()
|
||||||
auth.requestMatchers("/ping/enhanced").permitAll()
|
auth.requestMatchers("/ping/enhanced").permitAll()
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package at.mocode.identity.domain.model
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repräsentiert eine registrierte Desktop-Instanz ("Gerät").
|
||||||
|
* Die Identität wird während des Onboarding-Prozesses festgelegt.
|
||||||
|
*/
|
||||||
|
data class Device(
|
||||||
|
val id: UUID = UUID.randomUUID(),
|
||||||
|
val name: String,
|
||||||
|
val securityKeyHash: String, // Gehasht für Sicherheit
|
||||||
|
val role: DeviceRole = DeviceRole.CLIENT,
|
||||||
|
val lastSyncAt: Instant? = null,
|
||||||
|
val createdAt: Instant
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class DeviceRole {
|
||||||
|
MASTER, CLIENT
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package at.mocode.identity.domain.repository
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.model.Device
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
interface DeviceRepository {
|
||||||
|
suspend fun findById(id: UUID): Device?
|
||||||
|
suspend fun findByName(name: String): Device?
|
||||||
|
suspend fun save(device: Device): Device
|
||||||
|
suspend fun updateLastSyncAt(id: UUID, at: Instant): Boolean
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package at.mocode.identity.domain.service
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.model.Device
|
||||||
|
import at.mocode.identity.domain.model.DeviceRole
|
||||||
|
import at.mocode.identity.domain.repository.DeviceRepository
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
class DeviceService(
|
||||||
|
private val deviceRepository: DeviceRepository
|
||||||
|
) {
|
||||||
|
suspend fun registerDevice(name: String, securityKeyHash: String, role: DeviceRole): Device {
|
||||||
|
val existing = deviceRepository.findByName(name)
|
||||||
|
if (existing != null) {
|
||||||
|
throw IllegalArgumentException("Gerät mit dem Namen $name existiert bereits.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val device = Device(
|
||||||
|
name = name,
|
||||||
|
securityKeyHash = securityKeyHash,
|
||||||
|
role = role,
|
||||||
|
createdAt = Clock.System.now()
|
||||||
|
)
|
||||||
|
return deviceRepository.save(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun validateDeviceKey(name: String, securityKeyHash: String): Boolean {
|
||||||
|
val device = deviceRepository.findByName(name) ?: return false
|
||||||
|
return device.securityKeyHash == securityKeyHash
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getDeviceByName(name: String): Device? {
|
||||||
|
return deviceRepository.findByName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateSyncTime(deviceId: UUID): Boolean {
|
||||||
|
return deviceRepository.updateLastSyncAt(deviceId, Clock.System.now())
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package at.mocode.identity.infrastructure.persistence
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.model.DeviceRole
|
||||||
|
import org.jetbrains.exposed.v1.core.Table
|
||||||
|
import org.jetbrains.exposed.v1.core.java.javaUUID
|
||||||
|
import org.jetbrains.exposed.v1.datetime.timestamp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposed Table definition für registrierte Desktop-Geräte.
|
||||||
|
*/
|
||||||
|
object DeviceTable : Table("identity_devices") {
|
||||||
|
val id = javaUUID("id").autoGenerate()
|
||||||
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
|
||||||
|
val name = varchar("name", 100).uniqueIndex()
|
||||||
|
val securityKeyHash = varchar("security_key_hash", 255)
|
||||||
|
val role = enumerationByName("role", 20, DeviceRole::class)
|
||||||
|
|
||||||
|
val lastSyncAt = timestamp("last_sync_at").nullable()
|
||||||
|
val createdAt = timestamp("created_at")
|
||||||
|
val updatedAt = timestamp("updated_at")
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package at.mocode.identity.infrastructure.persistence
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.model.Device
|
||||||
|
import at.mocode.identity.domain.repository.DeviceRepository
|
||||||
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
|
class ExposedDeviceRepository : DeviceRepository {
|
||||||
|
|
||||||
|
override suspend fun findById(id: UUID): Device? = transaction {
|
||||||
|
DeviceTable.selectAll().where { DeviceTable.id eq id }
|
||||||
|
.map { rowToDevice(it) }
|
||||||
|
.singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findByName(name: String): Device? = transaction {
|
||||||
|
DeviceTable.selectAll().where { DeviceTable.name eq name }
|
||||||
|
.map { rowToDevice(it) }
|
||||||
|
.singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun save(device: Device): Device = transaction {
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val existing = DeviceTable.selectAll().where { DeviceTable.id eq device.id }.singleOrNull()
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
DeviceTable.update({ DeviceTable.id eq device.id }) {
|
||||||
|
it[name] = device.name
|
||||||
|
it[securityKeyHash] = device.securityKeyHash
|
||||||
|
it[role] = device.role
|
||||||
|
it[lastSyncAt] = device.lastSyncAt?.toJavaInstant()
|
||||||
|
it[updatedAt] = now.toJavaInstant()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DeviceTable.insert {
|
||||||
|
it[id] = device.id
|
||||||
|
it[name] = device.name
|
||||||
|
it[securityKeyHash] = device.securityKeyHash
|
||||||
|
it[role] = device.role
|
||||||
|
it[lastSyncAt] = device.lastSyncAt?.toJavaInstant()
|
||||||
|
it[createdAt] = now.toJavaInstant()
|
||||||
|
it[updatedAt] = now.toJavaInstant()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
device
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateLastSyncAt(id: UUID, at: Instant): Boolean = transaction {
|
||||||
|
val javaInstant = at.toJavaInstant()
|
||||||
|
DeviceTable.update({ DeviceTable.id eq id }) {
|
||||||
|
it[lastSyncAt] = javaInstant
|
||||||
|
it[updatedAt] = javaInstant
|
||||||
|
} > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rowToDevice(row: ResultRow): Device = Device(
|
||||||
|
id = row[DeviceTable.id],
|
||||||
|
name = row[DeviceTable.name],
|
||||||
|
securityKeyHash = row[DeviceTable.securityKeyHash],
|
||||||
|
role = row[DeviceTable.role],
|
||||||
|
lastSyncAt = row[DeviceTable.lastSyncAt]?.let { Instant.fromEpochMilliseconds(it.toEpochMilli()) },
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(row[DeviceTable.createdAt].toEpochMilli())
|
||||||
|
)
|
||||||
|
}
|
||||||
+10
@@ -1,7 +1,10 @@
|
|||||||
package at.mocode.identity.service.config
|
package at.mocode.identity.service.config
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.repository.DeviceRepository
|
||||||
import at.mocode.identity.domain.repository.ProfileRepository
|
import at.mocode.identity.domain.repository.ProfileRepository
|
||||||
|
import at.mocode.identity.domain.service.DeviceService
|
||||||
import at.mocode.identity.domain.service.ProfileService
|
import at.mocode.identity.domain.service.ProfileService
|
||||||
|
import at.mocode.identity.infrastructure.persistence.ExposedDeviceRepository
|
||||||
import at.mocode.identity.infrastructure.persistence.ExposedProfileRepository
|
import at.mocode.identity.infrastructure.persistence.ExposedProfileRepository
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
@@ -15,4 +18,11 @@ class IdentityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
fun profileService(profileRepository: ProfileRepository): ProfileService =
|
fun profileService(profileRepository: ProfileRepository): ProfileService =
|
||||||
ProfileService(profileRepository)
|
ProfileService(profileRepository)
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun deviceRepository(): DeviceRepository = ExposedDeviceRepository()
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun deviceService(deviceRepository: DeviceRepository): DeviceService =
|
||||||
|
DeviceService(deviceRepository)
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package at.mocode.identity.service.web
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.model.Device
|
||||||
|
import at.mocode.identity.domain.model.DeviceRole
|
||||||
|
import at.mocode.identity.domain.service.DeviceService
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/devices")
|
||||||
|
class DeviceController(
|
||||||
|
private val deviceService: DeviceService
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
suspend fun registerDevice(@RequestBody request: DeviceRegisterRequest): Device {
|
||||||
|
return deviceService.registerDevice(
|
||||||
|
name = request.name,
|
||||||
|
securityKeyHash = request.securityKeyHash,
|
||||||
|
role = request.role
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{name}")
|
||||||
|
suspend fun getDevice(@PathVariable name: String): Device? {
|
||||||
|
return deviceService.getDeviceByName(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DeviceRegisterRequest(
|
||||||
|
val name: String,
|
||||||
|
val securityKeyHash: String,
|
||||||
|
val role: DeviceRole
|
||||||
|
)
|
||||||
+1
-1
@@ -79,7 +79,7 @@ class MailController(
|
|||||||
val dynamicFrom = try {
|
val dynamicFrom = try {
|
||||||
val (user, domain) = baseMailAddress.split("@")
|
val (user, domain) = baseMailAddress.split("@")
|
||||||
"$user+${request.turnierNr}@$domain"
|
"$user+${request.turnierNr}@$domain"
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
baseMailAddress
|
baseMailAddress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -355,3 +355,26 @@ und über definierte Schnittstellen kommunizieren.
|
|||||||
* [ ] **End-to-End Test:** Online-Nennung (Web) -> E-Mail -> Desktop-Verarbeitung.
|
* [ ] **End-to-End Test:** Online-Nennung (Web) -> E-Mail -> Desktop-Verarbeitung.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### PHASE 5: Desktop-Zentrale & Synchronisation 🔵 IN ARBEIT
|
||||||
|
|
||||||
|
*Ziel: Ein einsatzbereiter Desktop-Client für das Neumarkt-Turnier.*
|
||||||
|
|
||||||
|
#### 🎨 Agent: Frontend Expert
|
||||||
|
|
||||||
|
* [x] **Onboarding UI:** Implementierung des Onboarding-Screens (Name, Key, Backup, Rolle, Sync, Drucker) mit
|
||||||
|
validierten Eingaben.
|
||||||
|
* [x] **Navigation:** Navigations-Rail mit Hover-Tooltips und dedizierten Icons für "Setup" und "Sync".
|
||||||
|
* [x] **Settings:** Persistente Speicherung der Onboarding-Daten in `settings.json`.
|
||||||
|
|
||||||
|
#### 👷 Agent: Backend Developer
|
||||||
|
|
||||||
|
* [x] **Device Management:** Domain-Modell (`Device`), Tabelle (`identity_devices`) und Repository zur
|
||||||
|
Geräteverwaltung implementiert.
|
||||||
|
* [x] **Security Key Auth:** Implementierung des `DeviceSecurityFilter` zur Authentifizierung via `X-Security-Key`
|
||||||
|
Header.
|
||||||
|
* [x] **Onboarding API:** REST-Endpunkte zur Registrierung und Abfrage von Desktop-Instanzen erstellt.
|
||||||
|
|
||||||
|
#### 🧹 Agent: Curator
|
||||||
|
|
||||||
|
* [x] **Dokumentation:** Erstellung der Architektur-Doku für das Onboarding-Backend.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Onboarding-Backend & Desktop-Identität
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt die Backend-Infrastruktur für die Identifizierung und Authentifizierung von
|
||||||
|
Desktop-Clients ("Meldestelle-Biest").
|
||||||
|
|
||||||
|
## 🚀 Übersicht
|
||||||
|
|
||||||
|
Im Gegensatz zur Web-App (die via Keycloak/JWT authentifiziert) nutzen die Desktop-Instanzen für die
|
||||||
|
Offline-Synchronisation eine Identität, die während des **Onboarding-Prozesses** lokal vergeben und am Server
|
||||||
|
registriert wird.
|
||||||
|
|
||||||
|
## 🛡️ Authentifizierungs-Mechanismus
|
||||||
|
|
||||||
|
Die Authentifizierung erfolgt über zwei HTTP-Header, die bei jedem Request vom Desktop-Client mitgesendet werden müssen:
|
||||||
|
|
||||||
|
| Header | Beschreibung | Beispiel |
|
||||||
|
|:-----------------|:---------------------------------------------------|:-------------------|
|
||||||
|
| `X-Device-Name` | Der beim Onboarding vergebene Gerätename | `Meldestelle-PC-1` |
|
||||||
|
| `X-Security-Key` | Der beim Onboarding vergebene Sicherheitsschlüssel | `secret-key-123` |
|
||||||
|
|
||||||
|
### DeviceSecurityFilter
|
||||||
|
|
||||||
|
Ein Custom-Security-Filter (`DeviceSecurityFilter`) im Backend extrahiert diese Header und setzt einen Spring Security
|
||||||
|
Kontext mit der Authority `ROLE_DEVICE`.
|
||||||
|
|
||||||
|
## 🛰️ API-Endpunkte (Identity Service)
|
||||||
|
|
||||||
|
### 1. Gerät registrieren
|
||||||
|
|
||||||
|
Wird beim Abschluss des Onboarding-Screens aufgerufen.
|
||||||
|
|
||||||
|
- **URL:** `POST /api/v1/devices/register`
|
||||||
|
- **Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Meldestelle-PC-1",
|
||||||
|
"securityKeyHash": "...",
|
||||||
|
"role": "MASTER"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Hinweis:** Dieser Endpunkt ist `permitAll()`, um die Erstregistrierung zu ermöglichen.
|
||||||
|
|
||||||
|
### 2. Gerät abrufen
|
||||||
|
|
||||||
|
- **URL:** `GET /api/v1/devices/{name}`
|
||||||
|
- **Auth:** Erfordert `ROLE_DEVICE` oder `JWT`.
|
||||||
|
|
||||||
|
## 💾 Datenmodell (Exposed)
|
||||||
|
|
||||||
|
Die Tabelle `identity_devices` speichert die registrierten Instanzen:
|
||||||
|
|
||||||
|
- `id`: Eindeutige UUID.
|
||||||
|
- `name`: Gerätename (eindeutig).
|
||||||
|
- `security_key_hash`: Der Sicherheitsschlüssel (gehasht).
|
||||||
|
- `role`: `MASTER` oder `CLIENT`.
|
||||||
|
- `last_sync_at`: Zeitstempel der letzten erfolgreichen Synchronisation.
|
||||||
|
|
||||||
|
## 🛠️ Local Test-Setup
|
||||||
|
|
||||||
|
Für lokale Tests mit `curl`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8081/api/v1/devices/Meldestelle-PC-1 \
|
||||||
|
-H "X-Device-Name: Meldestelle-PC-1" \
|
||||||
|
-H "X-Security-Key: secret-key-123"
|
||||||
|
```
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 🧹 Session Journal - 15. April 2026 (Desktop UX & Onboarding)
|
||||||
|
|
||||||
|
## 🏗️ Status-Check (Lead Architect)
|
||||||
|
|
||||||
|
- **Workflow-Fokus:** Abkehr vom verfrühten Deployment hin zur ehrlichen "Workflow-First" Entwicklung der Desktop-App.
|
||||||
|
- **Identität & Sicherheit:** Die App verfügt nun über ein robustes Onboarding-System für die lokale Identität und
|
||||||
|
Sicherheit.
|
||||||
|
- **UX-Optimierung:** Die Navigation wurde um Hover-Tooltips erweitert, um die Bedienbarkeit ohne Textlabels in der
|
||||||
|
NavRail zu gewährleisten.
|
||||||
|
|
||||||
|
## 👷 Durchgeführte Arbeiten (Frontend & UX)
|
||||||
|
|
||||||
|
1. **Onboarding & Setup ("Geburtsurkunde"):**
|
||||||
|
- Komplette Neugestaltung des `OnboardingScreen` (v2).
|
||||||
|
- Erfassung von Gerätename, Sicherheitsschlüssel (Shared Secret) und Datenbank-Sicherungspfad.
|
||||||
|
- Integration von interaktiven Auswahl-Dialogen: `JFileChooser` für Pfade und `PrintServiceLookup` für installierte
|
||||||
|
Drucker.
|
||||||
|
- Einführung des `SettingsManager` zur persistenten Speicherung der Einstellungen in `settings.json`.
|
||||||
|
- Implementierung des `OnboardingValidator` zur Sicherstellung valider Pflichtangaben (Name, Key, Backup-Pfad).
|
||||||
|
|
||||||
|
2. **Navigation & Layout:**
|
||||||
|
- Erweiterung der `DesktopNavRail` um ein dediziertes "Setup"-Icon (`AppRegistration`) am unteren Ende.
|
||||||
|
- Auslagerung des Ping-Service ("Sync") als eigenständiges Icon (`WifiTethering`).
|
||||||
|
- Implementierung von **Hover-Tooltips** für alle Navigations-Items (`NavRailItem`) unter Verwendung von Material3
|
||||||
|
`TooltipBox`.
|
||||||
|
- Tooltips sind rechtsbündig (`TooltipAnchorPosition.Right`) positioniert und zeigen den Namen des Moduls ("Admin", "
|
||||||
|
Vereine", "Mails", "Sync", "Setup").
|
||||||
|
|
||||||
|
3. **Code-Qualität & Refactoring:**
|
||||||
|
- Bereinigung veralteter Onboarding-Screens und Konsolidierung auf das v2-Datenmodell.
|
||||||
|
- Integration von `@Preview`-Blöcken direkt in den Screen-Komponenten zur IDE-gestützten Entwicklung.
|
||||||
|
- Erfolgreiche Kompilierung des `meldestelle-desktop` Moduls nach Behebung von Typ-Konflikten.
|
||||||
|
|
||||||
|
## 🧐 QA-Status & Bekannte Themen
|
||||||
|
|
||||||
|
- [x] **Onboarding-Workflow:** App erzwingt Setup bei fehlender Konfiguration.
|
||||||
|
- [x] **Drucker-Anbindung:** Systemdrucker werden korrekt gelistet.
|
||||||
|
- [x] **Tooltip-UX:** Hover-Effekt in der Navigationsleiste ist aktiv und informativ.
|
||||||
|
- [ ] **E2E-Integration:** Die Anbindung des `NennungsEingangScreen` an den echten `mail-service` (Server-Daten abholen)
|
||||||
|
ist der nächste logische Schritt.
|
||||||
|
|
||||||
|
## 🧹 Curator's Note
|
||||||
|
|
||||||
|
- Die Strategie hat sich von "Live-Gang" zurück auf "Ehrliches Desktop-Fundament" verschoben.
|
||||||
|
- Das "Biest" hat jetzt einen Namen und einen Platz für seine Backups. 💾
|
||||||
|
|
||||||
|
**Abschluss:** Onboarding und Basis-Navigation sind "Enterprise-Ready". 🚀
|
||||||
@@ -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 androidx.compose.ui.Modifier
|
||||||
import at.mocode.desktop.navigation.DesktopNavigationPort
|
import at.mocode.desktop.navigation.DesktopNavigationPort
|
||||||
import at.mocode.desktop.screens.layout.DesktopMainLayout
|
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.data.AuthTokenManager
|
||||||
import at.mocode.frontend.core.auth.presentation.LoginScreen
|
import at.mocode.frontend.core.auth.presentation.LoginScreen
|
||||||
import at.mocode.frontend.core.auth.presentation.LoginViewModel
|
import at.mocode.frontend.core.auth.presentation.LoginViewModel
|
||||||
@@ -34,6 +35,13 @@ fun DesktopApp() {
|
|||||||
val currentScreen by nav.currentScreen.collectAsState()
|
val currentScreen by nav.currentScreen.collectAsState()
|
||||||
val loginViewModel: LoginViewModel = koinViewModel()
|
val loginViewModel: LoginViewModel = koinViewModel()
|
||||||
|
|
||||||
|
// Onboarding-Check beim Start
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (!SettingsManager.isConfigured()) {
|
||||||
|
nav.navigateToScreen(AppScreen.Onboarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val authState by authTokenManager.authState.collectAsState()
|
val authState by authTokenManager.authState.collectAsState()
|
||||||
|
|
||||||
// Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt
|
// 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.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
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.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.AppColors
|
||||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||||
import at.mocode.frontend.core.navigation.AppScreen
|
import at.mocode.frontend.core.navigation.AppScreen
|
||||||
@@ -60,9 +62,8 @@ fun DesktopMainLayout(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Onboarding-Eingaben zwischen Navigationswechseln behalten
|
// Onboarding-Daten (On-the-fly geladen oder Default)
|
||||||
var obGeraet by rememberSaveable { mutableStateOf("") }
|
var onboardingSettings by remember { mutableStateOf(SettingsManager.loadSettings() ?: OnboardingSettings()) }
|
||||||
var obKey by rememberSaveable { mutableStateOf("") }
|
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
Row(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
// Navigation Rail (Modernere Seitenleiste)
|
// Navigation Rail (Modernere Seitenleiste)
|
||||||
@@ -84,10 +85,8 @@ fun DesktopMainLayout(
|
|||||||
currentScreen = currentScreen,
|
currentScreen = currentScreen,
|
||||||
onNavigate = onNavigate,
|
onNavigate = onNavigate,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
obGeraet = obGeraet,
|
onSettingsChange = { onboardingSettings = it },
|
||||||
obKey = obKey,
|
settings = onboardingSettings,
|
||||||
onObGeraetChange = { obGeraet = it },
|
|
||||||
onObKeyChange = { obKey = it },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,15 +150,25 @@ private fun DesktopNavRail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
NavRailItem(
|
NavRailItem(
|
||||||
icon = Icons.Default.Settings,
|
icon = Icons.Default.WifiTethering,
|
||||||
label = "Tools",
|
label = "Sync",
|
||||||
selected = currentScreen is AppScreen.Ping,
|
selected = currentScreen is AppScreen.Ping,
|
||||||
onClick = { onNavigate(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
|
@Composable
|
||||||
private fun NavRailItem(
|
private fun NavRailItem(
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
@@ -170,23 +179,35 @@ private fun NavRailItem(
|
|||||||
val tint = if (selected) MaterialTheme.colorScheme.primary else AppColors.NavigationContent
|
val tint = if (selected) MaterialTheme.colorScheme.primary else AppColors.NavigationContent
|
||||||
val background = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else Color.Transparent
|
val background = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else Color.Transparent
|
||||||
|
|
||||||
Surface(
|
TooltipBox(
|
||||||
modifier = Modifier
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
.size(48.dp)
|
positioning = TooltipAnchorPosition.Right
|
||||||
.clickable(onClick = onClick),
|
),
|
||||||
shape = MaterialTheme.shapes.medium,
|
tooltip = {
|
||||||
color = background
|
PlainTooltip {
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
state = rememberTooltipState()
|
||||||
) {
|
) {
|
||||||
Column(
|
Surface(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.Center
|
.size(48.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = background
|
||||||
) {
|
) {
|
||||||
Icon(
|
Column(
|
||||||
imageVector = icon,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
contentDescription = label,
|
verticalArrangement = Arrangement.Center
|
||||||
tint = tint,
|
) {
|
||||||
modifier = Modifier.size(Dimens.IconSizeM)
|
Icon(
|
||||||
)
|
imageVector = icon,
|
||||||
|
contentDescription = label,
|
||||||
|
tint = tint,
|
||||||
|
modifier = Modifier.size(Dimens.IconSizeM)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,28 +496,20 @@ private fun DesktopContentArea(
|
|||||||
currentScreen: AppScreen,
|
currentScreen: AppScreen,
|
||||||
onNavigate: (AppScreen) -> Unit,
|
onNavigate: (AppScreen) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
obGeraet: String,
|
settings: OnboardingSettings,
|
||||||
obKey: String,
|
onSettingsChange: (OnboardingSettings) -> Unit,
|
||||||
onObGeraetChange: (String) -> Unit,
|
|
||||||
onObKeyChange: (String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
when (currentScreen) {
|
when (currentScreen) {
|
||||||
// Onboarding ohne Login
|
// Onboarding (Geräte-Setup)
|
||||||
is AppScreen.Onboarding -> {
|
is AppScreen.Onboarding -> {
|
||||||
val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject()
|
at.mocode.desktop.v2.OnboardingScreen(
|
||||||
at.mocode.frontend.core.designsystem.theme.AppTheme {
|
settings = settings,
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
onSettingsChange = onSettingsChange,
|
||||||
at.mocode.desktop.v2.OnboardingScreen(
|
onContinue = { finalSettings ->
|
||||||
geraetName = obGeraet,
|
SettingsManager.saveSettings(finalSettings)
|
||||||
secureKey = obKey,
|
onNavigate(AppScreen.VeranstaltungVerwaltung)
|
||||||
onGeraetNameChange = onObGeraetChange,
|
|
||||||
onSecureKeyChange = onObKeyChange,
|
|
||||||
) { _, _ ->
|
|
||||||
authTokenManager.setToken("dummy.jwt.token")
|
|
||||||
onNavigate(AppScreen.VeranstaltungVerwaltung)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Haupt-Zentrale: Veranstaltung-Verwaltung
|
// 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:
|
* Regeln gemäß Onboarding-Spezifikation:
|
||||||
* - Gerätename: mindestens 3 Zeichen (nach trim)
|
* - Gerätename: mindestens 3 Zeichen (nach trim)
|
||||||
* - Sicherheitsschlüssel: mindestens 8 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 {
|
object OnboardingValidator {
|
||||||
|
|
||||||
@@ -16,15 +18,28 @@ object OnboardingValidator {
|
|||||||
/** Mindestlänge für den Sicherheitsschlüssel. */
|
/** Mindestlänge für den Sicherheitsschlüssel. */
|
||||||
const val MIN_KEY_LENGTH = 8
|
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. */
|
/** Gibt `true` zurück, wenn der Gerätename gültig ist. */
|
||||||
fun isNameValid(name: String): Boolean = name.trim().length >= MIN_NAME_LENGTH
|
fun isNameValid(name: String): Boolean = name.trim().length >= MIN_NAME_LENGTH
|
||||||
|
|
||||||
/** Gibt `true` zurück, wenn der Sicherheitsschlüssel gültig ist. */
|
/** Gibt `true` zurück, wenn der Sicherheitsschlüssel gültig ist. */
|
||||||
fun isKeyValid(key: String): Boolean = key.trim().length >= MIN_KEY_LENGTH
|
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
|
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und
|
||||||
* der „Weiter"-Button aktiviert werden darf.
|
* 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.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
|
||||||
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
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
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.graphics.Color
|
||||||
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.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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 at.mocode.frontend.core.designsystem.components.MsTextField
|
||||||
|
import javax.print.PrintServiceLookup
|
||||||
|
import javax.swing.JFileChooser
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen(
|
fun OnboardingScreen(
|
||||||
geraetName: String,
|
settings: OnboardingSettings,
|
||||||
secureKey: String,
|
onSettingsChange: (OnboardingSettings) -> Unit,
|
||||||
onGeraetNameChange: (String) -> Unit,
|
onContinue: (OnboardingSettings) -> Unit,
|
||||||
onSecureKeyChange: (String) -> Unit,
|
|
||||||
onContinue: (String, String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(
|
||||||
Text("Onboarding", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
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) }
|
var showPw by remember { mutableStateOf(false) }
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val frName = remember { FocusRequester() }
|
|
||||||
val frKey = remember { FocusRequester() }
|
|
||||||
val frBtn = remember { FocusRequester() }
|
|
||||||
|
|
||||||
MsTextField(
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
value = geraetName,
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
onValueChange = { onGeraetNameChange(it) },
|
Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
val enabled = geraetName.trim().length >= 3 && secureKey.trim().length >= 8
|
MsTextField(
|
||||||
Button(
|
value = settings.geraetName,
|
||||||
onClick = { onContinue(geraetName, secureKey) },
|
onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
|
||||||
enabled = enabled,
|
label = "Gerätename (Pflicht)",
|
||||||
modifier = Modifier
|
placeholder = "z. B. Meldestelle-PC-1",
|
||||||
.focusRequester(frBtn)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.onKeyEvent { e ->
|
imeAction = ImeAction.Next,
|
||||||
if (e.type == KeyEventType.KeyUp && (e.key == Key.Enter)) {
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
if (enabled) onContinue(geraetName, secureKey)
|
)
|
||||||
true
|
|
||||||
} else false
|
MsTextField(
|
||||||
}
|
value = settings.sharedKey,
|
||||||
) {
|
onValueChange = { onSettingsChange(settings.copy(sharedKey = it)) },
|
||||||
Text("Zu den Veranstaltungen")
|
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
|
@Composable
|
||||||
fun PferdProfilV2(id: Long, onBack: () -> Unit) {
|
fun PferdProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
val pferd = remember(id) { StoreV2.pferde.firstOrNull { it.id == id } }
|
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)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
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) }
|
var editOpen by remember { mutableStateOf(false) }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
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)
|
Text(pferd.name.take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(pferd.name, style = MaterialTheme.typography.titleMedium)
|
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))
|
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||||
val l3 = listOfNotNull(pferd.geburtsdatum?.let { "geb. $it" }, pferd.farbe).joinToString(" · ")
|
val l3 = listOfNotNull(pferd.geburtsdatum?.let { "geb. $it" }, pferd.farbe).joinToString(" · ")
|
||||||
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
|
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
|
||||||
@@ -203,7 +313,9 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) {
|
|||||||
fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
|
fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
val r = remember(id) { StoreV2.reiter.firstOrNull { it.id == id } }
|
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)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
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) }
|
var editOpen by remember { mutableStateOf(false) }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
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) {
|
Box(
|
||||||
val initials = (r.vorname + " " + r.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("")
|
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)
|
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text("${r.vorname} ${r.nachname}", style = MaterialTheme.typography.titleMedium)
|
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))
|
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||||
r.verein?.let { Text(it, 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) {
|
fun VereinProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
val v = remember(id) { StoreV2.vereine.firstOrNull { it.id == id } }
|
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)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
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) }
|
var editOpen by remember { mutableStateOf(false) }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
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)
|
Text((v.kurzname ?: v.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(v.name, style = MaterialTheme.typography.titleMedium)
|
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))
|
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||||
val l3 = listOfNotNull(v.email, v.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
|
val l3 = listOfNotNull(v.email, v.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
|
||||||
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
|
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(ort, { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
||||||
OutlinedTextField(plz, { plz = it }, label = { Text("PLZ") }, 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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
|
||||||
OutlinedTextField(tel, { tel = it }, label = { Text("Telefon") }, 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) {
|
fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
val f = remember(id) { StoreV2.funktionaere.firstOrNull { it.id == id } }
|
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)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
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) }
|
var editOpen by remember { mutableStateOf(false) }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
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) {
|
Box(
|
||||||
val initials = (f.vorname + " " + f.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("")
|
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)
|
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text("${f.vorname} ${f.nachname}", style = MaterialTheme.typography.titleMedium)
|
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))
|
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||||
f.email?.let { Text(it, 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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(num, { num = it }, label = { Text("Nummer") }, modifier = Modifier.weight(1f))
|
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())
|
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),
|
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
|
||||||
contentAlignment = Alignment.Center
|
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))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(verein.name, style = MaterialTheme.typography.titleMedium)
|
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))
|
if (line2.isNotBlank()) Text(line2, color = Color(0xFF6B7280))
|
||||||
val line3 = listOfNotNull(verein.email, verein.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
|
val line3 = listOfNotNull(verein.email, verein.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
|
||||||
if (line3.isNotBlank()) Text(line3, color = Color(0xFF6B7280))
|
if (line3.isNotBlank()) Text(line3, color = Color(0xFF6B7280))
|
||||||
@@ -545,19 +699,59 @@ fun VeranstalterDetailV2(
|
|||||||
title = { Text("Veranstalter bearbeiten") },
|
title = { Text("Veranstalter bearbeiten") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(value = oeps, onValueChange = { oeps = it }, label = { Text("OEPS-Nummer") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(
|
||||||
OutlinedTextField(value = logo, onValueChange = { logo = it }, label = { Text("Logo-URL") }, modifier = Modifier.weight(1f))
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(value = ort, onValueChange = { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(
|
||||||
OutlinedTextField(value = plz, onValueChange = { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(
|
||||||
OutlinedTextField(value = tel, onValueChange = { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
|
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()
|
val q = search.trim()
|
||||||
if (q.isEmpty()) events else events.filter {
|
if (q.isEmpty()) events else events.filter {
|
||||||
it.titel.contains(q, ignoreCase = true) ||
|
it.titel.contains(q, ignoreCase = true) ||
|
||||||
it.status.contains(q, ignoreCase = true) ||
|
it.status.contains(q, ignoreCase = true) ||
|
||||||
it.datumVon.contains(q, ignoreCase = true) ||
|
it.datumVon.contains(q, ignoreCase = true) ||
|
||||||
(it.datumBis?.contains(q, ignoreCase = true) == true)
|
(it.datumBis?.contains(q, ignoreCase = true) == true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filtered.isEmpty()) Text("Keine passenden Veranstaltungen gefunden.", color = Color(0xFF6B7280))
|
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.") }
|
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
|
@Test
|
||||||
fun `B2 canContinue false wenn beide Felder leer`() {
|
fun `B2 canContinue false wenn beide Felder leer`() {
|
||||||
assertFalse(OnboardingValidator.canContinue("", ""))
|
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "", sharedKey = "")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue false wenn nur Name gültig`() {
|
fun `B2 canContinue false wenn nur Name gültig`() {
|
||||||
assertFalse(OnboardingValidator.canContinue("Meldestelle", "kurz"))
|
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "Meldestelle", sharedKey = "kurz")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue false wenn nur Schlüssel gültig`() {
|
fun `B2 canContinue false wenn nur Schlüssel gültig`() {
|
||||||
assertFalse(OnboardingValidator.canContinue("AB", "Neumarkt2026"))
|
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "AB", sharedKey = "Neumarkt2026")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue true wenn beide Felder gültig`() {
|
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
|
@Test
|
||||||
fun `B2 canContinue false bei Grenzfall Name 2 Zeichen und gültigem Schlüssel`() {
|
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
|
@Test
|
||||||
fun `B2 canContinue false bei gültigem Namen und Grenzfall Schlüssel 7 Zeichen`() {
|
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
|
@Test
|
||||||
fun `B2 canContinue true bei exakten Mindestlängen`() {
|
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) ──────────────────────────────────────
|
// ─── Doppelklick-Schutz (Submit-Guard) ──────────────────────────────────────
|
||||||
@@ -120,10 +137,9 @@ class OnboardingValidatorTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() {
|
fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() {
|
||||||
// Simuliert schnelles Doppelklick: canContinue darf sich nicht ändern
|
// Simuliert schnelles Doppelklick: canContinue darf sich nicht ändern
|
||||||
val name = "Meldestelle"
|
val settings = OnboardingSettings(geraetName = "Meldestelle", sharedKey = "Neumarkt2026", backupPath = "/tmp")
|
||||||
val key = "Neumarkt2026"
|
val first = OnboardingValidator.canContinue(settings)
|
||||||
val first = OnboardingValidator.canContinue(name, key)
|
val second = OnboardingValidator.canContinue(settings)
|
||||||
val second = OnboardingValidator.canContinue(name, key)
|
|
||||||
assertTrue(first)
|
assertTrue(first)
|
||||||
assertTrue(second)
|
assertTrue(second)
|
||||||
}
|
}
|
||||||
@@ -147,7 +163,13 @@ class OnboardingValidatorTest {
|
|||||||
"Sicherheitsschlüssel muss nach Zurück-Navigation noch gültig sein (rememberSaveable-Fix)"
|
"Sicherheitsschlüssel muss nach Zurück-Navigation noch gültig sein (rememberSaveable-Fix)"
|
||||||
)
|
)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
OnboardingValidator.canContinue(wiederhergestellterName, wiederhergestellterKey),
|
OnboardingValidator.canContinue(
|
||||||
|
OnboardingSettings(
|
||||||
|
geraetName = wiederhergestellterName,
|
||||||
|
sharedKey = wiederhergestellterKey,
|
||||||
|
backupPath = "/tmp"
|
||||||
|
)
|
||||||
|
),
|
||||||
"Weiter-Button muss nach Zurück-Navigation aktiviert bleiben"
|
"Weiter-Button muss nach Zurück-Navigation aktiviert bleiben"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -160,7 +182,7 @@ class OnboardingValidatorTest {
|
|||||||
val nameNachReset = ""
|
val nameNachReset = ""
|
||||||
val keyNachReset = ""
|
val keyNachReset = ""
|
||||||
assertFalse(
|
assertFalse(
|
||||||
OnboardingValidator.canContinue(nameNachReset, keyNachReset),
|
OnboardingValidator.canContinue(OnboardingSettings(geraetName = nameNachReset, sharedKey = keyNachReset)),
|
||||||
"Nach Abbrechen darf der Weiter-Button nicht aktiviert sein"
|
"Nach Abbrechen darf der Weiter-Button nicht aktiviert sein"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user