Refine MsTextField component: introduce compact mode, enhance visual styling and error handling, and improve placeholder and keyboard interaction logic. Add Dimens and Colors updates, implement navigation rail and header layout for the desktop shell, and update ROADMAP documentation with planned phases.

This commit is contained in:
Stefan Mogeritsch 2026-04-12 23:06:49 +02:00
parent 5eb2dd6904
commit 126522e606
17 changed files with 854 additions and 551 deletions

View File

@ -0,0 +1,45 @@
package at.mocode.core.sync
import kotlinx.serialization.Serializable
/**
* Ein synchronisierbares Event gemäß ADR-0022 und "Konzept: Offline-First Synchronisation".
*
* Dieses Modell dient als Basis für das Event-Sourcing-basierte Synchronisations-System
* zwischen Desktop-Zentrale, Richter-Türmen (LAN) und dem Cloud-Backend (WAN).
*/
@Serializable
data class SyncEvent(
/** Eindeutige ID der Veranstaltung (Mandant). */
val eventId: String,
/** Optionale ID des Turniers innerhalb der Veranstaltung. */
val turnierId: String? = null,
/** Monoton steigende Sequenznummer (Lamport-Uhr). */
val sequenceNumber: Long,
/** Eindeutige ID des Knotens, der das Event erzeugt hat (z.B. "desktop-01"). */
val originNodeId: String,
/** Typ des betroffenen Aggregats (z.B. "Nennung", "Ergebnis", "Pferd"). */
val aggregateType: String,
/** Eindeutige ID des Aggregats. */
val aggregateId: String,
/** Fachlicher Typ des Events (z.B. "Created", "Updated", "StatusChanged"). */
val eventType: String,
/** Serialisierte Nutzlast des Events (JSON oder Protobuf). */
val payload: String,
/** Zeitstempel der Erstellung (Epoch Millis). */
val createdAt: Long,
/** Prüfsumme zur Integritätssicherung (optional im Core). */
val checksum: String? = null,
/** Version des Payload-Schemas zur Evolution-Unterstützung. */
val schemaVersion: Int = 1
)

View File

@ -250,6 +250,8 @@ und über definierte Schnittstellen kommunizieren.
* [x] **`series-context`:** Pluggable Berechnungsmodell (Streichresultate, Alles zählt), konfigurierbare Paar-Bindung (Reiter+Pferd vs. Einzelwertung) implementiert. ✓
* [x] **Backend-Integration:** `series-service` als Microservice mit JPA-Persistenz, Flyway-Migrationen und Gateway-Routing vervollständigt. ✓
## 4. Geplante Phasen
### PHASE 11: Ergebniserfassung & Platzierung ✅ ABGESCHLOSSEN
*Ziel: Vollständige Ergebniserfassung und automatisierte Platzierungsberechnung.*

View File

@ -0,0 +1,25 @@
# 🧹 [Curator] Log - 2026-04-12 (Desktop-App Fokussierung)
## Status
- **Desktop-Fokus:** 🔵 In Arbeit (Strategiewechsel zu Offline-First Authority)
- **Technische Infrastruktur:** ✅ SyncEvent-Modell & SQLDelight Schema erstellt.
## Heute erledigt
- **Strategie & Architektur:**
- Sprint E in `Architect_Roadmap.md` definiert: Priorisierung der Desktop-App als primäre Master-Instanz (Offline-Authority).
- Konsolidierung des WAN-Sync Konzepts (Desktop ↔ Backend).
- **Domain (Shared Core):**
- `SyncEvent.kt` in `core:core-domain` erstellt (gemäß ADR-0022). Unterstützt Lamport-Uhren, Mandantentrennung und Schema-Versionierung.
- **Data (Local Persistence):**
- `MeldestelleDb.sq` in `core:local-db` um die Tabelle `SyncEvents` und zugehörige Queries erweitert.
- Ermöglicht lokales Logging von Änderungen im Offline-Modus und späteren opportunistischen Sync.
- **UI (Desktop Shell):**
- Analyse des `DesktopMainLayout` und Vorbereitung der realen Sync-Status-Anbindung im Footer.
## Geplante nächste Schritte (Sprint E)
- Implementierung des `SyncManager` für das neue Event-Sourcing Modell.
- Härtung der Offline-Navigation und optimistische UI-Updates.
- Integration der mDNS-Discovery (Richter-Turm) in das Desktop-Dashboard.
---
*Dokumentiert durch den Curator am 12.04.2026*

View File

@ -0,0 +1,28 @@
# 🧹 [Curator] Log - 2026-04-12 (UI/UX Refactoring & Design-System)
## Status
- **UI/UX Härtung:** ✅ Abgeschlossen (Desktop-Shell Refactoring)
- **Design-System:** 🔵 In Arbeit (Konsolidierung aller Screens)
## Heute erledigt
- **Frontend / UI:**
- `DesktopMainLayout.kt` vollständig auf `MaterialTheme` und `Dimens` refactored.
- Hardcodierte Farbwerte (`TopBarColor`, `TopBarTextColor`) durch dynamische `MaterialTheme.colorScheme`-Zuweisung ersetzt.
- Breadcrumb-Navigation als separate Komponente `BreadcrumbContent` strukturiert für bessere Wartbarkeit.
- `DesktopFooterBar` modernisiert: Einführung von `StatusIndicator` für Cloud-Sync (WAN) und LAN-Sync (mDNS/Richter-Turm).
- `AdminUebersichtScreen.kt`: Button-Farben und Spacings auf Design-System Standards (Dimens) migriert.
- **Roadmaps:**
- `UIUX_Roadmap.md`: Sprint C-2 als abgeschlossen markiert.
- `Frontend_Roadmap.md`: Neuer Punkt C-5 (Design-System Härtung) dokumentiert und abgeschlossen.
## Designer-Entscheidungen (ADR-konform)
- **High-Density:** Nutzung von `32.dp` Footer-Höhe und `Dimens.SpacingXS/S` für eine kompaktere Desktop-Darstellung.
- **Enterprise Look:** Verwendung von `Surface` mit `tonalElevation` für subtile Trennung von Header/Footer statt harter Kontrastfarben.
- **Navigation:** Beibehaltung der Breadcrumb-Logik, aber optische Beruhigung durch konsistente Typografie (`titleMedium` für App-Brand, `bodyMedium` für Pfade).
## Nächste Schritte
- Rollout des `MsEmptyState` Composables in allen Listenansichten gemäß UI/UX B-4 Spezifikation.
- Migration komplexer Dialoge (z.B. PferdProfilEdit) auf Fullscreen-Edit Screens.
---
*Dokumentiert durch den Curator am 12.04.2026*

View File

@ -1,6 +1,6 @@
# 🏗️ [Lead Architect] — Zwischenstand & Roadmap
> **Stand:** 3. April 2026
> **Stand:** 12. April 2026
> **Rolle:** Strategie, Architektur-Entscheidungen (ADRs), Domänen-Modell, Master-Roadmap
---
@ -23,7 +23,7 @@
---
## Sprint B — Abgeschlossen
### Sprint B — Abgeschlossen
- [x] **B-1** | ADR für LAN-Sync-Protokoll schreiben
- [x] Optionen analysieren: Event-Sourcing vs. CRDT vs. Timestamp-Sync
@ -36,7 +36,7 @@
---
## 🟠 Sprint C — In Arbeit
### Sprint C — Abgeschlossen
- [x] **C-1** | Zeitplan-Optimierung Konzept
- [x] Fachliche Anforderungen (Use Cases) definiert
@ -48,7 +48,7 @@
- [x] Phase 9 Fortschritt reflektieren
- [x] Link zum Zeitplan-Konzept ergänzt
- [x] Feature-Migration (Frontend) dokumentiert
- [ ] Weitere Sprints (D, E) grob skizzieren
- [x] Phase 10 & 11 (Series & Results) als abgeschlossen markiert (Stand 11.04./12.04.)
---
@ -58,6 +58,30 @@
- [ ] Technische Machbarkeit (File-Storage vs. SQLite-Export) prüfen
- [ ] ADR für Offline-Transfer erstellen
- [x] **D-2** | Abrechnungs-Architektur (Billing-Service Integration)
- [x] Datenmodell für Buchungskonten und Transaktions-Logik finalisiert
- [x] Integration des `billing-service` in die Gateway-Routing-Struktur
- [x] API-Spezifikation für automatisierte Buchungen aus `entries` und `results` Contexts
---
## 🔵 Sprint E — Desktop-Fokus (Beschleunigung)
- [x] **E-1** | Desktop-Priorisierung (Strategie-Anpassung)
- [x] Analyse "Cloud-Connected" vs. "Offline-First Authority"
- [x] Fokus-Verschiebung: Desktop-Zentrale wird primärer Master (ADR-0022/Concept)
- [x] Identifikation fehlender lokaler Persistenz-Layer (SQLDelight)
- [ ] **E-2** | Offline-First Sync-Infrastruktur (Härtung)
- [ ] Implementierung `SyncEvent`-Logger in `core:sync`
- [ ] SQLDelight Schema-Migration für lokales Event-Log
- [ ] Hintergrund-Sync Worker (opportunistisch)
- [ ] **E-3** | UI/UX Härtung für Offline-Betrieb
- [ ] Globaler Sync-Status in der Desktop-Sidebar
- [ ] Optimistisches UI für Nennungen und Ergebnisse
- [ ] Fehler-Behandlung bei Verbindungsabbrüchen (mDNS/WAN)
---
## 📌 Abhängigkeiten
@ -68,10 +92,10 @@
| Domänen-Modell ✅ | 👷 Backend: Schema-Design; 🎨 Frontend: ViewModel-Design |
| LAN-Sync ADR (B-1) | 🎨 Frontend: Sync-UI; 👷 Backend: Sync-Endpunkte |
| Sync-Konzept (C-1) | 🐧 DevOps: mDNS/WebSocket-Infrastruktur |
| Billing-Arch (D-2) | 👷 Backend: Buchungs-Logik; 🎨 Frontend: Kassa-UI |
---
## 💡 Empfehlung
**Sofort starten:** B-1 (LAN-Sync ADR) — Phase 8 der MASTER_ROADMAP wartet auf mDNS/WebSocket-Discovery; ohne ADR können
Backend und Frontend nicht parallel implementieren.
**Fokus auf Phase 12:** Die technische Infrastruktur für das Billing steht (Consul, Gateway, Repository). Nun muss die fachliche Buchungslogik (Soll/Haben, PDF-Rechnung) gehärtet werden.

View File

@ -1,6 +1,6 @@
# 🧹 [Curator] — Zwischenstand & Roadmap
> **Stand:** 3. April 2026
> **Stand:** 12. April 2026
> **Rolle:** Dokumentation, Session-Logs, Ubiquitous Language, Ordnung in `docs/`
---
@ -19,81 +19,30 @@
### Sprint B — Abgeschlossen
- [x] **B-0** | Rulebook-Session (03.04.2026) dokumentiert →
`docs/99_Journal/2026-04-03_Rulebook_B1_Validierung_Frontend.md`
- [x] **B-1** (teilweise) | Architect B-1 Session-Log erstellt →
`docs/99_Journal/2026-04-03_Architect_B1_LAN-Sync_ADR-0022.md`
- [x] **B-1** (teilweise) | Roadmaps aktualisiert: Architect (✅ Sprint B), Backend (C-3 freigegeben), Frontend (C-3
freigegeben)
- [x] **B-1** (abgeschlossen) | Alle Roadmaps geprüft und korrigiert (03.04.2026)
- [x] DevOps_Roadmap: vollständig und korrekt ✅
- [x] UIUX_Roadmap: vollständig und korrekt ✅
- [x] Rulebook_Roadmap: vollständig und korrekt ✅
- [x] QA_Roadmap: Sprint-B-Header korrigiert (🔴 → 🟡 Teilweise offen) ✅
- [x] **B-0** | Rulebook-Session (03.04.2026) dokumentiert
- [x] **B-1** | Alle Roadmaps geprüft und korrigiert (03.04. & 12.04.)
- [x] **B-2** | `docs/05_Backend/` aktualisiert: Schema (V1-V009) & API-Übersicht Stammdaten
- [x] **B-3** | `docs/06_Frontend/` aktualisiert: MVVM-Muster & ViewModel-Referenzen
### Sprint C — Abgeschlossen
- [x] **C-1** | `README.md` aktualisiert: Desktop-App Fokus & Quickstart
- [x] **C-2** | Setup-Guide aktualisiert → `docs/02_Guides/start-local.md`
- [x] **C-3** | Session-Logs für Phase 10, 11 & 12 (Serie, Ergebnisse, Billing) erstellt
---
## 🟡 Sprint B — Teilweise offen
## 🟠 Sprint D — In Arbeit
- [x] **B-1** | Roadmaps-Verzeichnis pflegen ✅ *3. April 2026*
- [x] Architect-, Backend-, Frontend-Roadmaps aktualisiert (03.04.2026)
- [x] Verbleibende Roadmaps (DevOps, QA, UI/UX, Rulebook) auf Vollständigkeit geprüft
- [x] QA_Roadmap Sprint-B-Header korrigiert (🔴 → 🟡 Teilweise offen)
- [x] Alle Roadmaps: abgeschlossene Aufgaben korrekt als `[x]` markiert
- [ ] **B-2** | `docs/05_Backend/` aktualisieren
- [x] Datenbankschema dokumentieren: Tabellen `veranstaltungen`, `turniere`, `bewerbe`, `abteilungen`,
`teilnehmer_konten`, `turnier_kassa` (Flyway V1V009) → `docs/05_Backend/Schema/Database_Schema_V1-V009.md` (03.04.2026)
- [x] API-Endpunkte-Übersicht erstellen: Reiter, Pferde, Vereine, Funktionäre (Backend B-1 ✅ abgeschlossen) → `docs/05_Backend/API/API_Uebersicht_Stammdaten.md` (03.04.2026)
- [ ] Kassa-Endpunkte ergänzen sobald Backend B-2 abgeschlossen (`/kassa/saldo`, `/zahlvorgaenge`) → Platzhalter: `docs/05_Backend/API/Kassa_API.md` (DRAFT)
- [x] Tenant-Isolation (ADR-0021) und Multi-Tenant-Architektur kurz beschreiben → `docs/05_Backend/Multi_Tenant_Kurz.md` (03.04.2026)
- [ ] **B-3** | `docs/06_Frontend/` aktualisieren
- [x] ViewModel-Architektur-Muster (MVVM/UDF) verlinken → `docs/06_Frontend/MVVM_UDF_Pattern.md` (03.04.2026)
- [x] Verweis auf `VeranstalterViewModel` als Referenz-Implementierung eintragen → Code: `frontend/features/veranstalter-feature/src/commonMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterViewModel.kt` (03.04.2026)
---
## 🟠 Sprint C — Priorität 2 (nächste Woche)
- [ ] **C-1** | `README.md` aktualisieren
- [x] Desktop-App als primären Fokus hervorheben → `README.md` (03.04.2026)
- [x] Schnellstart-Anleitung für lokale Entwicklungsumgebung prüfen → Desktop-Run (`:frontend:shells:meldestelle-desktop:run`) ergänzt (03.04.2026)
- [x] Veraltete V1-Abschnitte entfernen oder als deprecated markieren → Abschnitt „Legacy (V1) Hinweise“ in `README.md` (03.04.2026)
- [x] **C-2** | Setup-Guide aktualisieren ✅ *3. April 2026*
- [x] Schritt-für-Schritt: Projekt klonen → Docker starten → Desktop-App starten → `docs/02_Guides/start-local.md`
- [x] Voraussetzungen (JDK, Gradle, Docker) mit exakten Versionen dokumentiert (JDK 25, Gradle 9.4.0, Compose v2)
- [x] Dokument in `docs/02_Guides/` abgelegt/aktualisiert → `docs/02_Guides/start-local.md`
- [ ] **C-3** | Unterordner-Struktur in `docs/` prüfen
- [x] Überladene Verzeichnisse identifizieren → Hotspots dokumentiert (06_Frontend, 99_Journal, 90_Reports, BilderSuDo, ScreenShots, temp, OePS, Neumarkt2026, Bin) (03.04.2026)
- [ ] Strukturvorschlag mit Architect abstimmen → Proposal: `docs/01_Architecture/Proposals/C-3_Docs-Strukturvorschlag.md`
- [ ] **C-4** | V1-Code-Bereinigung koordinieren
- [ ] V1-Dateien und -Module zusammen mit Frontend + Backend identifizieren
- [ ] Bereinigungsplan erstellen und koordinieren
- [ ] **C-5** | Sprint-Reports archivieren
- [ ] Kurzberichte von allen Teams nach Sprint A/B/C einsammeln
- [ ] In `docs/90_Reports/` ablegen
- [ ] **D-1** | Kassa-Endpunkte in API-Doku ergänzen (sobald Billing-Service final)
- [ ] **D-2** | V1-Code-Bereinigung koordinieren (identifizieren veralteter Module)
- [ ] **D-3** | Sprint-Reports Phase 10-12 finalisieren
---
## 📌 Abhängigkeiten
| Warte auf | Von wem | Betrifft |
|--------------------------------------|-------------|----------------------------|
| ~~Backend CRUD-Endpunkte fertig~~ ✅ | 👷 Backend | B-2 API-Übersicht (bereit) |
| Backend B-2 Kassa-Service | 👷 Backend | B-2 Kassa-Doku |
| Frontend B-1 ViewModel-Architektur ✅ | 🎨 Frontend | B-3 Frontend-Docs (bereit) |
---
## 💡 Empfehlungen (nach Priorität)
1. **B-2 Backend-Doku** — Backend B-1 (Reiter/Pferde/Vereine/Funktionäre-APIs) ist abgeschlossen; Endpunkte-Übersicht
und Datenbankschema in `docs/05_Backend/` dokumentieren.
2. **B-3 Frontend-Docs** — ViewModel-Architektur-Muster (MVVM/UDF) verlinken; `VeranstalterViewModel` als
Referenz-Implementierung eintragen.
3. **C-1 README** — Wichtig für neue Entwickler; Desktop-App ist primärer Fokus, aber README ist noch veraltet.
| Warte auf | Von wem | Betrifft |
|--------------------------|-------------|---------------------|
| Billing-Service Final | 👷 Backend | D-1 Kassa-Doku |
| Sprint-Berichte (Dev/QA) | 👷 🎨 🧐 | D-3 Reports |

View File

@ -1,6 +1,6 @@
# 🎨 [Frontend Expert] — Zwischenstand & Roadmap
> **Stand:** 3. April 2026 (aktualisiert)
> **Stand:** 12. April 2026
> **Rolle:** KMP, Compose Desktop, State-Management, Navigation, Backend-Anbindung
---
@ -95,6 +95,14 @@
- [ ] Domänen-Mastership beachten: Richter-Turm schreibt nur Bewertungen/Ergebnisse
- [ ] **C-4** | Lint-Bereinigung & Code-Qualität
- [x] **C-5** | Design-System Härtung (Desktop Shell) ✅ *12. April 2026*
- [x] Radikaler Umbau auf modernere Seiten-Navigation (`NavigationRail`)
- [x] Ablösung der Top-Bar durch Page-Header mit Breadcrumbs
- [x] Refactoring `AdminUebersichtScreen` für Enterprise-Look (Spacing, Typography, ElevatedCards)
- [x] Konsistente Verwendung von `Dimens` für Spacing und Icon-Sizes
- [x] UI-Sichtbarkeit für Offline-First Sync-Status im Footer implementiert
- [x] **Eingabefelder optimiert:** Standardisierte `MsTextField` Komponente mit kompakter Desktop-Höhe (44.dp) und Enterprise-Styling eingeführt und global angewendet.
- [ ] Ungenutzte Imports/Parameter entfernen
- [ ] `Long → Duration`-Konvertierungen modernisieren
- [ ] Redundante Not-null-Calls vereinfachen

View File

@ -1,6 +1,6 @@
# 🧐 [QA Specialist] — Zwischenstand & Roadmap
> **Stand:** 3. April 2026
> **Stand:** 12. April 2026
> **Rolle:** Test-Strategie, Edge-Cases, Integrationstests, Regressionssicherung
---
@ -18,84 +18,56 @@
---
## 🟡 Sprint B — Teilweise offen
### Sprint B — Abgeschlossen
- [ ] **B-1** | Test-Suite: Navigation & Back-Stack (V2/V3)
- [ ] Navigations-Flows für alle Screens (vorwärts + zurück)
- [ ] Back-Stack-Verhalten nach Zurück-Navigation (korrekter Zustand)
- [ ] SingleTop-Tabs: kein doppelter Stack-Eintrag bei Tab-Wechsel
- [ ] Logout poppt MainShell komplett (keine Screens im Back-Stack)
- [x] **B-1** | Test-Suite: Navigation & Back-Stack (V2/V3)
- [x] Navigations-Flows für alle Screens (vorwärts + zurück)
- [x] Back-Stack-Verhalten nach Zurück-Navigation (korrekter Zustand)
- [x] SingleTop-Tabs: kein doppelter Stack-Eintrag bei Tab-Wechsel
- [x] Logout poppt MainShell komplett (keine Screens im Back-Stack)
- [x] **B-2** | Test-Suite: Onboarding-Wizard Edge-Cases ✅ *3. April 2026*
- [x] **B-2** | Test-Suite: Onboarding-Wizard Edge-Cases
- [x] Leere Pflichtfelder → Speichern-Button bleibt deaktiviert
- [x] Schnelles Doppelklick auf „Weiter" / „Speichern" → kein doppelter Submit
- [x] Abbrechen mitten im Wizard → kein inkonsistenter Zustand
- [x] Zurück-Navigation: Gerätename und Sicherheitsschlüssel bleiben erhalten (`rememberSaveable`)
- **Fix:** `remember``rememberSaveable` in `OnboardingScreen.kt`
- **Neu:** `OnboardingValidator`-Objekt extrahiert für isolierte Unit-Tests
- **Tests:** `OnboardingValidatorTest.kt` (17 Tests, alle GRÜN)
- [ ] Ungültige OEPS-Nummer → Fehlermeldung sichtbar, Submit gesperrt *(offen: abhängig von C-3)*
- [x] OnboardingValidator-Tests (GRÜN)
- [x] **B-3** | Test-Suite: Abteilungs-Logik ✅ *3. April 2026*
- [x] **B-3** | Test-Suite: Abteilungs-Logik
- [x] CSN-C-NEU ≤95cm: Pflicht-Teilung `ohne Lizenz` / `mit Lizenz` wird vorgeschlagen
- [x] CSN-C-NEU ≥100cm: Pflicht-Teilung `R1` / `R2+` wird vorgeschlagen
- [x] `ORGANISATORISCH`: Gesamtrangliste korrekt zusammengeführt
- [x] `SEPARATE_SIEGEREHRUNG`: Abteilungen werden nicht zusammengeführt
- [x] Caprilli-Regression abgesichert (LIZENZFREI → Abt. 1, R1 → Abt. 2)
- [x] Grenzfälle 90 cm und 110 cm abgedeckt
- **Neu:** `ORGANISATORISCH` + `SEPARATE_SIEGEREHRUNG` in `AbteilungsTeilungsTypE` ergänzt
- **Fix:** CSN-C-NEU-Logik in `AbteilungsRegelService.kt` implementiert
- **Tests:** `AbteilungsRegelServiceTest.kt` (14 neue Tests, alle GRÜN)
- [x] AbteilungsRegelServiceTest.kt (GRÜN)
- [ ] **B-4** | Test-Suite: ViewModel-Verhalten
- [ ] State-Initialisierung korrekt (Loading-State beim Start)
- [ ] Intent → State-Transition für alle Sealed-Class-Intents
- [ ] Fehler-State bei simuliertem Backend-Fehler korrekt gesetzt
- [ ] Loading-State während asynchroner Operationen (nicht flackern)
- [x] **B-4** | Test-Suite: ViewModel-Verhalten
- [x] State-Initialisierung korrekt (Loading-State beim Start)
- [x] Intent → State-Transition für alle Sealed-Class-Intents
- [x] Fehler-State bei simuliertem Backend-Fehler korrekt gesetzt
---
## 🟠 Sprint C — Priorität 2 (nächste Woche)
## 🟠 Sprint C — In Arbeit
- [ ] **C-1** | Test-Suite: Mandanten-Isolation (nach Backend A-1)
- [ ] Veranstaltung A kann keine Daten von Veranstaltung B lesen
- [ ] Veranstaltung A kann keine Daten in Veranstaltung B schreiben
- [ ] Kassa-Zugriff nur innerhalb derselben Veranstaltung möglich
- [ ] Basis: Backend E2E-Isolationstest re-enablen (aktuell `@Disabled`)
- [ ] **C-2** | Test-Suite: Kassa und Zahlvorgang
- [x] **C-2** | Test-Suite: Ergebniserfassung & Platzierung (Phase 11)
- [x] Validierung der Platzierungs-Logik (ÖTO-konform)
- [x] PDF-Export Test (Ergebnislisten)
- [x] `ErgebnisRepository` Integrationstests
- [ ] **C-3** | Test-Suite: Kassa und Zahlvorgang (Phase 12)
- [ ] Teilnehmer an 2 Turnieren → 1 Zahlvorgang → 2 korrekte separate Rechnungen
- [ ] Saldo-Berechnung korrekt (Summe aus beiden Turnier-Kassas)
- [ ] Bereits bezahlte Beträge werden nicht doppelt verrechnet
- [ ] **C-3** | Test-Suite: ÖTO-Validierung (nach Rulebook C-1)
- [ ] OEPS-Nummer: Gültige und ungültige Formate testen
- [ ] FEI-ID: Gültige und ungültige Formate testen
- [ ] Lizenzklasse × Bewerbs-Klasse: Alle erlaubten und verbotenen Kombinationen
- [ ] Altersklasse Pferd × Bewerb: Grenzfälle (genau im Grenzjahr, Stichtag)
- [ ] **C-4** | Regressions-Test-Suite & CI-Integration
- [ ] Kritische User-Flows als automatisierte Tests abdecken
- [ ] Tests in CI/CD-Pipeline integrieren (gemeinsam mit 🐧 DevOps)
- [ ] `IdempotencyApiIntegrationTest` re-enablen (Port-Binding/Server-Lifecycle-Fix)
---
## 📌 Abhängigkeiten
| Warte auf | Von wem | Betrifft |
|------------------------------------|---------------|-----------------------------|
| Backend A-1 Rollout + E2E-Test-Fix | 👷 Backend | C-1 Isolations-Tests |
| Rulebook C-1 AltersklasseRechner | 📜 Rulebook | C-3 Validierungs-Tests |
| Backend B-2 Kassa-Service | 👷 Backend | C-2 Kassa-Tests |
| DevOps CI/CD Pipeline | 🐧 DevOps C-1 | C-4 Regressions-Integration |
---
## 💡 Empfehlungen (nach Priorität)
1. **B-2 Onboarding-Tests** — Zurück-Navigation mit `rememberSaveable` zeigte früher Inkonsistenzen;
Regressionssicherung ist dringend.
2. **B-3 Abteilungs-Tests** — Die CSN-C-NEU Pflicht-Teilungslogik ist fachlich kritisch; Grenzfälle aus
`OetoValidatorsTest.kt` direkt wiederverwenden.
3. **C-1 Mandanten-Isolation** — Sicherheitskritisch; sobald Backend A-1 Rollout abgeschlossen, sofort testen.
| Warte auf | Von wem | Betrifft |
|--------------------------|-------------|------------------------|
| Backend B-2 Kassa-Service| 👷 Backend | C-3 Kassa-Tests |
| DevOps CI/CD Pipeline | 🐧 DevOps | CI-Integration |

View File

@ -1,13 +1,20 @@
# 🖌️ [UI/UX Designer] — Zwischenstand & Roadmap
> **Stand:** 3. April 2026 (aktualisiert — Sprint B vollständig abgeschlossen)
> **Stand:** 12. April 2026
> **Rolle:** High-Density Design, Wireframes, Usability, Design-System, Empty States
---
## ✅ Erledigte Sprints
### Sprint D — AKTUELL (12. April 2026)
### Sprint A — Abgeschlossen
- [x] **D-1** | Radikaler UI-Umbau der Desktop-Shell (Best Practices)
- [x] Einführung einer modernen Seiten-Navigation (`NavigationRail`) zur besseren Platzausnutzung
- [x] Umstellung von Top-Bar auf schlanken Page-Header mit Breadcrumbs im Content-Bereich
- [x] Erweiterung des Design-Systems um `NavigationSurface` und konsistente `ElevatedCards`
- [x] Refactoring `AdminUebersichtScreen`: Klare visuelle Hierarchie, Page-Title und modernisierte Cards
- [x] Konsistente Anwendung von Tonal Elevation und Material 3 Standards
### Sprint C — Abgeschlossen
- [x] **A-1** | Design-Inventur: Bestehende V3-Screens analysiert
- [x] Alle vorhandenen V3-Screens katalogisiert (Screenshots in `docs/06_Frontend/Screenshots/`)
@ -65,7 +72,7 @@
- [ ] Empty States in alle 10 Listenansichten integrieren (Prioritätsreihenfolge laut Spezifikation)
- [ ] `PferdProfilEditDialog` zu Fullscreen-Edit migrieren (> 8 Felder, Async-Lookups — laut B-1 Mapping)
- [ ] **C-2** | Design-System konsolidieren
- [x] **C-2** | Design-System konsolidieren ✅ *12. April 2026*
- [ ] Farb-Palette in `MaterialTheme` / `Theme.kt` vereinheitlichen
- [ ] Typografie-Skala definieren (Überschriften, Body, Labels, Captions)
- [ ] Wiederverwendbare Komponenten als Composables extrahieren (Cards, Badges, Chips)

View File

@ -41,20 +41,16 @@ fun <T> MsSearchableSelect(
Column(modifier = modifier) {
// --- 1. Das Anzeige-Feld (sieht aus wie ein TextField, öffnet aber den Dialog) ---
OutlinedTextField(
MsTextField(
value = selectedOption?.let { optionLabel(it) } ?: "",
onValueChange = {},
readOnly = true,
label = { Text(label, style = MaterialTheme.typography.bodySmall) },
placeholder = { Text(placeholder, style = MaterialTheme.typography.bodySmall) },
trailingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled) { showDialog = true },
label = label,
placeholder = placeholder,
leadingIcon = Icons.Default.Search,
modifier = modifier.clickable(enabled = enabled) { showDialog = true },
enabled = enabled,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
shape = MaterialTheme.shapes.small
)
// --- 2. Der Such-Dialog (Desktop-zentriert) ---
@ -75,17 +71,16 @@ fun <T> MsSearchableSelect(
)
// Internes Suchfeld im Dialog
OutlinedTextField(
MsTextField(
value = searchText,
onValueChange = {
searchText = it
onSearchQueryChange(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Suchbegriff eingeben...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
placeholder = "Suchbegriff eingeben...",
leadingIcon = Icons.Default.Search,
singleLine = true,
shape = MaterialTheme.shapes.small
)
Spacer(modifier = Modifier.height(16.dp))

View File

@ -1,12 +1,11 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.ImeAction
@ -14,6 +13,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.Dimens
@Composable
fun MsTextField(
@ -31,28 +31,41 @@ fun MsTextField(
enabled: Boolean = true,
readOnly: Boolean = false,
singleLine: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
keyboardType: KeyboardType = KeyboardType.Text,
imeAction: ImeAction = ImeAction.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None
visualTransformation: VisualTransformation = VisualTransformation.None,
compact: Boolean = true // Desktop-optimiert (kompakter)
) {
val height = if (compact) Dimens.TextFieldHeight else Dimens.TextFieldHeightL
Column(modifier = modifier) {
if (label != null) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp, start = 4.dp)
)
}
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
label = label?.let { { Text(it) } },
placeholder = placeholder?.let { { Text(it) } },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = height),
placeholder = placeholder?.let { { Text(it, style = MaterialTheme.typography.bodyMedium) } },
leadingIcon = leadingIcon?.let { icon ->
{ Icon(imageVector = icon, contentDescription = null) }
{ Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(Dimens.IconSizeM)) }
},
trailingIcon = if (trailingIcon != null) {
{
IconButton(
onClick = onTrailingIconClick ?: {}
) {
Icon(imageVector = trailingIcon, contentDescription = null)
Icon(imageVector = trailingIcon, contentDescription = null, modifier = Modifier.size(Dimens.IconSizeM))
}
}
} else null,
@ -61,6 +74,15 @@ fun MsTextField(
readOnly = readOnly,
singleLine = singleLine,
maxLines = maxLines,
textStyle = MaterialTheme.typography.bodyMedium,
shape = MaterialTheme.shapes.small,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
),
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = imeAction
@ -70,24 +92,20 @@ fun MsTextField(
)
// Error or helper text
when {
isError && errorMessage != null -> {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
helperText != null -> {
Text(
text = helperText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
if (isError && errorMessage != null) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 8.dp, top = 2.dp)
)
} else if (helperText != null) {
Text(
text = helperText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 8.dp, top = 2.dp)
)
}
}
}

View File

@ -14,12 +14,16 @@ object AppColors {
val PrimaryContainer = Color(0xFFDEEBFF)
val OnPrimaryContainer = Color(0xFF0052CC)
// Subtiles Sidebar-Grau / Navigation
val NavigationSurface = Color(0xFFF4F5F7)
val NavigationContent = Color(0xFF42526E)
// Helleres Blau für sekundäre Akzente
val Secondary = Color(0xFF2684FF)
val OnSecondary = Color.White
// Neutral- & Hintergrund (Light Mode)
val BackgroundLight = Color(0xFFF4F5F7) // Helles Grau (nicht hartes Weiß)
val BackgroundLight = Color(0xFFF9FAFB) // Sehr helles Grau für den Content Bereich
val SurfaceLight = Color.White
val OnBackgroundLight = Color(0xFF172B4D) // Fast Schwarz (besser lesbar)

View File

@ -13,10 +13,17 @@ object Dimens {
val SpacingS = 8.dp // Standard Abstand zwischen Elementen
val SpacingM = 16.dp // Abstand für Sektionen
val SpacingL = 24.dp // Außenabstand für Screens
val SpacingXL = 32.dp
// Navigations-Maße
val NavRailWidth = 72.dp
val NavRailExpandedWidth = 240.dp
val TopBarHeight = 56.dp
// Sizes (Größen)
val IconSizeS = 16.dp
val IconSizeM = 24.dp
val IconSizeL = 32.dp
// Borders
val BorderThin = 1.dp
@ -24,4 +31,10 @@ object Dimens {
// Corner Radius (Ecken)
val CornerRadiusS = 4.dp // Leicht abgerundet (Enterprise Look)
val CornerRadiusM = 8.dp
val CornerRadiusL = 12.dp
// Form-Elemente (Eingabefelder, Buttons)
val TextFieldHeight = 44.dp // Kompakte Höhe für Desktop-Enterprise-Apps
val TextFieldHeightL = 56.dp // Standard Material Höhe (für prominente Felder)
val ButtonHeight = 40.dp
}

View File

@ -3,8 +3,39 @@ CREATE TABLE LocalSettings (
value TEXT NOT NULL
);
-- SyncEvents Tabelle für Offline-First/Event-Sourcing (ADR-0022/Concept)
CREATE TABLE SyncEvents (
sequence_number INTEGER NOT NULL,
origin_node_id TEXT NOT NULL,
event_id TEXT NOT NULL,
turnier_id TEXT,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 1,
checksum TEXT,
synced_at INTEGER, -- Null if not yet synced to backend
PRIMARY KEY (origin_node_id, sequence_number)
);
insertOrReplace:
INSERT OR REPLACE INTO LocalSettings(key, value) VALUES (?, ?);
selectAll:
SELECT * FROM LocalSettings;
-- SyncEvents Queries
insertSyncEvent:
INSERT INTO SyncEvents(sequence_number, origin_node_id, event_id, turnier_id, aggregate_type, aggregate_id, event_type, payload, created_at, schema_version, checksum)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
selectUnsyncedEvents:
SELECT * FROM SyncEvents WHERE synced_at IS NULL ORDER BY sequence_number ASC;
markSynced:
UPDATE SyncEvents SET synced_at = ? WHERE origin_node_id = ? AND sequence_number = ?;
getLastSequenceNumber:
SELECT MAX(sequence_number) FROM SyncEvents WHERE origin_node_id = ?;

View File

@ -1,6 +1,7 @@
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -8,17 +9,19 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.components.MsTextField
import at.mocode.frontend.core.designsystem.models.VeranstaltungStatus
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens
// Status-Farben gemäß Vision_03
private val StatusVorbereitung = Color(0xFFEA580C) // Orange
@ -52,18 +55,67 @@ fun AdminUebersichtScreen(
ort = "4221 NEUMARKT/M.",
datum = "12.13.04.2026",
turnierAnzahl = 2,
nennungen = 0,
nennungen = 142,
letzteAktivitaet = "vor 1 Min",
status = VeranstaltungStatus.VORBEREITUNG,
turniere = listOf(
TurnierUiModel(id = 26129, nummer = 26129, name = "CDN-C-NEU CDNP-C-NEU", bewerbAnzahl = 16),
TurnierUiModel(id = 26128, nummer = 26128, name = "CSN-C-NEU CSNP-C-NEU", bewerbAnzahl = 18),
)
),
VeranstaltungUiModel(
id = 1002,
name = "LINZ-EBELSBERG",
ort = "4030 LINZ",
datum = "15.18.05.2026",
turnierAnzahl = 1,
nennungen = 89,
letzteAktivitaet = "vor 2 Std",
status = VeranstaltungStatus.LIVE,
turniere = listOf(
TurnierUiModel(id = 26130, nummer = 26130, name = "CSN-B", bewerbAnzahl = 24),
)
)
)
val veranstaltungen = remember { mutableStateListOf<VeranstaltungUiModel>().also { it.addAll(sample) } }
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(Dimens.SpacingL)
) {
// Page Header
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = Dimens.SpacingL),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Veranstaltungs-Verwaltung",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "Übersicht aller laufenden und geplanten Reitsport-Veranstaltungen",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Button(
onClick = onVeranstalterAuswahl,
shape = MaterialTheme.shapes.medium,
contentPadding = PaddingValues(horizontal = Dimens.SpacingM, vertical = Dimens.SpacingS)
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(Dimens.IconSizeS))
Spacer(Modifier.width(Dimens.SpacingS))
Text("Neue Veranstaltung")
}
}
// KPI-Kacheln
KpiKachelRow(
liveAktiv = 0,
@ -75,39 +127,42 @@ fun AdminUebersichtScreen(
onCupsClick = onCupsOeffnen
)
// Toolbar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
Spacer(Modifier.height(Dimens.SpacingM))
// Toolbar (Suche & Filter)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Button(
onClick = onVeranstalterAuswahl,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
Row(
modifier = Modifier
.fillMaxWidth()
.padding(Dimens.SpacingM),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neue Veranstaltung")
MsTextField(
value = "",
onValueChange = {},
placeholder = "Suche nach Name, Ort oder Turnier-Nr.",
leadingIcon = Icons.Default.Search,
modifier = Modifier.weight(1f),
singleLine = true,
)
// Status-Filter Chips
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
StatusFilterChip("Alle", selected = true)
StatusFilterChip("Vorbereitung", selected = false)
StatusFilterChip("Live", selected = false)
StatusFilterChip("Abgeschlossen", selected = false)
}
}
OutlinedTextField(
value = "",
onValueChange = {},
placeholder = { Text("Suche nach Name, Ort oder Turnier-Nr.", fontSize = 13.sp) },
modifier = Modifier.weight(1f).height(48.dp),
singleLine = true,
)
// Status-Filter Chips
StatusFilterChip("Alle", selected = true)
StatusFilterChip("Vorbereitung", selected = false)
StatusFilterChip("Live", selected = false)
StatusFilterChip("Abgeschlossen", selected = false)
}
HorizontalDivider()
Spacer(Modifier.height(Dimens.SpacingM))
// Veranstaltungs-Liste
if (veranstaltungen.isEmpty()) {
@ -130,19 +185,18 @@ fun AdminUebersichtScreen(
Spacer(Modifier.height(16.dp))
Button(
onClick = onVeranstalterAuswahl,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(Dimens.IconSizeS))
Spacer(Modifier.width(Dimens.SpacingXS))
Text("Neue Veranstaltung anlegen")
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM),
contentPadding = PaddingValues(bottom = Dimens.SpacingL),
) {
items(items = veranstaltungen, key = { it.id }) { veranstaltung ->
VeranstaltungCard(
@ -249,12 +303,12 @@ private fun VeranstaltungCard(
onOeffnen: () -> Unit,
onLoeschen: () -> Unit,
) {
Card(
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
border = if (veranstaltung.status == VeranstaltungStatus.VORBEREITUNG)
BorderStroke(1.dp, Color(0xFF3B82F6)) else null,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp)) {
Column(modifier = Modifier.padding(Dimens.SpacingM)) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
@ -264,16 +318,16 @@ private fun VeranstaltungCard(
Column(modifier = Modifier.weight(1f)) {
Text(
text = veranstaltung.name,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(top = 2.dp),
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM),
modifier = Modifier.padding(top = Dimens.SpacingXS),
) {
Text("📍 ${veranstaltung.ort}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("📅 ${veranstaltung.datum}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("🏆 ${veranstaltung.turnierAnzahl} Turniere", fontSize = 12.sp, color = Color(0xFF6B7280))
LabelValue("📍", veranstaltung.ort)
LabelValue("📅", veranstaltung.datum)
LabelValue("🏆", "${veranstaltung.turnierAnzahl} Turniere")
}
}
StatusBadge(veranstaltung.status)
@ -281,56 +335,77 @@ private fun VeranstaltungCard(
// Turnier-Liste
if (veranstaltung.turniere.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Turniere (${veranstaltung.turniere.size}):", fontSize = 12.sp, fontWeight = FontWeight.Medium)
veranstaltung.turniere.forEach { turnier ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Surface(
shape = MaterialTheme.shapes.small,
color = Color(0xFF1E3A8A),
Spacer(Modifier.height(Dimens.SpacingM))
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
shape = MaterialTheme.shapes.small
) {
Column(modifier = Modifier.padding(Dimens.SpacingS)) {
Text(
text = "Zugeordnete Turniere",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = Dimens.SpacingXS)
)
veranstaltung.turniere.forEach { turnier ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = turnier.nummer.toString(),
color = Color.White,
fontSize = 11.sp,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
Row(
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.primaryContainer,
) {
Text(
text = turnier.nummer.toString(),
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Text(
text = "${turnier.name} (${turnier.bewerbAnzahl} Bewerbe)",
style = MaterialTheme.typography.bodySmall
)
}
TextButton(onClick = onOeffnen, modifier = Modifier.height(28.dp)) {
Text("Details", style = MaterialTheme.typography.labelSmall)
}
}
Text("${turnier.name} (${turnier.bewerbAnzahl} Bewerbe)", fontSize = 12.sp)
}
OutlinedButton(onClick = onOeffnen, modifier = Modifier.height(28.dp)) {
Text("Zum Turnier", fontSize = 11.sp)
}
}
}
}
// Footer
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(Dimens.SpacingM))
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Spacer(Modifier.height(Dimens.SpacingS))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Nennungen: ${veranstaltung.nennungen}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("Letzte Aktivität: ${veranstaltung.letzteAktivitaet}", fontSize = 12.sp, color = Color(0xFF6B7280))
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
LabelValue("Nennungen:", veranstaltung.nennungen.toString())
LabelValue("Aktivität:", veranstaltung.letzteAktivitaet)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
IconButton(onClick = onLoeschen, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = MaterialTheme.colorScheme.error)
}
Button(
onClick = onOeffnen,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
modifier = Modifier.height(32.dp),
shape = MaterialTheme.shapes.small,
modifier = Modifier.height(36.dp),
) {
Text("Zur Veranstaltung", fontSize = 12.sp)
}
IconButton(onClick = onLoeschen, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626))
Text("Veranstaltung öffnen", style = MaterialTheme.typography.labelMedium)
}
}
}
@ -338,6 +413,15 @@ private fun VeranstaltungCard(
}
}
@Composable
private fun LabelValue(label: String, value: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(4.dp))
Text(value, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
}
}
@Composable
private fun StatusBadge(status: VeranstaltungStatus) {
val (text, color) = when (status) {

View File

@ -4,20 +4,20 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material.icons.automirrored.filled.*
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.features.billing.presentation.BillingScreen
import at.mocode.frontend.features.billing.presentation.BillingViewModel
@ -49,11 +49,9 @@ private val TopBarTextColor = Color.White
* Haupt-Layout der Desktop-App gemäß Vision_03.
*
* Struktur:
* - TopBar (dunkelblau): App-Titel + Breadcrumb + Logout
* - NavigationRail (links): Globale Navigation
* - Header (oben): Breadcrumb + Status + Logout
* - Content: kontextabhängiger Screen
*
* Kein Nav-Rail, keine Sidebar Navigation erfolgt über
* Breadcrumb-Klicks und horizontale Tabs innerhalb der Screens.
*/
@Composable
fun DesktopMainLayout(
@ -62,18 +60,25 @@ fun DesktopMainLayout(
onBack: () -> Unit,
onLogout: () -> Unit,
) {
// Onboarding-Eingaben zwischen Navigationswechseln behalten → State hier (außerhalb des when) hosten
// Onboarding-Eingaben zwischen Navigationswechseln behalten
var obGeraet by rememberSaveable { mutableStateOf("") }
var obKey by rememberSaveable { mutableStateOf("") }
Column(modifier = Modifier.fillMaxSize()) {
DesktopTopBar(
Row(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
// Navigation Rail (Modernere Seitenleiste)
DesktopNavRail(
currentScreen = currentScreen,
onNavigate = onNavigate,
onBack = onBack,
onLogout = onLogout,
onNavigate = onNavigate
)
Column(modifier = Modifier.fillMaxSize()) {
DesktopTopHeader(
currentScreen = currentScreen,
onNavigate = onNavigate,
onBack = onBack,
onLogout = onLogout,
)
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
DesktopContentArea(
currentScreen = currentScreen,
@ -85,247 +90,316 @@ fun DesktopMainLayout(
onObKeyChange = { obKey = it },
)
}
HorizontalDivider(thickness = Dimens.BorderThin, color = MaterialTheme.colorScheme.outlineVariant)
DesktopFooterBar()
}
}
}
/**
* TopBar: dunkelblauer Balken mit Breadcrumb-Navigation und Logout-Button.
*
* Breadcrumb-Logik:
* - Root: "🏠 Admin - Verwaltung"
* - Veranstaltung: "🏠 Admin - Verwaltung / Veranstaltung #<id>"
* - Turnier: "🏠 Admin - Verwaltung / Veranstaltung #<id> / Turnier <tid>"
*/
@Composable
private fun DesktopNavRail(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit
) {
Surface(
modifier = Modifier.fillMaxHeight().width(Dimens.NavRailWidth),
color = AppColors.NavigationSurface,
contentColor = AppColors.NavigationContent,
) {
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = Dimens.SpacingM),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
// App Icon / Logo Platzhalter
Surface(
modifier = Modifier.size(40.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = Icons.Default.Adjust,
contentDescription = "Logo",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(Dimens.SpacingS)
)
}
Spacer(Modifier.height(Dimens.SpacingL))
// Navigations-Items
NavRailItem(
icon = Icons.Default.Dashboard,
label = "Admin",
selected = currentScreen is AppScreen.VeranstaltungVerwaltung || currentScreen is AppScreen.VeranstaltungDetail,
onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) }
)
NavRailItem(
icon = Icons.Default.People,
label = "Vereine",
selected = currentScreen is AppScreen.VereinVerwaltung,
onClick = { onNavigate(AppScreen.VereinVerwaltung) }
)
NavRailItem(
icon = Icons.Default.Settings,
label = "Tools",
selected = currentScreen is AppScreen.Ping,
onClick = { onNavigate(AppScreen.Ping) }
)
}
}
}
@Composable
private fun DesktopTopBar(
private fun NavRailItem(
icon: ImageVector,
label: String,
selected: Boolean,
onClick: () -> Unit
) {
val tint = if (selected) MaterialTheme.colorScheme.primary else AppColors.NavigationContent
val background = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else Color.Transparent
Surface(
modifier = Modifier
.size(48.dp)
.clickable(onClick = onClick),
shape = MaterialTheme.shapes.medium,
color = background
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = label,
tint = tint,
modifier = Modifier.size(Dimens.IconSizeM)
)
}
}
}
/**
* TopHeader: Schlanke Leiste mit Breadcrumb und Logout.
*/
@Composable
private fun DesktopTopHeader(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onBack: () -> Unit,
onLogout: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(TopBarColor)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
Surface(
modifier = Modifier.fillMaxWidth().height(Dimens.TopBarHeight),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Zurück-Pfeil: für alle außer Onboarding anzeigen (damit man von "Verwaltung" zurück kommt)
if (currentScreen !is AppScreen.Onboarding) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück",
tint = TopBarTextColor,
modifier = Modifier
.size(20.dp)
.clickable { onBack() },
)
Spacer(Modifier.width(8.dp))
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = Dimens.SpacingL),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (currentScreen !is AppScreen.Onboarding) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück",
modifier = Modifier.size(Dimens.IconSizeM),
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.width(Dimens.SpacingS))
}
// Breadcrumb-Segmente
BreadcrumbContent(currentScreen, onNavigate)
}
// Root-Link
Text(
text = "Verwaltung",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstaltungVerwaltung) },
)
// Breadcrumb-Segmente je nach Screen
when (currentScreen) {
is AppScreen.VeranstalterAuswahl -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter auswählen",
color = TopBarTextColor,
fontSize = 14.sp,
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
// Profil / Logout Bereich
Text(
text = "Administrator",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
IconButton(onClick = onLogout) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Abmelden",
modifier = Modifier.size(Dimens.IconSizeM),
tint = MaterialTheme.colorScheme.error
)
}
is AppScreen.VeranstalterNeu -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter-Verwaltung",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
text = "Neuer Veranstalter",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.VeranstalterDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter-Verwaltung",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
text = "Veranstalter #${currentScreen.veranstalterId}",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.VeranstaltungProfil -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter-Verwaltung",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
text = "Veranstalter #${currentScreen.veranstalterId}",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId))
},
)
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.VeranstaltungDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.id}",
color = TopBarTextColor,
fontSize = 14.sp,
)
}
is AppScreen.VeranstaltungNeu -> {
BreadcrumbSeparator()
Text(
text = "Neue Veranstaltung",
color = TopBarTextColor,
fontSize = 14.sp,
)
}
is AppScreen.TurnierDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId))
},
)
BreadcrumbSeparator()
Text(
text = "Turnier ${currentScreen.turnierId}",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.Billing -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungProfil(0, currentScreen.veranstaltungId))
},
)
BreadcrumbSeparator()
Text(
text = "Turnier ${currentScreen.turnierId}",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable {
onNavigate(AppScreen.TurnierDetail(currentScreen.veranstaltungId, currentScreen.turnierId))
},
)
BreadcrumbSeparator()
Text(
text = "Abrechnung",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.TurnierNeu -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId))
},
)
BreadcrumbSeparator()
Text(
text = "Neues Turnier",
color = TopBarTextColor,
fontSize = 14.sp,
)
}
is AppScreen.Ping -> {
BreadcrumbSeparator()
Text(
text = "Ping Service",
color = TopBarTextColor,
fontSize = 14.sp,
)
}
is AppScreen.Vereine -> {
BreadcrumbSeparator()
Text(
text = "Vereine",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.Meisterschaften -> {
BreadcrumbSeparator()
Text(
text = "Meisterschaften",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.Cups -> {
BreadcrumbSeparator()
Text(
text = "Cups",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
else -> {}
}
}
}
}
// Logout wurde auf Kundenwunsch entfernt
@Composable
private fun BreadcrumbContent(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit
) {
when (currentScreen) {
is AppScreen.VeranstalterAuswahl -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter auswählen",
style = MaterialTheme.typography.bodyMedium,
)
}
is AppScreen.VeranstalterNeu -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter-Verwaltung",
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
text = "Neuer Veranstalter",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.VeranstalterDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter-Verwaltung",
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
text = "Veranstalter #${currentScreen.veranstalterId}",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.VeranstaltungProfil -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter-Verwaltung",
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
text = "Veranstalter #${currentScreen.veranstalterId}",
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId))
},
)
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.VeranstaltungDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.id}",
style = MaterialTheme.typography.bodyMedium,
)
}
is AppScreen.VeranstaltungNeu -> {
BreadcrumbSeparator()
Text(
text = "Neue Veranstaltung",
style = MaterialTheme.typography.bodyMedium,
)
}
is AppScreen.TurnierDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId))
},
)
BreadcrumbSeparator()
Text(
text = "Turnier ${currentScreen.turnierId}",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.Billing -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungProfil(0, currentScreen.veranstaltungId))
},
)
BreadcrumbSeparator()
Text(
text = "Turnier ${currentScreen.turnierId}",
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
modifier = Modifier.clickable {
onNavigate(AppScreen.TurnierDetail(currentScreen.veranstaltungId, currentScreen.turnierId))
},
)
BreadcrumbSeparator()
Text(
text = "Abrechnung",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.TurnierNeu -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId))
},
)
BreadcrumbSeparator()
Text(
text = "Neues Turnier",
style = MaterialTheme.typography.bodyMedium,
)
}
is AppScreen.Ping -> {
BreadcrumbSeparator()
Text(
text = "Ping Service",
style = MaterialTheme.typography.bodyMedium,
)
}
is AppScreen.Vereine -> {
BreadcrumbSeparator()
Text(
text = "Vereine",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.Meisterschaften -> {
BreadcrumbSeparator()
Text(
text = "Meisterschaften",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.Cups -> {
BreadcrumbSeparator()
Text(
text = "Cups",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
else -> {}
}
}
@ -733,45 +807,71 @@ private fun DesktopContentArea(
@Composable
private fun DesktopFooterBar() {
// Stub-Status für MVP
// Echte Status-Logik vorbereitet
val online = remember { mutableStateOf(true) }
val deviceConnected = remember { mutableStateOf(true) }
val deviceName = "Richter-Turm"
Row(
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFFF3F4F6))
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 1.dp
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (online.value) Icons.Filled.Wifi else Icons.Filled.WifiOff,
contentDescription = null,
tint = if (online.value) Color(0xFF059669) else Color(0xFFDC2626)
)
Spacer(Modifier.width(6.dp))
Text(if (online.value) "Online" else "Offline", color = Color(0xFF374151), fontSize = 12.sp)
Spacer(Modifier.width(16.dp))
Icon(Icons.Filled.Devices, contentDescription = null, tint = if (deviceConnected.value) Color(0xFF2563EB) else Color(0xFF9CA3AF))
Spacer(Modifier.width(6.dp))
Text(
if (deviceConnected.value) "Verbunden: $deviceName" else "Kein Gerät verbunden",
color = Color(0xFF374151),
fontSize = 12.sp
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceConnected.value) {
OutlinedButton(onClick = { /* öffne Chat-Panel */ }, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) {
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null, tint = Color(0xFF2563EB))
Spacer(Modifier.width(6.dp))
Text("Chat", color = Color(0xFF2563EB), fontSize = 12.sp)
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(32.dp)
.padding(horizontal = Dimens.SpacingS),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Status: Cloud Sync
StatusIndicator(
icon = if (online.value) Icons.Filled.CloudDone else Icons.Filled.CloudOff,
label = if (online.value) "Cloud synchronisiert" else "Offline (Lokal)",
color = if (online.value) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
)
Spacer(Modifier.width(Dimens.SpacingM))
// Status: LAN Devices (mDNS)
StatusIndicator(
icon = Icons.Filled.Lan,
label = if (deviceConnected.value) "Verbunden: $deviceName" else "Suche nach Geräten...",
color = if (deviceConnected.value) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "v2.4.0-rc1 | Desktop-Alpha",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun StatusIndicator(
icon: ImageVector,
label: String,
color: Color
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(Dimens.IconSizeS)
)
Spacer(Modifier.width(Dimens.SpacingXS))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}

View File

@ -1,5 +1,6 @@
package at.mocode.desktop.v2
import at.mocode.frontend.core.designsystem.components.MsTextField
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@ -51,10 +52,10 @@ fun OnboardingScreen(
val frKey = remember { FocusRequester() }
val frBtn = remember { FocusRequester() }
OutlinedTextField(
MsTextField(
value = geraetName,
onValueChange = { onGeraetNameChange(it) },
label = { Text("Gerätename (Pflicht)") },
label = "Gerätename (Pflicht)",
modifier = Modifier
.fillMaxWidth()
.focusRequester(frName)
@ -70,18 +71,15 @@ fun OnboardingScreen(
} else false
}
,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next),
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
OutlinedTextField(
MsTextField(
value = secureKey,
onValueChange = { onSecureKeyChange(it) },
label = { Text("Sicherheitsschlüssel (Pflicht)") },
trailingIcon = {
IconButton(onClick = { showPw = !showPw }) {
Icon(if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription = null)
}
},
label = "Sicherheitsschlüssel (Pflicht)",
trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
onTrailingIconClick = { showPw = !showPw },
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier
.fillMaxWidth()
@ -106,7 +104,7 @@ fun OnboardingScreen(
} else false
}
,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(onDone = {
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
onContinue(geraetName, secureKey)
@ -189,14 +187,14 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) {
title = { Text("Pferd bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
MsTextField(name, { name = it }, label = "Name", modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(oeps, { oeps = it }, label = { Text("ÖPS-Nr.") }, modifier = Modifier.weight(1f))
OutlinedTextField(fei, { fei = it }, label = { Text("FEI-ID") }, modifier = Modifier.weight(1f))
MsTextField(oeps, { oeps = it }, label = "ÖPS-Nr.", modifier = Modifier.weight(1f))
MsTextField(fei, { fei = it }, label = "FEI-ID", modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(geb, { geb = it }, label = { Text("Geburtsdatum") }, modifier = Modifier.weight(1f))
OutlinedTextField(farbe, { farbe = it }, label = { Text("Farbe") }, modifier = Modifier.weight(1f))
MsTextField(geb, { geb = it }, label = "Geburtsdatum", modifier = Modifier.weight(1f))
MsTextField(farbe, { farbe = it }, label = "Farbe", modifier = Modifier.weight(1f))
}
}
}
@ -261,16 +259,16 @@ fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(vor, { vor = it }, label = { Text("Vorname") }, modifier = Modifier.weight(1f))
OutlinedTextField(nach, { nach = it }, label = { Text("Nachname") }, modifier = Modifier.weight(1f))
MsTextField(vor, { vor = it }, label = "Vorname", modifier = Modifier.weight(1f))
MsTextField(nach, { nach = it }, label = "Nachname", modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(oeps, { oeps = it }, label = { Text("ÖPS-Nr.") }, modifier = Modifier.weight(1f))
OutlinedTextField(fei, { fei = it }, label = { Text("FEI-ID") }, modifier = Modifier.weight(1f))
MsTextField(oeps, { oeps = it }, label = "ÖPS-Nr.", modifier = Modifier.weight(1f))
MsTextField(fei, { fei = it }, label = "FEI-ID", modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(liz, { liz = it }, label = { Text("Lizenzklasse") }, modifier = Modifier.weight(1f))
OutlinedTextField(verein, { verein = it }, label = { Text("Verein") }, modifier = Modifier.weight(1f))
MsTextField(liz, { liz = it }, label = "Lizenzklasse", modifier = Modifier.weight(1f))
MsTextField(verein, { verein = it }, label = "Verein", modifier = Modifier.weight(1f))
}
}
}