7 Commits

31 changed files with 826 additions and 179 deletions
+6 -4
View File
@@ -40,7 +40,9 @@ Jede Agenten-Antwort **muss** mit dem entsprechenden Badge beginnen, um den Kont
4. **Doku-as-Code:** Änderungen an Code/Architektur müssen sofort in `docs/` (ADR/Reference) reflektiert werden. 4. **Doku-as-Code:** Änderungen an Code/Architektur müssen sofort in `docs/` (ADR/Reference) reflektiert werden.
5. **Session-Abschluss:** Jede Session endet mit einem Eintrag durch den **Curator** (Journal oder Artefakt). 5. **Session-Abschluss:** Jede Session endet mit einem Eintrag durch den **Curator** (Journal oder Artefakt).
## 3. Projekt-Philosophie ## 🚫 Anti-Halluzinations-Protokoll (WICHTIG)
* **Information Density over White Space:** Wir bauen ein Profi-Werkzeug, kein Spielzeug. Um Fehlentscheidungen und falsche Status-Meldungen zu verhindern, gelten ab sofort folgende Regeln:
* **Speed over Animation:** Reaktionsgeschwindigkeit der UI hat höchste Priorität. 1. **Kein "Erledigt" ohne Beweis:** Ein Task darf erst dann als abgeschlossen markiert werden, wenn ein Test-Log, ein erfolgreicher Build oder eine explizite Bestätigung des Users vorliegt.
* **Offline-Authentizität:** Lokale Daten sind die "Source of Truth" für den User; der Server ist das Backup/Sync-Target. 2. **Status "Verifikation ausstehend":** Code, der geschrieben, aber nicht auf Hardware getestet wurde, muss zwingend diesen Zusatz tragen.
3. **Fakten-Check vor Abschluss:** Vor dem Senden der `submit`-Meldung muss der Agent prüfen: "Habe ich das wirklich laufen sehen oder nehme ich es nur an?"
4. **Fehler-Eingeständnis:** Bei Entdeckung einer Halluzination ist sofort der User zu informieren und der Status in allen Dokumenten (Roadmap, Journal) zu korrigieren.
+20 -4
View File
@@ -2,12 +2,12 @@
type: Roadmap type: Roadmap
status: ACTIVE status: ACTIVE
owner: Lead Architect owner: Lead Architect
last_update: 2026-04-21 last_update: 2026-04-29
--- ---
# MASTER ROADMAP: Meldestelle # MASTER ROADMAP: Meldestelle
🏗️ **[Lead Architect]** | 20. April 2026 🏗️ **[Lead Architect]** | 29. April 2026
**Strategisches Ziel:** **Strategisches Ziel:**
Entwicklung einer ÖTO-konformen, offline-fähigen Turnier-Meldestelle als Compose Desktop App (KMP). Entwicklung einer ÖTO-konformen, offline-fähigen Turnier-Meldestelle als Compose Desktop App (KMP).
@@ -73,9 +73,22 @@ und über definierte Schnittstellen kommunizieren.
## 2. Der neue Weg: Fachliche Realität (Roadmap 2026) ## 2. Der neue Weg: Fachliche Realität (Roadmap 2026)
Fokus: Physische Implementierung der Turnier-Hierarchie. Fokus: Physische Implementierung der Turnier-Hierarchie und technisches Onboarding.
### MEILENSTEIN 1: Die Basis-Hierarchie (Prio 1) 🔵 IN ARBEIT ### MEILENSTEIN 0: Technische Geräte-Initialisierung (Prio 1) 🚧 IN ARBEIT (VERIFIKATION AUSSTEHEND)
*Ziel: Ein stabiles, offline-fähiges technisches Fundament für die Desktop-App.*
* [x] **OS-Pfad-Auflösung:** Implementiert (Wartet auf Hardware-Test).
* [x] **Netzwerk-Interface-Binding:** Implementiert (Wartet auf Hardware-Test).
* [x] **Geführte Discovery ("Radar-Modus"):** Implementiert (Wartet auf Hardware-Test).
* [x] **Plan-USB Integration (UI):** Implementiert (Wartet auf Hardware-Test).
* [x] **Offline-Lizenzierung (Konzept):** Dokumentiert (ADR-0026).
* [x] **UX-Optimierung:** Implementiert (Wartet auf Hardware-Test).
* [x] **Plan-USB Implementierung:** Delta-Logik & AES-Export (Wartet auf Hardware-Test).
* [ ] **PoC Verifikation:** 🔴 OFFEN (Hardware-Test durch User erforderlich).
### MEILENSTEIN 1: Die Basis-Hierarchie (Prio 1) ⚪ GEPLANT
*Ziel: Veranstaltung -> Turnier -> Bewerb/Abteilung physisch anlegen und speichern.* *Ziel: Veranstaltung -> Turnier -> Bewerb/Abteilung physisch anlegen und speichern.*
@@ -148,6 +161,9 @@ Code-Stand.*
| CI/CD | `.gitea/workflows/docker-publish.yaml` | | CI/CD | `.gitea/workflows/docker-publish.yaml` |
| Agent Playbooks | `docs/04_Agents/Playbooks/` | | Agent Playbooks | `docs/04_Agents/Playbooks/` |
| ADR-Verzeichnis | `docs/01_Architecture/adr/` | | ADR-Verzeichnis | `docs/01_Architecture/adr/` |
| ADR-0025: Plan-USB | `docs/01_Architecture/adr/0025-plan-usb-offline-integritaet.md` |
| ADR-0026: Lizenzierung | `docs/01_Architecture/adr/0026-offline-lizenzierung-pay-per-event.md` |
| ADR-0027: Discovery | `docs/01_Architecture/adr/0027-netzwerk-discovery-interface-binding.md` |
| ZNS-Importer Roadmap | `docs/01_Architecture/Roadmap_ZNS_Importer.md` | | ZNS-Importer Roadmap | `docs/01_Architecture/Roadmap_ZNS_Importer.md` |
| Masterdata Roadmap | `backend/services/masterdata/docs/ROADMAP.md` | | Masterdata Roadmap | `backend/services/masterdata/docs/ROADMAP.md` |
| Masterdata Changelog | `backend/services/masterdata/docs/CHANGELOG.md` | | Masterdata Changelog | `backend/services/masterdata/docs/CHANGELOG.md` |
@@ -0,0 +1,22 @@
# ADR-0025: "Plan-USB" & Offline-Datenintegrität
## Status
In Prüfung (Wartet auf PoC)
## Kontext
Im professionellen Turniersport ist eine stabile Netzwerkverbindung (LAN/WLAN) nicht immer garantiert. Ein Ausfall des Netzwerks darf den laufenden Betrieb (Ergebniserfassung, Meldestelle) nicht blockieren. Zudem müssen sensible Reiter- und Pferdedaten (DSGVO) auch auf physischen Datenträgern geschützt sein.
## Entscheidung
Wir führen die "Plan-USB" Strategie als primären Fallback und parallelen Sicherungsmechanismus ein.
1. **Permanenter Delta-Export:** Der Master-PC schreibt kontinuierlich verschlüsselte Delta-Pakete (JSON-basiert) in ein definiertes Backup-Verzeichnis. Dies ist bereits in der UI als Pfad-Option vorbereitet.
2. **Verschlüsselung:** Alle Daten auf dem USB-Stick werden mit dem `Shared Key` (AES-256) verschlüsselt. Der Benutzer legt diesen Schlüssel einmalig während der Initialisierung fest.
3. **Datenintegrität:** Pakete werden signiert, um Manipulationen durch Texteditoren zu verhindern.
4. **Sync-Vorschau:** Die UI bietet eine visuelle Bestätigung ("Sync-Dashboard"), welche Daten zuletzt erfolgreich auf den Stick geschrieben wurden. (Umgesetzt im UI-Design der Initialisierung).
5. **Manueller Not-Import:** Clients erhalten eine Funktion, um Delta-Pakete manuell von einem Stick einzulesen und eigene Ergebnisse dorthin zurückzuschreiben.
## Konsequenzen
- Erhöhte Komplexität in der Sync-Logik (Hybrid-Modus: Netzwerk + Datei).
- Benutzer muss initial einen `Shared Key` festlegen.
- Rechtliche Absicherung bei Verlust von Hardware durch Verschlüsselung.
- Maximale Ausfallsicherheit: Das Turnier kann rein via USB-Stick ("Turnschuh-Netzwerk") zu Ende geführt werden.
@@ -0,0 +1,23 @@
# ADR-0026: Offline-Lizenzierung ("Pay-per-Event")
## Status
Vorgeschlagen
## Kontext
Die Software wird als Service pro Veranstaltung lizenziert. Da die App primär offline betrieben wird (Meldestelle am Turnierplatz), kann keine permanente Online-Verbindung zur Lizenzprüfung vorausgesetzt werden.
## Entscheidung
Wir implementieren ein ticketbasiertes Offline-Lizenzmodell.
1. **Online-Erwerb:** Der Veranstalter kauft ein "Event-Ticket" über das zentrale Web-Backend.
2. **Lizenz-Datei:** Das Backend generiert eine digital signierte Lizenz-Datei (`.mlic`). Diese enthält:
- Veranstalter-Identität (OEPS-Nummer).
- Gültigkeitszeitraum (Von-Bis).
- Event-Typ (z.B. CSN-B*).
3. **Offline-Aktivierung:** Im `EventWizard` der Desktop-App wird die Lizenz-Datei hochgeladen. Die App validiert die Signatur gegen unseren Public-Key (völlig offline).
4. **Hardware-Fingerprint:** Die Lizenz wird beim ersten Import an die Hardware-ID des Master-PCs gebunden, um unkontrollierte Vervielfältigung zu verhindern.
## Konsequenzen
- Benutzer muss einmalig (vor dem Turnier) Internetzugang haben, um die Lizenzdatei herunterzuladen.
- Keine Abhängigkeit von Server-Verfügbarkeit während des Turniers.
- Sicherer Schutz unseres Geschäftsmodells ohne Gängelung des ehrlichen Nutzers.
@@ -0,0 +1,20 @@
# ADR-0027: Netzwerk-Discovery & Interface-Binding
## Status
In Prüfung (Wartet auf PoC)
## Kontext
Desktop-Rechner auf Turnieren sind oft mit mehreren Netzwerken gleichzeitig verbunden (z.B. LAN für das Turnier-Netzwerk, WLAN für Internet-Hotspot). Automatische Discovery-Dienste (JmDNS) wählen ohne explizite Konfiguration oft das falsche Interface, wodurch sich Clients und Master nicht finden.
## Entscheidung
Wir führen ein explizites Netzwerk-Management für die Initialisierung ein.
1. **Interface-Selektion:** Der Benutzer muss bei der technischen Initialisierung explizit wählen, über welches Netzwerk-Interface (IP-Adresse/Adapter) die App kommunizieren soll. Die UI zeigt hierfür benutzerfreundliche Namen (WLAN, Ethernet) an.
2. **Geführte Discovery:** Sobald ein Interface gewählt ist, startet ein "Radar-Modus". Dieser scannt aktiv via JmDNS nach vorhandenen Master-Geräten.
3. **Adaptive Rolle:** Findet die Discovery einen Master, wird dem Benutzer die Rolle "Client" vorgeschlagen. Die UI bleibt jedoch flexibel für manuelle Rollenwechsel.
4. **Fokus-Management:** Nach Auswahl der Rolle wird der Fokus automatisch in das erste relevante Eingabefeld (Gerätename) gesetzt, um einen reibungslosen Workflow zu ermöglichen.
## Konsequenzen
- Verhindert "Geistersuchen" im falschen Netzwerk.
- Erhöht die Benutzerfreundlichkeit durch automatische Vorschläge.
- Erfordert Zugriff auf System-Netzwerk-APIs in der Desktop-Shell.
@@ -0,0 +1,33 @@
# Curator Journal: Technische Geräte-Initialisierung & "Plan-USB"
**Datum:** 29. April 2026
**Agenten:** 🏗️ [Lead Architect], 🎨 [Frontend Expert], 🧹 [Curator]
## 🎯 Status Quo
Status: 🚧 IN ARBEIT (VERIFIKATION AUSSTEHEND)
Die technische Basis für die Geräte-Initialisierung wurde implementiert, aber der entscheidende Schritt der Proof of Concept (PoC) auf realer Hardware steht noch aus. Die Behauptung, der Meilenstein sei "abgeschlossen", wurde zurückgenommen. Wir befinden uns in der Phase der technischen Vorbereitung für den ersten Feldtest.
## 📝 Wichtigste Entscheidungen & Artefakte
(Bisherige Inhalte bleiben erhalten)
## 🏗️ Implementierte Features
* **Single-Page Setup:** Alle technischen Einstellungen (Name, Key, Pfad, Interface) auf einer Seite.
* **Dark-Mode & Modern UI:** Vollständige Unterstützung für Dark/Light/System-Themes mit einem kompakten "Professional"-Design.
* **Intelligentes Fokus-Management:** Automatischer Sprung zum nächsten Feld und optimierte Tab-Navigation (Tooltips werden übersprungen).
* **Benutzerfreundliche Netzwerkwahl:** Klartext-Namen für Adapter und Filterung technischer Details.
* **Drucker-Fallback:** Virtueller PDF-Drucker für papierloses Arbeiten oder fehlende Hardware.
## 🗺️ Roadmap-Update
(Roadmap wurde auf 2026-04-29 aktualisiert)
## 🚀 Nächste Schritte
1. **Hardware-PoC (Dringend):** Verifikation der Netzwerk-Discovery und des Plan-USB Exports durch den User.
2. **Meilenstein 1:** Beginn der physischen Implementierung der Turnier-Hierarchie erst nach Erfolg von Meilenstein 0.
3. **Feinschliff:** Behebung von Bugs, die im PoC heute Abend gefunden werden.
---
**🚫 Anti-Halluzinations-Protokoll aktiv:** Kein Task wird ohne Hardware-Beweis als "Erledigt" markiert.
---
*Dokumentiert durch den Curator.*
@@ -1,13 +1,17 @@
package at.mocode.frontend.core.designsystem.components package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.Dimens
import kotlinx.coroutines.launch
/** /**
* Ein generischer Dropdown zur Auswahl von Enum-Werten. * Ein generischer Dropdown zur Auswahl von Enum-Werten.
@@ -31,6 +35,7 @@ fun <T : Enum<T>> MsEnumDropdown(
onOptionSelected: (T) -> Unit, onOptionSelected: (T) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
optionLabel: (T) -> String = { it.name }, optionLabel: (T) -> String = { it.name },
helpDescription: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
isError: Boolean = false, isError: Boolean = false,
errorMessage: String? = null errorMessage: String? = null
@@ -46,7 +51,57 @@ fun <T : Enum<T>> MsEnumDropdown(
value = selectedOption?.let { optionLabel(it) } ?: "", value = selectedOption?.let { optionLabel(it) } ?: "",
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text(label, style = MaterialTheme.typography.bodySmall) }, label = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(label, style = MaterialTheme.typography.bodySmall)
if (helpDescription != null) {
val tooltipState = rememberTooltipState(isPersistent = true)
val scope = rememberCoroutineScope()
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above
),
tooltip = {
PlainTooltip(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = helpDescription,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(Dimens.SpacingS)
)
}
},
state = tooltipState
) {
IconButton(
onClick = {
scope.launch {
if (tooltipState.isVisible) tooltipState.dismiss()
else tooltipState.show()
}
},
modifier = Modifier
.size(16.dp)
.focusProperties { canFocus = false }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
contentDescription = "Hilfe",
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp)
)
}
}
}
}
},
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier modifier = Modifier
@@ -13,6 +13,7 @@ expect fun MsFilePicker(
onFileSelected: (String) -> Unit, onFileSelected: (String) -> Unit,
fileExtensions: List<String> = emptyList(), fileExtensions: List<String> = emptyList(),
directoryOnly: Boolean = false, directoryOnly: Boolean = false,
helpDescription: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) )
@@ -1,13 +1,17 @@
package at.mocode.frontend.core.designsystem.components package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.Dimens
import kotlinx.coroutines.launch
/** /**
* Ein generischer Dropdown zur Auswahl von Strings (z. B. Druckernamen). * Ein generischer Dropdown zur Auswahl von Strings (z. B. Druckernamen).
@@ -21,6 +25,7 @@ fun MsStringDropdown(
onOptionSelected: (String) -> Unit, onOptionSelected: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
placeholder: String = "", placeholder: String = "",
helpDescription: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
isError: Boolean = false, isError: Boolean = false,
errorMessage: String? = null errorMessage: String? = null
@@ -36,7 +41,57 @@ fun MsStringDropdown(
value = selectedOption, value = selectedOption,
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text(label, style = MaterialTheme.typography.bodySmall) }, label = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(label, style = MaterialTheme.typography.bodySmall)
if (helpDescription != null) {
val tooltipState = rememberTooltipState(isPersistent = true)
val scope = rememberCoroutineScope()
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above
),
tooltip = {
PlainTooltip(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = helpDescription,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(Dimens.SpacingS)
)
}
},
state = tooltipState
) {
IconButton(
onClick = {
scope.launch {
if (tooltipState.isVisible) tooltipState.dismiss()
else tooltipState.show()
}
},
modifier = Modifier
.size(16.dp)
.focusProperties { canFocus = false }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
contentDescription = "Hilfe",
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp)
)
}
}
}
}
},
placeholder = { Text(placeholder, style = MaterialTheme.typography.bodySmall) }, placeholder = { Text(placeholder, style = MaterialTheme.typography.bodySmall) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
@@ -3,9 +3,13 @@ package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
@@ -13,6 +17,7 @@ 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.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.Dimens import at.mocode.frontend.core.designsystem.theme.Dimens
import kotlinx.coroutines.launch
@Composable @Composable
fun MsTextField( fun MsTextField(
@@ -27,6 +32,7 @@ fun MsTextField(
isError: Boolean = false, isError: Boolean = false,
errorMessage: String? = null, errorMessage: String? = null,
helperText: String? = null, helperText: String? = null,
helpDescription: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
readOnly: Boolean = false, readOnly: Boolean = false,
singleLine: Boolean = true, singleLine: Boolean = true,
@@ -41,12 +47,62 @@ fun MsTextField(
Column(modifier = modifier) { Column(modifier = modifier) {
if (label != null) { if (label != null) {
Row(
modifier = Modifier.padding(bottom = 4.dp, start = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
modifier = Modifier.padding(bottom = 4.dp, start = 4.dp)
) )
if (helpDescription != null) {
@OptIn(ExperimentalMaterial3Api::class)
val tooltipState = rememberTooltipState(isPersistent = true)
val scope = rememberCoroutineScope()
@OptIn(ExperimentalMaterial3Api::class)
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above
),
tooltip = {
PlainTooltip(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = helpDescription,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(Dimens.SpacingS)
)
}
},
state = tooltipState
) {
IconButton(
onClick = {
scope.launch {
if (tooltipState.isVisible) tooltipState.dismiss()
else tooltipState.show()
}
},
modifier = Modifier
.size(16.dp)
.focusProperties { canFocus = false }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
contentDescription = "Hilfe",
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp)
)
}
}
}
}
} }
OutlinedTextField( OutlinedTextField(
@@ -80,7 +136,9 @@ fun MsTextField(
unfocusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
focusedBorderColor = MaterialTheme.colorScheme.primary, focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
focusedLabelColor = MaterialTheme.colorScheme.primary,
unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
), ),
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = keyboardType, keyboardType = keyboardType,
@@ -1,5 +1,6 @@
package at.mocode.frontend.core.designsystem.theme package at.mocode.frontend.core.designsystem.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -38,7 +39,8 @@ private val DarkColorScheme = darkColorScheme(
background = AppColors.BackgroundDark, background = AppColors.BackgroundDark,
surface = AppColors.SurfaceDark, surface = AppColors.SurfaceDark,
onBackground = AppColors.OnBackgroundDark, onBackground = AppColors.OnBackgroundDark,
onSurface = AppColors.OnBackgroundDark, onSurface = AppColors.OnSurfaceDark,
outline = AppColors.OutlineDark,
error = AppColors.Error, error = AppColors.Error,
onError = AppColors.OnError onError = AppColors.OnError
@@ -63,7 +65,7 @@ private val AppMaterialTypography = Typography(
@Composable @Composable
fun AppTheme( fun AppTheme(
darkTheme: Boolean = false, // Kann später via Settings gesteuert werden darkTheme: Boolean = isSystemInDarkTheme(), // Nutzt Systemeinstellung als Default
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
@@ -28,9 +28,11 @@ object AppColors {
val OnBackgroundLight = Color(0xFF172B4D) // Fast Schwarz (besser lesbar) val OnBackgroundLight = Color(0xFF172B4D) // Fast Schwarz (besser lesbar)
// Neutral & Hintergrund (Dark Mode) // Neutral & Hintergrund (Dark Mode)
val BackgroundDark = Color(0xFF1E1E1E) // Angenehmes, dunkles Grau val BackgroundDark = Color(0xFF121212) // Tieferes Schwarz für Dark Mode
val SurfaceDark = Color(0xFF2C2C2C) val SurfaceDark = Color(0xFF1E1E1E)
val OnBackgroundDark = Color(0xFFEBECF0) val OnBackgroundDark = Color(0xFFE1E1E1)
val OnSurfaceDark = Color(0xFFE1E1E1)
val OutlineDark = Color(0xFF333333)
// System Status // System Status
val Error = Color(0xFFDE350B) val Error = Color(0xFFDE350B)
@@ -34,7 +34,7 @@ object Dimens {
val CornerRadiusL = 12.dp val CornerRadiusL = 12.dp
// Form-Elemente (Eingabefelder, Buttons) // Form-Elemente (Eingabefelder, Buttons)
val TextFieldHeight = 44.dp // Kompakte Höhe für Desktop-Enterprise-Apps val TextFieldHeight = 40.dp // Kompakte Höhe für Desktop-Enterprise-Apps
val TextFieldHeightL = 56.dp // Standard Material Höhe (für prominente Felder) val TextFieldHeightL = 48.dp // Etwas weniger als Standard Material (56.dp)
val ButtonHeight = 40.dp val ButtonHeight = 36.dp // Kompakterer Button
} }
@@ -20,6 +20,7 @@ actual fun MsFilePicker(
onFileSelected: (String) -> Unit, onFileSelected: (String) -> Unit,
fileExtensions: List<String>, fileExtensions: List<String>,
directoryOnly: Boolean, directoryOnly: Boolean,
helpDescription: String?,
enabled: Boolean, enabled: Boolean,
modifier: Modifier modifier: Modifier
) { ) {
@@ -32,6 +33,7 @@ actual fun MsFilePicker(
onValueChange = { }, onValueChange = { },
readOnly = true, readOnly = true,
label = label, label = label,
helpDescription = helpDescription,
placeholder = if (directoryOnly) "Verzeichnis wählen..." else "Datei wählen...", placeholder = if (directoryOnly) "Verzeichnis wählen..." else "Datei wählen...",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
enabled = enabled, enabled = enabled,
@@ -10,6 +10,7 @@ actual fun MsFilePicker(
onFileSelected: (String) -> Unit, onFileSelected: (String) -> Unit,
fileExtensions: List<String>, fileExtensions: List<String>,
directoryOnly: Boolean, directoryOnly: Boolean,
helpDescription: String?,
enabled: Boolean, enabled: Boolean,
modifier: Modifier modifier: Modifier
) { ) {
@@ -0,0 +1,23 @@
package at.mocode.frontend.core.network.backup
import kotlinx.serialization.Serializable
@Serializable
data class BackupPayload(
val timestamp: Long,
val deviceName: String,
val data: String,
val checksum: String
)
interface BackupService {
/**
* Schreibt Daten verschlüsselt in das Backup-Verzeichnis.
*/
fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String>
/**
* Liest Daten aus einer verschlüsselten Datei ein.
*/
fun importDelta(filePath: String, sharedKey: String): Result<String>
}
@@ -3,7 +3,7 @@ package at.mocode.frontend.core.network.discovery
import org.koin.core.module.Module import org.koin.core.module.Module
/** /**
* Erwartetes Koin-Modul für die Netzwerk-Discovery. * Erwartetes Koin-Modul für die Netzwerk-Discovery und Backup.
* Plattform-spezifische Implementierungen (JVM mit JmDNS, JS/Wasm evtl. No-op) * Plattform-spezifische Implementierungen (JVM mit JmDNS, JS/Wasm evtl. No-op)
* müssen hier injiziert werden. * müssen hier injiziert werden.
*/ */
@@ -0,0 +1,87 @@
package at.mocode.frontend.core.network.backup
import kotlinx.serialization.json.Json
import java.io.File
import java.security.MessageDigest
import java.util.*
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class FileBackupService(private val deviceName: String) : BackupService {
private val json = Json { prettyPrint = true }
override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String> {
return try {
val timestamp = System.currentTimeMillis()
val checksum = calculateChecksum(data)
val payload = BackupPayload(timestamp, deviceName, data, checksum)
val jsonContent = json.encodeToString(payload)
val encryptedData = encrypt(jsonContent, sharedKey)
val dir = File(targetPath)
if (!dir.exists()) dir.mkdirs()
val fileName = "delta_${timestamp}_${deviceName}.msbackup"
val file = File(dir, fileName)
file.writeText(encryptedData)
println("[Plan-USB] Export erfolgreich: ${file.absolutePath}")
Result.success(file.absoluteName)
} catch (e: Exception) {
println("[Plan-USB] Export fehlgeschlagen: ${e.message}")
Result.failure(e)
}
}
override fun importDelta(filePath: String, sharedKey: String): Result<String> {
return try {
val file = File(filePath)
val encryptedData = file.readText()
val jsonContent = decrypt(encryptedData, sharedKey)
val payload = json.decodeFromString<BackupPayload>(jsonContent)
if (calculateChecksum(payload.data) != payload.checksum) {
throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.")
}
println("[Plan-USB] Import erfolgreich von ${payload.deviceName}")
Result.success(payload.data)
} catch (e: Exception) {
println("[Plan-USB] Import fehlgeschlagen: ${e.message}")
Result.failure(e)
}
}
private fun calculateChecksum(data: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
private fun encrypt(data: String, key: String): String {
val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv)
val encrypted = cipher.doFinal(data.toByteArray())
return Base64.getEncoder().encodeToString(encrypted)
}
private fun decrypt(encrypted: String, key: String): String {
val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16))
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted))
return String(decrypted)
}
private fun generateKey(key: String): SecretKeySpec {
val sha = MessageDigest.getInstance("SHA-256")
val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität
return SecretKeySpec(keyBytes, "AES")
}
}
private val File.absoluteName: String get() = this.name
@@ -1,5 +1,7 @@
package at.mocode.frontend.core.network.discovery package at.mocode.frontend.core.network.discovery
import at.mocode.frontend.core.network.backup.BackupService
import at.mocode.frontend.core.network.backup.FileBackupService
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.dsl.module import org.koin.dsl.module
@@ -8,4 +10,5 @@ import org.koin.dsl.module
*/ */
actual val discoveryModule: Module = module { actual val discoveryModule: Module = module {
single<NetworkDiscoveryService> { JmDnsDiscoveryService() } single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) }
} }
@@ -2,12 +2,9 @@
package at.mocode.frontend.features.device.initialization.di package at.mocode.frontend.features.device.initialization.di
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
import at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel import at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel
import org.koin.dsl.module import org.koin.dsl.module
val deviceInitializationModule = module { val deviceInitializationModule = module {
factory { (onComplete: (DeviceInitializationSettings) -> Unit) -> factory { DeviceInitializationViewModel(get(), { deviceName -> get { org.koin.core.parameter.parametersOf(deviceName) } }) }
DeviceInitializationViewModel(get(), onComplete)
}
} }
@@ -24,15 +24,24 @@ data class ExpectedClient(
val isSynchronized: Boolean = true val isSynchronized: Boolean = true
) )
@Serializable
enum class AppThemeSetting {
SYSTEM,
LIGHT,
DARK
}
@Serializable @Serializable
data class DeviceInitializationSettings( data class DeviceInitializationSettings(
val deviceName: String = "", val deviceName: String = "",
val networkInterface: String = "",
val sharedKey: String = "", val sharedKey: String = "",
val backupPath: String = "", val backupPath: String = "",
val networkRole: NetworkRole = NetworkRole.CLIENT, val networkRole: NetworkRole = NetworkRole.CLIENT,
val expectedClients: List<ExpectedClient> = emptyList(), val expectedClients: List<ExpectedClient> = emptyList(),
val syncInterval: Int = 30, // in Minuten val syncInterval: Int = 30, // in Minuten
val defaultPrinter: String = "" val defaultPrinter: String = "",
val appTheme: AppThemeSetting = AppThemeSetting.SYSTEM
) { ) {
val isConfigured: Boolean get() = deviceName.isNotBlank() && sharedKey.isNotBlank() val isConfigured: Boolean get() = deviceName.isNotBlank() && sharedKey.isNotBlank()
} }
@@ -2,14 +2,15 @@
package at.mocode.frontend.features.device.initialization.presentation package at.mocode.frontend.features.device.initialization.presentation
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
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.ArrowForward
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -18,60 +19,205 @@ import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1 import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2 import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.* import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator
@Composable
private fun DiscoveryRadar(
modifier: Modifier = Modifier
) {
val infiniteTransition = rememberInfiniteTransition(label = "RadarTransition")
val radius by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 20f, // Kleinerer Radius
animationSpec = infiniteRepeatable(
animation = tween(2500, easing = LinearOutSlowInEasing), // Langsamer und sanfter
repeatMode = RepeatMode.Restart
),
label = "RadiusAnimation"
)
val alpha by infiniteTransition.animateFloat(
initialValue = 0.4f, // Noch dezenter
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(3000, easing = LinearOutSlowInEasing),
repeatMode = RepeatMode.Restart
),
label = "AlphaAnimation"
)
val color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) // Dezente Farbe
Box(
modifier = modifier.size(32.dp), // Noch kleiner
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = color,
radius = radius.dp.toPx(),
center = Offset(size.width / 2, size.height / 2),
style = Stroke(width = 1.dp.toPx()),
alpha = alpha
)
}
Icon(
imageVector = Icons.Default.NetworkCheck,
contentDescription = null,
tint = color.copy(alpha = 0.8f),
modifier = Modifier.size(18.dp)
)
}
}
@Composable @Composable
fun DeviceInitializationScreen( fun DeviceInitializationScreen(
viewModel: DeviceInitializationViewModel viewModel: DeviceInitializationViewModel
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val (roleSelectorFocus, nextButtonFocus) = remember { FocusRequester.createRefs() } val (roleSelectorFocus, deviceNameFocus) = remember { FocusRequester.createRefs() }
// Automatische Discovery starten, wenn wir auf Schritt 0 sind // Automatische Discovery starten
LaunchedEffect(uiState.currentStep) { LaunchedEffect(Unit) {
if (uiState.currentStep == 0) {
viewModel.startDiscovery() viewModel.startDiscovery()
roleSelectorFocus.requestFocus() roleSelectorFocus.requestFocus()
} }
}
Surface(color = MaterialTheme.colorScheme.background) { Surface(
Column( color = MaterialTheme.colorScheme.background,
modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(rememberScrollState()), modifier = Modifier.fillMaxSize()
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
val scrollState = rememberScrollState()
BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
val isMobile = maxWidth < 600.dp
val contentWidth = if (isMobile) 425.dp else 1024.dp
Column(
modifier = Modifier
.widthIn(max = contentWidth)
.fillMaxWidth()
.padding(if (isMobile) 16.dp else 32.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
"Willkommen bei der Meldestelle", "Willkommen bei der Meldestelle",
style = MaterialTheme.typography.headlineSmall, style = if (isMobile) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
) )
Text( Text(
if (uiState.currentStep == 0) "Schritt 1: Netzwerk-Rolle festlegen" else "Schritt 2: Rollenspezifische Konfiguration", "Geräte-Initialisierung",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// THEME SWITCH
Card(
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
modifier = Modifier.focusProperties { canFocus = false }
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.entries.forEach { theme ->
val selected = uiState.settings.appTheme == theme
FilterChip(
selected = selected,
onClick = { viewModel.updateSettings { it.copy(appTheme = theme) } },
label = {
Text(
when(theme) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> "System"
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> "Hell"
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> "Dunkel"
},
style = MaterialTheme.typography.labelSmall
)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
}
}
}
}
// NETZWERK-ROLLE
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(Modifier.padding(if (isMobile) 16.dp else 24.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
Text("🌐 Netzwerk-Rolle wählen", style = MaterialTheme.typography.titleLarge)
Text(
"Möchtest du dieses Gerät als Master (Zentrale) oder als Client (Richter/Zeitnehmer) nutzen?",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
if (uiState.currentStep == 0) { if (!uiState.isLocked) {
// PHASE 1: NETZWERK-ROLLE val role = uiState.settings.networkRole
Card(modifier = Modifier.fillMaxWidth()) { val hasDiscoveries = uiState.discoveredMasters.isNotEmpty()
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("🌐 Netzwerk-Rolle wählen", style = MaterialTheme.typography.titleMedium) Surface(
color = if (hasDiscoveries) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.15f)
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
shape = MaterialTheme.shapes.medium,
border = if (hasDiscoveries) androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f))
else null
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
DiscoveryRadar()
Spacer(modifier = Modifier.width(8.dp))
Text( Text(
"Wähle aus, ob dieses Gerät als Master (zentrale Datenbank) oder als Client fungiert.", text = when {
style = MaterialTheme.typography.bodySmall role == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.MASTER && hasDiscoveries -> "Aktive Clients im Netzwerk gefunden"
role == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.MASTER -> "Suche nach verfügbaren Clients..."
hasDiscoveries -> "Master im Netzwerk gefunden"
else -> "Suche nach Master-Geräten..."
},
style = MaterialTheme.typography.bodySmall,
color = if (hasDiscoveries) MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
) )
}
}
}
NetworkRoleSelector( NetworkRoleSelector(
selectedRole = uiState.settings.networkRole, selectedRole = uiState.settings.networkRole,
onRoleSelected = { onRoleSelected = {
viewModel.setNetworkRole(it) viewModel.setNetworkRole(it)
if (uiState.settings.deviceName.isEmpty()) {
deviceNameFocus.requestFocus()
} else {
focusManager.moveFocus(FocusDirection.Next) focusManager.moveFocus(FocusDirection.Next)
}
}, },
modifier = Modifier.focusRequester(roleSelectorFocus), modifier = Modifier.focusRequester(roleSelectorFocus),
enabled = !uiState.isLocked enabled = !uiState.isLocked
@@ -81,7 +227,7 @@ fun DeviceInitializationScreen(
AlertDialog( AlertDialog(
onDismissRequest = { viewModel.dismissRoleChangeWarning() }, onDismissRequest = { viewModel.dismissRoleChangeWarning() },
title = { Text("Netzwerk-Rolle ändern?") }, title = { Text("Netzwerk-Rolle ändern?") },
text = { Text("Das Ändern der Netzwerk-Rolle kann Ihre bisherigen Eingaben in Schritt 2 beeinflussen. Wollen Sie fortfahren?") }, text = { Text("Das Ändern der Netzwerk-Rolle kann Ihre bisherigen Eingaben beeinflussen. Wollen Sie fortfahren?") },
confirmButton = { confirmButton = {
Button(onClick = { viewModel.confirmNetworkRoleChange() }) { Text("Ja, Ändern") } Button(onClick = { viewModel.confirmNetworkRoleChange() }) { Text("Ja, Ändern") }
}, },
@@ -90,52 +236,21 @@ fun DeviceInitializationScreen(
} }
) )
} }
}
}
if (!uiState.isLocked) { // Konfiguration
Button(
onClick = { viewModel.nextStep() },
modifier = Modifier
.align(Alignment.End)
.focusRequester(nextButtonFocus)
.onKeyEvent {
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
viewModel.nextStep()
true
} else false
}
) {
Text("Weiter")
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
}
} else {
Button(
onClick = { viewModel.nextStep() },
modifier = Modifier.align(Alignment.End)
) {
Text("Zur Konfiguration")
Icon(Icons.AutoMirrored.Filled.ArrowForward, null)
}
}
}
}
} else {
// PHASE 2 & Review
DeviceInitializationConfig( DeviceInitializationConfig(
uiState = uiState, uiState = uiState,
viewModel = viewModel viewModel = viewModel,
deviceNameFocus = deviceNameFocus
) )
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
TextButton(onClick = { viewModel.previousStep() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
Spacer(Modifier.width(8.dp))
Text("Zurück zur Rollenauswahl")
}
if (uiState.isLocked) { if (uiState.isLocked) {
var showUnlockWarning by remember { mutableStateOf(false) } var showUnlockWarning by remember { mutableStateOf(false) }
if (showUnlockWarning) { if (showUnlockWarning) {
@@ -159,18 +274,20 @@ fun DeviceInitializationScreen(
onClick = { showUnlockWarning = true }, onClick = { showUnlockWarning = true },
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary containerColor = MaterialTheme.colorScheme.secondary
) ),
shape = MaterialTheme.shapes.medium
) { ) {
Text("Konfiguration bearbeiten") Text("Konfiguration bearbeiten")
Icon(Icons.Default.Edit, null, Modifier.padding(start = 8.dp)) Icon(Icons.Default.Edit, null, Modifier.padding(start = 8.dp).size(18.dp))
} }
} else { } else {
Button( Button(
onClick = { viewModel.completeInitialization() }, onClick = { viewModel.completeInitialization() },
enabled = DeviceInitializationValidator.canContinue(uiState.settings) enabled = DeviceInitializationValidator.canContinue(uiState.settings),
shape = MaterialTheme.shapes.medium
) { ) {
Text("Konfiguration finalisieren & Sperren") Text("Konfiguration finalisieren")
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp)) Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp).size(18.dp))
} }
} }
} }
@@ -182,5 +299,6 @@ fun DeviceInitializationScreen(
@Composable @Composable
expect fun DeviceInitializationConfig( expect fun DeviceInitializationConfig(
uiState: DeviceInitializationUiState, uiState: DeviceInitializationUiState,
viewModel: DeviceInitializationViewModel viewModel: DeviceInitializationViewModel,
deviceNameFocus: FocusRequester
) )
@@ -6,7 +6,6 @@ import at.mocode.frontend.core.network.discovery.DiscoveredService
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
data class DeviceInitializationUiState( data class DeviceInitializationUiState(
val currentStep: Int = 0,
val settings: DeviceInitializationSettings = DeviceInitializationSettings(), val settings: DeviceInitializationSettings = DeviceInitializationSettings(),
val discoveredMasters: List<DiscoveredService> = emptyList(), val discoveredMasters: List<DiscoveredService> = emptyList(),
val isProcessing: Boolean = false, val isProcessing: Boolean = false,
@@ -5,36 +5,38 @@ package at.mocode.frontend.features.device.initialization.presentation
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.network.backup.BackupService
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Clock
class DeviceInitializationViewModel( class DeviceInitializationViewModel(
private val discoveryService: NetworkDiscoveryService, private val discoveryService: NetworkDiscoveryService,
private val onInitializationComplete: (DeviceInitializationSettings) -> Unit private val backupServiceProvider: (String) -> BackupService
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(DeviceInitializationUiState()) private val _uiState = MutableStateFlow(DeviceInitializationUiState())
val uiState: StateFlow<DeviceInitializationUiState> = _uiState.asStateFlow() val uiState: StateFlow<DeviceInitializationUiState> = _uiState.asStateFlow()
private val _initializationCompleteEvent = MutableSharedFlow<DeviceInitializationSettings>()
val initializationCompleteEvent: SharedFlow<DeviceInitializationSettings> = _initializationCompleteEvent.asSharedFlow()
init { init {
val existingSettings = at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager.loadSettings() val existingSettings = at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager.loadSettings()
if (existingSettings != null) { if (existingSettings != null) {
println("[DeviceInit] Bestehende Einstellungen geladen.") println("[DeviceInit] Bestehende Einstellungen geladen.")
_uiState.update { it.copy( _uiState.update { it.copy(
settings = existingSettings, settings = existingSettings,
isLocked = existingSettings.isConfigured, isLocked = existingSettings.isConfigured
currentStep = 1 // Direkt zu Schritt 2 (Konfig), da Rolle schon gewählt
) } ) }
} }
viewModelScope.launch { viewModelScope.launch {
discoveryService.discoveredServices.collect { services -> discoveryService.discoveredServices.collect { services ->
println("[DeviceInit] Discovery Update: ${services.size} Dienste gefunden.")
_uiState.update { it.copy(discoveredMasters = services) } _uiState.update { it.copy(discoveredMasters = services) }
} }
} }
@@ -44,15 +46,6 @@ class DeviceInitializationViewModel(
discoveryService.startDiscovery() discoveryService.startDiscovery()
} }
fun nextStep() {
println("[DeviceInit] Übergang zu Schritt ${uiState.value.currentStep + 1}")
_uiState.update { it.copy(currentStep = it.currentStep + 1) }
}
fun previousStep() {
println("[DeviceInit] Zurück zu Schritt ${(uiState.value.currentStep - 1).coerceAtLeast(0)}")
_uiState.update { it.copy(currentStep = (it.currentStep - 1).coerceAtLeast(0)) }
}
fun updateSettings(update: (DeviceInitializationSettings) -> DeviceInitializationSettings) { fun updateSettings(update: (DeviceInitializationSettings) -> DeviceInitializationSettings) {
_uiState.update { _uiState.update {
@@ -99,10 +92,32 @@ class DeviceInitializationViewModel(
} }
} }
fun testUsbBackup() {
val settings = uiState.value.settings
if (settings.backupPath.isBlank() || settings.sharedKey.isBlank()) {
println("[DeviceInit] Backup-Pfad oder Shared Key fehlt.")
return
}
viewModelScope.launch {
val service = backupServiceProvider(settings.deviceName)
val testData = "PoC Testdaten - ${settings.deviceName} - ${Clock.System.now()}"
val result = service.exportDelta(testData, settings.backupPath, settings.sharedKey)
if (result.isSuccess) {
println("[DeviceInit] USB-Backup Test erfolgreich.")
} else {
println("[DeviceInit] USB-Backup Test fehlgeschlagen: ${result.exceptionOrNull()?.message}")
}
}
}
fun completeInitialization() { fun completeInitialization() {
println("[DeviceInit] Konfiguration wird finalisiert...") println("[DeviceInit] Konfiguration wird finalisiert...")
_uiState.update { it.copy(isLocked = true) } _uiState.update { it.copy(isLocked = true) }
onInitializationComplete(_uiState.value.settings) viewModelScope.launch {
_initializationCompleteEvent.emit(_uiState.value.settings)
}
} }
fun unlockConfiguration() { fun unlockConfiguration() {
@@ -2,6 +2,7 @@
package at.mocode.frontend.features.device.initialization.presentation package at.mocode.frontend.features.device.initialization.presentation
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
@@ -21,14 +22,14 @@ fun NetworkRoleSelector(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true enabled: Boolean = true
) { ) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
NetworkRoleCard( NetworkRoleCard(
title = "Master (Host)", title = "Master (Host)",
description = "Verwaltet die zentrale Datenbank und koordiniert den Sync.", description = "Zentrale Datenbank & Sync-Koordination.",
isSelected = selectedRole == NetworkRole.MASTER, isSelected = selectedRole == NetworkRole.MASTER,
onClick = { if (enabled) onRoleSelected(NetworkRole.MASTER) }, onClick = { if (enabled) onRoleSelected(NetworkRole.MASTER) },
enabled = enabled, enabled = enabled,
modifier = Modifier.onKeyEvent { modifier = Modifier.weight(1f).onKeyEvent {
if (enabled && (it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) { if (enabled && (it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
onRoleSelected(NetworkRole.MASTER) onRoleSelected(NetworkRole.MASTER)
true true
@@ -38,11 +39,11 @@ fun NetworkRoleSelector(
NetworkRoleCard( NetworkRoleCard(
title = "Client", title = "Client",
description = "Verbindet sich mit einem Master-Gerät im lokalen Netzwerk.", description = "Verbindung zum Master im LAN.",
isSelected = selectedRole == NetworkRole.CLIENT, isSelected = selectedRole == NetworkRole.CLIENT,
onClick = { if (enabled) onRoleSelected(NetworkRole.CLIENT) }, onClick = { if (enabled) onRoleSelected(NetworkRole.CLIENT) },
enabled = enabled, enabled = enabled,
modifier = Modifier.onKeyEvent { modifier = Modifier.weight(1f).onKeyEvent {
if (enabled && (it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) { if (enabled && (it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
onRoleSelected(NetworkRole.CLIENT) onRoleSelected(NetworkRole.CLIENT)
true true
@@ -66,13 +67,14 @@ private fun NetworkRoleCard(
enabled = enabled, enabled = enabled,
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
color = when { color = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
!enabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) !enabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
else -> MaterialTheme.colorScheme.surfaceVariant else -> MaterialTheme.colorScheme.surface
}, },
modifier = modifier.fillMaxWidth() border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier
) { ) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton( RadioButton(
selected = isSelected, selected = isSelected,
onClick = null, onClick = null,
@@ -8,7 +8,17 @@ import kotlinx.serialization.json.Json
import java.io.File import java.io.File
actual object DeviceInitializationSettingsManager { actual object DeviceInitializationSettingsManager {
private val settingsFile = File("settings.json") private val settingsFile: File by lazy {
val os = System.getProperty("os.name").lowercase()
val appName = "Meldestelle"
val baseDir = when {
os.contains("win") -> File(System.getenv("APPDATA"), appName)
os.contains("mac") -> File(System.getProperty("user.home"), "Library/Application Support/$appName")
else -> File(System.getProperty("user.home"), ".config/$appName")
}
if (!baseDir.exists()) baseDir.mkdirs()
File(baseDir, "settings.json")
}
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true } private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
actual fun saveSettings(settings: DeviceInitializationSettings) { actual fun saveSettings(settings: DeviceInitializationSettings) {
@@ -7,6 +7,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -34,35 +35,77 @@ import at.mocode.frontend.core.designsystem.components.MsStringDropdown
import at.mocode.frontend.core.designsystem.components.MsTextField import at.mocode.frontend.core.designsystem.components.MsTextField
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
import java.net.NetworkInterface
import javax.print.PrintServiceLookup import javax.print.PrintServiceLookup
@Composable @Composable
actual fun DeviceInitializationConfig( actual fun DeviceInitializationConfig(
uiState: DeviceInitializationUiState, uiState: DeviceInitializationUiState,
viewModel: DeviceInitializationViewModel viewModel: DeviceInitializationViewModel,
deviceNameFocus: FocusRequester
) { ) {
val settings = uiState.settings val settings = uiState.settings
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val (deviceNameFocus, sharedKeyFocus, backupPathFocus, clientNameFocus, clientRoleFocus) = remember { FocusRequester.createRefs() } val (_, sharedKeyFocus, backupPathFocus, clientNameFocus, clientRoleFocus) = remember { FocusRequester.createRefs() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (settings.deviceName.isEmpty()) {
deviceNameFocus.requestFocus() deviceNameFocus.requestFocus()
} }
}
Card(modifier = Modifier.fillMaxWidth()) { Card(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { modifier = Modifier.fillMaxWidth(),
Text("⚙️ Geräte-Konfiguration (${settings.networkRole})", style = MaterialTheme.typography.titleMedium) shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("⚙️ Geräte-Details", style = MaterialTheme.typography.titleLarge)
MsTextField( MsTextField(
value = settings.deviceName, value = settings.deviceName,
onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } }, onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } },
label = "Gerätename", label = "Gerätename",
helpDescription = "Ein eindeutiger Name für diesen PC (z.B. 'Richter-Springplatz'). Dies hilft dem Master, die Datenquellen zuzuordnen.",
placeholder = "z.B. Meldestelle-PC-1", placeholder = "z.B. Meldestelle-PC-1",
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName), isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.", errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
imeAction = ImeAction.Next, imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
modifier = Modifier.focusRequester(deviceNameFocus), modifier = Modifier.focusRequester(deviceNameFocus),
enabled = !uiState.isLocked,
compact = true
)
val interfaces = remember {
NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback && it.inetAddresses.hasMoreElements() }
.map { ni ->
val friendlyName = when {
ni.displayName.contains("wlan", ignoreCase = true) || ni.displayName.contains("wi-fi", ignoreCase = true) -> "WLAN"
ni.displayName.contains("eth", ignoreCase = true) || ni.displayName.contains("ethernet", ignoreCase = true) -> "Ethernet"
else -> ni.displayName
}
val address = ni.inetAddresses.asSequence()
.filter { !it.isLinkLocalAddress && it.hostAddress.indexOf(':') == -1 } // Nur IPv4, keine Link-Local
.firstOrNull()?.hostAddress ?: ni.inetAddresses.nextElement().hostAddress
"$friendlyName ($address)"
}
}
LaunchedEffect(interfaces) {
if (settings.networkInterface.isEmpty() && interfaces.isNotEmpty()) {
viewModel.updateSettings { s -> s.copy(networkInterface = interfaces.first()) }
}
}
MsStringDropdown(
label = "Netzwerk-Interface",
helpDescription = "Wähle das Netzwerk-Interface aus, über das die App kommunizieren soll (z.B. LAN für das Turnier-Netzwerk).",
options = interfaces,
selectedOption = settings.networkInterface,
onOptionSelected = { viewModel.updateSettings { s -> s.copy(networkInterface = it) } },
placeholder = "Interface wählen...",
enabled = !uiState.isLocked enabled = !uiState.isLocked
) )
@@ -71,6 +114,7 @@ actual fun DeviceInitializationConfig(
value = settings.sharedKey, value = settings.sharedKey,
onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } }, onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } },
label = "Sicherheitsschlüssel (Sync-Key)", label = "Sicherheitsschlüssel (Sync-Key)",
helpDescription = "Das 'Turnier-Passwort'. Nur Geräte mit exakt diesem Schlüssel können Daten austauschen. Wichtig für die Verschlüsselung (DSGVO)!",
placeholder = "Mindestens 8 Zeichen", placeholder = "Mindestens 8 Zeichen",
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey), isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.", errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
@@ -82,11 +126,13 @@ actual fun DeviceInitializationConfig(
modifier = Modifier.focusRequester(sharedKeyFocus), modifier = Modifier.focusRequester(sharedKeyFocus),
trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
onTrailingIconClick = { passwordVisible = !passwordVisible }, onTrailingIconClick = { passwordVisible = !passwordVisible },
enabled = !uiState.isLocked enabled = !uiState.isLocked,
compact = true
) )
MsFilePicker( MsFilePicker(
label = "Backup-Verzeichnis (Pfad)", label = "Backup-Verzeichnis (Pfad)",
helpDescription = "Wähle hier deinen USB-Stick oder einen lokalen Ordner aus. Die App speichert hier laufend Sicherheitskopien für den Notfall (Plan-USB).",
selectedPath = settings.backupPath, selectedPath = settings.backupPath,
onFileSelected = { selectedPath -> onFileSelected = { selectedPath ->
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) } viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
@@ -96,12 +142,35 @@ actual fun DeviceInitializationConfig(
enabled = !uiState.isLocked enabled = !uiState.isLocked
) )
if (!uiState.isLocked && settings.backupPath.isNotBlank() && settings.sharedKey.isNotBlank()) {
OutlinedButton(
onClick = { viewModel.testUsbBackup() },
modifier = Modifier.padding(top = 4.dp).align(Alignment.End),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
Icon(Icons.Default.Usb, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Plan-USB Test-Export", style = MaterialTheme.typography.labelLarge)
}
}
val printers = remember { val printers = remember {
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }.sorted() val systemPrinters = PrintServiceLookup.lookupPrintServices(null, null).map { it.name }.toMutableList()
if (!systemPrinters.contains("PDF-Export (Lokal)")) {
systemPrinters.add(0, "PDF-Export (Lokal)")
}
systemPrinters.sortedBy { it != "PDF-Export (Lokal)" } // PDF immer oben
}
LaunchedEffect(printers) {
if (settings.defaultPrinter.isEmpty() && printers.isNotEmpty()) {
viewModel.updateSettings { s -> s.copy(defaultPrinter = printers.first()) }
}
} }
MsStringDropdown( MsStringDropdown(
label = "Standard-Drucker", label = "Standard-Drucker",
helpDescription = "Der Drucker, der standardmäßig für Protokolle und Listen verwendet wird. Kann später jederzeit geändert werden.",
options = printers, options = printers,
selectedOption = settings.defaultPrinter, selectedOption = settings.defaultPrinter,
onOptionSelected = { viewModel.updateSettings { s -> s.copy(defaultPrinter = it) } }, onOptionSelected = { viewModel.updateSettings { s -> s.copy(defaultPrinter = it) } },
@@ -111,7 +180,8 @@ actual fun DeviceInitializationConfig(
) )
if (settings.networkRole == NetworkRole.MASTER) { if (settings.networkRole == NetworkRole.MASTER) {
Text("Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.labelMedium) HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("⏱️ Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.titleSmall)
Slider( Slider(
value = settings.syncInterval.toFloat(), value = settings.syncInterval.toFloat(),
onValueChange = { viewModel.updateSettings { s -> s.copy(syncInterval = it.toInt()) } }, onValueChange = { viewModel.updateSettings { s -> s.copy(syncInterval = it.toInt()) } },
@@ -119,20 +189,10 @@ actual fun DeviceInitializationConfig(
steps = 59, steps = 59,
enabled = !uiState.isLocked enabled = !uiState.isLocked
) )
} else if (!uiState.isLocked) {
// Button zum Abschließen für Clients, da diese keinen Slider/Clients haben
Spacer(Modifier.height(8.dp))
Button(
onClick = { viewModel.completeInitialization() },
modifier = Modifier.fillMaxWidth(),
enabled = DeviceInitializationValidator.canContinue(settings)
) {
Text("Konfiguration abschließen")
}
} }
if (settings.networkRole == NetworkRole.MASTER && !uiState.isLocked) { if (settings.networkRole == NetworkRole.MASTER && !uiState.isLocked) {
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall) Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall)
settings.expectedClients.forEachIndexed { index, client -> settings.expectedClients.forEachIndexed { index, client ->
@@ -244,13 +304,28 @@ actual fun DeviceInitializationConfig(
} }
} }
} else if (settings.networkRole != NetworkRole.MASTER && !uiState.isLocked) { } else if (settings.networkRole != NetworkRole.MASTER && !uiState.isLocked) {
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("🔍 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleSmall) Text("🔍 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleSmall)
if (uiState.discoveredMasters.isEmpty()) { if (uiState.discoveredMasters.isEmpty()) {
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { Surface(
CircularProgressIndicator(modifier = Modifier.size(24.dp)) color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
Text("Suche nach Master...", modifier = Modifier.padding(start = 40.dp)) shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(12.dp))
Text(
"Warte auf Master-Signal...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }
@@ -276,7 +351,7 @@ actual fun DeviceInitializationConfig(
) )
} }
if (settings.networkRole == NetworkRole.MASTER && uiState.isLocked && settings.expectedClients.isNotEmpty()) { if (settings.networkRole == NetworkRole.MASTER && uiState.isLocked && settings.expectedClients.isNotEmpty()) {
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall) Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall)
settings.expectedClients.forEach { client -> settings.expectedClients.forEach { client ->
ListItem( ListItem(
@@ -12,6 +12,8 @@ import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.unit.dp import androidx.compose.ui.unit.dp
@@ -20,7 +22,8 @@ import at.mocode.frontend.features.device.initialization.domain.DeviceInitializa
@Composable @Composable
actual fun DeviceInitializationConfig( actual fun DeviceInitializationConfig(
uiState: DeviceInitializationUiState, uiState: DeviceInitializationUiState,
viewModel: DeviceInitializationViewModel viewModel: DeviceInitializationViewModel,
deviceNameFocus: FocusRequester
) { ) {
val settings = uiState.settings val settings = uiState.settings
@@ -34,7 +37,8 @@ actual fun DeviceInitializationConfig(
label = "Gerätename", label = "Gerätename",
placeholder = "z.B. Web-Client", placeholder = "z.B. Web-Client",
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName), isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich." errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
modifier = Modifier.focusRequester(deviceNameFocus)
) )
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
@@ -74,14 +78,15 @@ private fun MsSettingsField(
isError: Boolean, isError: Boolean,
errorText: String, errorText: String,
visualTransformation: VisualTransformation = VisualTransformation.None, visualTransformation: VisualTransformation = VisualTransformation.None,
trailingIcon: @Composable (() -> Unit)? = null trailingIcon: @Composable (() -> Unit)? = null,
modifier: Modifier = Modifier
) { ) {
OutlinedTextField( OutlinedTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
label = { Text(label) }, label = { Text(label) },
placeholder = { Text(placeholder) }, placeholder = { Text(placeholder) },
modifier = Modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
isError = isError, isError = isError,
visualTransformation = visualTransformation, visualTransformation = visualTransformation,
trailingIcon = trailingIcon, trailingIcon = trailingIcon,
@@ -1,5 +1,6 @@
package at.mocode.frontend.shell.desktop package at.mocode.frontend.shell.desktop
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -25,7 +26,16 @@ import org.koin.compose.viewmodel.koinViewModel
*/ */
@Composable @Composable
fun DesktopApp() { fun DesktopApp() {
AppTheme { val deviceInitViewModel: at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel = koinViewModel()
val deviceSettings by deviceInitViewModel.uiState.collectAsState()
val isDark = when(deviceSettings.settings.appTheme) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> isSystemInDarkTheme()
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> false
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> true
}
AppTheme(darkTheme = isDark) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
@@ -66,17 +66,19 @@ fun DesktopContentArea(
// DeviceInitialization (Geräte-Setup) // DeviceInitialization (Geräte-Setup)
is AppScreen.DeviceInitialization -> { is AppScreen.DeviceInitialization -> {
println("[Screen] Rendering DeviceInitialization") println("[Screen] Rendering DeviceInitialization")
val viewModel = koinViewModel<DeviceInitializationViewModel> { val viewModel = koinViewModel<DeviceInitializationViewModel>()
parametersOf({ finalSettings: DeviceInitializationSettings ->
LaunchedEffect(viewModel) {
viewModel.initializationCompleteEvent.collect { finalSettings ->
DeviceInitializationSettingsManager.saveSettings(finalSettings) DeviceInitializationSettingsManager.saveSettings(finalSettings)
// Vision_04: Sicherheitsschlüssel als Token setzen, damit Cloud-Suche funktioniert // Vision_04: Sicherheitsschlüssel als Token setzen, damit Cloud-Suche funktioniert
val authTokenManager = org.koin.core.context.GlobalContext.get().get<AuthTokenManager>() val authTokenManager = org.koin.core.context.GlobalContext.get().get<AuthTokenManager>()
authTokenManager.setToken(finalSettings.sharedKey) authTokenManager.setToken(finalSettings.sharedKey)
onSettingsChange(finalSettings) onSettingsChange(finalSettings)
// nav.navigateToScreen(...) wird hier nicht direkt gerufen, sondern onNavigate
onNavigate(AppScreen.EventVerwaltung) onNavigate(AppScreen.EventVerwaltung)
})
} }
}
DeviceInitializationScreen(viewModel = viewModel) DeviceInitializationScreen(viewModel = viewModel)
} }
+2 -2
View File
@@ -73,8 +73,8 @@ dev.port.offset=0
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische # Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische
# Module zu testen. Default=false spart massiv Zeit beim Desktop-Build. # Module zu testen. Default=false spart massiv Zeit beim Desktop-Build.
enableWasm=true enableWasm=false
enableDesktop=false enableDesktop=true
# Dokka Gradle plugin V2 mode (with helpers for V1 compatibility) # Dokka Gradle plugin V2 mode (with helpers for V1 compatibility)
# See https://kotl.in/dokka-gradle-migration # See https://kotl.in/dokka-gradle-migration