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:
2026-04-12 23:06:49 +02:00
parent 5eb2dd6904
commit 126522e606
17 changed files with 854 additions and 551 deletions
@@ -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
)
+2
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] **`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. ✓ * [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 ### PHASE 11: Ergebniserfassung & Platzierung ✅ ABGESCHLOSSEN
*Ziel: Vollständige Ergebniserfassung und automatisierte Platzierungsberechnung.* *Ziel: Vollständige Ergebniserfassung und automatisierte Platzierungsberechnung.*
@@ -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*
@@ -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*
+30 -6
View File
@@ -1,6 +1,6 @@
# 🏗️ [Lead Architect] — Zwischenstand & Roadmap # 🏗️ [Lead Architect] — Zwischenstand & Roadmap
> **Stand:** 3. April 2026 > **Stand:** 12. April 2026
> **Rolle:** Strategie, Architektur-Entscheidungen (ADRs), Domänen-Modell, Master-Roadmap > **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] **B-1** | ADR für LAN-Sync-Protokoll schreiben
- [x] Optionen analysieren: Event-Sourcing vs. CRDT vs. Timestamp-Sync - [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] **C-1** | Zeitplan-Optimierung Konzept
- [x] Fachliche Anforderungen (Use Cases) definiert - [x] Fachliche Anforderungen (Use Cases) definiert
@@ -48,7 +48,7 @@
- [x] Phase 9 Fortschritt reflektieren - [x] Phase 9 Fortschritt reflektieren
- [x] Link zum Zeitplan-Konzept ergänzt - [x] Link zum Zeitplan-Konzept ergänzt
- [x] Feature-Migration (Frontend) dokumentiert - [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 - [ ] Technische Machbarkeit (File-Storage vs. SQLite-Export) prüfen
- [ ] ADR für Offline-Transfer erstellen - [ ] 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 ## 📌 Abhängigkeiten
@@ -68,10 +92,10 @@
| Domänen-Modell ✅ | 👷 Backend: Schema-Design; 🎨 Frontend: ViewModel-Design | | Domänen-Modell ✅ | 👷 Backend: Schema-Design; 🎨 Frontend: ViewModel-Design |
| LAN-Sync ADR (B-1) | 🎨 Frontend: Sync-UI; 👷 Backend: Sync-Endpunkte | | LAN-Sync ADR (B-1) | 🎨 Frontend: Sync-UI; 👷 Backend: Sync-Endpunkte |
| Sync-Konzept (C-1) | 🐧 DevOps: mDNS/WebSocket-Infrastruktur | | Sync-Konzept (C-1) | 🐧 DevOps: mDNS/WebSocket-Infrastruktur |
| Billing-Arch (D-2) | 👷 Backend: Buchungs-Logik; 🎨 Frontend: Kassa-UI |
--- ---
## 💡 Empfehlung ## 💡 Empfehlung
**Sofort starten:** B-1 (LAN-Sync ADR) — Phase 8 der MASTER_ROADMAP wartet auf mDNS/WebSocket-Discovery; ohne ADR können **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.
Backend und Frontend nicht parallel implementieren.
+18 -69
View File
@@ -1,6 +1,6 @@
# 🧹 [Curator] — Zwischenstand & Roadmap # 🧹 [Curator] — Zwischenstand & Roadmap
> **Stand:** 3. April 2026 > **Stand:** 12. April 2026
> **Rolle:** Dokumentation, Session-Logs, Ubiquitous Language, Ordnung in `docs/` > **Rolle:** Dokumentation, Session-Logs, Ubiquitous Language, Ordnung in `docs/`
--- ---
@@ -19,81 +19,30 @@
### Sprint B — Abgeschlossen ### Sprint B — Abgeschlossen
- [x] **B-0** | Rulebook-Session (03.04.2026) dokumentiert - [x] **B-0** | Rulebook-Session (03.04.2026) dokumentiert
`docs/99_Journal/2026-04-03_Rulebook_B1_Validierung_Frontend.md` - [x] **B-1** | Alle Roadmaps geprüft und korrigiert (03.04. & 12.04.)
- [x] **B-1** (teilweise) | Architect B-1 Session-Log erstellt → - [x] **B-2** | `docs/05_Backend/` aktualisiert: Schema (V1-V009) & API-Übersicht Stammdaten
`docs/99_Journal/2026-04-03_Architect_B1_LAN-Sync_ADR-0022.md` - [x] **B-3** | `docs/06_Frontend/` aktualisiert: MVVM-Muster & ViewModel-Referenzen
- [x] **B-1** (teilweise) | Roadmaps aktualisiert: Architect (✅ Sprint B), Backend (C-3 freigegeben), Frontend (C-3
freigegeben) ### Sprint C — Abgeschlossen
- [x] **B-1** (abgeschlossen) | Alle Roadmaps geprüft und korrigiert (03.04.2026)
- [x] DevOps_Roadmap: vollständig und korrekt ✅ - [x] **C-1** | `README.md` aktualisiert: Desktop-App Fokus & Quickstart
- [x] UIUX_Roadmap: vollständig und korrekt ✅ - [x] **C-2** | Setup-Guide aktualisiert → `docs/02_Guides/start-local.md`
- [x] Rulebook_Roadmap: vollständig und korrekt ✅ - [x] **C-3** | Session-Logs für Phase 10, 11 & 12 (Serie, Ergebnisse, Billing) erstellt
- [x] QA_Roadmap: Sprint-B-Header korrigiert (🔴 → 🟡 Teilweise offen) ✅
--- ---
## 🟡 Sprint BTeilweise offen ## 🟠 Sprint DIn Arbeit
- [x] **B-1** | Roadmaps-Verzeichnis pflegen ✅ *3. April 2026* - [ ] **D-1** | Kassa-Endpunkte in API-Doku ergänzen (sobald Billing-Service final)
- [x] Architect-, Backend-, Frontend-Roadmaps aktualisiert (03.04.2026) - [ ] **D-2** | V1-Code-Bereinigung koordinieren (identifizieren veralteter Module)
- [x] Verbleibende Roadmaps (DevOps, QA, UI/UX, Rulebook) auf Vollständigkeit geprüft - [ ] **D-3** | Sprint-Reports Phase 10-12 finalisieren
- [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
--- ---
## 📌 Abhängigkeiten ## 📌 Abhängigkeiten
| Warte auf | Von wem | Betrifft | | Warte auf | Von wem | Betrifft |
|--------------------------------------|-------------|----------------------------| |--------------------------|-------------|---------------------|
| ~~Backend CRUD-Endpunkte fertig~~ ✅ | 👷 Backend | B-2 API-Übersicht (bereit) | | Billing-Service Final | 👷 Backend | D-1 Kassa-Doku |
| Backend B-2 Kassa-Service | 👷 Backend | B-2 Kassa-Doku | | Sprint-Berichte (Dev/QA) | 👷 🎨 🧐 | D-3 Reports |
| 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.
+9 -1
View File
@@ -1,6 +1,6 @@
# 🎨 [Frontend Expert] — Zwischenstand & Roadmap # 🎨 [Frontend Expert] — Zwischenstand & Roadmap
> **Stand:** 3. April 2026 (aktualisiert) > **Stand:** 12. April 2026
> **Rolle:** KMP, Compose Desktop, State-Management, Navigation, Backend-Anbindung > **Rolle:** KMP, Compose Desktop, State-Management, Navigation, Backend-Anbindung
--- ---
@@ -95,6 +95,14 @@
- [ ] Domänen-Mastership beachten: Richter-Turm schreibt nur Bewertungen/Ergebnisse - [ ] Domänen-Mastership beachten: Richter-Turm schreibt nur Bewertungen/Ergebnisse
- [ ] **C-4** | Lint-Bereinigung & Code-Qualität - [ ] **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 - [ ] Ungenutzte Imports/Parameter entfernen
- [ ] `Long → Duration`-Konvertierungen modernisieren - [ ] `Long → Duration`-Konvertierungen modernisieren
- [ ] Redundante Not-null-Calls vereinfachen - [ ] Redundante Not-null-Calls vereinfachen
+25 -53
View File
@@ -1,6 +1,6 @@
# 🧐 [QA Specialist] — Zwischenstand & Roadmap # 🧐 [QA Specialist] — Zwischenstand & Roadmap
> **Stand:** 3. April 2026 > **Stand:** 12. April 2026
> **Rolle:** Test-Strategie, Edge-Cases, Integrationstests, Regressionssicherung > **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) - [x] **B-1** | Test-Suite: Navigation & Back-Stack (V2/V3)
- [ ] Navigations-Flows für alle Screens (vorwärts + zurück) - [x] Navigations-Flows für alle Screens (vorwärts + zurück)
- [ ] Back-Stack-Verhalten nach Zurück-Navigation (korrekter Zustand) - [x] Back-Stack-Verhalten nach Zurück-Navigation (korrekter Zustand)
- [ ] SingleTop-Tabs: kein doppelter Stack-Eintrag bei Tab-Wechsel - [x] SingleTop-Tabs: kein doppelter Stack-Eintrag bei Tab-Wechsel
- [ ] Logout poppt MainShell komplett (keine Screens im Back-Stack) - [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] Leere Pflichtfelder → Speichern-Button bleibt deaktiviert
- [x] Schnelles Doppelklick auf „Weiter" / „Speichern" → kein doppelter Submit - [x] Schnelles Doppelklick auf „Weiter" / „Speichern" → kein doppelter Submit
- [x] Abbrechen mitten im Wizard → kein inkonsistenter Zustand - [x] Abbrechen mitten im Wizard → kein inkonsistenter Zustand
- [x] Zurück-Navigation: Gerätename und Sicherheitsschlüssel bleiben erhalten (`rememberSaveable`) - [x] Zurück-Navigation: Gerätename und Sicherheitsschlüssel bleiben erhalten (`rememberSaveable`)
- **Fix:** `remember``rememberSaveable` in `OnboardingScreen.kt` - [x] OnboardingValidator-Tests (GRÜN)
- **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] **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 ≤95cm: Pflicht-Teilung `ohne Lizenz` / `mit Lizenz` wird vorgeschlagen
- [x] CSN-C-NEU ≥100cm: Pflicht-Teilung `R1` / `R2+` wird vorgeschlagen - [x] CSN-C-NEU ≥100cm: Pflicht-Teilung `R1` / `R2+` wird vorgeschlagen
- [x] `ORGANISATORISCH`: Gesamtrangliste korrekt zusammengeführt - [x] `ORGANISATORISCH`: Gesamtrangliste korrekt zusammengeführt
- [x] `SEPARATE_SIEGEREHRUNG`: Abteilungen werden nicht zusammengeführt - [x] `SEPARATE_SIEGEREHRUNG`: Abteilungen werden nicht zusammengeführt
- [x] Caprilli-Regression abgesichert (LIZENZFREI → Abt. 1, R1 → Abt. 2) - [x] AbteilungsRegelServiceTest.kt (GRÜN)
- [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)
- [ ] **B-4** | Test-Suite: ViewModel-Verhalten - [x] **B-4** | Test-Suite: ViewModel-Verhalten
- [ ] State-Initialisierung korrekt (Loading-State beim Start) - [x] State-Initialisierung korrekt (Loading-State beim Start)
- [ ] Intent → State-Transition für alle Sealed-Class-Intents - [x] Intent → State-Transition für alle Sealed-Class-Intents
- [ ] Fehler-State bei simuliertem Backend-Fehler korrekt gesetzt - [x] Fehler-State bei simuliertem Backend-Fehler korrekt gesetzt
- [ ] Loading-State während asynchroner Operationen (nicht flackern)
--- ---
## 🟠 Sprint C — Priorität 2 (nächste Woche) ## 🟠 Sprint C — In Arbeit
- [ ] **C-1** | Test-Suite: Mandanten-Isolation (nach Backend A-1) - [ ] **C-1** | Test-Suite: Mandanten-Isolation (nach Backend A-1)
- [ ] Veranstaltung A kann keine Daten von Veranstaltung B lesen - [ ] 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`) - [ ] 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 - [ ] Teilnehmer an 2 Turnieren → 1 Zahlvorgang → 2 korrekte separate Rechnungen
- [ ] Saldo-Berechnung korrekt (Summe aus beiden Turnier-Kassas) - [ ] Saldo-Berechnung korrekt (Summe aus beiden Turnier-Kassas)
- [ ] Bereits bezahlte Beträge werden nicht doppelt verrechnet - [ ] 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 ## 📌 Abhängigkeiten
| Warte auf | Von wem | Betrifft | | Warte auf | Von wem | Betrifft |
|------------------------------------|---------------|-----------------------------| |--------------------------|-------------|------------------------|
| Backend A-1 Rollout + E2E-Test-Fix | 👷 Backend | C-1 Isolations-Tests | | Backend B-2 Kassa-Service| 👷 Backend | C-3 Kassa-Tests |
| Rulebook C-1 AltersklasseRechner | 📜 Rulebook | C-3 Validierungs-Tests | | DevOps CI/CD Pipeline | 🐧 DevOps | CI-Integration |
| 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.
+11 -4
View File
@@ -1,13 +1,20 @@
# 🖌️ [UI/UX Designer] — Zwischenstand & Roadmap # 🖌️ [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 > **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] **A-1** | Design-Inventur: Bestehende V3-Screens analysiert
- [x] Alle vorhandenen V3-Screens katalogisiert (Screenshots in `docs/06_Frontend/Screenshots/`) - [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) - [ ] Empty States in alle 10 Listenansichten integrieren (Prioritätsreihenfolge laut Spezifikation)
- [ ] `PferdProfilEditDialog` zu Fullscreen-Edit migrieren (> 8 Felder, Async-Lookups — laut B-1 Mapping) - [ ] `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 - [ ] Farb-Palette in `MaterialTheme` / `Theme.kt` vereinheitlichen
- [ ] Typografie-Skala definieren (Überschriften, Body, Labels, Captions) - [ ] Typografie-Skala definieren (Überschriften, Body, Labels, Captions)
- [ ] Wiederverwendbare Komponenten als Composables extrahieren (Cards, Badges, Chips) - [ ] Wiederverwendbare Komponenten als Composables extrahieren (Cards, Badges, Chips)
@@ -41,20 +41,16 @@ fun <T> MsSearchableSelect(
Column(modifier = modifier) { Column(modifier = modifier) {
// --- 1. Das Anzeige-Feld (sieht aus wie ein TextField, öffnet aber den Dialog) --- // --- 1. Das Anzeige-Feld (sieht aus wie ein TextField, öffnet aber den Dialog) ---
OutlinedTextField( MsTextField(
value = selectedOption?.let { optionLabel(it) } ?: "", value = selectedOption?.let { optionLabel(it) } ?: "",
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text(label, style = MaterialTheme.typography.bodySmall) }, label = label,
placeholder = { Text(placeholder, style = MaterialTheme.typography.bodySmall) }, placeholder = placeholder,
trailingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, leadingIcon = Icons.Default.Search,
modifier = Modifier modifier = modifier.clickable(enabled = enabled) { showDialog = true },
.fillMaxWidth()
.clickable(enabled = enabled) { showDialog = true },
enabled = enabled, enabled = enabled,
singleLine = true, singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
shape = MaterialTheme.shapes.small
) )
// --- 2. Der Such-Dialog (Desktop-zentriert) --- // --- 2. Der Such-Dialog (Desktop-zentriert) ---
@@ -75,17 +71,16 @@ fun <T> MsSearchableSelect(
) )
// Internes Suchfeld im Dialog // Internes Suchfeld im Dialog
OutlinedTextField( MsTextField(
value = searchText, value = searchText,
onValueChange = { onValueChange = {
searchText = it searchText = it
onSearchQueryChange(it) onSearchQueryChange(it)
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Suchbegriff eingeben...") }, placeholder = "Suchbegriff eingeben...",
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, leadingIcon = Icons.Default.Search,
singleLine = true, singleLine = true,
shape = MaterialTheme.shapes.small
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -1,12 +1,11 @@
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.foundation.layout.padding
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.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.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.ImeAction 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.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
@Composable @Composable
fun MsTextField( fun MsTextField(
@@ -31,28 +31,41 @@ fun MsTextField(
enabled: Boolean = true, enabled: Boolean = true,
readOnly: Boolean = false, readOnly: Boolean = false,
singleLine: Boolean = true, singleLine: Boolean = true,
maxLines: Int = Int.MAX_VALUE, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
keyboardType: KeyboardType = KeyboardType.Text, keyboardType: KeyboardType = KeyboardType.Text,
imeAction: ImeAction = ImeAction.Default, imeAction: ImeAction = ImeAction.Default,
keyboardActions: KeyboardActions = KeyboardActions.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) { 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( OutlinedTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
label = label?.let { { Text(it) } }, .fillMaxWidth()
placeholder = placeholder?.let { { Text(it) } }, .heightIn(min = height),
placeholder = placeholder?.let { { Text(it, style = MaterialTheme.typography.bodyMedium) } },
leadingIcon = leadingIcon?.let { icon -> leadingIcon = leadingIcon?.let { icon ->
{ Icon(imageVector = icon, contentDescription = null) } { Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(Dimens.IconSizeM)) }
}, },
trailingIcon = if (trailingIcon != null) { trailingIcon = if (trailingIcon != null) {
{ {
IconButton( IconButton(
onClick = onTrailingIconClick ?: {} onClick = onTrailingIconClick ?: {}
) { ) {
Icon(imageVector = trailingIcon, contentDescription = null) Icon(imageVector = trailingIcon, contentDescription = null, modifier = Modifier.size(Dimens.IconSizeM))
} }
} }
} else null, } else null,
@@ -61,6 +74,15 @@ fun MsTextField(
readOnly = readOnly, readOnly = readOnly,
singleLine = singleLine, singleLine = singleLine,
maxLines = maxLines, 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( keyboardOptions = KeyboardOptions(
keyboardType = keyboardType, keyboardType = keyboardType,
imeAction = imeAction imeAction = imeAction
@@ -70,27 +92,23 @@ fun MsTextField(
) )
// Error or helper text // Error or helper text
when { if (isError && errorMessage != null) {
isError && errorMessage != null -> {
Text( Text(
text = errorMessage, text = errorMessage,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp) modifier = Modifier.padding(start = 8.dp, top = 2.dp)
) )
} } else if (helperText != null) {
helperText != null -> {
Text( Text(
text = helperText, text = helperText,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp) modifier = Modifier.padding(start = 8.dp, top = 2.dp)
) )
} }
} }
} }
}
@Suppress("unused") @Suppress("unused")
@Composable @Composable
@@ -14,12 +14,16 @@ object AppColors {
val PrimaryContainer = Color(0xFFDEEBFF) val PrimaryContainer = Color(0xFFDEEBFF)
val OnPrimaryContainer = Color(0xFF0052CC) val OnPrimaryContainer = Color(0xFF0052CC)
// Subtiles Sidebar-Grau / Navigation
val NavigationSurface = Color(0xFFF4F5F7)
val NavigationContent = Color(0xFF42526E)
// Helleres Blau für sekundäre Akzente // Helleres Blau für sekundäre Akzente
val Secondary = Color(0xFF2684FF) val Secondary = Color(0xFF2684FF)
val OnSecondary = Color.White val OnSecondary = Color.White
// Neutral- & Hintergrund (Light Mode) // 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 SurfaceLight = Color.White
val OnBackgroundLight = Color(0xFF172B4D) // Fast Schwarz (besser lesbar) val OnBackgroundLight = Color(0xFF172B4D) // Fast Schwarz (besser lesbar)
@@ -13,10 +13,17 @@ object Dimens {
val SpacingS = 8.dp // Standard Abstand zwischen Elementen val SpacingS = 8.dp // Standard Abstand zwischen Elementen
val SpacingM = 16.dp // Abstand für Sektionen val SpacingM = 16.dp // Abstand für Sektionen
val SpacingL = 24.dp // Außenabstand für Screens 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) // Sizes (Größen)
val IconSizeS = 16.dp val IconSizeS = 16.dp
val IconSizeM = 24.dp val IconSizeM = 24.dp
val IconSizeL = 32.dp
// Borders // Borders
val BorderThin = 1.dp val BorderThin = 1.dp
@@ -24,4 +31,10 @@ object Dimens {
// Corner Radius (Ecken) // Corner Radius (Ecken)
val CornerRadiusS = 4.dp // Leicht abgerundet (Enterprise Look) val CornerRadiusS = 4.dp // Leicht abgerundet (Enterprise Look)
val CornerRadiusM = 8.dp 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
} }
@@ -3,8 +3,39 @@ CREATE TABLE LocalSettings (
value TEXT NOT NULL 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: insertOrReplace:
INSERT OR REPLACE INTO LocalSettings(key, value) VALUES (?, ?); INSERT OR REPLACE INTO LocalSettings(key, value) VALUES (?, ?);
selectAll: selectAll:
SELECT * FROM LocalSettings; 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 = ?;
@@ -1,6 +1,7 @@
package at.mocode.veranstaltung.feature.presentation package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn 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.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.Search
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.components.MsTextField
import at.mocode.frontend.core.designsystem.models.VeranstaltungStatus 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 // Status-Farben gemäß Vision_03
private val StatusVorbereitung = Color(0xFFEA580C) // Orange private val StatusVorbereitung = Color(0xFFEA580C) // Orange
@@ -52,18 +55,67 @@ fun AdminUebersichtScreen(
ort = "4221 NEUMARKT/M.", ort = "4221 NEUMARKT/M.",
datum = "12.13.04.2026", datum = "12.13.04.2026",
turnierAnzahl = 2, turnierAnzahl = 2,
nennungen = 0, nennungen = 142,
letzteAktivitaet = "vor 1 Min", letzteAktivitaet = "vor 1 Min",
status = VeranstaltungStatus.VORBEREITUNG, status = VeranstaltungStatus.VORBEREITUNG,
turniere = listOf( turniere = listOf(
TurnierUiModel(id = 26129, nummer = 26129, name = "CDN-C-NEU CDNP-C-NEU", bewerbAnzahl = 16), 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), 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) } } 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 // KPI-Kacheln
KpiKachelRow( KpiKachelRow(
liveAktiv = 0, liveAktiv = 0,
@@ -75,39 +127,42 @@ fun AdminUebersichtScreen(
onCupsClick = onCupsOeffnen onCupsClick = onCupsOeffnen
) )
// Toolbar Spacer(Modifier.height(Dimens.SpacingM))
// Toolbar (Suche & Filter)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(Dimens.SpacingM),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM),
) { ) {
Button( MsTextField(
onClick = onVeranstalterAuswahl,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neue Veranstaltung")
}
OutlinedTextField(
value = "", value = "",
onValueChange = {}, onValueChange = {},
placeholder = { Text("Suche nach Name, Ort oder Turnier-Nr.", fontSize = 13.sp) }, placeholder = "Suche nach Name, Ort oder Turnier-Nr.",
modifier = Modifier.weight(1f).height(48.dp), leadingIcon = Icons.Default.Search,
modifier = Modifier.weight(1f),
singleLine = true, singleLine = true,
) )
// Status-Filter Chips // Status-Filter Chips
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
StatusFilterChip("Alle", selected = true) StatusFilterChip("Alle", selected = true)
StatusFilterChip("Vorbereitung", selected = false) StatusFilterChip("Vorbereitung", selected = false)
StatusFilterChip("Live", selected = false) StatusFilterChip("Live", selected = false)
StatusFilterChip("Abgeschlossen", selected = false) StatusFilterChip("Abgeschlossen", selected = false)
} }
}
}
HorizontalDivider() Spacer(Modifier.height(Dimens.SpacingM))
// Veranstaltungs-Liste // Veranstaltungs-Liste
if (veranstaltungen.isEmpty()) { if (veranstaltungen.isEmpty()) {
@@ -130,19 +185,18 @@ fun AdminUebersichtScreen(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Button( Button(
onClick = onVeranstalterAuswahl, onClick = onVeranstalterAuswahl,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
) { ) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(Dimens.IconSizeS))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(Dimens.SpacingXS))
Text("Neue Veranstaltung anlegen") Text("Neue Veranstaltung anlegen")
} }
} }
} }
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM),
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(bottom = Dimens.SpacingL),
) { ) {
items(items = veranstaltungen, key = { it.id }) { veranstaltung -> items(items = veranstaltungen, key = { it.id }) { veranstaltung ->
VeranstaltungCard( VeranstaltungCard(
@@ -249,12 +303,12 @@ private fun VeranstaltungCard(
onOeffnen: () -> Unit, onOeffnen: () -> Unit,
onLoeschen: () -> Unit, onLoeschen: () -> Unit,
) { ) {
Card( ElevatedCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
border = if (veranstaltung.status == VeranstaltungStatus.VORBEREITUNG) shape = MaterialTheme.shapes.medium,
BorderStroke(1.dp, Color(0xFF3B82F6)) else null, colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surface)
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(Dimens.SpacingM)) {
// Header // Header
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -264,16 +318,16 @@ private fun VeranstaltungCard(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = veranstaltung.name, text = veranstaltung.name,
fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium,
fontSize = 15.sp, fontWeight = FontWeight.Bold,
) )
Row( Row(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM),
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = Dimens.SpacingXS),
) { ) {
Text("📍 ${veranstaltung.ort}", fontSize = 12.sp, color = Color(0xFF6B7280)) LabelValue("📍", veranstaltung.ort)
Text("📅 ${veranstaltung.datum}", fontSize = 12.sp, color = Color(0xFF6B7280)) LabelValue("📅", veranstaltung.datum)
Text("🏆 ${veranstaltung.turnierAnzahl} Turniere", fontSize = 12.sp, color = Color(0xFF6B7280)) LabelValue("🏆", "${veranstaltung.turnierAnzahl} Turniere")
} }
} }
StatusBadge(veranstaltung.status) StatusBadge(veranstaltung.status)
@@ -281,56 +335,77 @@ private fun VeranstaltungCard(
// Turnier-Liste // Turnier-Liste
if (veranstaltung.turniere.isNotEmpty()) { if (veranstaltung.turniere.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(Dimens.SpacingM))
Text("Turniere (${veranstaltung.turniere.size}):", fontSize = 12.sp, fontWeight = FontWeight.Medium) 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 -> veranstaltung.turniere.forEach { turnier ->
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS),
verticalAlignment = Alignment.CenterVertically
) {
Surface( Surface(
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
color = Color(0xFF1E3A8A), color = MaterialTheme.colorScheme.primaryContainer,
) { ) {
Text( Text(
text = turnier.nummer.toString(), text = turnier.nummer.toString(),
color = Color.White, color = MaterialTheme.colorScheme.onPrimaryContainer,
fontSize = 11.sp, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
) )
} }
Text("${turnier.name} (${turnier.bewerbAnzahl} Bewerbe)", fontSize = 12.sp) 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)
}
} }
OutlinedButton(onClick = onOeffnen, modifier = Modifier.height(28.dp)) {
Text("Zum Turnier", fontSize = 11.sp)
} }
} }
} }
} }
// Footer // 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
Text("Nennungen: ${veranstaltung.nennungen}", fontSize = 12.sp, color = Color(0xFF6B7280)) LabelValue("Nennungen:", veranstaltung.nennungen.toString())
Text("Letzte Aktivität: ${veranstaltung.letzteAktivitaet}", fontSize = 12.sp, color = Color(0xFF6B7280)) LabelValue("Aktivität:", veranstaltung.letzteAktivitaet)
}
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)
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button( Button(
onClick = onOeffnen, onClick = onOeffnen,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)), shape = MaterialTheme.shapes.small,
modifier = Modifier.height(32.dp), modifier = Modifier.height(36.dp),
) { ) {
Text("Zur Veranstaltung", fontSize = 12.sp) Text("Veranstaltung öffnen", style = MaterialTheme.typography.labelMedium)
}
IconButton(onClick = onLoeschen, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626))
} }
} }
} }
@@ -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 @Composable
private fun StatusBadge(status: VeranstaltungStatus) { private fun StatusBadge(status: VeranstaltungStatus) {
val (text, color) = when (status) { val (text, color) = when (status) {
@@ -4,20 +4,20 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.*
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.core.navigation.AppScreen import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.features.billing.presentation.BillingScreen import at.mocode.frontend.features.billing.presentation.BillingScreen
import at.mocode.frontend.features.billing.presentation.BillingViewModel 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. * Haupt-Layout der Desktop-App gemäß Vision_03.
* *
* Struktur: * Struktur:
* - TopBar (dunkelblau): App-Titel + Breadcrumb + Logout * - NavigationRail (links): Globale Navigation
* - Header (oben): Breadcrumb + Status + Logout
* - Content: kontextabhängiger Screen * - Content: kontextabhängiger Screen
*
* Kein Nav-Rail, keine Sidebar Navigation erfolgt über
* Breadcrumb-Klicks und horizontale Tabs innerhalb der Screens.
*/ */
@Composable @Composable
fun DesktopMainLayout( fun DesktopMainLayout(
@@ -62,18 +60,25 @@ fun DesktopMainLayout(
onBack: () -> Unit, onBack: () -> Unit,
onLogout: () -> 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 obGeraet by rememberSaveable { mutableStateOf("") }
var obKey by rememberSaveable { mutableStateOf("") } var obKey by rememberSaveable { mutableStateOf("") }
Row(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
// Navigation Rail (Modernere Seitenleiste)
DesktopNavRail(
currentScreen = currentScreen,
onNavigate = onNavigate
)
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
DesktopTopBar( DesktopTopHeader(
currentScreen = currentScreen, currentScreen = currentScreen,
onNavigate = onNavigate, onNavigate = onNavigate,
onBack = onBack, onBack = onBack,
onLogout = onLogout, onLogout = onLogout,
) )
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) { Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
DesktopContentArea( DesktopContentArea(
currentScreen = currentScreen, currentScreen = currentScreen,
@@ -85,67 +90,168 @@ fun DesktopMainLayout(
onObKeyChange = { obKey = it }, onObKeyChange = { obKey = it },
) )
} }
HorizontalDivider(thickness = Dimens.BorderThin, color = MaterialTheme.colorScheme.outlineVariant)
DesktopFooterBar() DesktopFooterBar()
} }
} }
} }
/** @Composable
* TopBar: dunkelblauer Balken mit Breadcrumb-Navigation und Logout-Button. private fun DesktopNavRail(
* currentScreen: AppScreen,
* Breadcrumb-Logik: onNavigate: (AppScreen) -> Unit
* - Root: "🏠 Admin - Verwaltung" ) {
* - Veranstaltung: "🏠 Admin - Verwaltung / Veranstaltung #<id>" Surface(
* - Turnier: "🏠 Admin - Verwaltung / Veranstaltung #<id> / Turnier <tid>" 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 @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, currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit, onNavigate: (AppScreen) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth().height(Dimens.TopBarHeight),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(horizontal = Dimens.SpacingL),
.fillMaxWidth()
.height(48.dp)
.background(TopBarColor)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { 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) { if (currentScreen !is AppScreen.Onboarding) {
IconButton(onClick = onBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück", contentDescription = "Zurück",
tint = TopBarTextColor, modifier = Modifier.size(Dimens.IconSizeM),
modifier = Modifier tint = MaterialTheme.colorScheme.primary
.size(20.dp)
.clickable { onBack() },
) )
Spacer(Modifier.width(8.dp)) }
Spacer(Modifier.width(Dimens.SpacingS))
} }
// Root-Link // Breadcrumb-Segmente
Text( BreadcrumbContent(currentScreen, onNavigate)
text = "Verwaltung", }
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstaltungVerwaltung) },
)
// Breadcrumb-Segmente je nach Screen 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
)
}
}
}
}
}
@Composable
private fun BreadcrumbContent(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit
) {
when (currentScreen) { when (currentScreen) {
is AppScreen.VeranstalterAuswahl -> { is AppScreen.VeranstalterAuswahl -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstalter auswählen", text = "Veranstalter auswählen",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium,
fontSize = 14.sp,
) )
} }
@@ -153,47 +259,39 @@ private fun DesktopTopBar(
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstalter-Verwaltung", text = "Veranstalter-Verwaltung",
color = TopBarTextColor.copy(alpha = 0.75f), style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) }, modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
) )
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Neuer Veranstalter", text = "Neuer Veranstalter",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
) )
} }
is AppScreen.VeranstalterDetail -> { is AppScreen.VeranstalterDetail -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstalter-Verwaltung", text = "Veranstalter-Verwaltung",
color = TopBarTextColor.copy(alpha = 0.75f), style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) }, modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
) )
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstalter #${currentScreen.veranstalterId}", text = "Veranstalter #${currentScreen.veranstalterId}",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
) )
} }
is AppScreen.VeranstaltungProfil -> { is AppScreen.VeranstaltungProfil -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstalter-Verwaltung", text = "Veranstalter-Verwaltung",
color = TopBarTextColor.copy(alpha = 0.75f), style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) }, modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
) )
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstalter #${currentScreen.veranstalterId}", text = "Veranstalter #${currentScreen.veranstalterId}",
color = TopBarTextColor.copy(alpha = 0.75f), style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
fontSize = 14.sp,
modifier = Modifier.clickable { modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId)) onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId))
}, },
@@ -201,33 +299,28 @@ private fun DesktopTopBar(
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}", text = "Veranstaltung #${currentScreen.veranstaltungId}",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
) )
} }
is AppScreen.VeranstaltungDetail -> { is AppScreen.VeranstaltungDetail -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltung #${currentScreen.id}", text = "Veranstaltung #${currentScreen.id}",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium,
fontSize = 14.sp,
) )
} }
is AppScreen.VeranstaltungNeu -> { is AppScreen.VeranstaltungNeu -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Neue Veranstaltung", text = "Neue Veranstaltung",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium,
fontSize = 14.sp,
) )
} }
is AppScreen.TurnierDetail -> { is AppScreen.TurnierDetail -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}", text = "Veranstaltung #${currentScreen.veranstaltungId}",
color = TopBarTextColor.copy(alpha = 0.75f), style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
fontSize = 14.sp,
modifier = Modifier.clickable { modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId))
}, },
@@ -235,17 +328,14 @@ private fun DesktopTopBar(
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Turnier ${currentScreen.turnierId}", text = "Turnier ${currentScreen.turnierId}",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
) )
} }
is AppScreen.Billing -> { is AppScreen.Billing -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}", text = "Veranstaltung #${currentScreen.veranstaltungId}",
color = TopBarTextColor.copy(alpha = 0.75f), style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
fontSize = 14.sp,
modifier = Modifier.clickable { modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungProfil(0, currentScreen.veranstaltungId)) onNavigate(AppScreen.VeranstaltungProfil(0, currentScreen.veranstaltungId))
}, },
@@ -253,8 +343,7 @@ private fun DesktopTopBar(
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Turnier ${currentScreen.turnierId}", text = "Turnier ${currentScreen.turnierId}",
color = TopBarTextColor.copy(alpha = 0.75f), style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
fontSize = 14.sp,
modifier = Modifier.clickable { modifier = Modifier.clickable {
onNavigate(AppScreen.TurnierDetail(currentScreen.veranstaltungId, currentScreen.turnierId)) onNavigate(AppScreen.TurnierDetail(currentScreen.veranstaltungId, currentScreen.turnierId))
}, },
@@ -262,17 +351,14 @@ private fun DesktopTopBar(
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Abrechnung", text = "Abrechnung",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
) )
} }
is AppScreen.TurnierNeu -> { is AppScreen.TurnierNeu -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}", text = "Veranstaltung #${currentScreen.veranstaltungId}",
color = TopBarTextColor.copy(alpha = 0.75f), style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f)),
fontSize = 14.sp,
modifier = Modifier.clickable { modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId))
}, },
@@ -280,8 +366,7 @@ private fun DesktopTopBar(
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Neues Turnier", text = "Neues Turnier",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium,
fontSize = 14.sp,
) )
} }
@@ -289,8 +374,7 @@ private fun DesktopTopBar(
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Ping Service", text = "Ping Service",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium,
fontSize = 14.sp,
) )
} }
@@ -298,37 +382,27 @@ private fun DesktopTopBar(
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Vereine", text = "Vereine",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
) )
} }
is AppScreen.Meisterschaften -> { is AppScreen.Meisterschaften -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Meisterschaften", text = "Meisterschaften",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
) )
} }
is AppScreen.Cups -> { is AppScreen.Cups -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Cups", text = "Cups",
color = TopBarTextColor, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
) )
} }
else -> {} else -> {}
} }
} }
// Logout wurde auf Kundenwunsch entfernt
}
}
// Hilfsfunktion: OEPS-Bundeslandcode → Abkürzung // Hilfsfunktion: OEPS-Bundeslandcode → Abkürzung
private fun mapOepsToBundesland(code: String): String = when (code.uppercase()) { private fun mapOepsToBundesland(code: String): String = when (code.uppercase()) {
"OOE" -> "" "OOE" -> ""
@@ -733,45 +807,71 @@ private fun DesktopContentArea(
@Composable @Composable
private fun DesktopFooterBar() { private fun DesktopFooterBar() {
// Stub-Status für MVP // Echte Status-Logik vorbereitet
val online = remember { mutableStateOf(true) } val online = remember { mutableStateOf(true) }
val deviceConnected = remember { mutableStateOf(true) } val deviceConnected = remember { mutableStateOf(true) }
val deviceName = "Richter-Turm" val deviceName = "Richter-Turm"
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 1.dp
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(36.dp) .height(32.dp)
.background(Color(0xFFF3F4F6)) .padding(horizontal = Dimens.SpacingS),
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( // Status: Cloud Sync
imageVector = if (online.value) Icons.Filled.Wifi else Icons.Filled.WifiOff, StatusIndicator(
contentDescription = null, icon = if (online.value) Icons.Filled.CloudDone else Icons.Filled.CloudOff,
tint = if (online.value) Color(0xFF059669) else Color(0xFFDC2626) label = if (online.value) "Cloud synchronisiert" else "Offline (Lokal)",
color = if (online.value) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
) )
Spacer(Modifier.width(6.dp))
Text(if (online.value) "Online" else "Offline", color = Color(0xFF374151), fontSize = 12.sp) Spacer(Modifier.width(Dimens.SpacingM))
Spacer(Modifier.width(16.dp))
Icon(Icons.Filled.Devices, contentDescription = null, tint = if (deviceConnected.value) Color(0xFF2563EB) else Color(0xFF9CA3AF)) // Status: LAN Devices (mDNS)
Spacer(Modifier.width(6.dp)) StatusIndicator(
Text( icon = Icons.Filled.Lan,
if (deviceConnected.value) "Verbunden: $deviceName" else "Kein Gerät verbunden", label = if (deviceConnected.value) "Verbunden: $deviceName" else "Suche nach Geräten...",
color = Color(0xFF374151), color = if (deviceConnected.value) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
fontSize = 12.sp
) )
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceConnected.value) { Text(
OutlinedButton(onClick = { /* öffne Chat-Panel */ }, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { text = "v2.4.0-rc1 | Desktop-Alpha",
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null, tint = Color(0xFF2563EB)) style = MaterialTheme.typography.labelSmall,
Spacer(Modifier.width(6.dp)) color = MaterialTheme.colorScheme.onSurfaceVariant
Text("Chat", color = Color(0xFF2563EB), fontSize = 12.sp) )
} }
} }
} }
} }
@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
)
}
} }
@@ -1,5 +1,6 @@
package at.mocode.desktop.v2 package at.mocode.desktop.v2
import at.mocode.frontend.core.designsystem.components.MsTextField
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -51,10 +52,10 @@ fun OnboardingScreen(
val frKey = remember { FocusRequester() } val frKey = remember { FocusRequester() }
val frBtn = remember { FocusRequester() } val frBtn = remember { FocusRequester() }
OutlinedTextField( MsTextField(
value = geraetName, value = geraetName,
onValueChange = { onGeraetNameChange(it) }, onValueChange = { onGeraetNameChange(it) },
label = { Text("Gerätename (Pflicht)") }, label = "Gerätename (Pflicht)",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(frName) .focusRequester(frName)
@@ -70,18 +71,15 @@ fun OnboardingScreen(
} else false } else false
} }
, ,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }) keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
) )
OutlinedTextField( MsTextField(
value = secureKey, value = secureKey,
onValueChange = { onSecureKeyChange(it) }, onValueChange = { onSecureKeyChange(it) },
label = { Text("Sicherheitsschlüssel (Pflicht)") }, label = "Sicherheitsschlüssel (Pflicht)",
trailingIcon = { trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
IconButton(onClick = { showPw = !showPw }) { onTrailingIconClick = { showPw = !showPw },
Icon(if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription = null)
}
},
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -106,7 +104,7 @@ fun OnboardingScreen(
} else false } else false
} }
, ,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(onDone = { keyboardActions = KeyboardActions(onDone = {
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) { if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
onContinue(geraetName, secureKey) onContinue(geraetName, secureKey)
@@ -189,14 +187,14 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) {
title = { Text("Pferd bearbeiten") }, title = { Text("Pferd bearbeiten") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 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)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(oeps, { oeps = it }, label = { Text("ÖPS-Nr.") }, modifier = Modifier.weight(1f)) MsTextField(oeps, { oeps = it }, label = "ÖPS-Nr.", modifier = Modifier.weight(1f))
OutlinedTextField(fei, { fei = it }, label = { Text("FEI-ID") }, modifier = Modifier.weight(1f)) MsTextField(fei, { fei = it }, label = "FEI-ID", modifier = Modifier.weight(1f))
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(geb, { geb = it }, label = { Text("Geburtsdatum") }, modifier = Modifier.weight(1f)) MsTextField(geb, { geb = it }, label = "Geburtsdatum", modifier = Modifier.weight(1f))
OutlinedTextField(farbe, { farbe = it }, label = { Text("Farbe") }, 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 = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(vor, { vor = it }, label = { Text("Vorname") }, modifier = Modifier.weight(1f)) MsTextField(vor, { vor = it }, label = "Vorname", modifier = Modifier.weight(1f))
OutlinedTextField(nach, { nach = it }, label = { Text("Nachname") }, modifier = Modifier.weight(1f)) MsTextField(nach, { nach = it }, label = "Nachname", modifier = Modifier.weight(1f))
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(oeps, { oeps = it }, label = { Text("ÖPS-Nr.") }, modifier = Modifier.weight(1f)) MsTextField(oeps, { oeps = it }, label = "ÖPS-Nr.", modifier = Modifier.weight(1f))
OutlinedTextField(fei, { fei = it }, label = { Text("FEI-ID") }, modifier = Modifier.weight(1f)) MsTextField(fei, { fei = it }, label = "FEI-ID", modifier = Modifier.weight(1f))
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(liz, { liz = it }, label = { Text("Lizenzklasse") }, modifier = Modifier.weight(1f)) MsTextField(liz, { liz = it }, label = "Lizenzklasse", modifier = Modifier.weight(1f))
OutlinedTextField(verein, { verein = it }, label = { Text("Verein") }, modifier = Modifier.weight(1f)) MsTextField(verein, { verein = it }, label = "Verein", modifier = Modifier.weight(1f))
} }
} }
} }