Compare commits
7 Commits
e1bf4d8454
...
46d993e47f
| Author | SHA1 | Date | |
|---|---|---|---|
| 46d993e47f | |||
| 62f9472695 | |||
| b94984043c | |||
| fd78404d72 | |||
| 884ccc0db5 | |||
| 8ecc9fbe52 | |||
| d0edfa2538 |
@@ -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.
|
||||||
|
|||||||
@@ -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.*
|
||||||
+59
-4
@@ -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
|
||||||
|
|||||||
+1
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
+59
-4
@@ -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(),
|
||||||
|
|||||||
+61
-3
@@ -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,
|
||||||
|
|||||||
+4
-2
@@ -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
|
||||||
|
|||||||
+5
-3
@@ -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)
|
||||||
|
|||||||
+3
-3
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
|
|||||||
+1
@@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
+23
@@ -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>
|
||||||
|
}
|
||||||
+1
-1
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+87
@@ -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
|
||||||
+3
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-4
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+184
-66
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
-1
@@ -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,
|
||||||
|
|||||||
+32
-17
@@ -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() {
|
||||||
|
|||||||
+12
-10
@@ -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,
|
||||||
|
|||||||
+11
-1
@@ -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) {
|
||||||
|
|||||||
+99
-24
@@ -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(
|
||||||
|
|||||||
+9
-4
@@ -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,
|
||||||
|
|||||||
+11
-1
@@ -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,
|
||||||
|
|||||||
+6
-4
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user