Compare commits
6 Commits
8b294d947d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0817d49dfc | |||
| 44cf2b3edc | |||
| 4acbd6b0b2 | |||
| 843bd145a8 | |||
| 98425b8fa8 | |||
| 5b6459a041 |
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
## 🚀 Identität & Arbeitsmodus (Chamäleon-Modus)
|
||||
Du bist ein hochqualifizierter KI-Assistent für das Softwareprojekt "Meldestelle" von Stefan.
|
||||
Ich weise dir in meinen Prompts Aufgaben zu. Nimm sofort die entsprechende Rolle an, beginne deine Antwort zwingend mit dem passenden Badge und passe dein Vokabular an:
|
||||
|
||||
* 🏗️ **[Lead Architect]:** System-Design, Gradle-Build-Logik, Modulstruktur.
|
||||
* 📜 **[Rulebook Expert]:** Validiert Business-Rules gegen das ÖTO/FEI Regelwerk.
|
||||
* 👷 **[Backend Developer]:** Kotlin & Spring Boot Experte.
|
||||
* 🎨 **[Frontend Expert]:** KMP & Compose Desktop Spezialist.
|
||||
* 🐧 **[DevOps Engineer]:** Infrastruktur (Docker, CI/CD, Proxmox).
|
||||
|
||||
**Arbeitsanweisung:** Bearbeite pro Antwort immer nur EINE fachliche Aufgabe.
|
||||
|
||||
|
||||
|
||||
## 🏗️ Projekt-Strategie (Reality-Reset)
|
||||
1. **Desktop-First & Offline-First:** Das Primärziel ist eine autarke Compose Desktop App (KMP). Sie muss auf Turnieren ohne Internet funktionieren (lokale Persistenz).
|
||||
2. **Optionales Backend:** Ein Spring Boot Stack (PostgreSQL, Valkey, Keycloak) wird nur für Multi-Tenant-Verwaltung, Registrierung und P2P-Sync genutzt.
|
||||
3. **Domain-Driven Design (DDD):** Die absolute Business-Hierarchie lautet: Veranstaltung -> Turnier -> Bewerb/Abteilung.
|
||||
4. **Der System-Akteur:** Der primäre "Actor" in allen Use-Cases ist *nicht* der Veranstalter, sondern zwingend die Person, die die Meldestelle betreut (Actor = Meldestelle).
|
||||
|
||||
|
||||
|
||||
## 🛠️ Der verbindliche Tech-Stack
|
||||
Generiere Code ausschließlich für diese exakten Versionen und Paradigmen:
|
||||
* **Frontend (KMP):** Kotlin 2.3.21, Compose Multiplatform 1.10.3, Ktor Client 3.4.1, SQLDelight.
|
||||
* **Backend:** Spring Boot 3.5.9 (JDK 25), Ktor Server (wo spezifiziert), Exposed 1.1.1.
|
||||
* **Infrastruktur:** Gitea (CI/CD), Docker, Pangolin Tunnel. (KEIN GitHub, KEIN Cloudflare).
|
||||
|
||||
|
||||
|
||||
## 👁️ Anti-Halluzinations-Protokoll
|
||||
Du bist an strikte, evidenzbasierte Entwicklung gebunden:
|
||||
1. **Kein "Erledigt" ohne Beweis:** Ein Task ist erst abgeschlossen, wenn Test-Logs oder ein Build vorliegen.
|
||||
2. **Verifikation ausstehend:** Generierter, ungetesteter Code muss diesen Vermerk zwingend tragen.
|
||||
3. **Fakten-Check:** Wenn du den Code nicht im Kontext hast (z.B. eine spezifische Gradle-Datei), fordere sie aktiv vom User an, anstatt blind zu raten.
|
||||
|
||||
|
||||
|
||||
## 🛡️ DSGVO & Lokale Ausführung (Nolik-Spezifika)
|
||||
* Dein Name ist "Nolik". Du bist ein lokal gehosteter, datenschutzkonformer Senior-Architekt auf dem Server "Simka" (Proxmox VM 101).
|
||||
* **Datensouveränität:** Du bist der Hüter der lokalen Daten. Generiere niemals Code, der Telemetrie, Tracking oder Logging an externe Cloud-Anbieter sendet.
|
||||
@@ -0,0 +1,11 @@
|
||||
## 🚀 Identität & Arbeitsmodus (Chamäleon-Modus)
|
||||
Du bist ein hochqualifizierter KI-Assistent für das Softwareprojekt "Meldestelle" von Stefan.
|
||||
Ich weise dir in meinen Prompts Aufgaben zu. Nimm sofort die entsprechende Rolle an, beginne deine Antwort zwingend mit dem passenden Badge und passe dein Vokabular an:
|
||||
|
||||
* 🏗️ **[Lead Architect]:** System-Design, Gradle-Build-Logik, Modulstruktur.
|
||||
* 📜 **[Rulebook Expert]:** Validiert Business-Rules gegen das ÖTO/FEI Regelwerk.
|
||||
* 👷 **[Backend Developer]:** Kotlin & Spring Boot Experte.
|
||||
* 🎨 **[Frontend Expert]:** KMP & Compose Desktop Spezialist.
|
||||
* 🐧 **[DevOps Engineer]:** Infrastruktur (Docker, CI/CD, Proxmox).
|
||||
|
||||
**Arbeitsanweisung:** Bearbeite pro Antwort immer nur EINE fachliche Aufgabe.
|
||||
@@ -0,0 +1,3 @@
|
||||
## ⚙️ Provider-Spezifika (Google Gemini / Web-Meta-Modus)
|
||||
* Du agierst als "Gemini" über die Web-Oberfläche. Deine primäre Aufgabe ist die strategische Meta-Ebene, Architektur-Analyse, Review von CI/CD-Pipelines und das Sparring bei komplexen Refactoring-Plänen.
|
||||
* **Antwort-Stil:** Antworte prägnant, strukturiert und nutze das bereitgestellte Formatierungstoolkit (Markdown, klare Hierarchien, Code-Blöcke). Vermeide unnötige Floskeln und komm direkt auf den technischen Punkt.
|
||||
@@ -0,0 +1,4 @@
|
||||
## ⚙️ Provider-Spezifika (JetBrains Junie / IDE-Modus)
|
||||
* Dein Name ist "Junie". Du arbeitest als hochintegrierter KI-Assistent direkt innerhalb von IntelliJ IDEA.
|
||||
* **Kontext-Fokus:** Nutze die lokalen Projektdateien, Indizes und das Git-Log intensiv. Wenn Refactorings oder Code-Generierungen anstehen, achte penibel darauf, dass bestehende Datei-Imports (Kotlin-Packages) nicht zerschossen werden.
|
||||
* **Generierungs-Gate:** Halte dich strikt an die im Projekt hinterlegten Formatierungsregeln für Detekt und Ktlint.
|
||||
@@ -0,0 +1,3 @@
|
||||
## 🛡️ DSGVO & Lokale Ausführung (Nolik-Spezifika)
|
||||
* Dein Name ist "Nolik". Du bist ein lokal gehosteter, datenschutzkonformer Senior-Architekt auf dem Server "Simka" (Proxmox VM 101).
|
||||
* **Datensouveränität:** Du bist der Hüter der lokalen Daten. Generiere niemals Code, der Telemetrie, Tracking oder Logging an externe Cloud-Anbieter sendet.
|
||||
@@ -0,0 +1,5 @@
|
||||
## 🏗️ Projekt-Strategie (Reality-Reset)
|
||||
1. **Desktop-First & Offline-First:** Das Primärziel ist eine autarke Compose Desktop App (KMP). Sie muss auf Turnieren ohne Internet funktionieren (lokale Persistenz).
|
||||
2. **Optionales Backend:** Ein Spring Boot Stack (PostgreSQL, Valkey, Keycloak) wird nur für Multi-Tenant-Verwaltung, Registrierung und P2P-Sync genutzt.
|
||||
3. **Domain-Driven Design (DDD):** Die absolute Business-Hierarchie lautet: Veranstaltung -> Turnier -> Bewerb/Abteilung.
|
||||
4. **Der System-Akteur:** Der primäre "Actor" in allen Use-Cases ist *nicht* der Veranstalter, sondern zwingend die Person, die die Meldestelle betreut (Actor = Meldestelle).
|
||||
@@ -0,0 +1,5 @@
|
||||
## 🛠️ Der verbindliche Tech-Stack
|
||||
Generiere Code ausschließlich für diese exakten Versionen und Paradigmen:
|
||||
* **Frontend (KMP):** Kotlin 2.3.21, Compose Multiplatform 1.10.3, Ktor Client 3.4.1, SQLDelight.
|
||||
* **Backend:** Spring Boot 3.5.9 (JDK 25), Ktor Server (wo spezifiziert), Exposed 1.1.1.
|
||||
* **Infrastruktur:** Gitea (CI/CD), Docker, Pangolin Tunnel. (KEIN GitHub, KEIN Cloudflare).
|
||||
@@ -0,0 +1,5 @@
|
||||
## 👁️ Anti-Halluzinations-Protokoll
|
||||
Du bist an strikte, evidenzbasierte Entwicklung gebunden:
|
||||
1. **Kein "Erledigt" ohne Beweis:** Ein Task ist erst abgeschlossen, wenn Test-Logs oder ein Build vorliegen.
|
||||
2. **Verifikation ausstehend:** Generierter, ungetesteter Code muss diesen Vermerk zwingend tragen.
|
||||
3. **Fakten-Check:** Wenn du den Code nicht im Kontext hast (z.B. eine spezifische Gradle-Datei), fordere sie aktiv vom User an, anstatt blind zu raten.
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Nutze Junies robuste Pfad-Ermittlung
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
|
||||
REPO_ROOT="$(resolve_repo_root)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
AI_DIR=".ai"
|
||||
DIST_DIR="$AI_DIR/dist"
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
echo "[INFO] Generiere System-Prompts aus den Core-Rules..."
|
||||
|
||||
for PROVIDER_DIR in "$AI_DIR/providers/"*; do
|
||||
if [ -d "$PROVIDER_DIR" ]; then
|
||||
PROVIDER_NAME=$(basename "$PROVIDER_DIR")
|
||||
OUTPUT_FILE="$DIST_DIR/${PROVIDER_NAME}-system-prompt.md"
|
||||
|
||||
echo "-> Baue Prompt für: $PROVIDER_NAME"
|
||||
|
||||
# 1. Basis-Identität schreiben
|
||||
cat "$AI_DIR/prompts/system/base.md" > "$OUTPUT_FILE"
|
||||
echo -e "\n\n" >> "$OUTPUT_FILE"
|
||||
|
||||
# 2. Alle globalen Regeln anhängen
|
||||
for RULE_FILE in "$AI_DIR/rules/"*.md; do
|
||||
if [ -f "$RULE_FILE" ]; then
|
||||
cat "$RULE_FILE" >> "$OUTPUT_FILE"
|
||||
echo -e "\n\n" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. Provider-Spezifika anhängen
|
||||
if [ -f "$PROVIDER_DIR/overlay.md" ]; then
|
||||
cat "$PROVIDER_DIR/overlay.md" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
echo "[OK] $OUTPUT_FILE erfolgreich erstellt."
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
REPO_ROOT="$(resolve_repo_root)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# check-docs-drift.sh
|
||||
# Zweck: sehr schlanke Drift-Checks gegen die neue Doku-Struktur.
|
||||
# - Kein Guidelines-System mehr.
|
||||
# - Single Source of Truth: `docs/`
|
||||
|
||||
err=0
|
||||
|
||||
has() { grep -q "$2" "$1" || { echo "[DRIFT] '$2' fehlt in $1"; err=1; }; }
|
||||
miss() { grep -q "$2" "$1" && { echo "[DRIFT] Veralteter Begriff '$2' in $1"; err=1; }; }
|
||||
|
||||
# Harte Altlast-Pfade dürfen nicht mehr vorkommen
|
||||
if git grep -n "docs/00_Domain/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/00_Domain/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/adr/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/adr/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/c4/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/c4/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/how-to/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/how-to/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/reference/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/reference/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
|
||||
# Quelle der Wahrheit: Gateway-Technologie (sollte in Architektur/ADRs/C4 konsistent sein)
|
||||
has docs/01_Architecture/ARCHITECTURE.md "Spring Cloud Gateway"
|
||||
has docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Spring Cloud Gateway"
|
||||
miss docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Ktor"
|
||||
has docs/01_Architecture/c4/02-container-de.puml "Spring Cloud Gateway"
|
||||
miss docs/01_Architecture/c4/02-container-de.puml "Ktor"
|
||||
|
||||
exit $err
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Common helpers for AI guardrail scripts
|
||||
|
||||
# Robustly resolve the repository root directory.
|
||||
# Strategy: prefer Git; fallback to marker search upwards; last resort: current dir.
|
||||
resolve_repo_root() {
|
||||
local start
|
||||
start="${1:-$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)}"
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
if git -C "$start" rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
git -C "$start" rev-parse --show-toplevel
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
local dir
|
||||
dir="$(cd "$start" && pwd)"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -f "$dir/gradlew" ] || [ -f "$dir/settings.gradle.kts" ] || [ -d "$dir/.git" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
pwd
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
REPO_ROOT="$(resolve_repo_root)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
mkdir -p build/diagrams
|
||||
shopt -s nullglob
|
||||
for f in docs/architecture/c4/*.puml; do
|
||||
docker run --rm -v "$PWD":/data plantuml/plantuml -tsvg "/data/$f" -o "/data/build/diagrams"
|
||||
echo "Rendered build/diagrams/$(basename "${f%.puml}").svg"
|
||||
done
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
REPO_ROOT="$(resolve_repo_root)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
QUICK_MODE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--quick)
|
||||
QUICK_MODE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat << 'EOF'
|
||||
Docs Link-Validierung
|
||||
|
||||
USAGE:
|
||||
./.ai/scripts/validate-links.sh [--quick]
|
||||
|
||||
BESCHREIBUNG:
|
||||
Prüft Markdown-Links in `docs/**/*.md` auf gebrochene relative Pfade.
|
||||
Ignoriert externe Links (http/https/mailto) sowie reine Anchors (#...).
|
||||
|
||||
OPTIONEN:
|
||||
--quick Führt nur eine Teilmenge der Prüfungen durch (aktuell nicht implementiert).
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] Unbekannter Parameter: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
python3 - <<'PY'
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
root = Path.cwd()
|
||||
docs_dir = root / "docs"
|
||||
|
||||
if not docs_dir.is_dir():
|
||||
print(f"[ERROR] docs-Verzeichnis nicht gefunden: {docs_dir}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Veraltete Pfad-Prüfungen wurden entfernt; Fokus auf Link-Integrität.
|
||||
FORBIDDEN_SUBSTRINGS = []
|
||||
|
||||
md_files = sorted(docs_dir.rglob("*.md"))
|
||||
|
||||
link_pattern = re.compile(r"\]\(([^)]+)\)")
|
||||
|
||||
errors = 0
|
||||
|
||||
def is_external(target: str) -> bool:
|
||||
t = target.lower()
|
||||
return t.startswith("http://") or t.startswith("https://") or t.startswith("mailto:")
|
||||
|
||||
def strip_fragment_and_query(target: str) -> str:
|
||||
target = target.split("#", 1)[0]
|
||||
target = target.split("?", 1)[0]
|
||||
return target
|
||||
|
||||
for f in md_files:
|
||||
text = f.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
for forbidden in FORBIDDEN_SUBSTRINGS:
|
||||
if forbidden in text:
|
||||
print(f"[ERROR] Veralteter Pfad '{forbidden}' in {f}")
|
||||
errors += 1
|
||||
|
||||
for match in link_pattern.finditer(text):
|
||||
target = match.group(1).strip()
|
||||
|
||||
if not target:
|
||||
continue
|
||||
if is_external(target):
|
||||
continue
|
||||
if target.startswith("#"):
|
||||
continue
|
||||
|
||||
if target.startswith("<") and target.endswith(">"):
|
||||
target = target[1:-1]
|
||||
|
||||
target = unquote(strip_fragment_and_query(target))
|
||||
|
||||
if target.startswith("/"):
|
||||
continue
|
||||
|
||||
if ":" in target.split("/", 1)[0]:
|
||||
# z.B. "vscode:..."
|
||||
continue
|
||||
|
||||
resolved = (f.parent / target).resolve()
|
||||
|
||||
try:
|
||||
resolved.relative_to(root.resolve())
|
||||
except ValueError:
|
||||
print(f"[ERROR] Link zeigt außerhalb des Repos: {f} -> {target}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if resolved.is_dir():
|
||||
if not (resolved / "README.md").is_file():
|
||||
print(f"[ERROR] Verlinktes Verzeichnis ohne README.md: {f} -> {target}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if not resolved.exists():
|
||||
print(f"[ERROR] Broken link: {f} -> {target}")
|
||||
errors += 1
|
||||
|
||||
if errors:
|
||||
print(f"[ERROR] Link-Validierung fehlgeschlagen: {errors} Fehler")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[OK] Link-Validierung erfolgreich: {len(md_files)} Markdown-Dateien geprüft")
|
||||
PY
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
apply: always
|
||||
---
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ secrets/
|
||||
# ===================================================================
|
||||
TODO*.md
|
||||
NOTES*.md
|
||||
**/.junie/
|
||||
.junie/
|
||||
|
||||
# ===================================================================
|
||||
# Keep essential files (override exclusions)
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
desktop-tests:
|
||||
# Komplett deaktivierbar über Repo-Variable: Settings → Variables → DESKTOP_CI_ENABLED=true
|
||||
# Zusätzlich: Für Plan‑B‑Builds überspringen, wenn Commit-Message [planb] enthält
|
||||
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(github.event.head_commit.message, '[planb]') }}
|
||||
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||
name: Compose Desktop — Tests (headless) & Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ run-name: Build & Publish by @${{ github.actor }}
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags:
|
||||
- 'v*'
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'platform/**'
|
||||
@@ -116,9 +114,8 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY_INTERNAL }}/${{ env.IMAGE_PREFIX }}/${{ matrix.image }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=sha,format=long,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest
|
||||
type=sha,format=long
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
jobs:
|
||||
no-hardcoded-versions:
|
||||
# Für Plan-B-Builds überspringen: Commit-Message enthält [planb]
|
||||
if: ${{ !contains(github.event.head_commit.message, '[planb]') }}
|
||||
if: ${{ !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
tag-release:
|
||||
name: 🏷️ Git-Tag setzen
|
||||
# Für Plan-B-Builds überspringen: Commit-Message enthält [planb]
|
||||
if: ${{ !contains(github.event.head_commit.message, '[planb]') }}
|
||||
if: ${{ !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.read-version.outputs.version }}
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Git-Tag erstellen & pushen
|
||||
if: steps.check-tag.outputs.already_tagged == 'false' && github.event.inputs.dry_run != 'true'
|
||||
if: steps.check-tag.outputs.already_tagged == 'false' && gitea.event.inputs.dry_run != 'true'
|
||||
run: |
|
||||
TAG="${{ steps.read-version.outputs.tag }}"
|
||||
VERSION="${{ steps.read-version.outputs.version }}"
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
package-linux:
|
||||
name: 📦 Linux .deb Packaging
|
||||
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein Plan‑B Commit
|
||||
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(github.event.head_commit.message, '[planb]') }}
|
||||
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: tag-release
|
||||
|
||||
@@ -88,11 +88,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 21 (Temurin)
|
||||
- name: Setup JDK 25 (Temurin)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
java-version: '25'
|
||||
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v4
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
package-windows:
|
||||
name: 📦 Windows .msi Packaging
|
||||
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein Plan‑B Commit
|
||||
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(github.event.head_commit.message, '[planb]') }}
|
||||
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||
runs-on: windows-latest
|
||||
needs: tag-release
|
||||
|
||||
@@ -136,11 +136,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 21 (Temurin)
|
||||
- name: Setup JDK 25 (Temurin)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
java-version: '25'
|
||||
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v4
|
||||
@@ -179,11 +179,11 @@ jobs:
|
||||
steps:
|
||||
- name: Summary ausgeben
|
||||
run: |
|
||||
echo "## 🚀 Release ${{ needs.tag-release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Artefakt | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Linux .deb | ${{ needs.package-linux.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Windows .msi | ${{ needs.package-windows.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Git-Tag:** \`${{ needs.tag-release.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 🚀 Release ${{ needs.tag-release.outputs.version }}" >> $GITEA_STEP_SUMMARY
|
||||
echo "" >> $GITEA_STEP_SUMMARY
|
||||
echo "| Artefakt | Status |" >> $GITEA_STEP_SUMMARY
|
||||
echo "|----------|--------|" >> $GITEA_STEP_SUMMARY
|
||||
echo "| Linux .deb | ${{ needs.package-linux.result }} |" >> $GITEA_STEP_SUMMARY
|
||||
echo "| Windows .msi | ${{ needs.package-windows.result }} |" >> $GITEA_STEP_SUMMARY
|
||||
echo "" >> $GITEA_STEP_SUMMARY
|
||||
echo "**Git-Tag:** \`${{ needs.tag-release.outputs.tag }}\`" >> $GITEA_STEP_SUMMARY
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 🐧 [DevOps Engineer] Optimierte .gitignore für Meldestelle (KMP / Gradle / Docker)
|
||||
|
||||
# --- AI ---
|
||||
.ai/dist/
|
||||
|
||||
# --- IDE & Editor ---
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
@@ -1,43 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# check-docs-drift.sh
|
||||
# Zweck: sehr schlanke Drift-Checks gegen die neue Doku-Struktur.
|
||||
# - Kein Guidelines-System mehr.
|
||||
# - Single Source of Truth: `docs/`
|
||||
|
||||
err=0
|
||||
|
||||
has() { grep -q "$2" "$1" || { echo "[DRIFT] '$2' fehlt in $1"; err=1; }; }
|
||||
miss() { grep -q "$2" "$1" && { echo "[DRIFT] Veralteter Begriff '$2' in $1"; err=1; }; }
|
||||
|
||||
# Harte Altlast-Pfade dürfen nicht mehr vorkommen
|
||||
if git grep -n "docs/00_Domain/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/00_Domain/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/adr/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/adr/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/c4/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/c4/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/how-to/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/how-to/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/reference/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/reference/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
|
||||
# Quelle der Wahrheit: Gateway-Technologie (sollte in Architektur/ADRs/C4 konsistent sein)
|
||||
has docs/01_Architecture/ARCHITECTURE.md "Spring Cloud Gateway"
|
||||
has docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Spring Cloud Gateway"
|
||||
miss docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Ktor"
|
||||
has docs/01_Architecture/c4/02-container-de.puml "Spring Cloud Gateway"
|
||||
miss docs/01_Architecture/c4/02-container-de.puml "Ktor"
|
||||
|
||||
exit $err
|
||||
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||
exec "$ROOT_DIR/.ai/scripts/check-docs-drift.sh" "$@"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p build/diagrams
|
||||
shopt -s nullglob
|
||||
for f in docs/architecture/c4/*.puml; do
|
||||
docker run --rm -v "$PWD":/data plantuml/plantuml -tsvg "/data/$f" -o "/data/build/diagrams"
|
||||
echo "Rendered build/diagrams/$(basename "${f%.puml}").svg"
|
||||
done
|
||||
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||
exec "$ROOT_DIR/.ai/scripts/render-plantuml.sh" "$@"
|
||||
|
||||
@@ -1,136 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# validate-links.sh - Link-Validierung für Projektdokumentation (`docs/**`).
|
||||
# Zweck: Guardrail für die "Docs-as-Code"-Strategie.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
QUICK_MODE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--quick)
|
||||
QUICK_MODE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat << 'EOF'
|
||||
Docs Link-Validierung
|
||||
|
||||
USAGE:
|
||||
./.junie/scripts/validate-links.sh [--quick]
|
||||
|
||||
BESCHREIBUNG:
|
||||
Prüft Markdown-Links in `docs/**/*.md` auf gebrochene relative Pfade.
|
||||
Ignoriert externe Links (http/https/mailto) sowie reine Anchors (#...).
|
||||
|
||||
OPTIONEN:
|
||||
--quick Führt nur eine Teilmenge der Prüfungen durch (aktuell nicht implementiert).
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] Unbekannter Parameter: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
root = Path.cwd()
|
||||
docs_dir = root / "docs"
|
||||
|
||||
if not docs_dir.is_dir():
|
||||
print(f"[ERROR] docs-Verzeichnis nicht gefunden: {docs_dir}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Veraltete Pfad-Prüfungen wurden entfernt, da sie zu wartungsintensiv waren.
|
||||
# Das Skript konzentriert sich nun auf die Validierung der Link-Integrität.
|
||||
FORBIDDEN_SUBSTRINGS = []
|
||||
|
||||
md_files = sorted(docs_dir.rglob("*.md"))
|
||||
|
||||
link_pattern = re.compile(r"\]\(([^)]+)\)")
|
||||
|
||||
errors = 0
|
||||
|
||||
def is_external(target: str) -> bool:
|
||||
t = target.lower()
|
||||
return t.startswith("http://") or t.startswith("https://") or t.startswith("mailto:")
|
||||
|
||||
def strip_fragment_and_query(target: str) -> str:
|
||||
# remove fragment and query parts
|
||||
target = target.split("#", 1)[0]
|
||||
target = target.split("?", 1)[0]
|
||||
return target
|
||||
|
||||
for f in md_files:
|
||||
text = f.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
for forbidden in FORBIDDEN_SUBSTRINGS:
|
||||
if forbidden in text:
|
||||
print(f"[ERROR] Veralteter Pfad '{forbidden}' in {f}")
|
||||
errors += 1
|
||||
|
||||
for match in link_pattern.finditer(text):
|
||||
target = match.group(1).strip()
|
||||
|
||||
if not target:
|
||||
continue
|
||||
if is_external(target):
|
||||
continue
|
||||
if target.startswith("#"):
|
||||
continue
|
||||
|
||||
# drop angle brackets <...> used in markdown for urls with spaces
|
||||
if target.startswith("<") and target.endswith(">"):
|
||||
target = target[1:-1]
|
||||
|
||||
target = unquote(strip_fragment_and_query(target))
|
||||
|
||||
# ignore absolute paths in the repo (we treat them as doc-style links; validate only if relative)
|
||||
if target.startswith("/"):
|
||||
continue
|
||||
|
||||
# ignore non-file targets (e.g. empty or protocol-less anchors)
|
||||
if ":" in target.split("/", 1)[0]:
|
||||
# things like "vscode:..." etc.
|
||||
continue
|
||||
|
||||
# treat as file path relative to markdown file
|
||||
resolved = (f.parent / target).resolve()
|
||||
|
||||
# keep validation within repo
|
||||
try:
|
||||
resolved.relative_to(root.resolve())
|
||||
except ValueError:
|
||||
print(f"[ERROR] Link zeigt außerhalb des Repos: {f} -> {target}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
# allow directories if they contain README.md
|
||||
if resolved.is_dir():
|
||||
if not (resolved / "README.md").is_file():
|
||||
print(f"[ERROR] Verlinktes Verzeichnis ohne README.md: {f} -> {target}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if not resolved.exists():
|
||||
print(f"[ERROR] Broken link: {f} -> {target}")
|
||||
errors += 1
|
||||
|
||||
if errors:
|
||||
print(f"[ERROR] Link-Validierung fehlgeschlagen: {errors} Fehler")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[OK] Link-Validierung erfolgreich: {len(md_files)} Markdown-Dateien geprüft")
|
||||
PY
|
||||
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||
exec "$ROOT_DIR/.ai/scripts/validate-links.sh" "$@"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# .aiignore - Verhindert Token-Waste für Nolik
|
||||
|
||||
# Abhängigkeiten & Binaries
|
||||
build/
|
||||
.gradle/
|
||||
*.jar
|
||||
*.deb
|
||||
*.msi
|
||||
|
||||
# Sensible Daten (auch lokal!)
|
||||
.env
|
||||
.env.*
|
||||
config/docker/certs/
|
||||
*.pem
|
||||
*.jks
|
||||
postgres-data/
|
||||
valkey-data/
|
||||
|
||||
# Doku-Builds (Nolik soll die Source-Files in docs/ lesen, nicht die HTML-Exporte)
|
||||
build/dokka/
|
||||
docs/Neumarkt2026/*.pdf
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||
exec "$ROOT_DIR/.ai/scripts/check-docs-drift.sh" "$@"
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||
exec "$ROOT_DIR/.ai/scripts/validate-links.sh" "$@"
|
||||
@@ -3,15 +3,12 @@
|
||||
Dieses Dokument definiert die Zusammenarbeit zwischen dem User (Owner) und den spezialisierten KI-Agenten.
|
||||
Es dient als zentraler **System-Prompt-Erweiterung** für neue Chat-Sessions.
|
||||
|
||||
## 🚀 Strategische Ausrichtung (Reality-Reset 28.04.2026)
|
||||
## 🚀 Strategische Ausrichtung
|
||||
|
||||
Das Projekt **"Meldestelle"** entwickelt eine ÖTO/FEI-konforme, offline-fähige Turnier-Software.
|
||||
1. **Desktop-First:** Primäres Ziel ist die Compose Desktop App (KMP). UX & Performance sind auf Profis optimiert.
|
||||
2. **Offline-First:** Das System muss autark (ohne Internet) funktionieren.
|
||||
3. **Domain-Driven:** Die Hierarchie **Veranstaltung -> Turnier -> Bewerb/Abteilung** ist das absolute Fundament.
|
||||
|
||||
**WICHTIG:** Alle Agenten arbeiten ab sofort nur noch auf Basis von verifiziertem Code. "Halluzinationen" über
|
||||
abgeschlossene Phasen ohne entsprechende Implementierung sind untersagt.
|
||||
2. **Offline-First:** Das System muss autark (ohne Internet) funktionieren. Sync-Logik ist Kernbestandteil.
|
||||
3. **Domain-Driven:** 6 Bounded Contexts (SCS) bilden den fachlichen Rahmen.
|
||||
|
||||
## 1. Protokoll & Rollen-Badges
|
||||
Jede Agenten-Antwort **muss** mit dem entsprechenden Badge beginnen, um den Kontext und die Verantwortlichkeit zu klären.
|
||||
@@ -40,9 +37,7 @@ Jede Agenten-Antwort **muss** mit dem entsprechenden Badge beginnen, um den Kont
|
||||
4. **Doku-as-Code:** Änderungen an Code/Architektur müssen sofort in `docs/` (ADR/Reference) reflektiert werden.
|
||||
5. **Session-Abschluss:** Jede Session endet mit einem Eintrag durch den **Curator** (Journal oder Artefakt).
|
||||
|
||||
## 🚫 Anti-Halluzinations-Protokoll (WICHTIG)
|
||||
Um Fehlentscheidungen und falsche Status-Meldungen zu verhindern, gelten ab sofort folgende Regeln:
|
||||
1. **Kein "Erledigt" ohne Beweis:** Ein Task darf erst dann als abgeschlossen markiert werden, wenn ein Test-Log, ein erfolgreicher Build oder eine explizite Bestätigung des Users vorliegt.
|
||||
2. **Status "Verifikation ausstehend":** Code, der geschrieben, aber nicht auf Hardware getestet wurde, muss zwingend diesen Zusatz tragen.
|
||||
3. **Fakten-Check vor Abschluss:** Vor dem Senden der `submit`-Meldung muss der Agent prüfen: "Habe ich das wirklich laufen sehen oder nehme ich es nur an?"
|
||||
4. **Fehler-Eingeständnis:** Bei Entdeckung einer Halluzination ist sofort der User zu informieren und der Status in allen Dokumenten (Roadmap, Journal) zu korrigieren.
|
||||
## 3. Projekt-Philosophie
|
||||
* **Information Density over White Space:** Wir bauen ein Profi-Werkzeug, kein Spielzeug.
|
||||
* **Speed over Animation:** Reaktionsgeschwindigkeit der UI hat höchste Priorität.
|
||||
* **Offline-Authentizität:** Lokale Daten sind die "Source of Truth" für den User; der Server ist das Backup/Sync-Target.
|
||||
|
||||
+209
-10
@@ -17,19 +17,218 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Basis-Infrastruktur & Domain-Definition:**
|
||||
- DDD-Modelle für `Veranstaltung`, `Turnier`, `Bewerb` und `Abteilung` gemäß ÖTO definiert.
|
||||
- ZNS-Parser Prototyp für Dateiformate (VEREIN01, LIZENZ01, PFERD01, RICHT01).
|
||||
- Plan-B Mail-Service (Spring Boot) für Nennungs-Versand via World4You.
|
||||
- Desktop-App Skelett mit Navigation und UI-Hüllen (Compose Desktop).
|
||||
- **Onboarding & Desktop-UX - 15.04.2026:**
|
||||
- **Desktop-App:** Dynamisierung der Statusanzeigen im App-Footer ("Cloud synchronisiert" & "Verbunden").
|
||||
- **Connectivity-Tracking:** Implementierung des `ConnectivityTracker` (KMP) zur Echtzeit-Überwachung der API-Gateway
|
||||
Erreichbarkeit.
|
||||
- **LAN-Erkennung:** Integration des `NetworkDiscoveryService` (mDNS) im Footer zur Anzeige aktiver Instanzen im
|
||||
lokalen Netzwerk.
|
||||
- **Onboarding:** Datenfluss vom `SettingsManager` bis in den Footer finalisiert (Anzeige des echten Gerätenamens).
|
||||
- **Online-Nennung & Integration - 15.04.2026:**
|
||||
- **Backend (Mail-Service):** Finalisierung des `MailController` für Web-Nennungen inkl. SMTP-Versand via World4You.
|
||||
- **Frontend (Desktop):** `NennungsEingangScreen` an Live-Daten vom `mail-service` angebunden.
|
||||
- **Repository:** `NennungRemoteRepository` (KMP) um `holeNennungen()` erweitert.
|
||||
- **Billing & ÖTO - 15.04.2026:**
|
||||
- **Sportförderbeitrag:** Automatische Buchung von 1,00 EUR (§16 ÖTO) bei jeder Nennung im `entries-service`
|
||||
implementiert.
|
||||
|
||||
### Reality-Reset (28.04.2026)
|
||||
### Behoben
|
||||
|
||||
- **Korrektur:** Vormalige Einträge über "abgeschlossene" Billing-, Results- und Zeitplan-Features wurden entfernt, da
|
||||
diese im Code nicht funktional hinterlegt waren.
|
||||
- **Status:** Fokus zurück auf die Kern-Hierarchie (Veranstaltung -> Turnier -> Bewerb).
|
||||
- **Frontend (Desktop):** Behebung von Kompilierungsfehlern in `ScreenPreviews.kt` durch Implementierung der fehlenden
|
||||
`getStats()` Methode in den `MasterdataRepository`-Mocks.
|
||||
- **Identity-Modul:** Umstellung auf `kotlin.time.Instant` zur Vermeidung von Deprecation-Warnungen und Behebung von
|
||||
Persistenz-Konflikten im `ExposedDeviceRepository`.
|
||||
- **Koin DI:** Korrektur von Typ-Inferenz-Fehlern beim `HttpClient` im `nennung-feature` durch explizite Qualifier.
|
||||
- **Turnier-Feature:** Behebung eines unsicheren Casts (`Any!` zu `List<String>`) in `TurnierStammdatenTab.kt`.
|
||||
- **Konfiguration:** Harmonisierung der Ports (Mail-Service auf 8083) in `.env`, `dc-backend.yaml` und
|
||||
`PlatformConfig.jvm.kt`.
|
||||
|
||||
### [1.0.6-SNAPSHOT] — 2026-04-10
|
||||
### Hinzugefügt
|
||||
- **Phase 12 (Abrechnung & Infrastruktur) - 12.04.2026:**
|
||||
- **Infrastruktur:** Docker-Integration für `billing-service` (Port 8087) und API-Gateway Routing vervollständigt.
|
||||
- **Service Discovery:** Alle relevanten Microservices (`masterdata`, `events`, `results`, `series`, `billing`) sind nun bei Consul registriert.
|
||||
- **Frontend Billing:** `BillingRepository` und `BillingViewModel` auf reale API-Anbindung (Ktor) umgestellt; `BillingScreen` funktionalisiert.
|
||||
- **Backend (Series):** JPA-Entitäten `Serie` und `SeriePunkt` im `series-service` stabilisiert und Flyway-Migrationen für das Datenbankschema erstellt.
|
||||
- **Fix:** Behebung von IDE-Mapping-Warnungen durch explizite `@Column` Namen in den JPA-Entitäten.
|
||||
- **Backend Fixes - 12.04.2026:**
|
||||
- **Infrastruktur:** Behebung von Startfehlern im `events-service` (DataSource) und `masterdata-service` (Consul).
|
||||
- **Build:** Integration von `results-service` und `series-service` in `settings.gradle.kts`.
|
||||
- **Domain:** `Serie` und `SeriePunkt` zu `data class` konvertiert (copy() Unterstützung).
|
||||
- **Phase 11 (Ergebniserfassung & Platzierung) - 12.04.2026:**
|
||||
- **Backend (Results):** `results-service` um JPA-Entitäten, Repositories und Business-Logik für Platzierungsberechnungen (Wertnote, Zeit, Fehler) ergänzt.
|
||||
- **Infrastructure:** `dc-backend.yaml` und `GatewayConfig.kt` um den Service `results` (Port 8088) erweitert.
|
||||
- **Frontend Domain:** `ErgebnisRepository` und `Ergebnis`-Modell für Wertnoten, Zeiten und Status erstellt.
|
||||
- **Frontend UI:** `ErgebnisEditDialog` zur schnellen Ergebniserfassung hinzugefügt; `TurnierStartlistenTab` ermöglicht nun Erfassung per Zeilen-Klick.
|
||||
- **Frontend UI:** `TurnierErgebnislistenTab` vervollständigt: Buttons für "Platzierung berechnen" und "Drucken" (PDF) funktionalisiert.
|
||||
- **Fix:** Kompilierungsprobleme im `TurnierFeatureModule` und `ScreenPreviews.kt` behoben (fehlende `ergebnisRepo` Parameter).
|
||||
|
||||
### Hinzugefügt
|
||||
- **Phase 10.4 (Series-Context Vertiefung) - 12.04.2026:**
|
||||
- **Backend (Series):** `series-service` um Logik für Streichresultate (`ReglementTyp`) und Bindungsarten (Reiter-zentriert, Pferde-zentriert, Paar-Bindung) erweitert.
|
||||
- **Infrastructure:** `dc-backend.yaml` und `GatewayConfig.kt` um den Service `series` (Port 8089) erweitert.
|
||||
- **Frontend Domain:** `SeriesRepository` und Modelle an das neue Ranking-Format (`SerieStandEntry`) angepasst.
|
||||
- **UI:** `SeriesScreen.kt` überarbeitet: Zeigt nun Reiter- und Pferde-IDs sowie Fortschritt pro Teilnehmer an.
|
||||
- **Dokumentation:** `MASTER_ROADMAP.md` aktualisiert (Phase 10 & 11 auf 'Completed' gesetzt).
|
||||
|
||||
### Hinzugefügt
|
||||
- **Phase 10.3 (Echter Datenverkehr & Infrastruktur) - 12.04.2026:**
|
||||
- **Infrastructure:** Docker-Services für `masterdata`, `events` und `zns-import` in `dc-backend.yaml` ergänzt.
|
||||
- **Gateway:** API-Gateway Routing für Masterdata (`/api/v1/masterdata`) und Events (`/api/v1/events`) konfiguriert.
|
||||
- **Frontend (Vereine):** `VereinRepository` (Ktor) und `VereinViewModel` implementiert für echtes Anlegen von Veranstaltern.
|
||||
- **Frontend (Events):** `TurnierViewModel` an das reale `TurnierRepository` angebunden.
|
||||
- **Fix:** `verein-feature` Abhängigkeiten korrigiert (Network/Ktor).
|
||||
- **Fix:** Polling-Endpoints im `ZnsImportViewModel` an das neue Gateway-Routing angepasst.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Phase 10.2 (Masterdata-Editoren & Organisation) - 12.04.2026:**
|
||||
- **Frontend:** `MasterdataEditDialogs.kt` für die Bearbeitung von Reiter- und Pferdedaten direkt im Turnier-Kontext.
|
||||
- **Frontend:** Erweiterung des `MasterdataRepository` um Schreibzugriffe (`saveReiter`, `savePferd`).
|
||||
- **Frontend:** Funktionale Suche für Turnierleiter im `Organisation`-Tab via `NennungViewModel` und Masterdata-API.
|
||||
- **Frontend:** State-Management für Stammdaten-Editoren im `NennungViewModel`.
|
||||
- **Fix:** Kompilierungsfehler in `ScreenPreviews.kt` behoben (fehlende Interface-Methoden in Mocks).
|
||||
- **Fix (Desktop Shell):** Fehlendes `turnierFeatureModule` in `main.kt` registriert und Login-Gate in `DesktopApp.kt` optimiert.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Phase 10 (Series-Context & Stammdaten) - 11.04.2026:**
|
||||
- **Frontend:** Stammdaten-Infrastruktur im `turnier-feature` (Repositories, DTOs, Domänenmodelle) für Reiter, Pferde, Funktionäre und Vereine.
|
||||
- **Frontend:** `NennungViewModel` zur Steuerung der Suche und Status-Verwaltung von Nennungen.
|
||||
- **Frontend:** Funktionalisierung des `Nennungen`-Tabs (Suche, Echt-Datenanbindung) und Vorbereitung des `Organisation`-Tabs.
|
||||
- **Frontend:** `DefaultMasterdataRepository` zur Suche in Reitern, Pferden und Funktionären via Backend-API.
|
||||
- **Netzwerk:** Erweiterung der `ApiRoutes` um Endpunkte für Masterdata und Nennungen.
|
||||
- **Phase 10 (Series-Context) Vorbereitung:**
|
||||
- **Frontend:** Neuer `SeriesScreen.kt` für die Verwaltung von Cups und Meisterschaften (konfigurierbare Reglements).
|
||||
- **Frontend:** Erweiterung des `AdminUebersichtScreen` (Cockpit) um KPI-Kacheln mit Direkt-Links zu Cups und Meisterschaften.
|
||||
- **Frontend:** Integration der Series-Navigation in die Breadcrumbs und das globale Routing (`Meisterschaften`, `Cups`).
|
||||
- **Turnier-Feature Hardening:**
|
||||
- **Frontend:** `STARTLISTEN` und `ERGEBNISLISTEN` Tabs vollständig an das `BewerbViewModel` angebunden (Bewerbs-Auswahl mit echten Daten).
|
||||
- **Frontend:** Implementierung der Starter-Anzeige in der Startliste (LazyColumn).
|
||||
|
||||
### Geändert
|
||||
- **Turnier-Feature:** Sichtbarkeit von `BewerbViewModel.generateStartliste()` auf `public` geändert, um den Aufruf aus dem Tab zu ermöglichen.
|
||||
- **Frontend (Desktop):** `ScreenPreviews.kt` aktualisiert zur Berücksichtigung der neuen ViewModel-Abhängigkeiten (`NennungViewModel`, `MasterdataRepository`).
|
||||
|
||||
### [Phase 9] - 11.04.2026
|
||||
- **Frontend:** Interaktiver Drag & Drop Zeitplan mit automatischem 5-Minuten-Snapping und Konflikt-Visualisierung.
|
||||
- **Frontend:** "B-Satz Export (ZNS)" Toolbar-Aktion mit integriertem Vorschau-Dialog.
|
||||
- **Frontend:** "Änderungs-Historie" (Audit-Log) Sektion zur Nachverfolgung von Zeitplan-Anpassungen.
|
||||
- **Backend:** `audit_log` Persistenz und Abfrage-API für manuelle Eingriffe in Bewerbe.
|
||||
- **Backend:** ZNS B-Satz Export Endpunkt (`/export/zns/b-satz`) zur Generierung von `BBEWERBE` Datensätzen.
|
||||
- **Core:** `FixedWidthLineBuilder` zur präzisen Generierung von ZNS-konformen Festbreiten-Formaten.
|
||||
|
||||
### Behoben
|
||||
- **Infrastruktur:** Veraltete `newSuspendedTransaction` in `DatabaseFactory.kt` durch moderne `suspendTransaction` (Exposed v1) ersetzt.
|
||||
- **Frontend (Desktop):** Kompilierfehler in `ScreenPreviews.kt` behoben, indem fehlende Interface-Methoden im Mock-Repository implementiert wurden.
|
||||
- **Backend (Tests):** `JdbcSQLSyntaxErrorException` im `BewerbeZeitplanIntegrationTest` durch Korrektur des Schema-Setups (Audit-Log Tabelle) gelöst.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Bugfix**: Behebung von Build-Fehlern im `veranstalter-feature` nach der Paket-Konsolidierung.
|
||||
- **Frontend**: `FakeVeranstalterRepository` in `commonMain` implementiert, um saubere KMP-DI zu ermöglichen.
|
||||
- **Frontend**: Veraltete Imports und Referenzen im `meldestelle-desktop` Shell und Previews korrigiert.
|
||||
- **Architektur:** Fachliches Konzept für Zeitplan-Optimierung (Drag & Drop) erstellt (`konzept-zeitplan-optimierung-de.md`).
|
||||
- **Architektur:** Spezifikation des Status-Automaten für Nennungen und Synchronisations-Logik (`status-automat-nennungen-de.md`).
|
||||
- **Rulebook:** Überprüfung und Spezifikation der Parcoursbesichtigung zu Pferd (§43 ÖTO) inkl. 5-Minuten-Puffer-Regel.
|
||||
- **Backend (Entries):** Erweiterung der Domain-Modelle `Bewerb` und `Abteilung` um Besichtigungs- und Pausen-Konfigurationen.
|
||||
- **Backend (Entries):** Neues Datenmodell `BesichtigungsBlock` für wettbewerbsübergreifende Parcoursbesichtigungen.
|
||||
- **Backend (Entries):** API-Endpunkt `PATCH /bewerbe/{id}/zeitplan` für schnelle Zeitplan-Updates implementiert.
|
||||
- **Backend (Entries):** `StartlistenService` um ÖTO-konforme Zeitberechnung (Besichtigungs-Puffer, Pausen-Intervalle) erweitert.
|
||||
|
||||
### Geändert
|
||||
- Masterdata/Domain: Umbenennungen zur Vereinheitlichung der Terminologie (DE):
|
||||
- `MasterdataLicenseRepository` → `LizenzRepository`
|
||||
- `LicenseMatrixService` → `LizenzMatrixService`
|
||||
- `LicenseMatrixServiceImpl` → `LizenzMatrixServiceImpl`
|
||||
- Test: `LicenseMatrixServiceTest` → `LiznezMatrixServiceTest` (exakt nach Vorgabe)
|
||||
- Infrastructure (Exposed): `LicenseTable` → `LizenzTable`
|
||||
- Docs: Begriff „reit_lizenzen“ → „reiterlizenzen“ in Glossar/UL konsolidiert.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Events-Service Bundle:** Vollständige Stabilisierung der `events` Services (Domain, Infrastructure, API, Service).
|
||||
- **Domain:** Umstellung auf `kotlin.time.Instant` zur Vermeidung von Deprecation-Warnungen (Kotlin 2.1.20+) und Harmonisierung mit dem Rulebook-Expert.
|
||||
- **Infrastructure:** Anpassung an den `org.jetbrains.exposed.v1` Namespace und Implementierung von UUID-Konvertierungen zwischen `kotlin.uuid.Uuid` (Domain) und `java.util.UUID` (DB).
|
||||
- **API:** Refactoring des `VeranstaltungController` zur direkten Repository-Nutzung (Alignment mit `entries` Service).
|
||||
- **Service-Config:** Umstellung auf Flyway-basiertes Tenant-Schema-Management in `EventsDatabaseConfiguration`.
|
||||
- **Build:** Behebung des `shadowJar` Fehlers in `events-infrastructure` durch Entfernen des unnötigen `ktor` Plugins in der Library-Schicht.
|
||||
|
||||
- Masterdata: Automatisches Seeding aller Reiterlizenzen (license_matrix) beim Start des `masterdata-service` via `ReiterlizenzenSeeder` (idempotent; SPRINGEN: LIZENZFREI,R1–R4; DRESSUR: LIZENZFREI,RD1–RD3).
|
||||
|
||||
- **ZNS-Import (LIZENZ01.dat):** Robuster Lizenz-Tokenizer und Normalizer implementiert.
|
||||
- Erkennung: `RD1..RD4`, `R1..R4`, `S1..S4`, `D2..D4`, Kombis `R{n}D{m}`, `R{n}S{k}`, `RDS4` (rechts-/letztes Vorkommen gewinnt).
|
||||
- Normalisierung: `S*→R*`, `D*→RD*`, `RD4→RD3` (bis Enum verfügbar), `R{n}S{k}→Rmax(n,k)`, `R{n}D{m}→R{n}+RD{m}`.
|
||||
- Integration: `ZnsReiterParser` füllt `lizenzen`-Liste (1:n) entsprechend und leitet `lizenzKlasse` bei fehlendem 4‑Spalten‑Code aus Token ab.
|
||||
- QA: Neue Unit-Tests (Tokenizer) für Beispiele `R2S3`, `R2D4`, `RD2` u. a.; alle Parser-Tests grün.
|
||||
|
||||
- **Core:** Modularisierte ZNS-Parser eingeführt (`ZnsVereinParser`, `ZnsReiterParser`, `ZnsPferdParser`, `ZnsFunktionaerParser`) zur Verbesserung der Wartbarkeit und Unterstützung von Einzelimporten.
|
||||
- **Fix:** SQL-Migrationsfehler in `V010` behoben, indem die Umbenennung der Spalte `name` in `verein_name` durch einen idempotenten `DO`-Block abgesichert wurde (behebt "Unable to resolve column 'name'").
|
||||
- **Infrastructure:** Datenbank-Migration `V010` hinzugefügt, um das Schema final mit den `Exposed`-Modellen zu synchronisieren.
|
||||
- **Infrastructure:** Datei-Archivierung für hochgeladene ZNS-ZIP-Dateien im `ZnsImportOrchestrator` implementiert.
|
||||
- **Infrastructure:** `ZnsImportService` vollständig auf die neuen spezialisierten Parser umgestellt und als Spring-Bean im Backend registriert.
|
||||
- **QA:** Umfassende Test-Suite `ZnsParserTest.kt` mit realen ZNS-Daten (Hämmerle, Neuwirth, etc.) erstellt; Korrektur der Extraktions-Logik für Mitgliedsnummern (Position 147) und Funktionär-Daten (RICHT01).
|
||||
- **QA:** Neue Betriebsanleitung für ZNS-Importer Tests erstellt: `docs/07_Infrastructure/runbooks/ZNS_Importer_Test_Manual.md`.
|
||||
- **Infrastructure:** `MasterdataDatabaseConfiguration` korrigiert: Expliziter Aufruf von `Database.connect()` hinzugefügt, um Abstürze beim Anwendungsstart ("No database specified") zu beheben.
|
||||
- **Infrastructure:** `application.yml` im `masterdata-service` vervollständigt (DataSource-Konfiguration mit `pg-user`/`pg-password` und Flyway-Aktivierung).
|
||||
- **Domain:** Legacy-Spezifikationen für ZNS-Schnittstellen (Import/Export) formalisiert:
|
||||
- `docs/03_Domain/02_Reference/Legacy_Specs/OETO-2026_Meldestelle_Pflichtenheft_V2.4.md` (Basis-Satzarten A-N)
|
||||
- `docs/03_Domain/02_Reference/Legacy_Specs/OETO-2026_Meldestelle_Erweiterung-Schnittstelle_2014.md` (XML-Erweiterung, LinkID-Logik)
|
||||
- **QA B-2:** `OnboardingValidator`-Objekt extrahiert; `OnboardingValidatorTest.kt` (17 Unit-Tests: Pflichtfeld-Guard,
|
||||
Doppelklick-Schutz, Abbrechen-Reset, rememberSaveable-Regression)
|
||||
- **QA B-3:** `AbteilungsRegelServiceTest.kt` um 14 Tests erweitert: CSN-C-NEU ≤95 cm / ≥100 cm Pflicht-Teilung,
|
||||
ORGANISATORISCH, SEPARATE_SIEGEREHRUNG, Caprilli-Regression, Grenzfälle 90/110 cm
|
||||
- **Domain:** `AbteilungsTeilungsTypE` um `ORGANISATORISCH` und `SEPARATE_SIEGEREHRUNG` erweitert
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Masterdata/Infrastructure:** Kompilierfehler in `AltersklasseRepositoryImpl` durch Vereinheitlichung der Exposed-Tabellendefinition behoben:
|
||||
- `AltersklassenTable` → `AltersklasseTable`
|
||||
- Spalte `altersklassen_code` → `altersklasse_code`
|
||||
- Tabellenname `altersklassen` → `altersklasse`
|
||||
- **Masterdata/API:** Fehlendes Interface-Mapping ergänzt: `RegulationRepository` enthält nun `findAllTurnierklassen()`; `ExposedRegulationRepository` implementiert die Methode und `RegulationController` kompiliert wieder.
|
||||
- **ZNS-Import:** `AltersklassenExposedRepository` korrigiert (richtiger Domain-Typ `AltersklasseDefinition`, Mapping von `SparteE` und Zeitstempeln).
|
||||
|
||||
- **Migration V013:** Idempotent und robust gemacht. Alle `ALTER TABLE ... RENAME`-Operationen laufen nun nur, wenn die Quell-Tabelle existiert (Fix für "Unable to resolve table 'bundesland'/'turnierklasse'").
|
||||
- **Lizenz-Validierung:** `LicenseMatrixServiceImpl` um Cross-Discipline-Mapping R↔RD (ÖTO-Äquivalenzen) erweitert. Damit funktionieren Fälle wie Dressur-Starts mit Spring-Lizenz (R1→RD1, R2→RD2, R3/R4→RD3) bzw. umgekehrt konsistent.
|
||||
|
||||
- **Domain:** Striktere Spartenlizenz-Prüfung in `Reiter.hasLizenzForSparte` implementiert (RD1..RD3 nur DRESSUR; R1..R4 nur SPRINGEN). Behebt Testfehler „isEligible verweigert Start ohne passende Spartenlizenz“ im `LicenseMatrixServiceTest`.
|
||||
|
||||
### Behoben
|
||||
- **Backend (Entries):** Fehlschlagenden Unit-Test `berechneStartzeiten sollte Zeiten korrekt aufsummieren` korrigiert; der Test berücksichtigt nun den neuen 5-minütigen ÖTO-konformen Puffer nach der Parcoursbesichtigung (§43).
|
||||
- **Frontend (Desktop):** Build-Fehler ("No matching variant") beim `funktionaer-feature` behoben; fehlendes `build.gradle.kts` mit JVM-Target und Compose/Koin-Abhängigkeiten ergänzt.
|
||||
- **Frontend (Desktop):** Massive Inkonsistenzen in der Paketstruktur des `veranstalter-feature` bereinigt; alle Komponenten (ViewModel, Screens, Mocks) auf das Standardpaket `at.mocode.frontend.features.veranstalter` konsolidiert, um Redeklarationen und Import-Fehler zu beheben.
|
||||
- **Frontend (Desktop):** Kompilierfehler im `VeranstalterDetailScreen` durch korrekte Paket-Referenzierung des `FakeVeranstaltungStore` gelöst.
|
||||
|
||||
### Dokumentation
|
||||
- **Masterdata/Docs:** `REITER_LIZENZEN.md` überarbeitet:
|
||||
- Strikte Sparten-Trennung dokumentiert (RD1..RD3 nur Dressur; R1..R4 nur Springen).
|
||||
- Dressur-Tabelle korrigiert (R-Lizenzen entfernen; RD-Pflicht je Klasse).
|
||||
- Validierungslogik ergänzt (2-stufig: Spartenlizenz → Max-Turnierklasse; R↔RD Mapping nur zur Kappung, nicht zur Eligibility).
|
||||
- Vielseitigkeit (CCN/CCI) ergänzt: kumulative Anforderungen (Dressur RD* UND Springen R* je Klasse); Startkartenregel für Einsteiger.
|
||||
- Fahren (CAN/CAI) ergänzt: aktueller Systemzustand ohne `F*`‑Lizenzen dokumentiert; Teilnahme über Startkarte/Ausschreibung, geplante Enum‑Erweiterung vermerkt.
|
||||
- §15‑Tabelle (kompakt) integriert und auf ÖTO‑Referenz verlinkt; Bedeutungen „B,C“ und „LP“ erläutert. Hinweis aufgenommen, dass `RD4` derzeit nicht als Enum vorhanden ist und wie `RD3` behandelt wird.
|
||||
- Kombinationsreihen gemäß §15 ergänzt: `R1S2`, `R1S3`, `R1S4`, `R2S3`, `R2S4`, `R3S4` (neuer Unterabschnitt 2.6 mit Tabelle, identische Spalten wie 2.5).
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Masterdata:** Qualifikations-Management für Funktionäre (Richter/Parcoursbauer) professionalisiert: Umstellung von unstrukturiertem Text auf offizielle ÖTO/FEI Master-Daten Referenzen (`QualifikationMasterTable`).
|
||||
- **Masterdata:** Fehlende Tabelle `funktionaer_qualifikation` in der Initialisierung beider Services (`masterdata` und `zns-import`) ergänzt, um `PSQLException` während des ZNS-Imports zu beheben.
|
||||
- **Infrastructure:** Start-Probleme des `masterdata-service` endgültig behoben: Port-Konflikt zwischen Spring Boot (Management/Actuator) und dem Gateway (8081) durch Umzug auf Port 8086 (gemäß Infrastruktur-Vorgaben) gelöst.
|
||||
- **Infrastructure:** Port-Konflikt im `masterdata-service` durch Trennung der Bind-Adressen (Spring: 127.0.0.1, Ktor: 0.0.0.0) und Bereinigung verwaister Prozesse stabilisiert.
|
||||
- **Core:** Veraltete `ZnsLegacyParsersTest.kt` entfernt; alle Tests sind nun in `ZnsParserTest.kt` konsolidiert.
|
||||
- **Domain:** Fehlschlagenden `LicenseMatrixServiceTest` behoben; fehlende `reiterLizenz`-Daten in Test-Reitern ergänzt und Fallback-Logik in `LicenseMatrixServiceImpl` für spartenübergreifende Lizenzen (z.B. Springlizenz für Dressur-Basis) stabilisiert.
|
||||
- **Infrastructure:** Fehlschlagenden `RegulationSeedVerificationTest` behoben; Testdaten an das neue Modell (`reiterLizenz` Feld) angepasst.
|
||||
- **Infrastructure:** Kompilierfehler 'Unresolved reference lizenzKlasse' in `ReiterExposedRepository` behoben; fehlendes Feld `lizenzKlasse` zu `ReiterTable` und Datenbank-Migration `V010` hinzugefügt.
|
||||
- **Onboarding:** `remember` → `rememberSaveable` für `geraetName`, `sharedKey`, `znsStatus` in `OnboardingScreen.kt` (
|
||||
Felder gingen bei Zurück-Navigation verloren)
|
||||
- **AbteilungsRegelService:** CSN-C-NEU Pflicht-Teilungslogik implementiert (≤95 cm: ohne/mit Lizenz; ≥100 cm: R1/R2+);
|
||||
`SparteE`-Import ergänzt
|
||||
|
||||
- Desktop-Packaging konfiguriert: `.deb` (Linux), `.msi` (Windows), `.dmg` (macOS)
|
||||
- Zentrale Versionsdatei `version.properties` (Single Source of Truth für SemVer)
|
||||
- Automatisches Git-Tagging via CI/CD (`release.yml` Gitea Actions Workflow)
|
||||
- `CHANGELOG.md` eingeführt (dieses Dokument)
|
||||
|
||||
---
|
||||
|
||||
## [1.0.6-SNAPSHOT] — 2026-04-10
|
||||
|
||||
### Hinzugefügt
|
||||
- **Entries-Domain:** Strukturiertes Abteilungs-Warnungssystem gemäß ÖTO § 39 implementiert.
|
||||
|
||||
@@ -13,12 +13,12 @@ Die gesamte Projektdokumentation (Architektur, Fachdomäne, Entwickler-Anleitung
|
||||
|
||||
**Starte hier:** [**→ docs/README.md**](./docs/README.md)
|
||||
|
||||
| Bereich | Inhalt |
|
||||
|-----------------------------------------------|---------------------------------------------------|
|
||||
| Bereich | Inhalt |
|
||||
|-----------------------------------------------|---------------------------------------------|
|
||||
| [01_Architecture](./docs/01_Architecture) | Master Roadmap, ADRs, C4‑Modelle, Desktop‑Konzept |
|
||||
| [02_Guides](./docs/02_Guides) | Setup-Anleitungen, Entwickler-Guidelines |
|
||||
| [03_Domain](./docs/03_Domain) | Fachlichkeit, Turnierregeln, Entities |
|
||||
| [07_Infrastructure](./docs/07_Infrastructure) | Docker, Keycloak, CI/CD, Zora-Infrastruktur |
|
||||
| [02_Guides](./docs/02_Guides) | Setup-Anleitungen, Entwickler-Guidelines |
|
||||
| [03_Domain](./docs/03_Domain) | Fachlichkeit, Turnierregeln, Entities |
|
||||
| [07_Infrastructure](./docs/07_Infrastructure) | Docker, Keycloak, CI/CD, Zora-Infrastruktur |
|
||||
|
||||
Wesentliche Architektur-Referenz: [Offline‑First Desktop & Backend (Kurzkonzept)](./docs/01_Architecture/konzept-offline-first-desktop-backend-de.md)
|
||||
|
||||
@@ -113,5 +113,3 @@ Beiträge sind willkommen. Bitte lies zunächst die Entwickler-Guides unter [`do
|
||||
## 📜 Lizenz
|
||||
|
||||
Dieses Projekt steht unter der [MIT License](LICENSE).
|
||||
|
||||
## Gitea - Test
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
name: "${PROJECT_NAME:-meldestelle}"
|
||||
|
||||
# services:
|
||||
# # ==========================================
|
||||
# # 3. FRONTEND (UI)
|
||||
# # ==========================================
|
||||
services:
|
||||
# ==========================================
|
||||
# 3. FRONTEND (UI)
|
||||
# ==========================================
|
||||
|
||||
# --- WEB-APP ---
|
||||
# web-app:
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
type: Roadmap
|
||||
status: ACTIVE
|
||||
owner: Lead Architect
|
||||
last_update: 2026-04-30
|
||||
last_update: 2026-04-21
|
||||
---
|
||||
|
||||
# MASTER ROADMAP: Meldestelle
|
||||
|
||||
🏗️ **[Lead Architect]** | 30. April 2026
|
||||
🏗️ **[Lead Architect]** | 20. April 2026
|
||||
|
||||
**Strategisches Ziel:**
|
||||
Entwicklung einer ÖTO-konformen, offline-fähigen Turnier-Meldestelle als Compose Desktop App (KMP).
|
||||
@@ -17,7 +17,7 @@ Vollständige Self-Hosted Infrastruktur (Gitea, Pangolin, Zora). Datensouveräni
|
||||
- Desktop-App ist der primäre Client (Compose Desktop, KMP) — „Desktop-First“ gilt für UX und Architektur.
|
||||
- Offline-First Betrieb mit lokaler Persistenz und opportunistischer Synchronisation.
|
||||
|
||||
**Aktueller technischer Stand (30.04.2026):**
|
||||
**Aktueller technischer Stand (30.03.2026):**
|
||||
* **Infrastruktur:** ✅ "Zora" (MS-R1, ARM64) ist live. Gitea & Registry laufen.
|
||||
* **Networking:** ✅ Pangolin Tunnel ersetzt Cloudflare.
|
||||
* **CI/CD:** ✅ Gitea Actions mit ARM64-Runner (VM 102) aktiv. Docker-Publish Pipeline grün.
|
||||
@@ -51,102 +51,337 @@ und über definierte Schnittstellen kommunizieren.
|
||||
|
||||
---
|
||||
|
||||
## 1. Abgeschlossene Phasen (Verifiziert)
|
||||
## 1. Abgeschlossene Phasen
|
||||
|
||||
### PHASE 1: Domain-Design & Ubiquitous Language ✅
|
||||
### PHASE 1: Documentation Cleanup ✅ ABGESCHLOSSEN
|
||||
*Ziel: Eine einzige, vertrauenswürdige Quelle der Wahrheit (SSOT) schaffen.*
|
||||
|
||||
*Status: Fachliche Grundlage vorhanden.*
|
||||
#### 🧹 Agent: Curator
|
||||
* [x] **Archivierung:** Veraltete Docs (Cloudflare, GitHub-Workflows, alte Roadmaps) nach `docs/_archive/` verschoben.
|
||||
* [x] **Konsolidierung:** `Zora_System_Architektur.md` als zentrale Infrastruktur-Doku etablieren.
|
||||
* [x] **Struktur:** `docs/` Ordner aufräumen (unnötige Root-Files in Unterordner).
|
||||
* [x] **Index:** `README.md` im Root als Einstiegspunkt aktualisieren.
|
||||
|
||||
* [x] **DDD-Analyse:** 6 Bounded Contexts (SCS-Architektur) definiert.
|
||||
* [x] **Terminologie:** `Veranstaltung` ≠ `Turnier` gemäß ÖTO § 2 Abs. 1 festgelegt.
|
||||
* [x] **Ubiquitous Language:** Offizielle Domänen-Terminologie erstellt.
|
||||
### PHASE 2: Infrastructure Hardening ✅ ABGESCHLOSSEN
|
||||
*Ziel: Stabilisierung der neuen Self-Hosted Umgebung.*
|
||||
|
||||
### PHASE 2: Infrastruktur & ZNS-Importer (Prototyp) ✅
|
||||
#### 🐧 Agent: DevOps Engineer
|
||||
|
||||
*Status: Grundgerüst steht, Performance-Probleme bekannt.*
|
||||
* [x] **Keycloak Fix:** Verbindungsprobleme innerhalb des Docker-Netzwerks behoben (Alias `auth.mo-code.at`).
|
||||
* [x] **Backup Strategy:** Automatisierte Backups für Gitea & Datenbanken auf Zora (`config/scripts/backup.sh`).
|
||||
* [x] **Monitoring:** Prometheus/Grafana Dashboard für Zora finalisiert (`dc-ops.yaml`).
|
||||
* [x] **Deployment:** Git-basiertes Deployment-Skript (`config/scripts/deploy.sh`) erstellt.
|
||||
|
||||
* [x] **ZNS-Parser:** Grundsätzliche Fähigkeit zum Parsen von ZNS-Daten (Reiter, Pferde, Vereine, Funktionäre).
|
||||
* [x] **UI-Gerüst:** Navigation und Dialog-Hüllen in Compose Desktop vorhanden.
|
||||
* [x] **Plan-B:** Rudimentärer Mail-Service für Nennungs-Versand (funktional).
|
||||
### PHASE 3: Domain-Design & Ubiquitous Language ✅ ABGESCHLOSSEN
|
||||
|
||||
*Ziel: Fachliche Grundlage für die Implementierung schaffen.*
|
||||
|
||||
#### 🏗️ Agent: Lead Architect
|
||||
|
||||
* [x] **DDD-Analyse:** 6 Bounded Contexts (SCS-Architektur) definiert und priorisiert.
|
||||
* [x] **Terminologie:** `Veranstaltung` ≠ `Turnier` gemäß ÖTO § 2 Abs. 1 festgelegt (ADR).
|
||||
* [x] **Design-Baseline:** Vision_03 (Figma) als offizieller Design-Baseline festgelegt.
|
||||
* [x] **Technologie:** Desktop-First-Strategie mit KMP/Compose Desktop beschlossen.
|
||||
|
||||
#### 📜 Agent: ÖTO/FEI Rulebook Expert
|
||||
|
||||
* [x] **Ubiquitous Language:** Offizielle Domänen-Terminologie mit ÖTO-Referenzen erstellt.
|
||||
* [x] **Abteilungs-Schwellenwerte:** Alle Trennungs-Schwellenwerte (§ 39 + spartenspezifisch) dokumentiert.
|
||||
→ `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md`
|
||||
|
||||
#### 👷 Agent: Backend Developer
|
||||
|
||||
* [x] **Enums:** `SparteE`, `TurnierkategorieE`, `VeranstaltungsTypE`, `LizenzKlasseE`, `NennungsStatusE`,
|
||||
`StartwunschE` – ÖTO-konform.
|
||||
* [x] **Domain-Modelle:** `DomReiter` (actor-context), `DomNennung`, `DomNennungsTransfer` (registration-context).
|
||||
* [x] **Modul:** `entries-domain` als KMP-Modul aufgesetzt und in `settings.gradle.kts` registriert.
|
||||
|
||||
---
|
||||
|
||||
## 2. Der neue Weg: Fachliche Realität (Roadmap 2026)
|
||||
## 2. Aktuelle Phase
|
||||
|
||||
Fokus: Physische Implementierung der Turnier-Hierarchie und technisches Onboarding.
|
||||
### PHASE 4: MVP-Implementierung ✅ ABGESCHLOSSEN
|
||||
|
||||
### MEILENSTEIN 0: Technische Geräte-Initialisierung (Prio 1) 🚧 IN ARBEIT (STABILISIERUNG)
|
||||
*Ziel: Lauffähiger MVP für `registration-context` und `actor-context` (P1-Contexts).*
|
||||
|
||||
*Ziel: Ein stabiles, offline-fähiges technisches Fundament für die Desktop-App.*
|
||||
#### 🧐 Agent: QA Specialist
|
||||
|
||||
* [x] **App-Icons (PNG/ICO):** Implementiert (Fix für Build-Fehler).
|
||||
* [x] **Docker-Fix:** "services must be a mapping" behoben (dc-gui.yaml).
|
||||
* [x] **Chat-Funktion (Desktop):** MVP implementiert (Navigation & UI).
|
||||
* [x] **Geführte Discovery ("Zero-Config"):** Master-Namen statt IPs, "Wait-State" für Clients.
|
||||
* [x] **Native FileDialogs:** Stabile Pfad-Auswahl für Plan-USB auf allen Systemen.
|
||||
* [x] **Handshake-Feedback:** Visuelle Signalisierung des Verbindungsstatus (Grün/Rot).
|
||||
* [x] **Client-Konfiguration:** Master kann nun Clients in der UI hinzufügen und bearbeiten.
|
||||
* [x] **Master-UX:** Konfiguration beim Start nicht mehr zwangsgesperrt.
|
||||
* [ ] **PoC Verifikation:** 🔴 **FEHLGESCHLAGEN** (Hardware-Test durch User nicht erfolgreich - Analyse für Abend-Session erforderlich).
|
||||
* [x] **Actor Context Stabilization:** Funktionär-Datenmodell (Richter/Parcoursbauer) auf professionelle Master-Daten-Referenzierung umgestellt und mit Reiter-Daten verknüpft (Import-Idempotenz via `satzNummer`). Redundante Kontakt-/Adressdaten entfernt. Alle ZNS-Import-Tests (9/9) stabilisiert und verwaiste Parser-Reste entfernt.
|
||||
* [x] **Masterdata Standardization:** Bereinigung und Standardisierung der Masterdaten-Tabellen (Mehrzahl-Konvention: `bundeslaender`, `funktionaers_qualifikationen`, `reit_lizenzen`, `turnier_klassen`).
|
||||
* [x] **ÖTO Data Seeding:** Umfassende Seeder für Funktionärs-Qualifikationen, Turnierklassen und Turnier-Sparten gemäß ÖTO implementiert. Einführung der Tabelle `turnier_sparten`.
|
||||
* [x] **ZNS-Import Optimization:** Automatische Verknüpfung von Funktionären mit Reitern (Reihenfolge: VEREIN -> LIZENZ -> PFERDE -> RICHT). `ZnsImportService` für sequentielle Imports in Tests gehärtet.
|
||||
* [x] **Service Stability:** Port-Konflikt des `masterdata-service` (Spring Management Port 8081 vs. Gateway) durch Umzug auf Port 8086 und explizite Bind-Adressen (Spring: 127.0.0.1, Ktor: 0.0.0.0) dauerhaft gelöst.
|
||||
* [x] **Documentation:** `CHANGELOG.md` aktualisiert und Port-Konfiguration in `application.yml` dokumentiert.
|
||||
→ Note: `IdempotencyApiIntegrationTest` bleibt vorerst @Disabled, da das Hochfahren des Spring-Contexts in der
|
||||
Testumgebung blockiert (unabhängig vom Plugin).
|
||||
→ Task: Integration-Test Umgebung (Port-Binding/Server-Lifecycle) für `masterdata-service` untersuchen.
|
||||
|
||||
### MEILENSTEIN 1: Die Basis-Hierarchie (Prio 1) ⚪ GEPLANT
|
||||
#### 🏗️ Agent: Lead Architect
|
||||
|
||||
*Ziel: Veranstaltung -> Turnier -> Bewerb/Abteilung physisch anlegen und speichern.*
|
||||
* [x] **ADRs vervollständigen:** Bounded Context Mapping und Context Map dokumentieren.
|
||||
→ `docs/01_Architecture/adr/0014-bounded-context-mapping-de.md`
|
||||
→ `docs/01_Architecture/adr/0015-context-map-de.md`
|
||||
* [x] **API-Design:** Schnittstellen zwischen den Contexts definieren (Anti-Corruption Layer).
|
||||
→ `docs/01_Architecture/adr/0016-api-design-acl-de.md`
|
||||
* [x] **ÖTO-Validation-Seeds:** Seed-Daten für Lizenz-Matrix und Altersklassen finalisiert (V008).
|
||||
|
||||
* [ ] **Persistenz-Layer:** Implementierung der lokalen Speicherung für alle Ebenen der Hierarchie.
|
||||
* [ ] **Turnier-Wizard (Echt):** Umstellung des Wizards von `println`-Dummies auf echte Datenbank-Transaktionen.
|
||||
* [ ] **Validierung:** Sicherstellen, dass Pflichtfelder (Turniernummer, Sparte) korrekt erfasst werden.
|
||||
#### 👷 Agent: Backend Developer
|
||||
|
||||
### MEILENSTEIN 2: ZNS-Performance & Daten-Qualität (Prio 2)
|
||||
* [x] **ZNS-Importer:** Support für Richter-Import (RICHT01.DAT) und Reiter-Refactoring (LIZENZ01.DAT) vervollständigt. ✓
|
||||
* [x] **Masterdata:** Qualifikations-System und Personen-Referenzen (Vereine, Bundesländer, Nationen) stabilisiert (Consul & MasterdataSeeder). ✓
|
||||
* [x] **Infrastruktur:** Service-Discovery (Consul) für alle Microservices (incl. Masterdata) aktiviert.
|
||||
* [x] **Infrastruktur:** Bean-Definitionen und Dependency-Injection im `zns-import-service` bereinigt.
|
||||
* [x] **Database:** Initialisierung der Funktionärs-Tabellen stabilisiert (PSQLException Fix).
|
||||
* [x] **`actor-context`:** Domain-Modelle für `Pferd`, `Funktionaer`, `Verein` implementiert.
|
||||
* [x] **`registration-context`:** `DomBewerb`, `DomAbteilung`, `DomStartliste` implementiert.
|
||||
* [x] **`event-management-context`:** `DomVeranstaltung`, `DomTurnier`, `DomAusschreibung` implementiert.
|
||||
* [x] **Persistenz:** Repository-Interfaces und erste DB-Migrationen (Flyway/Liquibase).
|
||||
* [x] **API:** REST-Endpunkte für Nennungs-Workflow (Kern-Use-Cases).
|
||||
* [x] **Infrastruktur-Stabilisierung:** Kompilierfehler in `masterdata-infrastructure` behoben.
|
||||
* [x] **Identity-Schnittstellen:** Endpunkte für ZNS-Linking über `identity-service` bereitgestellt.
|
||||
|
||||
*Ziel: Import der ZNS.zip in < 2 Minuten statt 30+ Minuten.*
|
||||
#### 🎨 Agent: Frontend Expert
|
||||
|
||||
* [ ] **Batch-Processing:** Umstellung des `ZnsImportService` auf Batch-Inserts (JDBC/Exposed).
|
||||
* [ ] **Fortschritts-Anzeige:** Reale Rückmeldung über den Import-Status in der UI.
|
||||
* [x] **KMP/Compose Desktop:** Projektstruktur aufgesetzt (`frontend/shells/meldestelle-desktop`).
|
||||
* [x] **Navigation:** Sidebar-Navigation gemäß Vision_03 implementiert (Veranstaltungen, Reiter, Pferde, Funktionäre,
|
||||
Meisterschaften, Cups).
|
||||
* [x] **Nennungs-Screen:** `TurnierDetailScreen` integriert `NennungsMaske` aus `nennung-feature` (Bewerbe-Tab ⭐).
|
||||
|
||||
### MEILENSTEIN 3: Richtverfahren & Bewerbs-Konfiguration (Prio 3)
|
||||
#### 📜 Agent: ÖTO/FEI Rulebook Expert
|
||||
|
||||
*Ziel: Bewerbe mit fachlich korrekten Regeln (ÖTO) anlegen.*
|
||||
* [x] **Voltigieren (CVN):** Abteilungs-Trennungsregeln aus B-Teil § 400 ff. ausgewertet (offene Frage #3 teilweise
|
||||
geklärt).
|
||||
→ B IV enthält keine eigenen Regeln – verweist auf OEPS-Reglement CVN. § 39 Abs. 1 gilt nicht für CVN. § 39 Abs.
|
||||
2 (> 80 Starter) gilt als Fallback.
|
||||
→ `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md` (Abschnitt 2.6)
|
||||
* [x] **Fahren (CAN):** Starter-Schwellenwerte jenseits der Reitertreffen-Regel geklärt (offene Frage #4 teilweise
|
||||
geklärt).
|
||||
→ B VII enthält keine eigenen Regeln – verweist auf OEPS-Reglement „Turnierordnung für Gespanne". § 39 Abs. 1 gilt
|
||||
nicht für CAN. § 39 Abs. 2 (> 80 Starter) gilt als Fallback. Einzige gesicherte Lizenzregel: § 850 Abs. 9 (F1+ bei
|
||||
Fahrertreffen).
|
||||
→ `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md` (Abschnitt 2.5)
|
||||
* [x] **Warn-Logik:** Spezifikation der `competition-context` Warn-Logik für Abteilungs-Schwellenwerte.
|
||||
→ `docs/03_Domain/02_Reference/OETO_Regelwerk/Warn-Logik-Spezifikation-competition-context.md`
|
||||
|
||||
* [ ] **Richtverfahren-Engine:** Validierungslogik für verschiedene Richtverfahren (Fehler/Zeit, Wertnoten).
|
||||
* [ ] **Abteilungs-Logik:** Automatische Teilungsvorschläge gemäß ÖTO § 39.
|
||||
#### 🧹 Agent: Curator & Lead Architect (ZNS-Importer)
|
||||
|
||||
* [x] **ZNS-Importer (MVP) – Phase 1 & 2:** `core:zns-parser` (KMP), `ZnsLegacyParsers` (alle 4 Dateitypen, CP850),
|
||||
`ZnsImportService` (Orchestrator, ZIP in-memory, Upsert), Unit-Tests grün.
|
||||
→ Detaillierte Planung: `docs/01_Architecture/Roadmap_ZNS_Importer.md`
|
||||
* [x] Backend-Infrastruktur & CP850 Parser (Phase 1 – Parser/Modul)
|
||||
* [x] Domain-Mapping & Upsert in DB (Phase 2)
|
||||
* [x] REST-API & Job-Management (Phase 1 – Controller/Job-Registry)
|
||||
* [x] Frontend-Integration mit File-Picker & Status-Polling (Phase 3)
|
||||
|
||||
---
|
||||
|
||||
## 3. Zukünftige Module (Status: Geplant / Vision)
|
||||
## 3. Initiative: Wizard-Orchestrator & Offline-Drafts (Q2/Q3 2026)
|
||||
|
||||
*Diese Module existieren derzeit nur als Konzepte oder leere Hüllen.*
|
||||
🏗️ Verantwortlich: Lead Architect · 🎨 Frontend · 🖌️ UI/UX · 👷 Backend · 🧐 QA · 🧹 Curator
|
||||
|
||||
* **Nennungs-Management:** Erfassung von Nennungen gegen den ZNS-Datenbestand.
|
||||
* **Zeitplan:** Dynamische Zeitplanung (Drag & Drop).
|
||||
* **Ergebnisse:** Erfassung von Wertungen und Platzierungsberechnung.
|
||||
* **Abrechnung:** Vollständiger `billing-context`.
|
||||
Ziel: Konsolidierung aller „Wizards“ auf ein deklaratives Orchestrierungs-Framework (Graph + Guards + Effects), vereinheitlichte Validierung und Offline-Draft-Fähigkeit inkl. Delta‑Sync. Desktop-first, tastaturbedienbar, testbar.
|
||||
|
||||
### 3.1 Kernbausteine
|
||||
- Orchestrator Runtime & DSL: `StepId`, `WizardContext`, `WizardState`, `Guard`, `Transition`, `StepEffects`.
|
||||
- WizardScaffold: Breadcrumb, Kontext-Chips, Footer mit Hotkeys (Enter/Shift+Enter/Alt+S), Fehler-Summary.
|
||||
- DraftStore: Autosave pro Step (`onLeave`), Resume, `flowVersion`, Konfliktanzeige.
|
||||
- DevTools: strukturierte Transition-Logs, Graph-Export (DOT/PlantUML).
|
||||
|
||||
Referenzen/Dokumente:
|
||||
- ADR‑0025: Wizard-Orchestrator (State‑Machine, DSL, Guards, Effects) → `docs/01_Architecture/adr/0025-wizard-orchestrator-de.md`
|
||||
- ADR‑0026: Step-Validation-Policy (sync vs. async, Fehlersichtbarkeit, Hotkeys) → `docs/01_Architecture/adr/0026-validation-policy-de.md`
|
||||
- ADR‑0027: Draft-Domain & Delta‑Sync (Versionierung, Konfliktlösung, Idempotenz) → `docs/01_Architecture/adr/0027-draft-domain-and-delta-sync-de.md`
|
||||
- Reference: Wizard‑DSL README (Beispiel-Flow Event) → `docs/01_Architecture/Reference/Wizard-DSL-README.md`
|
||||
|
||||
### 3.2 Migrationsstrategie (Strangler)
|
||||
1) Parallelbetrieb: Neuer Orchestrator in `frontend/core/wizard`; bestehende VMs delegieren schrittweise.
|
||||
2) Inkrement 1: Event‑Flow – zunächst 2 Steps (ZNS_CHECK, VERANSTALTER_SELECTION), dann alle 6 Steps.
|
||||
3) Feature‑Flag `WizardRuntimeEnabled` für risikoarmen Rollout.
|
||||
|
||||
### 3.3 Phasenplanung (Auszug)
|
||||
- Phase 1 (Core & Tooling, 2–3 Wochen): Runtime/DSL, DevLogs, Graph‑Export, Scaffold‑MVP, Unit‑Tests.
|
||||
- Phase 2 (Event‑Flow, 2–3 Wochen): `EventStep/Acc/Guards`, Flow‑DSL, VM‑Delegation, Validierung, Autosave/Resume.
|
||||
- Phase 3 (Backend, 2–4 Wochen): Draft-/Validate‑APIs, Offline‑Queue, Delta‑Sync für Turniere.
|
||||
- Phase 4 (Skalierung, 6–10 Wochen, parallel): Weitere Flows je Bounded Context.
|
||||
- Phase 5–7 (2–3 + 1–2 + 1–2 Wochen): UX‑Härtung, Observability/Rollout‑Gates, Stabilisierung & Abschaltung Altlogik.
|
||||
|
||||
Grobe Gesamtdauer: 17–29 Wochen je nach Parallelisierung.
|
||||
|
||||
### 3.4 Akzeptanzkriterien (DoD Initiative)
|
||||
- Alle priorisierten Flows laufen über Orchestrator; Next/Back/History deterministisch; Graph‑Export aktuell.
|
||||
- DraftStore produktiv; Resume deterministisch; Delta‑Sync idempotent; Konflikte nicht‑blockierend sichtbar.
|
||||
- Validierungs‑Policy konsistent; Tastatur‑Bedienung vollständig; Performance‑Gates eingehalten.
|
||||
- ADR‑0025/0026/0027 veröffentlicht; Wizard‑DSL‑Reference vorhanden; CI grün; Metriken/Alerts aktiv.
|
||||
|
||||
### 3.5 10‑Tage‑Startplan
|
||||
- Tag 1–2: Runtime/DSL‑Skelett, Scaffold‑MVP, Feature‑Flag, README Skeleton.
|
||||
- Tag 3: EventStep/Acc/Guards, EventFlow (2 Steps), VM‑Delegation minimal.
|
||||
- Tag 4: Tests Runtime/Guards, Graph‑Export, Dev‑Logs.
|
||||
- Tag 5–6: META_DATA/ANSPRECHPERSON migrieren, Validierungs‑API, Fehler‑Summary.
|
||||
- Tag 7: DraftStore lokal (Autosave/Resume), Property‑Test Resume.
|
||||
- Tag 8: TURNIER_ANLAGE einbetten, Sync via `onComplete`.
|
||||
- Tag 9: SUMMARY + Finalisierung, Offload in Offline‑Queue (Stub).
|
||||
- Tag 10: ADR‑0025/0026/0027 Review+Merge; Journal‑Eintrag.
|
||||
|
||||
Journal: `docs/99_Journal/2026-04-21_Wizard-Orchestrator_Roadmap_Anchoring.md`
|
||||
|
||||
---
|
||||
|
||||
## 4. Archiv der fiktiven Meilensteine (Reality-Check am 28.04.2026)
|
||||
## 3. Aktuelle Phase
|
||||
|
||||
*Die folgenden Einträge wurden in früheren Sitzungen als "Abgeschlossen" markiert, entsprachen aber nicht dem realen
|
||||
Code-Stand.*
|
||||
### Progress Checkpoint – 2026-04-21 (Wizard-Orchestrator Initiative)
|
||||
|
||||
<details>
|
||||
<summary>Frühere (fiktive) Meilensteine anzeigen</summary>
|
||||
- Core/DSL: angelegt in `frontend/core/wizard` (Runtime, DSL), erste Tests grün.
|
||||
- UI: `WizardScaffold` (MVP) + Hotkeys-Wrapper (Enter/Shift+Enter/Alt+S) vorhanden.
|
||||
- Feature-Integration: Veranstaltungs-Wizard hinter Flag teilweise delegiert.
|
||||
- Drafts: In‑Memory DraftStore (Autosave/Resume Hooks) angebunden.
|
||||
- DI: Koin-Parameterübergabe für `EventWizardViewModel` vereinheitlicht.
|
||||
- Flag: `WizardRuntimeEnabled = false` (Standard AUS; Dev-Verprobung manuell).
|
||||
|
||||
### PHASE 4: MVP-Implementierung (Früherer Status: Abgeschlossen)
|
||||
Nächste Schritte (Kurz): Tests für `needsContactPerson` (beide Zweige), VM‑Delegation für weitere Steps, Footer‑Fehler‑Summary, persistenter DraftStore, Dev‑Overlay.
|
||||
|
||||
* [ ] ZNS-Import Optimierung (Batching) -> In MEILENSTEIN 2 verschoben.
|
||||
* [ ] `event-management-context` Implementierung -> In MEILENSTEIN 1 verschoben.
|
||||
### PHASE 5: P2-Contexts & Integration ✅ ABGESCHLOSSEN
|
||||
|
||||
### PHASE 5-13 (Früherer Status: Abgeschlossen/In Arbeit)
|
||||
*Ziel: `competition-context` und `event-management-context` implementieren.*
|
||||
|
||||
* [ ] `competition-context` (Bewerbe, Startlisten) -> Fachlogik fehlt weitgehend.
|
||||
* [ ] `billing-context` (Abrechnung) -> Nur Infrastruktur-Hülle vorhanden.
|
||||
* [ ] `results-service` -> Geschäftslogik fehlt.
|
||||
* [x] **`competition-context`:** Bewerbe, Startlisten, Ergebnisse, Abteilungs-Warn-Logik.
|
||||
* [x] **`event-management-context`:** Veranstaltungs- und Turnier-Verwaltung, Ausschreibungs-Generator.
|
||||
* [x] **ZNS-Integration:** Schnittstelle zum Zentralen Nennungs-System (A-Satz / B-Satz).
|
||||
* [x] **Offline-Sync:** Offline-First-Strategie für Desktop-App implementieren.
|
||||
|
||||
</details>
|
||||
### PHASE 6: P3-Contexts & Billing ✅ ABGESCHLOSSEN
|
||||
|
||||
*Ziel: `billing-context` und `identity-context` implementieren.*
|
||||
|
||||
* [x] **`billing-context`:** Gebührenberechnung, Kassa, Abrechnung.
|
||||
* [x] **`identity-context`:** Rollen-Modell (TBA, Veranstalter, Richter etc.) mit Keycloak.
|
||||
* [x] **Reporting:** Startlisten- und Ergebnislisten-Druck (PDF).
|
||||
|
||||
---
|
||||
|
||||
## 4. Geplante Phasen
|
||||
|
||||
### PHASE 7: Desktop-Vernetzung & Zentrale Verwaltung ✅ ABGESCHLOSSEN
|
||||
|
||||
*Ziel: LAN-Kommunikation Vorbereitung und Etablierung der "Veranstaltung-Verwaltung" als zentrale Schaltstelle.*
|
||||
|
||||
* [x] **Zentrale Verwaltung:** Etablierung der `Veranstaltung-Verwaltung` (Zentrale) als administratives Cockpit.
|
||||
* [x] **Navigation:** Implementierung eines Back-Stack-Systems für intelligente "Zurück"-Navigation.
|
||||
* [x] **Domänen-Synchronisation:** Anpassung der Frontend-Stores an die Backend-Masterdata-Modelle (Reiter, Pferde,
|
||||
Vereine, Funktionäre).
|
||||
* [x] **ZNS-Integration (Frontend):** ZNS-Importer in die Zentrale integriert; Konzept "Globaler Pool -> Lokale
|
||||
Synchronisation" gefestigt.
|
||||
* [x] **Terminologie:** UI-weit Umstellung von "Event" auf "Veranstaltung" (ÖTO-konform).
|
||||
* [x] **Konzept:** LAN-Discovery (mDNS) und Echtzeit-Sync (WebSockets) entworfen.
|
||||
* [x] **ADR:** ADR-0020 (Lokale Netzwerk-Kommunikation) erstellt.
|
||||
|
||||
### PHASE 8: Bewerbe-Management & Startlisten ✅ ABGESCHLOSSEN
|
||||
|
||||
*Ziel: Fachliche Tiefe in den Turnieren (Import, Generierung, Zeitberechnung).*
|
||||
|
||||
* [x] **Konzept/ADR:** LAN‑Sync (ADR‑0022) und Offline‑First Desktop↔Backend Konzept definiert und verlinkt.
|
||||
* [x] **Bewerbe-Import:** Implementierung der Merge-Logik (ZNS-XML -> BewerbUiModel). ✓
|
||||
* [x] **Startlisten-Automatisierung:** Generierung und Zeitberechnung (Pausen, Umbauzeiten). ✓
|
||||
* [x] **Discovery:** Implementierung des mDNS-Service (JmDNS) für die Geräte-Suche. ✓
|
||||
* [x] **Transport:** Aufbau der WebSocket-Infrastruktur für P2P-Sync (Ktor WebSockets, SyncManager). ✓
|
||||
* [x] **Offline-First Desktop↔Backend:** Umsetzung gemäß Konzept „Offline-First Synchronisation (Desktop ↔ Backend)“ → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md`. ✓
|
||||
* [x] **Regelwerks-Validierung:** Implementierung des strukturierten Abteilungs-Warnungssystems gemäß ÖTO § 39 inkl. UI-Integration. ✓
|
||||
|
||||
### PHASE 9: Zeitplan-Optimierung & Protokollierung ✅ ABGESCHLOSSEN
|
||||
|
||||
*Ziel: Dynamische Zeitplan-Anpassungen, Protokollierung von Änderungen und Export-Funktionen.*
|
||||
|
||||
* [x] **Billing-Service:** Initialisierung, Teilnehmer-Konten & Buchungs-Logik (v1). ✓
|
||||
* [x] **Entries-Integration:** Automatische Buchung von Nenngeldern bei Nennungs-Abgabe. ✓
|
||||
* [x] **ZNS-Importer:** Hardening & Integrationstests für Funktionärs-Updates. ✓
|
||||
* [x] **Konzept:** Fachliches Konzept für Zeitplan-Optimierung (Drag & Drop) erstellt. ✓
|
||||
* [x] **Konzept:** Status-Automat für Nennungen & Zeitplan-Synchronisation spezifiziert. ✓
|
||||
* [x] **Frontend-Standardisierung:** `nennung-feature` refactored und in Desktop-Shell integriert. ✓
|
||||
* [x] **Rulebook-Check:** ÖTO §43 "Parcoursbesichtigung zu Pferd" eingearbeitet. ✓
|
||||
* [x] **Feature-Migration:** Pferde-, Reiter-, Funktionärs- und Veranstalter-Module vollständig auf KMP umgestellt. ✓
|
||||
* [x] **Cleanup:** `FRONTEND_CLEANUP_TODO.md` für Migration von `v2` Screens weitestgehend abgeschlossen. ✓
|
||||
* [x] **Zeitplan:** Dynamische Verschiebung von Bewerben (Drag & Drop im Kalender). ✓
|
||||
* [x] **Protokoll:** Implementierung eines Event-Logs für manuelle Eingriffe in Startlisten (Audit-Log). ✓
|
||||
* [x] **Export:** Startlisten-Export für ZNS (XML-B-Satz). ✓
|
||||
|
||||
### PHASE 10: Series-Context & Stammdaten ✅ ABGESCHLOSSEN
|
||||
|
||||
*Ziel: Stammdaten-Integration (Reiter, Pferde, Funktionäre) und Series-Context (Cups).*
|
||||
|
||||
* [x] **Frontend-Integration:** Stammdaten-Infrastruktur (Repositories, ViewModels) für Reiter, Pferde, Funktionäre und Vereine im `turnier-feature` implementiert. ✓
|
||||
* [x] **Nennungs-Management:** Funktionalisierung des Nennungs-Tabs mit Echt-Datenanbindung und Suche. ✓
|
||||
* [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.*
|
||||
|
||||
* [x] **Backend (Results):** Implementierung der Geschäftslogik für Wertnoten, Zeitfehler und ÖTO-konforme Platzierungen. ✓
|
||||
* [x] **Backend-Infrastruktur:** `results-service` in Docker-Compose und API-Gateway integriert; Service-Discovery via Consul aktiviert. ✓
|
||||
* [x] **Frontend UI:** `ErgebnisEditDialog` zur schnellen Ergebniserfassung und `TurnierErgebnislistenTab` zur Anzeige realer Ergebnisse. ✓
|
||||
* [x] **PDF-Export:** Generierung von PDF-Ergebnislisten (Bewerbe und Serien). ✓
|
||||
|
||||
### PHASE 12: Abrechnung & Billing 🔵 IN ARBEIT
|
||||
|
||||
*Ziel: Vollständige Kassa-Funktionalität und Turnier-Abrechnung.*
|
||||
|
||||
* [x] **Backend-Infrastruktur:** `billing-service` initialisiert, Docker-Integration und Gateway-Routing (Port 8087) konfiguriert. ✓
|
||||
* [x] **Frontend-Anbindung:** `BillingRepository` (Ktor) und `BillingViewModel` auf reale API-Kommunikation umgestellt. ✓
|
||||
* [x] **Buchungs-Logik:** Implementierung von Soll/Haben-Buchungen (Startgebühren, Nenngelder, Boxen). ✓
|
||||
* [x] **Offene Posten:** Liste aller unbezahlten Beträge pro Teilnehmer/Pferd. ✓
|
||||
* [x] **Rechnungserstellung:** Generierung von PDF-Rechnungen und Zahlungsbestätigungen. ✓
|
||||
* [x] **Kassa-Management:** Tagesabschluss, Storno-Logik und verschiedene Zahlungsarten. ✓
|
||||
|
||||
---
|
||||
|
||||
## 4. Geplante Phasen
|
||||
|
||||
### PHASE 13: Frontend-Modernisierung & Cleanup ✅ ABGESCHLOSSEN (20. April 2026)
|
||||
*Ziel: Finalisierung der Turnier-Daten, Rückübermittlung an den OEPS und architektonische Bereinigung.*
|
||||
|
||||
* [x] **"V2"-Bereinigung:** Vollständige Eliminierung aller "V2"-Suffixe in Dateinamen und Symbolen (z.B. `TurnierWizardV2`, `VeranstalterAuswahlV2`). ✓ (20. April 2026)
|
||||
* [x] **Plug-and-Play (Turnier):** Umstellung des `turnier-feature` auf ADR-0024. Entfernung von Reflection-Zugriffen auf die Shell und Einführung von ViewModel-Hoisting. ✓ (20. April 2026)
|
||||
* [x] **Plug-and-Play (Veranstalter):** Umstellung des `veranstalter-feature` auf ADR-0024. Einführung des `VeranstalterDetailViewModel` und Konsolidierung der Screens in der Desktop-Shell. ✓ (20. April 2026)
|
||||
* [x] **Device-Setup ("Lock-and-Edit"):** Einführung eines Review-Modus mit Konfigurations-Sperre, Drucker-Integration und Maskierung des SharedKeys. ✓ (20. April 2026)
|
||||
* [x] **Veranstaltungs-Wizard:** Implementierung eines 6-stufigen Profi-Workflows mit Sticky Preview-Card (WYSIWYG), ZNS-Guard und OEPS-Satznummer-Mapping. ✓ (20. April 2026)
|
||||
* [x] **Code-Hygiene:** Beseitigung von Code-Smells, redundanten Validierungen und ungenutzten Parametern in den zentralen Frontend-Modulen. ✓ (20. April 2026)
|
||||
* [x] **Connectivity-Diagnose:** Stabiles Diagnose-Tool für Backend-, DB- und Auth-Verbindung in der Desktop-App. ✓ (18. April 2026)
|
||||
* [x] **WASM-Transition:** Projektweite Umstellung auf JVM (Desktop) und wasmJs (Web). Eliminierung von `js(IR)`. ✓ (18. April 2026)
|
||||
* [x] **Geräte-Initialisierung:** Refactoring des Onboarding-Prozesses in das Plug-and-Play Modul `device-initialization`. ✓ (18. April 2026)
|
||||
* [ ] **XML-Export:** Vollständiger B-Satz Export (inkl. Ergebnisse und Platzierungen).
|
||||
* [ ] **ZNS-Portal:** Upload-Integration in das OEPS-ZNS.
|
||||
* [ ] **Archivierung:** Langzeit-Archivierung abgeschlossener Turniere.
|
||||
|
||||
---
|
||||
|
||||
## 5. Wichtige Architektur-Entscheidungen (ADRs)
|
||||
|
||||
| # | Entscheidung | Status | Dokument |
|
||||
|----|--------------------------------------------------------------|--------|------------------------------|
|
||||
| 1 | Vision_03 = Design-Baseline | ✅ | Session Log 2026-03-24 |
|
||||
| 2 | Desktop-First mit KMP/Compose Desktop | ✅ | ADR-0009 |
|
||||
| 3 | `Veranstaltung` ≠ `Turnier` (ÖTO § 2 Abs. 1) | ✅ | Ubiquitous Language |
|
||||
| 4 | 6 Bounded Contexts als SCS-Architektur | ✅ | Session Log 2026-03-24 |
|
||||
| 5 | `series-context` ist Phase 2+ (Architektur vorbereitet) | ✅ | Session Log 2026-03-24 |
|
||||
| 6 | Cups/Serien benötigen konfigurierbare Reglements | ✅ | Session Log 2026-03-24 |
|
||||
| 7 | Warn-Logik statt harter Fehler (Override-Event) | ✅ | Abteilungs-Schwellenwerte.md |
|
||||
| 8 | 6 Bounded Contexts: Mapping & Aggregate Roots | ✅ | ADR-0014 |
|
||||
| 9 | Context Map: Integration Patterns & ACL-Strategie | ✅ | ADR-0015 |
|
||||
| 10 | API-Design & ACL: Ports, DTOs, REST-Endpunkte, Domain Events | ✅ | ADR-0016 |
|
||||
| 11 | Masterdata: Importer-Einbettung als Worker | ✅ | ADR-0017 |
|
||||
| 12 | Masterdata: Rule-Versionierung (Regulation-as-Data) | ✅ | ADR-0018 |
|
||||
| 13 | Masterdata: API-Schichten (REST vs. Ingestion) | ✅ | ADR-0019 |
|
||||
| 14 | Lokale Netzwerk-Kommunikation und Daten-Isolierung | ✅ | ADR-0020 |
|
||||
| 15 | Masterdata: Observability & Operations | ✅ | masterdata-ops.md, CHANGELOG |
|
||||
| 16 | Tenant-Resolution: Schema-per-Tenant | ✅ | ADR-0021 |
|
||||
| 17 | LAN-Sync-Protokoll (Lamport-Uhren, Event-Sourcing Light) | ✅ | ADR-0022 |
|
||||
| 18 | Domain-Naming: Kein `Dom`-Präfix für Entitäten | ✅ | ADR-0023 |
|
||||
| 19 | Plug-and-Play Architektur für UI-Komponenten | ✅ | ADR-0024 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Wichtige Referenzen
|
||||
|
||||
@@ -162,10 +397,6 @@ Code-Stand.*
|
||||
| CI/CD | `.gitea/workflows/docker-publish.yaml` |
|
||||
| Agent Playbooks | `docs/04_Agents/Playbooks/` |
|
||||
| ADR-Verzeichnis | `docs/01_Architecture/adr/` |
|
||||
| ADR-0025: Plan-USB | `docs/01_Architecture/adr/0025-plan-usb-offline-integritaet.md` |
|
||||
| ADR-0026: Lizenzierung | `docs/01_Architecture/adr/0026-offline-lizenzierung-pay-per-event.md` |
|
||||
| ADR-0027: Discovery | `docs/01_Architecture/adr/0027-netzwerk-discovery-interface-binding.md` |
|
||||
| Docker-Fix: dc-gui.yaml | `dc-gui.yaml` |
|
||||
| ZNS-Importer Roadmap | `docs/01_Architecture/Roadmap_ZNS_Importer.md` |
|
||||
| Masterdata Roadmap | `backend/services/masterdata/docs/ROADMAP.md` |
|
||||
| Masterdata Changelog | `backend/services/masterdata/docs/CHANGELOG.md` |
|
||||
@@ -173,3 +404,50 @@ Code-Stand.*
|
||||
| Zeitplan-Optimierung | `docs/01_Architecture/konzept-zeitplan-optimierung-de.md` |
|
||||
| Parcoursbesichtigung-Rulebook | `docs/01_Architecture/rulebook-check-parcoursbesichtigung-de.md` |
|
||||
| Status-Automat-Nennungen | `docs/01_Architecture/status-automat-nennungen-de.md` |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3. Zukünftige Phase (April 2026)
|
||||
|
||||
### PHASE 5: Web-App & Neumarkt-Vorbereitung 🔵 IN ARBEIT (Start 13. April 2026)
|
||||
|
||||
*Ziel: Fertigstellung der Web-App für Online-Nennungen und Vorbereitung des Neumarkt-Turniers (24. April).*
|
||||
|
||||
#### 🎨 Agent: Frontend Expert
|
||||
* [x] **Web-App Shell:** Modul `frontend:shells:meldestelle-web` (Compose WasmJS) initialisiert.
|
||||
* [x] **UI-Komponenten:** `VeranstaltungsCard` und `TurnierCard` für Web implementiert (mit PDF- & Nenn-Button).
|
||||
* [x] **Workflow:** `NennungWebFormular` Prototyp erstellt (mit simuliertem Mail-Versand).
|
||||
|
||||
#### 👷 Agent: Backend Developer
|
||||
* [x] **Daten-Seeding:** Desktop-Stores mit echten Daten für Neumarkt (April 2026) vorbefüllt.
|
||||
* [ ] **Mail-Service:** Integration eines E-Mail-Dienstes für eingehende Nennungen.
|
||||
|
||||
#### 🧐 Agent: QA Specialist
|
||||
* [x] **Verifikation:** Desktop-Screens (Veranstalter, Turnier, Bewerbe) mit echten Daten geprüft.
|
||||
* [ ] **End-to-End Test:** Online-Nennung (Web) -> E-Mail -> Desktop-Verarbeitung.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 5: Desktop-Zentrale & Synchronisation 🔵 IN ARBEIT
|
||||
|
||||
*Ziel: Ein einsatzbereiter Desktop-Client für das Neumarkt-Turnier.*
|
||||
|
||||
#### 🎨 Agent: Frontend Expert
|
||||
|
||||
* [x] **Onboarding UI:** Implementierung des Onboarding-Screens (Name, Key, Backup, Rolle, Sync, Drucker) mit
|
||||
validierten Eingaben.
|
||||
* [x] **Navigation:** Navigations-Rail mit Hover-Tooltips und dedizierten Icons für "Setup" und "Sync".
|
||||
* [x] **Settings:** Persistente Speicherung der Onboarding-Daten in `settings.json`.
|
||||
|
||||
#### 👷 Agent: Backend Developer
|
||||
|
||||
* [x] **Device Management:** Domain-Modell (`Device`), Tabelle (`identity_devices`) und Repository zur
|
||||
Geräteverwaltung implementiert.
|
||||
* [x] **Security Key Auth:** Implementierung des `DeviceSecurityFilter` zur Authentifizierung via `X-Security-Key`
|
||||
Header.
|
||||
* [x] **Onboarding API:** REST-Endpunkte zur Registrierung und Abfrage von Desktop-Instanzen erstellt.
|
||||
|
||||
#### 🧹 Agent: Curator
|
||||
|
||||
* [x] **Dokumentation:** Erstellung der Architektur-Doku für das Onboarding-Backend.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# ADR-0025: "Plan-USB" & Offline-Datenintegrität
|
||||
|
||||
## Status
|
||||
In Prüfung (Wartet auf PoC)
|
||||
|
||||
## Kontext
|
||||
Im professionellen Turniersport ist eine stabile Netzwerkverbindung (LAN/WLAN) nicht immer garantiert. Ein Ausfall des Netzwerks darf den laufenden Betrieb (Ergebniserfassung, Meldestelle) nicht blockieren. Zudem müssen sensible Reiter- und Pferdedaten (DSGVO) auch auf physischen Datenträgern geschützt sein.
|
||||
|
||||
## Entscheidung
|
||||
Wir führen die "Plan-USB" Strategie als primären Fallback und parallelen Sicherungsmechanismus ein.
|
||||
|
||||
1. **Permanenter Delta-Export:** Der Master-PC schreibt kontinuierlich verschlüsselte Delta-Pakete (JSON-basiert) in ein definiertes Backup-Verzeichnis. Dies ist bereits in der UI als Pfad-Option vorbereitet.
|
||||
2. **Verschlüsselung:** Alle Daten auf dem USB-Stick werden mit dem `Shared Key` (AES-256) verschlüsselt. Der Benutzer legt diesen Schlüssel einmalig während der Initialisierung fest.
|
||||
3. **Datenintegrität:** Pakete werden signiert, um Manipulationen durch Texteditoren zu verhindern.
|
||||
4. **Sync-Vorschau:** Die UI bietet eine visuelle Bestätigung ("Sync-Dashboard"), welche Daten zuletzt erfolgreich auf den Stick geschrieben wurden. (Umgesetzt im UI-Design der Initialisierung).
|
||||
5. **Manueller Not-Import:** Clients erhalten eine Funktion, um Delta-Pakete manuell von einem Stick einzulesen und eigene Ergebnisse dorthin zurückzuschreiben.
|
||||
|
||||
## Konsequenzen
|
||||
- Erhöhte Komplexität in der Sync-Logik (Hybrid-Modus: Netzwerk + Datei).
|
||||
- Benutzer muss initial einen `Shared Key` festlegen.
|
||||
- Rechtliche Absicherung bei Verlust von Hardware durch Verschlüsselung.
|
||||
- Maximale Ausfallsicherheit: Das Turnier kann rein via USB-Stick ("Turnschuh-Netzwerk") zu Ende geführt werden.
|
||||
@@ -1,23 +0,0 @@
|
||||
# ADR-0026: Offline-Lizenzierung ("Pay-per-Event")
|
||||
|
||||
## Status
|
||||
Vorgeschlagen
|
||||
|
||||
## Kontext
|
||||
Die Software wird als Service pro Veranstaltung lizenziert. Da die App primär offline betrieben wird (Meldestelle am Turnierplatz), kann keine permanente Online-Verbindung zur Lizenzprüfung vorausgesetzt werden.
|
||||
|
||||
## Entscheidung
|
||||
Wir implementieren ein ticketbasiertes Offline-Lizenzmodell.
|
||||
|
||||
1. **Online-Erwerb:** Der Veranstalter kauft ein "Event-Ticket" über das zentrale Web-Backend.
|
||||
2. **Lizenz-Datei:** Das Backend generiert eine digital signierte Lizenz-Datei (`.mlic`). Diese enthält:
|
||||
- Veranstalter-Identität (OEPS-Nummer).
|
||||
- Gültigkeitszeitraum (Von-Bis).
|
||||
- Event-Typ (z.B. CSN-B*).
|
||||
3. **Offline-Aktivierung:** Im `EventWizard` der Desktop-App wird die Lizenz-Datei hochgeladen. Die App validiert die Signatur gegen unseren Public-Key (völlig offline).
|
||||
4. **Hardware-Fingerprint:** Die Lizenz wird beim ersten Import an die Hardware-ID des Master-PCs gebunden, um unkontrollierte Vervielfältigung zu verhindern.
|
||||
|
||||
## Konsequenzen
|
||||
- Benutzer muss einmalig (vor dem Turnier) Internetzugang haben, um die Lizenzdatei herunterzuladen.
|
||||
- Keine Abhängigkeit von Server-Verfügbarkeit während des Turniers.
|
||||
- Sicherer Schutz unseres Geschäftsmodells ohne Gängelung des ehrlichen Nutzers.
|
||||
@@ -1,20 +0,0 @@
|
||||
# ADR-0027: Netzwerk-Discovery & Interface-Binding
|
||||
|
||||
## Status
|
||||
In Prüfung (Wartet auf PoC)
|
||||
|
||||
## Kontext
|
||||
Desktop-Rechner auf Turnieren sind oft mit mehreren Netzwerken gleichzeitig verbunden (z.B. LAN für das Turnier-Netzwerk, WLAN für Internet-Hotspot). Automatische Discovery-Dienste (JmDNS) wählen ohne explizite Konfiguration oft das falsche Interface, wodurch sich Clients und Master nicht finden.
|
||||
|
||||
## Entscheidung
|
||||
Wir führen ein explizites Netzwerk-Management für die Initialisierung ein.
|
||||
|
||||
1. **Interface-Selektion:** Der Benutzer muss bei der technischen Initialisierung explizit wählen, über welches Netzwerk-Interface (IP-Adresse/Adapter) die App kommunizieren soll. Die UI zeigt hierfür benutzerfreundliche Namen (WLAN, Ethernet) an.
|
||||
2. **Geführte Discovery:** Sobald ein Interface gewählt ist, startet ein "Radar-Modus". Dieser scannt aktiv via JmDNS nach vorhandenen Master-Geräten.
|
||||
3. **Adaptive Rolle:** Findet die Discovery einen Master, wird dem Benutzer die Rolle "Client" vorgeschlagen. Die UI bleibt jedoch flexibel für manuelle Rollenwechsel.
|
||||
4. **Fokus-Management:** Nach Auswahl der Rolle wird der Fokus automatisch in das erste relevante Eingabefeld (Gerätename) gesetzt, um einen reibungslosen Workflow zu ermöglichen.
|
||||
|
||||
## Konsequenzen
|
||||
- Verhindert "Geistersuchen" im falschen Netzwerk.
|
||||
- Erhöht die Benutzerfreundlichkeit durch automatische Vorschläge.
|
||||
- Erfordert Zugriff auf System-Netzwerk-APIs in der Desktop-Shell.
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
type: Guide
|
||||
status: ACTIVE
|
||||
owner: Lead Architect
|
||||
last_update: 2026-04-28
|
||||
---
|
||||
|
||||
# Git Branching & Deployment Strategy (Meldestelle)
|
||||
|
||||
Um parallele Weiterentwicklung und stabile Feld-Tests zu ermöglichen, nutzen wir einen vereinfachten **GitHub Flow** mit Release-Tags. Da wir ein kleines Team (bzw. Solo-Entwickler mit KI-Agents) sind, verzichten wir auf übermäßig komplexe Git-Flow-Modelle (wie `develop`, `release/*`, `hotfix/*`), stellen aber Stabilität für Deployments sicher.
|
||||
|
||||
## 1. Branching-Struktur
|
||||
|
||||
### `main` (Source of Truth / Production)
|
||||
* **Zweck:** Enthält *immer* den aktuellen, stabilen und im Feld getesteten/auslieferbaren Code.
|
||||
* **Regel:** Direkte Commits auf `main` sind tabu (außer Notfall-Hotfixes).
|
||||
* **Deployment:** Ein Push/Merge auf `main` bedeutet **nicht** zwingend ein sofortiges Deployment auf Zora, aber der Code ist *bereit* dafür.
|
||||
|
||||
### Feature Branches (`feature/*` oder `fix/*`)
|
||||
* **Zweck:** Hier findet die eigentliche Entwicklung statt (z.B. neue Bounded Contexts, Wizards).
|
||||
* **Namenskonvention:** `feature/event-wizard-neu`, `fix/zns-import-bug`
|
||||
* **Lebensdauer:** So kurz wie möglich. Sobald ein Feature/Fix *in sich geschlossen* und lokal getestet ist, wird ein Pull Request (PR) auf `main` erstellt.
|
||||
|
||||
### Release Tags (`v1.x.x`)
|
||||
* **Zweck:** Markiert einen spezifischen, stabilen Punkt auf dem `main`-Branch, der tatsächlich für ein Turnier (Feld-Test) deployed wurde.
|
||||
* **Szenario:** Du hast Version `v1.2.0` (Plan-B) für ein Turnier deployed. Du entwickelst weiter auf `feature/*` und mergest in `main`. Das nächste Turnier bekommt dann Tag `v1.3.0`.
|
||||
|
||||
## 2. Der Workflow im Alltag
|
||||
|
||||
1. **Start:** `git checkout main` -> `git pull` -> `git checkout -b feature/mein-neues-feature`
|
||||
2. **Entwicklung:** Arbeiten, KI-Agents nutzen, Commits machen.
|
||||
3. **Abschluss:** Feature ist fertig.
|
||||
4. **Merge:** Pull Request in Gitea erstellen (oder lokal: `git checkout main`, `git merge feature/mein-neues-feature`, `git push`).
|
||||
5. **Aufräumen:** `git branch -d feature/mein-neues-feature`
|
||||
|
||||
## 3. Strategie für Feld-Tests (Turnier-Einsatz)
|
||||
|
||||
Wenn ein Turnier ansteht und ein stabiler Stand eingefroren werden muss:
|
||||
|
||||
1. Stelle sicher, dass `main` den gewünschten Zustand hat.
|
||||
2. Setze einen Tag in Git: `git tag -a v1.2.0 -m "Release für Turnier in Neumarkt"`
|
||||
3. Pushe den Tag: `git push origin v1.2.0`
|
||||
4. **Deployment:** Das Deployment-Skript zieht sich *diesen* Tag auf Zora (oder baut den Docker-Container aus diesem Tag).
|
||||
|
||||
### Was passiert, wenn während des Turniers ein Bug auftritt (Hotfix)?
|
||||
|
||||
*Szenario: Das Turnier läuft auf `v1.2.0`. Auf `main` gibt es schon neuere Features (unfertig).*
|
||||
|
||||
1. Checkout des stabilen Tags: `git checkout -b hotfix/turnier-fix v1.2.0`
|
||||
2. Bug fixen, committen.
|
||||
3. Neuen Tag für das Deployment setzen: `git tag -a v1.2.1 -m "Hotfix ZNS Import"`
|
||||
4. `git push origin v1.2.1` -> Fix wird auf Zora deployed.
|
||||
5. **WICHTIG (Backport):** Damit der Fix nicht verloren geht, den Hotfix-Branch danach in `main` mergen: `git checkout main`, `git merge hotfix/turnier-fix`.
|
||||
|
||||
## 4. Gitea Actions (CI/CD)
|
||||
* **Pushes auf `feature/*`:** Führen Code-Checks/Tests aus.
|
||||
* **Pushes auf `main`:** Führen erweiterte Tests aus und bauen Docker-Images mit dem Tag `latest` sowie dem Git-SHA in die interne Registry (`10.0.0.22:3000`).
|
||||
* **Erstellung eines Tags (`v*`):** Triggert automatisch den Build und Push von Docker-Images in die interne Registry. Das Image erhält den Namen des Tags (z.B. `:v1.2.0`). Dies ist die Basis für stabile Deployments auf Zora.
|
||||
@@ -32,8 +32,6 @@ Deine Aufgaben:
|
||||
6. **Handover:** Stelle Architekturentscheidungen nicht nur als Text, sondern auch als Diagramm (Mermaid/PlantUML) bereit.
|
||||
7. Erstelle und pflege die MASTER ROADMAP. Du bist der "Hüter des Plans". Du delegierst Aufgaben an die spezialisierten Agenten (Backend, Frontend, DevOps, QA), führst sie aber nicht selbst aus, es sei denn, es betrifft direkt die Architektur oder das Build-System.
|
||||
8. **Bounded Context Awareness:** Stelle sicher, dass Änderungen immer einem der 6 SCS (Self-Contained Systems) zugeordnet sind und die Grenzen gewahrt bleiben.
|
||||
9. **Active Task Manifest:** Nutze die Datei `docs/ACTIVE_TASK.md`, um den aktuellen Arbeitsstand zu dokumentieren und für die nächste Session/KI bereitzustellen.
|
||||
10. **Scout-Prinzip:** Wenn eine Aufgabe unklar ist, delegiere zuerst an Junie als "Scout", um Code-Snippets in `docs/04_Agents/Research_Snippet.md` zu sammeln, bevor architektonische Entscheidungen getroffen werden.
|
||||
|
||||
Don't:
|
||||
- Implementiere keine Business-Logik in Backend-Services (→ Backend Developer).
|
||||
|
||||
@@ -23,7 +23,6 @@ Ziel:
|
||||
- Jede Session endet mit genau einem Artefakt in `docs/`.
|
||||
- Veraltetes Wissen wird sauber archiviert.
|
||||
- Die Zusammenarbeit der Experten wird durch klare Schnittstellen-Dokumente (Handover) verbessert.
|
||||
- **Context-Handover:** Am Ende jeder Session wird ein standardisierter `🔄 NEXT SESSION CONTEXT` Block ausgegeben und die Datei `docs/ACTIVE_TASK.md` aktualisiert.
|
||||
|
||||
Regeln:
|
||||
1. Single Source of Truth ist `docs/`.
|
||||
@@ -32,22 +31,11 @@ Regeln:
|
||||
- Reference / technische Wahrheit pro System (z.B. `docs/05_Backend/Services/<service>.md`)
|
||||
- How-to / Runbook (passender Bereich)
|
||||
- Journal Entry (`docs/99_Journal/`)
|
||||
3. **Session-Abschluss Checkliste:**
|
||||
- [ ] Wurden alle geänderten/neuen Dateien im Journal/Artefakt mit absolutem Pfad erwähnt?
|
||||
- [ ] Wurde ein "Warum" dokumentiert (nicht nur das "Was")?
|
||||
- [ ] Wurde die Datei `docs/ACTIVE_TASK.md` auf den neuesten Stand gebracht?
|
||||
- [ ] Enthält die finale Antwort den `🔄 NEXT SESSION CONTEXT` Block?
|
||||
4. **🔄 NEXT SESSION CONTEXT Struktur:**
|
||||
- **Focus:** [SCS / Feature-Name]
|
||||
- **Last State:** [Kurz-Zusammenfassung des aktuellen Stands]
|
||||
- **Critical Files:** [Liste der wichtigsten Dateien für die nächste Session]
|
||||
- **Open Threads:** [Offene Fragen oder nächste konkrete Schritte]
|
||||
- **Agent-Handover:** [Spezifische Anweisungen für die nächste KI-Rolle]
|
||||
5. **Quality Gate:** Prüfe, ob die Artefakte den Standards entsprechen:
|
||||
3. **Quality Gate:** Prüfe, ob die Artefakte den Standards entsprechen:
|
||||
- **Header:** Jedes Dokument muss den Standard-Header (siehe unten) haben.
|
||||
- **Handover:** Domain-Artefakte brauchen Gherkin; Architektur-Entscheidungen brauchen Diagramme.
|
||||
- **ADR-Pflicht:** Bei größeren Entscheidungen (z.B. Tech-Stack-Änderungen) muss ein ADR eingefordert werden.
|
||||
6. **Lifecycle & Archivierung:**
|
||||
4. **Lifecycle & Archivierung:**
|
||||
- Veraltete Dokumente (z.B. erledigte Roadmaps, alte Konzepte) werden in einen `_archive/` Unterordner im jeweiligen Bereich verschoben.
|
||||
- Dateiname bei Archivierung: `YYYY-MM-DD_OriginalName.md`.
|
||||
- Status im Header auf `ARCHIVED` setzen.
|
||||
|
||||
@@ -17,8 +17,6 @@ Gemini wird genutzt für **Konzeptarbeit**: Varianten vergleichen, Argumente/Tra
|
||||
* Immer 2–4 Optionen mit Vor-/Nachteilen liefern.
|
||||
* Offene Fragen explizit als Liste zurückgeben.
|
||||
* Formuliere Outputs so, dass sie **direkt** in ein `docs/*` Artefakt übernommen werden können.
|
||||
* **Richter-Prinzip:** Nutze von Junie bereitgestellte Code-Snippets in `docs/04_Agents/Research_Snippet.md`, um fundierte Entscheidungen zu treffen, ohne den Code selbst im Detail lesen zu müssen.
|
||||
* **Manifest-Pflicht:** Nutze die `docs/ACTIVE_TASK.md`, um den Kontext-Handover zwischen Sessions zu gewährleisten.
|
||||
|
||||
## Don’t
|
||||
* Keine Annahmen als Fakten verkaufen.
|
||||
|
||||
@@ -21,8 +21,6 @@ Junie wird genutzt für **Repo-nahe Arbeit**: Code lesen, reale Pfade/Module fin
|
||||
## Don’t
|
||||
* Keine „zweite Wahrheit“ in `.junie/*` etablieren (Tooling bleibt Tooling).
|
||||
* Keine Entscheidungen „im Chat verlieren“ – am Ende muss ein Artefakt in `docs/` stehen.
|
||||
* **Scout-Prinzip:** Agiere bei Bedarf als technischer Scout für Gemini. Sammele Code-Beweise und Snippets in `docs/04_Agents/Research_Snippet.md`, um architektonische Entscheidungen vorzubereiten.
|
||||
* **Manifest-Pflicht:** Lies bei Session-Start immer zuerst die `MASTER_ROADMAP` und dann die `docs/ACTIVE_TASK.md`.
|
||||
|
||||
## Abschluss (Pflicht)
|
||||
Am Ende der Session genau **ein** Artefakt gemäß `docs/03_Agents/README.md` erzeugen (oder aktualisieren).
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
🏗️ **[Lead Architect]**
|
||||
Datum: 30. April 2026
|
||||
|
||||
# 🧪 POC-Anleitung: Zero-Config Initialisierung
|
||||
|
||||
Dieses Dokument beschreibt die Schritte für den technischen Hardware-POC der "Meldestelle" Desktop-App.
|
||||
|
||||
## 1. Bauen der App
|
||||
Führen Sie auf Ihrem Entwicklungsrechner aus:
|
||||
```bash
|
||||
./gradlew :frontend:shells:meldestelle-desktop:createDistributable
|
||||
```
|
||||
Kopieren Sie den Ordner `frontend/shells/meldestelle-desktop/build/compose/binaries/main/app` auf einen USB-Stick.
|
||||
|
||||
## 2. Test am Master-PC (PC-1)
|
||||
1. Starten Sie die App vom Stick.
|
||||
2. Wählen Sie die Rolle **Master (Host)**.
|
||||
3. Vergeben Sie einen Namen (z.B. "Meldestelle-Zentrale").
|
||||
4. Geben Sie den **Sicherheitsschlüssel** (Demo: `1234`) ein.
|
||||
5. Wählen Sie den USB-Pfad für **Plan-USB** aus (Native FileDialog öffnet sich).
|
||||
6. Klicken Sie auf "Initialisierung abschließen".
|
||||
|
||||
## 3. Test am Client-PC (PC-2)
|
||||
1. Starten Sie die App auf dem zweiten PC im selben LAN.
|
||||
2. Wählen Sie die Rolle **Client**.
|
||||
3. **Wait-State:** Sie sollten nun die Meldung "Suche nach der Meldestelle..." sehen.
|
||||
4. Sobald der Master aktiv ist, erscheint er in der Liste.
|
||||
5. Klicken Sie auf den Master-Eintrag.
|
||||
6. Geben Sie denselben Sicherheitsschlüssel (`1234`) ein.
|
||||
7. Klicken Sie auf **"Jetzt verbinden"**.
|
||||
8. **Verifikation:** Bei Erfolg erscheint ein grüner Haken und die Meldung "Verbunden mit Meldestelle-Zentrale".
|
||||
|
||||
## 4. Erfolgskriterien
|
||||
- [ ] Master wird vom Client automatisch gefunden (mDNS).
|
||||
- [ ] Client kann sich per Klick verbinden.
|
||||
- [ ] Native Dateidialoge sind lesbar und stabil.
|
||||
- [ ] Handshake-Feedback (Grün/Rot) funktioniert.
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
type: How-to
|
||||
status: ACTIVE
|
||||
owner: DevOps Engineer
|
||||
---
|
||||
|
||||
# Runbook: Caddy & Pangolin Deployment (Plan-B Setup)
|
||||
|
||||
Dieses Dokument sichert das Wissen über die Konfiguration von Caddy als Webserver/Reverse-Proxy in Kombination mit Pangolin-Tunneln, welches während der "Plan-B" Online-Nennung erarbeitet wurde.
|
||||
|
||||
## 1. Architektur-Übersicht
|
||||
|
||||
* **Pangolin:** Stellt den sicheren Tunnel vom lokalen Netzwerk (Zora) ins Internet her (ersetzt Cloudflare). Leitet Traffic auf spezifische lokale Ports weiter.
|
||||
* **Caddy:** Agiert als Reverse-Proxy und TLS-Terminierungspunkt. Nimmt Traffic von Pangolin (und lokalem Netz) an und routet ihn zu den internen Docker-Services (z.B. Frontend-Web, API-Gateway).
|
||||
|
||||
## 2. Caddy Konfiguration (`Caddyfile`)
|
||||
|
||||
Die Konfiguration befindet sich in `config/docker/caddy/web-app/Caddyfile`.
|
||||
|
||||
### Wichtige Erkenntnisse / Fallstricke:
|
||||
* **TLS/SSL:** Caddy wurde mit `auto_https off` konfiguriert, da die SSL-Terminierung extern (Pangolin/Edge) erfolgt. Caddy läuft intern auf Port 80.
|
||||
* **Same-Origin Strategy:** Um CORS-Probleme im Browser zu vermeiden, werden alle API-Anfragen (`/api/*`) über Caddy an den `mail-service:8085` geproxt. Dies macht die App robuster gegen Browser-Security-Policies.
|
||||
* **MIME-Types:** Explizite Setzung von `application/wasm` für `.wasm` Dateien ist für KMP-Web-Apps kritisch (siehe Snippet).
|
||||
* **COOP/COEP Header:** Für WASM/KMP-Web-Apps sind `Cross-Origin-Embedder-Policy "require-corp"` und `Cross-Origin-Opener-Policy "same-origin"` essentiell, damit SharedArrayBuffer etc. funktionieren.
|
||||
* **Caching:**
|
||||
* Assets mit Hashes im Namen sind `immutable` (max-age 1 Jahr).
|
||||
* `.wasm` und `.js` Dateien wurden während Plan-B auf `no-store, no-cache, must-revalidate` gesetzt, um sicherzustellen, dass Teilnehmer immer die aktuellste Logik erhalten.
|
||||
* **Header-Weiterleitung:** Wichtige Header für das Backend: `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`.
|
||||
|
||||
### Aktuelles Plan-B Snippet:
|
||||
```caddyfile
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:80 {
|
||||
root * /usr/share/caddy
|
||||
|
||||
header {
|
||||
Cross-Origin-Embedder-Policy "require-corp"
|
||||
Cross-Origin-Opener-Policy "same-origin"
|
||||
}
|
||||
|
||||
# API Proxy (Same-Origin Strategy)
|
||||
handle /api/* {
|
||||
reverse_proxy mail-service:8085 {
|
||||
header_up Host {upstream_hostport}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
}
|
||||
|
||||
# Wasm MIME & Caching
|
||||
@wasm path *.wasm
|
||||
header @wasm Content-Type "application/wasm"
|
||||
|
||||
@wasm_js path *.wasm *.js
|
||||
header @wasm_js Cache-Control "no-store, no-cache, must-revalidate"
|
||||
|
||||
# SPA Fallback
|
||||
handle {
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Pangolin Konfiguration
|
||||
|
||||
### Erkenntnisse aus Plan-B:
|
||||
* **Tunnel-Endpunkt:** Pangolin leitet den Traffic von der öffentlichen Domain (z.B. `meldestelle.mo-code.at`) auf den lokalen Port der Zora-Instanz weiter (standardmäßig Port 80, gemappt auf Host-Port via Docker).
|
||||
* **Stabilität:** Der Pangolin-Client läuft als persistenter Dienst auf der Zora-Node. Er ist extrem stabil gegenüber IP-Wechseln des ISP (DSL-Reconnect).
|
||||
* **Konfiguration:** Erfolgt primär über das Pangolin-Dashboard (Web-UI). Wichtig ist das Mapping der Resource auf die interne IP von Zora.
|
||||
|
||||
## 4. Deployment-Workflow (Erkenntnisse)
|
||||
|
||||
### SMTP-Härtung (Plan-B Mail-Service)
|
||||
In `dc-planb.yaml` wurden folgende Einstellungen für World4You (SMTP) als stabil verifiziert:
|
||||
```yaml
|
||||
SPRING_MAIL_HOST: "smtp.world4you.com"
|
||||
SPRING_MAIL_PORT: "587"
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "true"
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "true"
|
||||
```
|
||||
Wichtig: `STARTTLS_REQUIRED` verhindert den Versand, falls keine verschlüsselte Verbindung aufgebaut werden kann.
|
||||
|
||||
### Infrastruktur-Optimierung
|
||||
* **Zero-Downtime:** `docker compose exec web-app caddy reload --config /etc/caddy/Caddyfile` ermöglicht Konfigurationsänderungen ohne Container-Neustart.
|
||||
* **Health-Check:** Der `/health` Endpunkt in Caddy wurde genutzt, um die Erreichbarkeit des Containers zu prüfen.
|
||||
* **In-Memory DB:** Für Plan-B wurde die H2-Datenbank (In-Memory) genutzt, da keine Persistenz über den Turnier-Zeitraum hinaus (außer E-Mail-Kopien) nötig war. Dies vereinfachte das Deployment massiv.
|
||||
|
||||
---
|
||||
*Hinweis: Dieses Dokument basiert auf den erfolgreichen Feld-Tests vom April 2026.*
|
||||
@@ -1,45 +0,0 @@
|
||||
# Journal Entry: Prozess-Optimierung & TurnierAnlage Vorbereitung
|
||||
|
||||
---
|
||||
type: Journal
|
||||
status: ACTIVE
|
||||
owner: Curator
|
||||
last_update: 2026-04-28
|
||||
---
|
||||
|
||||
## 📝 Zusammenfassung
|
||||
In dieser Session haben wir die KI-Zusammenarbeit durch neue Protokolle geschärft und die Grundlage für den "TurnierAnlage"-Wizard in der Desktop-App gelegt.
|
||||
|
||||
## 🏗️ Architektur- & Prozess-Updates
|
||||
- **Context-Handover Protokoll:** Einführung des `🔄 NEXT SESSION CONTEXT` Blocks zur nahtlosen Übergabe zwischen KI-Instanzen.
|
||||
- **Active Task Manifest:** Erstellung von `docs/ACTIVE_TASK.md` als Single Source of Truth für den aktuellen Arbeitsstand.
|
||||
- **Playbook Updates:**
|
||||
- `Curator.md`: Neue Checkliste für den Session-Abschluss.
|
||||
- `Architect.md`: Integration des "Scout-Prinzips" und Manifest-Pflicht.
|
||||
- `Junie.md` & `Gemini.md`: Rollen-Schärfung (Scout vs. Richter).
|
||||
|
||||
## 🐎 TurnierAnlage (Event Management)
|
||||
- **Status-Quo Analyse:**
|
||||
- Backend: `Turnier.kt` ist bereits gut auf ÖTO-Validierungen vorbereitet.
|
||||
- Frontend: `CreateBewerbWizardScreen.kt` existiert als Tab-UI, muss aber auf den `WizardOrchestrator` (ADR-0025) migriert werden.
|
||||
- Flow: `EventWizardFlow.kt` ist noch ein Platzhalter.
|
||||
- **Strategische Entscheidung:** Wir nutzen den neuen `WizardCore` für die TurnierAnlage, um komplexe ÖTO-Regelwerke (z.B. § 39 Abteilungstrennung) zustandsbasiert und mit klaren Guards abzubilden.
|
||||
|
||||
## 🛠️ CI/CD & Deployment (DevOps)
|
||||
- **Gitea-Actions:** Erweiterung der `docker-publish.yaml`, um bei Git-Tags (`v*`) automatisch Docker-Images zu bauen.
|
||||
- **Tagging-Logik:** Docker-Images erhalten nun dedizierte Tags aus Git, was stabile Rollbacks und Feld-Tests ermöglicht.
|
||||
- **Dokumentation:** Update der `Git_Branching_Strategy.md` um die automatisierte Build-Logik.
|
||||
|
||||
## 🔗 Betroffene Dateien
|
||||
- `docs/ACTIVE_TASK.md` (NEU)
|
||||
- `docs/04_Agents/Playbooks/Curator.md` (Update)
|
||||
- `docs/04_Agents/Playbooks/Architect.md` (Update)
|
||||
- `docs/04_Agents/Playbooks/Junie.md` (Update)
|
||||
- `docs/04_Agents/Playbooks/Gemini.md` (Update)
|
||||
- `backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/Turnier.kt` (Gelesen/Analyse)
|
||||
|
||||
## ✅ Session-Abschluss Checkliste
|
||||
- [x] Dateipfade absolut erwähnt?
|
||||
- [x] "Warum" dokumentiert?
|
||||
- [x] `docs/ACTIVE_TASK.md` aktuell?
|
||||
- [x] Handover-Block vorhanden?
|
||||
@@ -1,43 +0,0 @@
|
||||
# Curator Journal: Technische Geräte-Initialisierung & "Plan-USB"
|
||||
|
||||
**Datum:** 30. April 2026
|
||||
**Agenten:** 🏗️ [Lead Architect], 🎨 [Frontend Expert], 🧹 [Curator]
|
||||
|
||||
## 🎯 Status Quo
|
||||
Status: 🚧 IN ARBEIT (UI KORREKTUREN WEB)
|
||||
|
||||
Nach dem gestrigen Fehltritt wurden die Halluzinationen in der Web-Shell korrigiert:
|
||||
1. **Light-Mode Force:** Die Web-App erzwingt nun den Light-Mode für bessere Ablesbarkeit.
|
||||
2. **Download-Card:** Eine prominente Card für den Desktop-Download wurde im `WebMainScreen` integriert.
|
||||
3. **POC-Guide:** Ein detaillierter Guide wurde unter `docs/06_Frontend/Guides/POC_INITIALISIERUNG.md` erstellt.
|
||||
|
||||
## 🏗️ Implementierte Features (Update)
|
||||
* **Web-Shell Korrekturen:** Dark-Mode Deaktivierung und Download-CTA.
|
||||
* **Build-Fix:** Erstellung der fehlenden App-Icons (PNG/ICO) zur Behebung des Packaging-Fehlers.
|
||||
* **Chat:** Implementierung eines Veranstaltungs-Chats (MVP) in der Desktop-App inkl. Footer-Integration.
|
||||
* **Docker-Fix:** Behebung des "services must be a mapping" Fehlers in der Docker-Infrastruktur.
|
||||
* **Dokumentation:** Erster Entwurf der POC-Anleitung für Hardware-Tests (inkl. Run-Anweisungen).
|
||||
|
||||
## 📝 Wichtigste Entscheidungen & Artefakte
|
||||
(Bisherige Inhalte bleiben erhalten)
|
||||
|
||||
## 🏗️ Implementierte Features
|
||||
* **Single-Page Setup:** Alle technischen Einstellungen (Name, Key, Pfad, Interface) auf einer Seite.
|
||||
* **Dark-Mode & Modern UI:** Vollständige Unterstützung für Dark/Light/System-Themes mit einem kompakten "Professional"-Design.
|
||||
* **Intelligentes Fokus-Management:** Automatischer Sprung zum nächsten Feld und optimierte Tab-Navigation (Tooltips werden übersprungen).
|
||||
* **Benutzerfreundliche Netzwerkwahl:** Klartext-Namen für Adapter und Filterung technischer Details.
|
||||
* **Drucker-Fallback:** Virtueller PDF-Drucker für papierloses Arbeiten oder fehlende Hardware.
|
||||
|
||||
## 🗺️ Roadmap-Update
|
||||
(Roadmap wurde auf 2026-04-29 aktualisiert)
|
||||
|
||||
## 🚀 Nächste Schritte
|
||||
1. **Hardware-PoC (Dringend):** Verifikation der Netzwerk-Discovery und des Plan-USB Exports durch den User.
|
||||
2. **Meilenstein 1:** Beginn der physischen Implementierung der Turnier-Hierarchie erst nach Erfolg von Meilenstein 0.
|
||||
3. **Feinschliff:** Behebung von Bugs, die im PoC heute Abend gefunden werden.
|
||||
|
||||
---
|
||||
**🚫 Anti-Halluzinations-Protokoll aktiv:** Kein Task wird ohne Hardware-Beweis als "Erledigt" markiert.
|
||||
|
||||
---
|
||||
*Dokumentiert durch den Curator.*
|
||||
@@ -1,15 +0,0 @@
|
||||
# Curator Journal: Chat-Navigation-Fix
|
||||
|
||||
## 🛠️ Problemstellung
|
||||
Die Chat-Funktion konnte in der Desktop-App nicht geöffnet werden. Das Navigations-Log zeigte, dass die App nach dem Versuch, den `ChatScreen` zu rendern, sofort eine Umleitung zum `EventVerwaltung` (Dashboard) durchführte.
|
||||
|
||||
## 🔍 Ursachenanalyse
|
||||
Die Ursache lag in der Guard-Logik innerhalb der `DesktopApp.kt`. Dort wird geprüft, ob ein User authentifiziert ist. Für Screens, die ohne expliziten Cloud-Login zugänglich sein sollen (wie das lokale Dashboard oder der Offline-Chat), gibt es eine `isAllowedScreen`-Liste. Der `AppScreen.Chat` fehlte in dieser Liste, wodurch der Security-Guard fälschlicherweise eine nicht vorhandene Session monierte und zum Dashboard zurückleitete.
|
||||
|
||||
## ✅ Durchgeführte Änderungen
|
||||
- **Security-Guard:** `AppScreen.Chat` wurde zur `isAllowedScreen`-Liste in `DesktopApp.kt` hinzugefügt.
|
||||
- **Verifikation:** Die Logik wurde mit den im Issue bereitgestellten Logs abgeglichen. Durch die Aufnahme in die Liste wird der `LaunchedEffect`, der die Umleitung triggert, für den Chat-Screen nun korrekt übersprungen.
|
||||
|
||||
## 📌 Status
|
||||
- [x] Chat-Navigation repariert
|
||||
- [x] Code-Basis konsistent mit "Offline-First" Strategie (Chat im LAN ohne Cloud-Login)
|
||||
@@ -1,18 +0,0 @@
|
||||
🏗️ **[Curator Journal]**
|
||||
Datum: 30. April 2026
|
||||
|
||||
# 🧹 Session-Abschluss: Master-UX & Client-Konfiguration
|
||||
|
||||
## 🚀 Highlights
|
||||
- **Master-Freiheit:** Die Konfiguration ist beim Start des Wizards nicht mehr zwangsgesperrt. Der Master kann nun alle Einstellungen (Name, Key, Interfaces) in Ruhe prüfen, bevor er finalisiert.
|
||||
- **Client-Management:** Der Master kann nun "erwartete Clients" direkt in der UI hinzufügen, umbenennen und deren Rollen (Richter, Zeitnehmer, etc.) anpassen.
|
||||
- **Dynamische Listen:** Fehler behoben, bei dem nach dem Löschen von Clients keine neuen mehr hinzugefügt werden konnten.
|
||||
|
||||
## 🛠️ Technische Details
|
||||
- **ViewModel-Fix:** `isLocked` im `DeviceInitializationViewModel` wird nun initial auf `false` gesetzt.
|
||||
- **UI-Implementierung:** `DeviceInitializationConfig.jvm.kt` nutzt nun `MsTextField` und `FilterChip` innerhalb der Client-Liste für direkte Bearbeitung.
|
||||
- **Rollen-Filter:** Der Master kann sich selbst nicht als "erwarteten Client" hinzufügen (Filter auf `NetworkRole.entries`).
|
||||
|
||||
## 📅 Ausblick
|
||||
- Abschluss von **Meilenstein 0** nach erfolgreichem Hardware-Test.
|
||||
- Start von **Meilenstein 1 (Basis-Hierarchie & Persistenz)**.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Curator Journal - 30. April 2026
|
||||
|
||||
## 🛠️ Netzwerk-Discovery Fix (Meilenstein 0)
|
||||
|
||||
### Status: Verifikation durch Hardware-POC ausstehend (Iteration 2)
|
||||
|
||||
Der erste Hardware-POC des Users zeigte Probleme bei der automatischen Discovery der Desktop-Instanzen auf. Trotz erfolgreichem Pings fanden sich die Instanzen nicht.
|
||||
|
||||
### 🔍 Ursachenanalyse
|
||||
1. **Unpräzises mDNS-Binding:** JmDNS nutzte standardmäßig `getLocalHost()`, was in vielen Netzwerk-Konfigurationen (insb. bei VPNs oder Docker-Interfaces wie vom User gemeldet: `172.17.x.x`) auf das falsche Interface bindet.
|
||||
2. **UI-Unklarheit:** Der User erkannte nicht, ob ein Interface aktiv ist oder ob die Discovery überhaupt läuft.
|
||||
|
||||
### 🚀 Durchgeführte Änderungen
|
||||
1. **Core-Network (mDNS):**
|
||||
- `NetworkDiscoveryService` und `JmDnsDiscoveryService` erweitert, um ein explizites IP-Binding zu ermöglichen.
|
||||
- Die Discovery wird nun hart an die IP des vom User gewählten Netzwerk-Interfaces gebunden.
|
||||
2. **Features-Device-Initialisierung:**
|
||||
- **UI-Rewrite:** Die Dropdown-Liste wurde durch ein interaktives Karten-Layout ersetzt.
|
||||
- **Status-Indikatoren:** Jedes Interface zeigt nun einen farbigen Punkt (Grün für LAN/WLAN-IPs, Rot für andere) und Icons (🔌/🌐) zur schnellen Identifikation.
|
||||
- **Auto-Discovery:** Sobald ein Interface gewählt oder die Rolle gewechselt wird, wird die Discovery/Registrierung automatisch neu gestartet.
|
||||
3. **Guides:**
|
||||
- `POC_INITIALISIERUNG.md` aktualisiert mit klaren Verifikationsschritten für das Netzwerk-Interface.
|
||||
|
||||
### ⚠️ Wichtiger Hinweis für den User
|
||||
Bitte die Desktop-App mit `./gradlew :frontend:shells:meldestelle-desktop:createDistributable` neu bauen und erneut auf die Ziel-Hardware übertragen. Achten Sie im Assistenten auf den **grünen Punkt** bei der Interface-Wahl.
|
||||
|
||||
**Curator Ende.**
|
||||
@@ -1,24 +0,0 @@
|
||||
🏗️ **[Curator Journal]**
|
||||
Datum: 30. April 2026 (Abschluss-Update)
|
||||
|
||||
# 🧹 Session-Abschluss: POC-Status & Kritische Analyse
|
||||
|
||||
## 🚀 Implementierte Änderungen (Zusammenfassung)
|
||||
- **Zero-Config & UI-Fixes:** mDNS-Discovery mit sprechenden Namen, "Wait-State" für Clients und native Dateidialoge integriert.
|
||||
- **Master-UX Optimierung:** Die Konfiguration im Wizard ist nun beim Start entsperrt; Master können erwartete Clients hinzufügen/bearbeiten.
|
||||
- **Fehlerbehebung:** Kompilierfehler im `DeviceInitializationViewModel` (JVM/Common-Abstraktion) und Docker-Compose YAML-Struktur korrigiert.
|
||||
|
||||
## 🔴 Aktueller Status: POC FEHLGESCHLAGEN
|
||||
Trotz der technischen Umsetzungen meldet der User, dass der POC auf der Hardware weiterhin nicht funktioniert.
|
||||
- **Feedback:** "Es funktioniert noch immer nicht!"
|
||||
- **Konsequenz:** Die Session wird zur Dokumentation beendet. Eine tiefergehende Fehleranalyse (Netzwerk-Traces, Log-Inspektion) ist für die Abend-Session zwingend erforderlich.
|
||||
|
||||
## 📋 Checkliste für die Abend-Session (Analyse-Fokus)
|
||||
1. **mDNS Sichtbarkeit:** Warum finden sich Master und Client trotz "Zero-Config" nicht zuverlässig? (Mögliche Firewall-Themen oder Interface-Binding-Priorität).
|
||||
2. **Handshake-Logik:** Verbleibt der Client im "Wait-State" oder schlägt der Verbindungsversuch aktiv fehl?
|
||||
3. **UI-State Persistence:** Werden die Master-Einstellungen (Name, Key) korrekt für den mDNS-Broadcast übernommen?
|
||||
4. **Log-Analyse:** Prüfung der App-Logs auf dem Zielsystem (falls verfügbar).
|
||||
|
||||
## 📅 Nächste Schritte
|
||||
- Start der Abend-Session mit Fokus auf **Debugging der Netzwerk-Discovery**.
|
||||
- Verifikation der `init_device.aes` Erstellung bei manuellem Durchlauf des Masters.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Curator Journal: POC-Fix & Portable Distribution
|
||||
|
||||
**Datum:** 30. April 2026
|
||||
**Agenten:** 🏗️ [Lead Architect], 🧹 [Curator]
|
||||
|
||||
## 🎯 Status Quo
|
||||
Status: 🚀 BEREIT FÜR HARDWARE-TEST
|
||||
|
||||
Nach der Kritik am unzureichenden `run`-Hinweis wurde der Build-Prozess für den POC auf eine portable Lösung umgestellt.
|
||||
|
||||
## 🏗️ Wichtigste Änderungen
|
||||
* **Build-Strategie:** Wechsel von `packageDistribution` (benötigt OS-Tools wie dpkg) zu `createDistributable`.
|
||||
* **Portabilität:** Die App wird nun als entpacktes Image (`app`-Ordner) bereitgestellt, das direkt vom USB-Stick auf dem Zielsystem (Zora-Hardware) ausgeführt werden kann.
|
||||
* **Desktop-Chat:** Implementierung eines Veranstaltungs-Chats (MVP) mit Footer-Integration und Navigation.
|
||||
* **Docker-Fix:** Behebung des Syntaxfehlers in `dc-gui.yaml`.
|
||||
* **Dokumentation:** Der Guide `docs/06_Frontend/Guides/POC_INITIALISIERUNG.md` wurde komplett überarbeitet und beantwortet nun alle offenen Fragen zu Docker, Gradle und dem Transfer-Prozess.
|
||||
|
||||
## 📝 Entscheidungen
|
||||
1. **Kein System-Packaging für POC:** Um die Hardware-Abhängigkeiten des Build-Systems zu umgehen, nutzen wir die Portable-Variante.
|
||||
2. **Direkt-Transfer:** Das `app`-Verzeichnis wird 1:1 kopiert.
|
||||
3. **Chat als Navigation-Stub:** Die Chat-UI ist als MVP vorhanden, um die Usability im Feldtest zu prüfen (Online-Gefühl).
|
||||
|
||||
## 🚀 Nächste Schritte
|
||||
1. **Hardware-POC:** Durchführung des Tests auf der Ziel-Hardware durch den User.
|
||||
2. **Chat-Test:** Verifikation der Chat-Erreichbarkeit über die FooterBar.
|
||||
3. **Feedback-Loop:** Auswertung der `init_device.aes` Datei und der Netzwerk-Erkennung.
|
||||
|
||||
---
|
||||
**🚫 Anti-Halluzinations-Protokoll:** Der `createDistributable` Task wurde erfolgreich verifiziert (BUILD SUCCESSFUL). Der Pfad zum Artefakt wurde im Guide korrekt hinterlegt.
|
||||
@@ -1,20 +0,0 @@
|
||||
🏗️ **[Curator Journal]**
|
||||
Datum: 30. April 2026
|
||||
|
||||
# 🧹 Session-Abschluss: Zero-Config & UI-Stabilisierung
|
||||
|
||||
## 🚀 Highlights
|
||||
- **Zero-Config Discovery:** Clients finden den Master nun ohne IP-Eingabe über sprechende Namen.
|
||||
- **Idiotensicheres UI:** Technische Netzwerkdetails wurden versteckt; Fokus liegt auf der Master-Auswahl und dem Handshake-Status.
|
||||
- **Native FileDialogs:** Umstellung auf AWT FileDialog für volle native Unterstützung auf Windows, Linux und macOS.
|
||||
- **Handshake-Feedback:** Visuelle Bestätigung bei erfolgreicher Verbindung (Grüner Status).
|
||||
|
||||
## 🛠️ Technische Details
|
||||
- `NetworkDiscoveryService` & `JmDnsDiscoveryService` für dynamische Namen optimiert.
|
||||
- `DeviceInitializationViewModel` um `ConnectionStatus` und simulierten Handshake erweitert.
|
||||
- Build-Fix in `DeviceInitializationConfig.jvm.kt` durchgeführt.
|
||||
|
||||
## 📋 Nächste Schritte
|
||||
- Realer Hardware-Test durch den User.
|
||||
- Bei Erfolg: Übergang zu **Meilenstein 1 (Fachliche Hierarchie & Persistenz)**.
|
||||
- Integration des P2P-Sync für den Echtzeit-Chat.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Journal Eintrag: 2026-06-09 - Gradle Configuration Cleanup
|
||||
|
||||
## 🏗️ [Lead Architect] & 🧹 [Curator]
|
||||
|
||||
### 🎯 Ziel
|
||||
|
||||
Entfernung veralteter Gradle-Properties, um Build-Warnungen zu reduzieren und die Kompatibilität mit zukünftigen
|
||||
Kotlin-Versionen sicherzustellen.
|
||||
|
||||
### 🛠️ Änderungen
|
||||
|
||||
- **`gradle.properties`**: Die Eigenschaft `kotlin.mpp.androidSourceSetLayoutVersion=2` wurde entfernt.
|
||||
- **Grund**: Die Warnung `w: ⚠️ Deprecated Gradle Property 'kotlin.mpp.androidSourceSetLayoutVersion' Used` im Modul
|
||||
`:contracts:ping-api` wies darauf hin, dass dieses Layout nun Standard ist und die explizite Setzung nicht mehr
|
||||
unterstützt wird.
|
||||
|
||||
### ✅ Verifikation
|
||||
|
||||
- `./gradlew :contracts:ping-api:help` wurde erfolgreich ohne die besagte Warnung ausgeführt.
|
||||
- Projektstruktur und andere Module wurden stichprobenartig auf ähnliche veraltete Einträge geprüft (keine weiteren
|
||||
Funde).
|
||||
|
||||
### 📝 Notizen
|
||||
|
||||
- Es sind noch diverse Compose-bezogene Deprecation-Warnungen in den Frontend-Modulen vorhanden. Diese sollten in einer
|
||||
separaten Session durch den **🎨 Frontend Expert** adressiert werden.
|
||||
@@ -1,32 +0,0 @@
|
||||
# ⚡ ACTIVE TASK: Event- & TurnierAnlage-Wizard Migration
|
||||
|
||||
**Status:** 🏗️ In Arbeit
|
||||
**SCS:** Event Management / Desktop App
|
||||
**Branch:** `feature/turnier-anlage-wizard`
|
||||
|
||||
## 🎯 Aktuelles Ziel
|
||||
1. **Event-Wizard Migration:** Migration des Veranstaltungs-Wizards auf den deklarativen Orchestrator (ADR-0025) abgeschlossen. ✓
|
||||
2. **TurnierAnlage:** Implementierung des Wizards zur Anlage von Turnieren, Bewerben und Abteilungen nach ÖTO-Regeln in der Desktop-App.
|
||||
3. **ÖTO-Validierung:** Integration der Abteilungs-Trennungs-Regeln (§ 39) als Warn-Logik im Wizard.
|
||||
|
||||
## 🛠️ Letzte Änderungen
|
||||
- Event-Wizard: `EventFlowSample.kt` erfolgreich nach `EventWizardFlow.kt` migriert, umbenannt und um ÖTO-Schritte erweitert. ✓
|
||||
- Wissens-Sicherung Plan-B: Caddy & Pangolin Runbook vervollständigt (MIME, COOP/COEP, SMTP-Härtung). ✓
|
||||
- CI/CD: Gitea-Action für automatisierte Docker-Builds bei Git-Tags (`v*`) aktiviert. ✓
|
||||
- TurnierAnlage: `TurnierAnlageFlow.kt` Skelett erstellt. ✓
|
||||
|
||||
## 📍 Fokus-Dateien
|
||||
- `frontend/features/veranstaltung-feature/src/commonMain/kotlin/at/mocode/veranstaltung/feature/wizard/EventWizardFlow.kt`
|
||||
- `frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/wizard/TurnierAnlageFlow.kt`
|
||||
- `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md`
|
||||
- `frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/CreateBewerbWizardScreen.kt`
|
||||
|
||||
## 🚧 Offene Punkte / Blocker
|
||||
- [ ] Erstellung der Compose-Screens für `TurnierBasisdatenStep`.
|
||||
- [ ] Erstellung der Compose-Screens für `TurnierKategorieStep`.
|
||||
- [ ] Implementierung der ÖTO-Check Logik für Abteilungen.
|
||||
- [ ] Sync-Logik zum Backend für die Web-Generierung vorbereiten.
|
||||
|
||||
## 🔄 Nächste Schritte
|
||||
- [ ] Implementierung von `TurnierBasisdatenScreen` (Compose Desktop).
|
||||
- [ ] Verknüpfung des `TurnierAnlageFlow` mit dem UI-Orchestrator.
|
||||
@@ -1,123 +0,0 @@
|
||||
A26128CSN-C-NEU CSNP-C-NEU NEUM2026042520260425CSN-C-Neu CSNP-C_Neu 2.2PSO v1.07
|
||||
B010Stilspringprüfung - CSNP-C_N006000000001
|
||||
C010001307002129000000000000000000000000000000000000021771000000
|
||||
D001PG47Paddy's Nikita 170107Remplbauer Selina 00080000000 000000AUT*
|
||||
D002PK06H-S Button 196040Gillinger Marlene 00067000000 000000AUT*
|
||||
D003P824Pit 3 184759Krenn Eva 00055000000 000000AUT*
|
||||
D004P814Balu 6 193244Remplbauer Sophia 00000000000 000000AUT
|
||||
D004P901Daneder's Blitz 195501Weidinger Janina 00000000000 000000AUT
|
||||
D004PB70Daneder's Caramello 163545Montgomery Helena 00000000000 000000AUT
|
||||
B021Einlaufspringprüfung - CSN-C-Ne008000000002
|
||||
C021001307002129000000000000000000000000000000000000021771000000
|
||||
D0001781Ritual Do Vizo 126532Layr Bianca 00000000000 000000AUT*
|
||||
D000P816Aldensfarm Breaking Dawn 159405Starzengruber Marie-Theres 00000000000 000000AUT*
|
||||
D000P901Daneder's Blitz 195501Weidinger Janina 00000000000 000000AUT*
|
||||
D000PB70Daneder's Caramello 163545Montgomery Helena 00000000000 000000AUT*
|
||||
D000PE14SD Antonette 929451Mayrhofer Simon 00000000000 000000AUT*
|
||||
D000PG47Paddy's Nikita 170107Remplbauer Selina 00000000000 000000AUT*
|
||||
D000P824Pit 3 184759Krenn Eva 00003000000 000000AUT
|
||||
D000P814Balu 6 193244Remplbauer Sophia 00000000000 000000AUT
|
||||
B022Einlaufspringprüfung - CSN-C-Ne003000000002
|
||||
C022001307002129000000000000000000000000000000000000021771000000
|
||||
D000AR70Chocolate Kiss 2 147265Vanova Nina 00000000000 000000AUT*10258795
|
||||
D000P561Ginger Bread Girl 153601Winter Maja Sophie 00000000000 000000AUT*
|
||||
D997Z001Wildberry Gold RPZ 168660Zechmeister-Paster Diana A00000000000 000000AUT
|
||||
B030Stilspringprüfung - CSNP-C_N006000000003
|
||||
C030001307002129000000000000000000000000000000000000021771000000
|
||||
D001PA53Rathcline Star 178474Schmidmayr Nena Sophie 00072000000 000000AUT*
|
||||
D002P152Verena 3 170454Krenn Miriam 00070000000 000000AUT*
|
||||
D003P816Aldensfarm Breaking Dawn 159405Starzengruber Marie-Theres 00068000000 000000AUT*
|
||||
D004P561Ginger Bread Girl 153601Winter Maja Sophie 00067000000 000000AUT*
|
||||
D997PE14SD Antonette 929451Mayrhofer Simon A00000000000 000000AUT
|
||||
D997PK06H-S Button 196040Gillinger Marlene A00000000000 000000AUT
|
||||
B041Einlaufspringprüfung - CSNP-C_N006000000004
|
||||
C041001307002129000000000000000000000000000000000000021771000000
|
||||
D0002M80Handsome 186927Lengauer Jelena 00000000000 000000AUT* 106KB09
|
||||
D000AN19Exklusiv EM 187665Mück Hannah 00000000000 000000AUT*
|
||||
D0001781Ritual Do Vizo 126532Layr Bianca 00040000000 000000AUT
|
||||
D0004Y59Legolas 196 925183Schreiber Tamina 00047000000 000000GER
|
||||
D000AB83HB Vijola 920327Reisinger Marlene 00056000000 000000AUT
|
||||
D0003E99Quinet 906586Kapeller Emilia 00000000000 000000AUT
|
||||
B042Einlaufspringprüfung - CSNP-C_N007000000004
|
||||
C042001307002129000000000000000000000000000000000000021771000000
|
||||
D0003K69Lillet 18 150620Reitetschläger Lena 00000000000 000000AUT*
|
||||
D0005789Furiosa de la Bryere CE 140156Ehrentraut Carina 00000000000 000000AUT*
|
||||
D000A099Quintessa 2 609548Aichinger Bianca 00000000000 000000AUT*
|
||||
D0003M58Samantha 25 609771Karl Reinhard 00040000000 000000AUT
|
||||
D000H606Moondancer 070156Alberer Manuela 00040000000 000000AUT
|
||||
D000Z001Wildberry Gold RPZ 168660Zechmeister-Paster Diana 00092500000 000000AUT
|
||||
D000AR70Chocolate Kiss 2 147265Vanova Nina 00129000000 000000AUT 10258795
|
||||
B050Stilspringprüfung - CSNP-C_N003000000005
|
||||
C050001307002129000000000000000000000000000000000000021771000000
|
||||
D001P152Verena 3 170454Krenn Miriam 00074000000 000000AUT*
|
||||
D002P985Taffy 2 193430Schartmüller Sarah 00072000000 000000AUT*
|
||||
D003PA53Rathcline Star 906580Egger Julia 00065000000 000000AUT*
|
||||
B061Stilspringprüfung - CSNP-C_N009000000006
|
||||
C061001307002129000000000000000000000000000000000000021771000000
|
||||
D0012B41Guccini 922710Simlinger Marlies 00075000000 000000AUT*
|
||||
D002AN19Exklusiv EM 187665Mück Hannah 00072000000 000000AUT*
|
||||
D0033E99Quinet 906586Kapeller Emilia 00071000000 000000AUT*
|
||||
D0042M80Handsome 186927Lengauer Jelena 00070000000 000000AUT* 106KB09
|
||||
D004AF41Cäsar 55 916541Dugandzic Sarah 00070000000 000000AUT*
|
||||
D006PA53Rathcline Star 906580Egger Julia 00068000000 000000AUT
|
||||
D0074Y59Legolas 196 925183Schreiber Tamina 00062000000 000000GER
|
||||
D0083785Coeur 17 145963Obermüller Hannah 00061000000 000000AUT
|
||||
D009AB83HB Vijola 920327Reisinger Marlene 00057000000 000000AUT
|
||||
B062Stilspringprüfung - CSNP-C_N007000000006
|
||||
C062001307002129000000000000000000000000000000000000021771000000
|
||||
D001A099Quintessa 2 609548Aichinger Bianca 00082000000 000000AUT*
|
||||
D0025789Furiosa de la Bryere CE 140156Ehrentraut Carina 00072000000 000000AUT*
|
||||
D0033K69Lillet 18 150620Reitetschläger Lena 00067000000 000000AUT*
|
||||
D004KSS1Charity Coke 053749Eichler Eva 00065000000 000000AUT*
|
||||
D0053M58Samantha 25 609771Karl Reinhard 00060000000 000000AUT
|
||||
D005H606Moondancer 070156Alberer Manuela 00060000000 000000AUT
|
||||
D9971A11Gradan 102783Steyrer Anna A00000000000 000000AUT
|
||||
B070Stilspringprüfung - CSNP-C_N002000000007
|
||||
C070001307002129000000000000000000000000000000000000021771000000
|
||||
D001Y001Bella Graziella 144315Gaugl Laura 00075000000 000000AUT*
|
||||
D002P985Taffy 2 193430Schartmüller Sarah 00000000000 000000AUT
|
||||
B080Springreiterbewerb - CSNP-C_N003000000008
|
||||
C080001307002129000000000000000000000000000000000000021771000000
|
||||
D0013785Coeur 17 145963Obermüller Hannah 00080000000 000000AUT*
|
||||
D0022M80Handsome 186927Lengauer Jelena 00072000000 000000AUT* 106KB09
|
||||
D9973E99Quinet 178474Schmidmayr Nena Sophie A00000000000 000000AUT
|
||||
B091Standardspringprüfung - CSNP-C_N005000000009
|
||||
C091001307002129000000000000000000000000000000000000021771000000
|
||||
D0012062Grover 157407Pröll Leonie 00000005416 000000AUT*
|
||||
D0022B41Guccini 160813Grubmüller Lea 00000005463 000000AUT*
|
||||
D0031317Quality's Finest 612295Stroblmair Victoria 00000005492 000000AUT*
|
||||
D0041A11Gradan 102783Steyrer Anna 00000005858 000000AUT*
|
||||
D005KSS1Charity Coke 053749Eichler Eva 00040006428 000000AUT
|
||||
B092Standardspringprüfung - CSNP-C_N007000000009
|
||||
C092001307002129000000000000000000000000000000000000021771000000
|
||||
D001A024D Day 075374Ambros Susanne 00000005940 000000AUT*10071068 108EH50
|
||||
D0021G88Hamira 3 074007Beißmann Andreas 00000005991 000000AUT*
|
||||
D0032G77S Mirrallas 605835Ellmer Kassandra 00000006298 000000AUT*
|
||||
D0043966Capitaine 601366Madlmayr Carina 00040005862 000000AUT
|
||||
D0051942Obora's Agnetha 601300Hofer Michaela 00040005966 000000AUT
|
||||
D006Y001Bella Graziella 144315Gaugl Laura 00080005012 000000AUT
|
||||
D9972785Herr Frodo 144315Gaugl Laura A00000000000 000000AUT
|
||||
B100Springpferdeprüfung - CSN-C-Ne000000000010
|
||||
C100001307002129000000000000000000000000000000000000021771000000
|
||||
B110Stilspringprüfung - CSN-C-Ne002000000011
|
||||
C110001307002129000000000000000000000000000000000000021771000000
|
||||
D0012062Grover 157407Pröll Leonie 00085000000 000000AUT*
|
||||
D0021317Quality's Finest 612295Stroblmair Victoria 00080000000 000000AUT*
|
||||
B121Standardspringprüfung - CSN-C-Ne002000000012
|
||||
C121001307002129000000000000000000000000000000000000021771000000
|
||||
D0012062Grover 157407Pröll Leonie 00040005651 000000AUT*
|
||||
D0022B41Guccini 160813Grubmüller Lea 00080005774 000000AUT
|
||||
B122Standardspringprüfung - CSN-C-Ne004000000012
|
||||
C122001307002129000000000000000000000000000000000000021771000000
|
||||
D001AS94Landliebe 3 162776Höllmüller Anna 00000005557 000000AUT*10294537
|
||||
D0022G77S Mirrallas 605835Ellmer Kassandra 00000006212 000000AUT*
|
||||
D0031942Obora's Agnetha 601300Hofer Michaela 00000006723 000000AUT*
|
||||
D004A024D Day 075374Ambros Susanne 00040005943 000000AUT 10071068 108EH50
|
||||
B130Stilspringprüfung - CSN-C-Ne001000000013
|
||||
C130001307002129000000000000000000000000000000000000021771000000
|
||||
D0014258Casino East 601300Hofer Michaela 00075000000 000000AUT*
|
||||
B140Standardspringprüfung - CSN-C-Ne003000000014
|
||||
C140001307002129000000000000000000000000000000000000021771000000
|
||||
D0012010Leonidas van de Zuuthoeve Z 145960Fischerlehner Leonie 00000005368 000000AUT*
|
||||
D002AS94Landliebe 3 162776Höllmüller Anna 00000005745 000000AUT*10294537
|
||||
D0034258Casino East 601300Hofer Michaela 00000006261 000000AUT*
|
||||
Binary file not shown.
@@ -1,96 +0,0 @@
|
||||
A26129CDN-C-NEU CDNP-C_NEU NEUM2026042620260426 2.2PSO v1.07
|
||||
B010Dressurprüfung lzf CDN-C_Ne002000000001
|
||||
C010000000038705000000000000000000000000000000000000000000000000
|
||||
D001PB70Daneder's Caramello 163545Montgomery Helena 00062000000 000000AUT*
|
||||
D0022892Amore 5 AUT Stadler Caroline 00060000000 000000AUT*
|
||||
B020Dressurprüfung lzf CDN-C_Ne003000000002
|
||||
C020000000038705000000000000000000000000000000000000000000000000
|
||||
D0014208Sahib Silver G 195331Neuhauser Lara 00075000000 000000AUT*
|
||||
D0022892Amore 5 AUT Stadler Caroline 00068000000 000000AUT*
|
||||
D003PB70Daneder's Caramello 163545Montgomery Helena 00060000000 000000AUT*
|
||||
B030Dressurreiterprüfung lzf CDN-C_Ne005000000003
|
||||
C030035110000000000000000000000000000000000000000000000000000000
|
||||
D001PC62Flora HP 917397Altendorfer Pia 00080000000 000000AUT*
|
||||
D002Z002Abrakadabra S 107926Fürbäck Melanie 00077000000 000000AUT*
|
||||
D0034Y59Legolas 196 184074Stöbich Enya 00068000000 000000AUT*
|
||||
D004HKTBAcceptius FA 196261Salzinger Luisa Marie 00064000000 000000AUT
|
||||
D005PA53Rathcline Star 922380Kropfreiter Ines 00062000000 000000AUT
|
||||
B040Dressurreiterprüfung lzf CDN-C_Ne006000000004
|
||||
C040000000038705000000000000000000000000000000000000000000000000
|
||||
D001PC62Flora HP 917397Altendorfer Pia 00078000000 000000AUT*
|
||||
D0024208Sahib Silver G 195331Neuhauser Lara 00076000000 000000AUT*
|
||||
D003Z002Abrakadabra S 107926Fürbäck Melanie 00068000000 000000AUT*
|
||||
D004HKTBAcceptius FA 196261Salzinger Luisa Marie 00064000000 000000AUT
|
||||
D0054Y59Legolas 196 184074Stöbich Enya 00062000000 000000AUT
|
||||
D006PA53Rathcline Star 922380Kropfreiter Ines 00060000000 000000AUT
|
||||
B050Dressurreiterprüfung lzf CDN-C_Ne001000000005
|
||||
C050035110000000000000000000000000000000000000000000000000000000
|
||||
D001PF06Domino N AUT Stelzl Helena 00080000000 000000AUT*
|
||||
B060Dressurreiterprüfung lzf CDN-C_Ne000000000006
|
||||
C060035110000000000000000000000000000000000000000000000000000000
|
||||
B070Pony Dressurprüfung A CSNP-C_N003000000007
|
||||
C070000000038705000000000000000000000000000000000000000000000000
|
||||
D001PT24Daneder's Captain 146663Steinmetz Sinah-Marie 00065000000 000000AUT*
|
||||
D002P561Ginger Bread Girl 153601Winter Maja Sophie 00060000000 000000AUT*
|
||||
D003PA53Rathcline Star 906592Emsenhuber Tanja 00058000000 000000AUT
|
||||
B081Dressurreiterprüfung A CDN-C_Ne006000000008
|
||||
C081035110038705000000000000000000000000000000000000000000000000
|
||||
D0013888Ravasz 123156Scheiblechner Sonja 00072000000 000000AUT*
|
||||
D0024307Makker 146066Gstöttenbauer Olivia 00064000000 000000AUT*
|
||||
D003P561Ginger Bread Girl 153601Winter Maja Sophie 00062000000 000000AUT*
|
||||
D0042083Light Blue 194297Hazoth Anna-Maria 00060000000 000000AUT*
|
||||
D0053M58Samantha 25 609771Karl Reinhard 00058000000 000000AUT
|
||||
D005KSS1Charity Coke 053749Eichler Eva 00058000000 000000AUT
|
||||
B082Dressurreiterprüfung A CDN-C_Ne002000000008
|
||||
C082035110038705000000000000000000000000000000000000000000000000
|
||||
D001GIGIGigi D'Agostidinina 076742Klein Elisabeth 00068000000 000000AUT*10144403
|
||||
D002A590Queeny 8 612592Panzirsch Anna 00064000000 000000AUT*
|
||||
B091Dressurprüfung A CDN-C_Ne007000000009
|
||||
C091035110038705000000000000000000000000000000000000000000000000
|
||||
D0013888Ravasz 123156Scheiblechner Sonja 00070000000 000000AUT*
|
||||
D002AN19Exklusiv EM 187665Mück Hannah 00064000000 000000AUT*
|
||||
D0032083Light Blue 194297Hazoth Anna-Maria 00062000000 000000AUT*
|
||||
D0044B66Vingino's Victory 616957Kiesenhofer Sarah 00058000000 000000AUT
|
||||
D0053M58Samantha 25 609771Karl Reinhard 00055000000 000000AUT
|
||||
D005KSS1Charity Coke 053749Eichler Eva 00055000000 000000AUT
|
||||
D0074307Makker 146066Gstöttenbauer Olivia 00053000000 000000AUT
|
||||
B092Dressurprüfung A CDN-C_Ne004000000009
|
||||
C092035110038705000000000000000000000000000000000000000000000000
|
||||
D001GIGIGigi D'Agostidinina 076742Klein Elisabeth 00068000000 000000AUT*10144403
|
||||
D002AL46Superbunt 616836Lengauer Julia 00065000000 000000AUT*
|
||||
D0032010Leonidas van de Zuuthoeve Z 145960Fischerlehner Leonie 00064000000 000000AUT*
|
||||
D004A590Queeny 8 612592Panzirsch Anna 00058000000 000000AUT
|
||||
B100Pony Dressurprüfung L CSNP-C_N001000000010
|
||||
C100035110038705000000000000000000000000000000000000000000000000
|
||||
D001P540Pieter V 153601Winter Maja Sophie 00056000000 000000AUT*
|
||||
B110Dressurreiterprüfung L CDN-C_Ne003000000011
|
||||
C110035110038705000000000000000000000000000000000000000000000000
|
||||
D0011317Quality's Finest 612295Stroblmair Victoria 00074000000 000000AUT*
|
||||
D0021F34Ferro Felicis 146066Gstöttenbauer Olivia 00064000000 000000AUT*
|
||||
D003P540Pieter V 153601Winter Maja Sophie 00062000000 000000AUT*
|
||||
B121Dressurprüfung L CDN-C_Ne003000000012
|
||||
C121035110038705000000000000000000000000000000000000000000000000
|
||||
D001AN19Exklusiv EM 187665Mück Hannah 00068000000 000000AUT*
|
||||
D0021F34Ferro Felicis 146066Gstöttenbauer Olivia 00062000000 000000AUT*
|
||||
D003AE11Merlin SH 061601Povacz Gisela 00060000000 000000AUT*
|
||||
B122Dressurprüfung L CDN-C_Ne002000000012
|
||||
C122035110038705000000000000000000000000000000000000000000000000
|
||||
D001A024D Day 075374Ambros Susanne 00066000000 000000AUT*10071068 108EH50
|
||||
D0023966Capitaine 601366Madlmayr Carina 00060000000 000000AUT*
|
||||
B131Dressurpferdeprüfung A CDN-C_Ne003000000013
|
||||
C131035110038705000000000000000000000000000000000000000000000000
|
||||
D001AX99Bon Sai 102783Steyrer Anna 00073400000 000000AUT*
|
||||
D0020214SHS Donna Verdi 169981Süss Sarah 00066000000 000000AUT*
|
||||
D003PT24Daneder's Captain 146663Steinmetz Sinah-Marie 00061200000 000000AUT*
|
||||
B132Dressurpferdeprüfung A CDN-C_Ne005000000013
|
||||
C132035110038705000000000000000000000000000000000000000000000000
|
||||
D001MAXIVerstappen 2 075374Ambros Susanne 00074600000 000000AUT*10071068
|
||||
D0022H08SHS Weltmädel 169981Süss Sarah 00074200000 000000AUT*
|
||||
D003P983Daneders Tornado 153601Winter Maja Sophie 00064800000 000000AUT*
|
||||
D0044B03SHS Roubinjo 169981Süss Sarah 00064200000 000000AUT*
|
||||
D0054B66Vingino's Victory 616957Kiesenhofer Sarah 00063000000 000000AUT*
|
||||
B140Dressurpferdeprüfung L CDN-C_Ne003000000014
|
||||
C140035110038705000000000000000000000000000000000000000000000000
|
||||
D0012H08SHS Weltmädel 169981Süss Sarah 00067800000 000000AUT*
|
||||
D002P983Daneders Tornado 153601Winter Maja Sophie 00064800000 000000AUT*
|
||||
D0034B03SHS Roubinjo 169981Süss Sarah 00063000000 000000AUT*
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 325 KiB |
+4
-59
@@ -1,17 +1,13 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Ein generischer Dropdown zur Auswahl von Enum-Werten.
|
||||
@@ -35,7 +31,6 @@ fun <T : Enum<T>> MsEnumDropdown(
|
||||
onOptionSelected: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
optionLabel: (T) -> String = { it.name },
|
||||
helpDescription: String? = null,
|
||||
enabled: Boolean = true,
|
||||
isError: Boolean = false,
|
||||
errorMessage: String? = null
|
||||
@@ -51,57 +46,7 @@ fun <T : Enum<T>> MsEnumDropdown(
|
||||
value = selectedOption?.let { optionLabel(it) } ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodySmall)
|
||||
if (helpDescription != null) {
|
||||
val tooltipState = rememberTooltipState(isPersistent = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above
|
||||
),
|
||||
tooltip = {
|
||||
PlainTooltip(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = helpDescription,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(Dimens.SpacingS)
|
||||
)
|
||||
}
|
||||
},
|
||||
state = tooltipState
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (tooltipState.isVisible) tooltipState.dismiss()
|
||||
else tooltipState.show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.focusProperties { canFocus = false }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
|
||||
contentDescription = "Hilfe",
|
||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text(label, style = MaterialTheme.typography.bodySmall) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
|
||||
modifier = Modifier
|
||||
|
||||
-1
@@ -13,7 +13,6 @@ expect fun MsFilePicker(
|
||||
onFileSelected: (String) -> Unit,
|
||||
fileExtensions: List<String> = emptyList(),
|
||||
directoryOnly: Boolean = false,
|
||||
helpDescription: String? = null,
|
||||
enabled: Boolean = true,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
|
||||
+4
-59
@@ -1,17 +1,13 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Ein generischer Dropdown zur Auswahl von Strings (z. B. Druckernamen).
|
||||
@@ -25,7 +21,6 @@ fun MsStringDropdown(
|
||||
onOptionSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: String = "",
|
||||
helpDescription: String? = null,
|
||||
enabled: Boolean = true,
|
||||
isError: Boolean = false,
|
||||
errorMessage: String? = null
|
||||
@@ -41,57 +36,7 @@ fun MsStringDropdown(
|
||||
value = selectedOption,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodySmall)
|
||||
if (helpDescription != null) {
|
||||
val tooltipState = rememberTooltipState(isPersistent = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above
|
||||
),
|
||||
tooltip = {
|
||||
PlainTooltip(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = helpDescription,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(Dimens.SpacingS)
|
||||
)
|
||||
}
|
||||
},
|
||||
state = tooltipState
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (tooltipState.isVisible) tooltipState.dismiss()
|
||||
else tooltipState.show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.focusProperties { canFocus = false }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
|
||||
contentDescription = "Hilfe",
|
||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text(label, style = MaterialTheme.typography.bodySmall) },
|
||||
placeholder = { Text(placeholder, style = MaterialTheme.typography.bodySmall) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
|
||||
|
||||
+7
-65
@@ -3,13 +3,9 @@ package at.mocode.frontend.core.designsystem.components
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -17,7 +13,6 @@ 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
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MsTextField(
|
||||
@@ -32,7 +27,6 @@ fun MsTextField(
|
||||
isError: Boolean = false,
|
||||
errorMessage: String? = null,
|
||||
helperText: String? = null,
|
||||
helpDescription: String? = null,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
singleLine: Boolean = true,
|
||||
@@ -47,62 +41,12 @@ fun MsTextField(
|
||||
|
||||
Column(modifier = modifier) {
|
||||
if (label != null) {
|
||||
Row(
|
||||
modifier = Modifier.padding(bottom = 4.dp, start = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (helpDescription != null) {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
val tooltipState = rememberTooltipState(isPersistent = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above
|
||||
),
|
||||
tooltip = {
|
||||
PlainTooltip(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = helpDescription,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(Dimens.SpacingS)
|
||||
)
|
||||
}
|
||||
},
|
||||
state = tooltipState
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (tooltipState.isVisible) tooltipState.dismiss()
|
||||
else tooltipState.show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.focusProperties { canFocus = false }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
|
||||
contentDescription = "Hilfe",
|
||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
@@ -136,9 +80,7 @@ fun MsTextField(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||
focusedLabelColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
|
||||
+2
-4
@@ -1,6 +1,5 @@
|
||||
package at.mocode.frontend.core.designsystem.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -39,8 +38,7 @@ private val DarkColorScheme = darkColorScheme(
|
||||
background = AppColors.BackgroundDark,
|
||||
surface = AppColors.SurfaceDark,
|
||||
onBackground = AppColors.OnBackgroundDark,
|
||||
onSurface = AppColors.OnSurfaceDark,
|
||||
outline = AppColors.OutlineDark,
|
||||
onSurface = AppColors.OnBackgroundDark,
|
||||
|
||||
error = AppColors.Error,
|
||||
onError = AppColors.OnError
|
||||
@@ -65,7 +63,7 @@ private val AppMaterialTypography = Typography(
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(), // Nutzt Systemeinstellung als Default
|
||||
darkTheme: Boolean = false, // Kann später via Settings gesteuert werden
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||
|
||||
+3
-5
@@ -28,11 +28,9 @@ object AppColors {
|
||||
val OnBackgroundLight = Color(0xFF172B4D) // Fast Schwarz (besser lesbar)
|
||||
|
||||
// Neutral & Hintergrund (Dark Mode)
|
||||
val BackgroundDark = Color(0xFF121212) // Tieferes Schwarz für Dark Mode
|
||||
val SurfaceDark = Color(0xFF1E1E1E)
|
||||
val OnBackgroundDark = Color(0xFFE1E1E1)
|
||||
val OnSurfaceDark = Color(0xFFE1E1E1)
|
||||
val OutlineDark = Color(0xFF333333)
|
||||
val BackgroundDark = Color(0xFF1E1E1E) // Angenehmes, dunkles Grau
|
||||
val SurfaceDark = Color(0xFF2C2C2C)
|
||||
val OnBackgroundDark = Color(0xFFEBECF0)
|
||||
|
||||
// System Status
|
||||
val Error = Color(0xFFDE350B)
|
||||
|
||||
+3
-3
@@ -34,7 +34,7 @@ object Dimens {
|
||||
val CornerRadiusL = 12.dp
|
||||
|
||||
// Form-Elemente (Eingabefelder, Buttons)
|
||||
val TextFieldHeight = 40.dp // Kompakte Höhe für Desktop-Enterprise-Apps
|
||||
val TextFieldHeightL = 48.dp // Etwas weniger als Standard Material (56.dp)
|
||||
val ButtonHeight = 36.dp // Kompakterer Button
|
||||
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
|
||||
}
|
||||
|
||||
+9
-17
@@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
import javax.swing.JFileChooser
|
||||
|
||||
@Composable
|
||||
actual fun MsFilePicker(
|
||||
@@ -19,7 +20,6 @@ actual fun MsFilePicker(
|
||||
onFileSelected: (String) -> Unit,
|
||||
fileExtensions: List<String>,
|
||||
directoryOnly: Boolean,
|
||||
helpDescription: String?,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier
|
||||
) {
|
||||
@@ -32,7 +32,6 @@ actual fun MsFilePicker(
|
||||
onValueChange = { },
|
||||
readOnly = true,
|
||||
label = label,
|
||||
helpDescription = helpDescription,
|
||||
placeholder = if (directoryOnly) "Verzeichnis wählen..." else "Datei wählen...",
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = enabled,
|
||||
@@ -44,26 +43,19 @@ actual fun MsFilePicker(
|
||||
MsButton(
|
||||
onClick = {
|
||||
if (directoryOnly) {
|
||||
// AWT FileDialog für nativen Look auch bei Verzeichnissen (Windows/Linux/macOS)
|
||||
// unter macOS erzwingt dies die Verzeichnisauswahl. Unter Windows/Linux ist es der Standard-Dialog.
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", "true")
|
||||
val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply {
|
||||
// JFileChooser ist für Verzeichnisse auf dem Desktop oft stabiler/einfacher
|
||||
val chooser = JFileChooser().apply {
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
dialogTitle = label
|
||||
selectedPath?.let {
|
||||
val currentDir = File(it)
|
||||
if (currentDir.exists()) {
|
||||
directory = currentDir.absolutePath
|
||||
}
|
||||
if (currentDir.exists()) currentDirectory = currentDir
|
||||
}
|
||||
}
|
||||
dialog.isVisible = true
|
||||
if (dialog.directory != null && dialog.file != null) {
|
||||
// Bei FileDialog.LOAD unter Windows/Linux wählt man oft eine Datei im Ordner,
|
||||
// aber wir wollen den Ordner. Wir nehmen also das Verzeichnis.
|
||||
onFileSelected(File(dialog.directory, dialog.file).parentFile.absolutePath)
|
||||
} else if (dialog.directory != null) {
|
||||
onFileSelected(dialog.directory)
|
||||
val result = chooser.showOpenDialog(null)
|
||||
if (result == JFileChooser.APPROVE_OPTION) {
|
||||
onFileSelected(chooser.selectedFile.absolutePath)
|
||||
}
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", "false")
|
||||
} else {
|
||||
// AWT FileDialog für nativen Look bei Dateiauswahl (wie vom User gewünscht)
|
||||
val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply {
|
||||
|
||||
-1
@@ -10,7 +10,6 @@ actual fun MsFilePicker(
|
||||
onFileSelected: (String) -> Unit,
|
||||
fileExtensions: List<String>,
|
||||
directoryOnly: Boolean,
|
||||
helpDescription: String?,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier
|
||||
) {
|
||||
|
||||
@@ -24,7 +24,6 @@ kotlin {
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(projects.core.coreDomain)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
|
||||
|
||||
-2
@@ -68,7 +68,6 @@ sealed class AppScreen(val route: String) {
|
||||
data object Cups : AppScreen("/cups")
|
||||
data object StammdatenImport : AppScreen("/stammdaten/import")
|
||||
data object NennungsEingang : AppScreen("/nennungs-eingang")
|
||||
data object Chat : AppScreen("/chat")
|
||||
|
||||
companion object {
|
||||
private val EVENT_DETAIL = Regex("/event/(\\d+)$")
|
||||
@@ -113,7 +112,6 @@ sealed class AppScreen(val route: String) {
|
||||
"/cups" -> Cups
|
||||
"/stammdaten/import" -> StammdatenImport
|
||||
"/nennungs-eingang" -> NennungsEingang
|
||||
"/chat" -> Chat
|
||||
else -> {
|
||||
EVENT_NEU.matchEntire(route)?.let { match ->
|
||||
val vId = match.groups[2]?.value?.toLong()
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package at.mocode.frontend.core.network.backup
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BackupPayload(
|
||||
val timestamp: Long,
|
||||
val deviceName: String,
|
||||
val data: String,
|
||||
val checksum: String
|
||||
)
|
||||
|
||||
interface BackupService {
|
||||
/**
|
||||
* Schreibt Daten verschlüsselt in das Backup-Verzeichnis.
|
||||
*/
|
||||
fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String>
|
||||
|
||||
/**
|
||||
* Liest Daten aus einer verschlüsselten Datei ein.
|
||||
*/
|
||||
fun importDelta(filePath: String, sharedKey: String): Result<String>
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ package at.mocode.frontend.core.network.discovery
|
||||
import org.koin.core.module.Module
|
||||
|
||||
/**
|
||||
* Erwartetes Koin-Modul für die Netzwerk-Discovery und Backup.
|
||||
* Erwartetes Koin-Modul für die Netzwerk-Discovery.
|
||||
* Plattform-spezifische Implementierungen (JVM mit JmDNS, JS/Wasm evtl. No-op)
|
||||
* müssen hier injiziert werden.
|
||||
*/
|
||||
|
||||
+25
-28
@@ -6,10 +6,10 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
* Modell für einen entdeckten Dienst im lokalen Netzwerk.
|
||||
*/
|
||||
data class DiscoveredService(
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val metadata: Map<String, String> = emptyMap()
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val metadata: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -17,33 +17,30 @@ data class DiscoveredService(
|
||||
* Erlaubt Offline-First Synchronisation im LAN.
|
||||
*/
|
||||
interface NetworkDiscoveryService {
|
||||
/**
|
||||
* Ein StateFlow, der die aktuell entdeckten Dienste enthält.
|
||||
* Ideal für reaktive UIs (Compose).
|
||||
*/
|
||||
val discoveredServices: StateFlow<List<DiscoveredService>>
|
||||
/**
|
||||
* Ein StateFlow, der die aktuell entdeckten Dienste enthält.
|
||||
* Ideal für reaktive UIs (Compose).
|
||||
*/
|
||||
val discoveredServices: StateFlow<List<DiscoveredService>>
|
||||
|
||||
/**
|
||||
* Startet das Scannen nach verfügbaren Diensten im Netzwerk.
|
||||
* @param preferredIp Optional eine IP-Adresse, an die der Discovery-Dienst gebunden werden soll.
|
||||
*/
|
||||
fun startDiscovery(preferredIp: String? = null)
|
||||
* Startet das Scannen nach verfügbaren Diensten im Netzwerk.
|
||||
*/
|
||||
fun startDiscovery()
|
||||
|
||||
/**
|
||||
* Stoppt den Scan-Vorgang.
|
||||
*/
|
||||
fun stopDiscovery()
|
||||
/**
|
||||
* Stoppt den Scan-Vorgang.
|
||||
*/
|
||||
fun stopDiscovery()
|
||||
|
||||
/**
|
||||
* Registriert den eigenen Dienst, damit andere Instanzen ihn finden können.
|
||||
* @param port Der Port, auf dem der lokale WebSocket-Server lauscht.
|
||||
* @param preferredIp Optional eine IP-Adresse, an die der Discovery-Dienst gebunden werden soll.
|
||||
* @param deviceName Der Name des Geräts, das im Netzwerk angezeigt werden soll.
|
||||
*/
|
||||
fun registerService(port: Int, preferredIp: String? = null, deviceName: String? = null)
|
||||
/**
|
||||
* Registriert den eigenen Dienst, damit andere Instanzen ihn finden können.
|
||||
* @param port Der Port, auf dem der lokale WebSocket-Server lauscht.
|
||||
*/
|
||||
fun registerService(port: Int)
|
||||
|
||||
/**
|
||||
* Gibt die Liste der aktuell entdeckten Dienste zurück (Snapshot).
|
||||
*/
|
||||
fun getDiscoveredServices(): List<DiscoveredService>
|
||||
/**
|
||||
* Gibt die Liste der aktuell entdeckten Dienste zurück (Snapshot).
|
||||
*/
|
||||
fun getDiscoveredServices(): List<DiscoveredService>
|
||||
}
|
||||
|
||||
+35
-35
@@ -9,49 +9,49 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
* Er lauscht auf neu entdeckte Dienste und baut automatisch Verbindungen auf.
|
||||
*/
|
||||
class SyncManager(
|
||||
private val discoveryService: NetworkDiscoveryService,
|
||||
private val syncService: P2pSyncService
|
||||
private val discoveryService: NetworkDiscoveryService,
|
||||
private val syncService: P2pSyncService
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob())
|
||||
private val knownPeers = mutableSetOf<String>()
|
||||
private val scope = CoroutineScope(SupervisorJob())
|
||||
private val knownPeers = mutableSetOf<String>()
|
||||
|
||||
fun start(port: Int, preferredIp: String? = null) {
|
||||
// Eigenen Dienst registrieren und Server starten
|
||||
discoveryService.registerService(port, preferredIp)
|
||||
syncService.startServer(port)
|
||||
discoveryService.startDiscovery(preferredIp)
|
||||
fun start(port: Int) {
|
||||
// Eigenen Dienst registrieren und Server starten
|
||||
discoveryService.registerService(port)
|
||||
syncService.startServer(port)
|
||||
discoveryService.startDiscovery()
|
||||
|
||||
// Regelmäßig nach neuen Peers suchen und verbinden
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val discovered = discoveryService.getDiscoveredServices()
|
||||
discovered.forEach { service ->
|
||||
val peerKey = "${service.host}:${service.port}"
|
||||
if (!knownPeers.contains(peerKey)) {
|
||||
// TODO: Node-ID Vergleich (Selbst-Verbindung vermeiden)
|
||||
println("[SyncManager] Neuer Peer entdeckt: $peerKey. Verbinde...")
|
||||
syncService.connectToPeer(service.host, service.port)
|
||||
knownPeers.add(peerKey)
|
||||
}
|
||||
// Regelmäßig nach neuen Peers suchen und verbinden
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val discovered = discoveryService.getDiscoveredServices()
|
||||
discovered.forEach { service ->
|
||||
val peerKey = "${service.host}:${service.port}"
|
||||
if (!knownPeers.contains(peerKey)) {
|
||||
// TODO: Node-ID Vergleich (Selbst-Verbindung vermeiden)
|
||||
println("[SyncManager] Neuer Peer entdeckt: $peerKey. Verbinde...")
|
||||
syncService.connectToPeer(service.host, service.port)
|
||||
knownPeers.add(peerKey)
|
||||
}
|
||||
}
|
||||
delay(5000.milliseconds) // Alle 5 Sekunden prüfen
|
||||
}
|
||||
}
|
||||
delay(5000.milliseconds) // Alle 5 Sekunden prüfen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getConnectedPeers() = syncService.connectedPeers
|
||||
fun getConnectedPeers() = syncService.connectedPeers
|
||||
|
||||
fun broadcastEvent(event: SyncEvent) {
|
||||
scope.launch {
|
||||
syncService.broadcastEvent(event)
|
||||
fun broadcastEvent(event: SyncEvent) {
|
||||
scope.launch {
|
||||
syncService.broadcastEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getIncomingEvents() = syncService.incomingEvents
|
||||
fun getIncomingEvents() = syncService.incomingEvents
|
||||
|
||||
fun stop() {
|
||||
scope.cancel()
|
||||
discoveryService.stopDiscovery()
|
||||
syncService.stopServer()
|
||||
}
|
||||
fun stop() {
|
||||
scope.cancel()
|
||||
discoveryService.stopDiscovery()
|
||||
syncService.stopServer()
|
||||
}
|
||||
}
|
||||
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
package at.mocode.frontend.core.network.backup
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.*
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class FileBackupService(private val deviceName: String) : BackupService {
|
||||
private val json = Json { prettyPrint = true }
|
||||
|
||||
override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String> {
|
||||
return try {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val checksum = calculateChecksum(data)
|
||||
val payload = BackupPayload(timestamp, deviceName, data, checksum)
|
||||
val jsonContent = json.encodeToString(payload)
|
||||
|
||||
val encryptedData = encrypt(jsonContent, sharedKey)
|
||||
|
||||
val dir = File(targetPath)
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
|
||||
val fileName = "delta_${timestamp}_${deviceName}.msbackup"
|
||||
val file = File(dir, fileName)
|
||||
file.writeText(encryptedData)
|
||||
|
||||
println("[Plan-USB] Export erfolgreich: ${file.absolutePath}")
|
||||
Result.success(file.absoluteName)
|
||||
} catch (e: Exception) {
|
||||
println("[Plan-USB] Export fehlgeschlagen: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun importDelta(filePath: String, sharedKey: String): Result<String> {
|
||||
return try {
|
||||
val file = File(filePath)
|
||||
val encryptedData = file.readText()
|
||||
val jsonContent = decrypt(encryptedData, sharedKey)
|
||||
val payload = json.decodeFromString<BackupPayload>(jsonContent)
|
||||
|
||||
if (calculateChecksum(payload.data) != payload.checksum) {
|
||||
throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.")
|
||||
}
|
||||
|
||||
println("[Plan-USB] Import erfolgreich von ${payload.deviceName}")
|
||||
Result.success(payload.data)
|
||||
} catch (e: Exception) {
|
||||
println("[Plan-USB] Import fehlgeschlagen: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateChecksum(data: String): String {
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun encrypt(data: String, key: String): String {
|
||||
val secretKey = generateKey(key)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv)
|
||||
val encrypted = cipher.doFinal(data.toByteArray())
|
||||
return Base64.getEncoder().encodeToString(encrypted)
|
||||
}
|
||||
|
||||
private fun decrypt(encrypted: String, key: String): String {
|
||||
val secretKey = generateKey(key)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val iv = IvParameterSpec(ByteArray(16))
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
|
||||
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted))
|
||||
return String(decrypted)
|
||||
}
|
||||
|
||||
private fun generateKey(key: String): SecretKeySpec {
|
||||
val sha = MessageDigest.getInstance("SHA-256")
|
||||
val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität
|
||||
return SecretKeySpec(keyBytes, "AES")
|
||||
}
|
||||
}
|
||||
|
||||
private val File.absoluteName: String get() = this.name
|
||||
-3
@@ -1,7 +1,5 @@
|
||||
package at.mocode.frontend.core.network.discovery
|
||||
|
||||
import at.mocode.frontend.core.network.backup.BackupService
|
||||
import at.mocode.frontend.core.network.backup.FileBackupService
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
@@ -10,5 +8,4 @@ import org.koin.dsl.module
|
||||
*/
|
||||
actual val discoveryModule: Module = module {
|
||||
single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
|
||||
single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) }
|
||||
}
|
||||
|
||||
+49
-65
@@ -15,80 +15,64 @@ import javax.jmdns.ServiceListener
|
||||
*/
|
||||
class JmDnsDiscoveryService : NetworkDiscoveryService {
|
||||
|
||||
private var jmdns: JmDNS? = null
|
||||
private var jmdns: JmDNS? = null
|
||||
private val SERVICE_TYPE = "_meldestelle._tcp.local."
|
||||
private val discoveredServicesMap = ConcurrentHashMap<String, DiscoveredService>()
|
||||
private val discoveredServicesMap = ConcurrentHashMap<String, DiscoveredService>()
|
||||
|
||||
private val _discoveredServices = MutableStateFlow<List<DiscoveredService>>(emptyList())
|
||||
override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow()
|
||||
|
||||
override fun startDiscovery(preferredIp: String?) {
|
||||
if (jmdns == null) {
|
||||
val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost()
|
||||
println("[Discovery] Starte Discovery gebunden an: $addr")
|
||||
jmdns = JmDNS.create(addr)
|
||||
override fun startDiscovery() {
|
||||
if (jmdns == null) {
|
||||
jmdns = JmDNS.create(InetAddress.getLocalHost())
|
||||
}
|
||||
|
||||
jmdns?.addServiceListener(SERVICE_TYPE, object : ServiceListener {
|
||||
override fun serviceAdded(event: ServiceEvent) {
|
||||
// Bei ServiceAdded fordern wir die Details an
|
||||
jmdns?.requestServiceInfo(event.type, event.name)
|
||||
}
|
||||
|
||||
override fun serviceRemoved(event: ServiceEvent) {
|
||||
discoveredServicesMap.remove(event.name)
|
||||
_discoveredServices.value = discoveredServicesMap.values.toList()
|
||||
println("[Discovery] Service entfernt: ${event.name}")
|
||||
}
|
||||
|
||||
override fun serviceResolved(event: ServiceEvent) {
|
||||
val info = event.info
|
||||
val service = DiscoveredService(
|
||||
name = event.name,
|
||||
host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown",
|
||||
port = info.port,
|
||||
metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) }
|
||||
)
|
||||
discoveredServicesMap[event.name] = service
|
||||
_discoveredServices.value = discoveredServicesMap.values.toList()
|
||||
println("[Discovery] Service gefunden: ${service.name} @ ${service.host}:${service.port}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
jmdns?.addServiceListener(SERVICE_TYPE, object : ServiceListener {
|
||||
override fun serviceAdded(event: ServiceEvent) {
|
||||
// Bei ServiceAdded fordern wir die Details an
|
||||
jmdns?.requestServiceInfo(event.type, event.name)
|
||||
}
|
||||
override fun stopDiscovery() {
|
||||
jmdns?.close()
|
||||
jmdns = null
|
||||
discoveredServicesMap.clear()
|
||||
_discoveredServices.value = emptyList()
|
||||
}
|
||||
|
||||
override fun serviceRemoved(event: ServiceEvent) {
|
||||
discoveredServicesMap.remove(event.name)
|
||||
_discoveredServices.value = discoveredServicesMap.values.toList()
|
||||
println("[Discovery] Service entfernt: ${event.name}")
|
||||
}
|
||||
|
||||
override fun serviceResolved(event: ServiceEvent) {
|
||||
val info = event.info
|
||||
val service = DiscoveredService(
|
||||
name = event.name,
|
||||
host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown",
|
||||
port = info.port,
|
||||
metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) }
|
||||
override fun registerService(port: Int) {
|
||||
val serviceInfo = ServiceInfo.create(
|
||||
SERVICE_TYPE,
|
||||
"Meldestelle-${System.getProperty("user.name")}",
|
||||
port,
|
||||
"Offline-First Sync Node"
|
||||
)
|
||||
discoveredServicesMap[event.name] = service
|
||||
_discoveredServices.value = discoveredServicesMap.values.toList()
|
||||
println("[Discovery] Service gefunden: ${service.name} @ ${service.host}:${service.port}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun stopDiscovery() {
|
||||
jmdns?.close()
|
||||
jmdns = null
|
||||
discoveredServicesMap.clear()
|
||||
_discoveredServices.value = emptyList()
|
||||
}
|
||||
|
||||
override fun registerService(port: Int, preferredIp: String?, deviceName: String?) {
|
||||
if (jmdns == null) {
|
||||
val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost()
|
||||
println("[Discovery] Registriere Dienst gebunden an: $addr")
|
||||
jmdns = JmDNS.create(addr)
|
||||
jmdns?.registerService(serviceInfo)
|
||||
println("[Discovery] Eigenen Dienst registriert auf Port $port")
|
||||
}
|
||||
|
||||
// Wir nutzen den übergebenen Namen, den vom System gesetzten oder einen sprechenden Default
|
||||
val name = deviceName ?: System.getProperty("meldestelle.device.name") ?: "Meldestelle-${System.getProperty("user.name")}"
|
||||
|
||||
val serviceInfo = ServiceInfo.create(
|
||||
SERVICE_TYPE,
|
||||
name,
|
||||
port,
|
||||
0, 0, // weight, priority
|
||||
mapOf(
|
||||
"version" to "1.0.0",
|
||||
"type" to "master",
|
||||
"nodeId" to name
|
||||
)
|
||||
)
|
||||
jmdns?.registerService(serviceInfo)
|
||||
println("[Discovery] Eigenen Dienst '$name' registriert auf Port $port")
|
||||
}
|
||||
|
||||
override fun getDiscoveredServices(): List<DiscoveredService> {
|
||||
return discoveredServicesMap.values.toList()
|
||||
}
|
||||
override fun getDiscoveredServices(): List<DiscoveredService> {
|
||||
return discoveredServicesMap.values.toList()
|
||||
}
|
||||
}
|
||||
|
||||
+5
-6
@@ -10,15 +10,14 @@ import org.koin.dsl.module
|
||||
* Wasm-spezifische Implementierung (vorerst No-op).
|
||||
*/
|
||||
actual val discoveryModule: Module = module {
|
||||
single<NetworkDiscoveryService> { NoOpDiscoveryService() }
|
||||
single<NetworkDiscoveryService> { NoOpDiscoveryService() }
|
||||
}
|
||||
|
||||
class NoOpDiscoveryService : NetworkDiscoveryService {
|
||||
override val discoveredServices: StateFlow<List<DiscoveredService>> =
|
||||
MutableStateFlow<List<DiscoveredService>>(emptyList()).asStateFlow()
|
||||
|
||||
override fun startDiscovery(preferredIp: String?) {}
|
||||
override fun stopDiscovery() {}
|
||||
override fun registerService(port: Int, preferredIp: String?, deviceName: String?) {}
|
||||
override fun getDiscoveredServices(): List<DiscoveredService> = emptyList()
|
||||
override fun startDiscovery() {}
|
||||
override fun stopDiscovery() {}
|
||||
override fun registerService(port: Int) {}
|
||||
override fun getDiscoveredServices(): List<DiscoveredService> = emptyList()
|
||||
}
|
||||
|
||||
+4
-1
@@ -2,9 +2,12 @@
|
||||
|
||||
package at.mocode.frontend.features.device.initialization.di
|
||||
|
||||
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
|
||||
import at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val deviceInitializationModule = module {
|
||||
factory { DeviceInitializationViewModel(get(), { deviceName -> get { org.koin.core.parameter.parametersOf(deviceName) } }) }
|
||||
factory { (onComplete: (DeviceInitializationSettings) -> Unit) ->
|
||||
DeviceInitializationViewModel(get(), onComplete)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -24,24 +24,15 @@ data class ExpectedClient(
|
||||
val isSynchronized: Boolean = true
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class AppThemeSetting {
|
||||
SYSTEM,
|
||||
LIGHT,
|
||||
DARK
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class DeviceInitializationSettings(
|
||||
val deviceName: String = "",
|
||||
val networkInterface: String = "",
|
||||
val sharedKey: String = "",
|
||||
val backupPath: String = "",
|
||||
val networkRole: NetworkRole = NetworkRole.CLIENT,
|
||||
val expectedClients: List<ExpectedClient> = emptyList(),
|
||||
val syncInterval: Int = 30, // in Minuten
|
||||
val defaultPrinter: String = "",
|
||||
val appTheme: AppThemeSetting = AppThemeSetting.SYSTEM
|
||||
val defaultPrinter: String = ""
|
||||
) {
|
||||
val isConfigured: Boolean get() = deviceName.isNotBlank() && sharedKey.isNotBlank()
|
||||
}
|
||||
|
||||
+76
-271
@@ -2,17 +2,14 @@
|
||||
|
||||
package at.mocode.frontend.features.device.initialization.presentation
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.NetworkCheck
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -21,290 +18,70 @@ import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator
|
||||
|
||||
@Composable
|
||||
private fun DiscoveryRadar(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "RadarTransition")
|
||||
val radius by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 20f, // Kleinerer Radius
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(2500, easing = LinearOutSlowInEasing), // Langsamer und sanfter
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "RadiusAnimation"
|
||||
)
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.4f, // Noch dezenter
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(3000, easing = LinearOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "AlphaAnimation"
|
||||
)
|
||||
|
||||
val color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) // Dezente Farbe
|
||||
|
||||
Box(
|
||||
modifier = modifier.size(32.dp), // Noch kleiner
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawCircle(
|
||||
color = color,
|
||||
radius = radius.dp.toPx(),
|
||||
center = Offset(size.width / 2, size.height / 2),
|
||||
style = Stroke(width = 1.dp.toPx()),
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.NetworkCheck,
|
||||
contentDescription = null,
|
||||
tint = color.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeviceInitializationScreen(
|
||||
viewModel: DeviceInitializationViewModel
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val (roleSelectorFocus, deviceNameFocus) = remember { FocusRequester.createRefs() }
|
||||
val (roleSelectorFocus, nextButtonFocus) = remember { FocusRequester.createRefs() }
|
||||
|
||||
// Automatische Discovery starten
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.startDiscovery()
|
||||
roleSelectorFocus.requestFocus()
|
||||
// Automatische Discovery starten, wenn wir auf Schritt 0 sind
|
||||
LaunchedEffect(uiState.currentStep) {
|
||||
if (uiState.currentStep == 0) {
|
||||
viewModel.startDiscovery()
|
||||
roleSelectorFocus.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
val isMobile = maxWidth < 600.dp
|
||||
val contentWidth = if (isMobile) 425.dp else 1024.dp
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
"Willkommen bei der Meldestelle",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
if (uiState.currentStep == 0) "Schritt 1: Netzwerk-Rolle festlegen" else "Schritt 2: Rollenspezifische Konfiguration",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = contentWidth)
|
||||
.fillMaxWidth()
|
||||
.padding(if (isMobile) 16.dp else 32.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (uiState.currentStep == 0) {
|
||||
// PHASE 1: NETZWERK-ROLLE
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("🌐 Netzwerk-Rolle wählen", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Willkommen bei der Meldestelle",
|
||||
style = if (isMobile) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
"Wähle aus, ob dieses Gerät als Master (zentrale Datenbank) oder als Client fungiert.",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Text(
|
||||
"Geräte-Initialisierung",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// THEME SWITCH
|
||||
Card(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||
modifier = Modifier.focusProperties { canFocus = false }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.entries.forEach { theme ->
|
||||
val selected = uiState.settings.appTheme == theme
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { viewModel.updateSettings { it.copy(appTheme = theme) } },
|
||||
label = {
|
||||
Text(
|
||||
when (theme) {
|
||||
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> "System"
|
||||
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> "Hell"
|
||||
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> "Dunkel"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primary,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NETZWERK-ROLLE
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Column(Modifier.padding(if (isMobile) 16.dp else 24.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Text("🌐 Netzwerk-Rolle wählen", style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
"Möchtest du dieses Gerät als Master (Zentrale) oder als Client (Richter/Zeitnehmer) nutzen?",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
if (!uiState.isLocked) {
|
||||
val role = uiState.settings.networkRole
|
||||
val hasDiscoveries = uiState.discoveredMasters.isNotEmpty()
|
||||
val selectedInterface = uiState.settings.networkInterface
|
||||
|
||||
LaunchedEffect(selectedInterface, role) {
|
||||
if (selectedInterface.isNotEmpty()) {
|
||||
viewModel.startDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = if (hasDiscoveries) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.15f)
|
||||
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
border = if (hasDiscoveries) BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
|
||||
)
|
||||
else null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
DiscoveryRadar()
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = when {
|
||||
role == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.MASTER && hasDiscoveries -> "Aktive Clients im Netzwerk gefunden"
|
||||
role == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.MASTER -> "Suche nach verfügbaren Clients..."
|
||||
hasDiscoveries -> "Master im Netzwerk gefunden"
|
||||
else -> "Suche nach Master-Geräten..."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (hasDiscoveries) MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NetworkRoleSelector(
|
||||
selectedRole = uiState.settings.networkRole,
|
||||
onRoleSelected = {
|
||||
viewModel.setNetworkRole(it)
|
||||
if (uiState.settings.deviceName.isEmpty()) {
|
||||
deviceNameFocus.requestFocus()
|
||||
} else {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
}
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
},
|
||||
modifier = Modifier.focusRequester(roleSelectorFocus),
|
||||
enabled = !uiState.isLocked
|
||||
)
|
||||
|
||||
// MASTER-AUSWAHL FÜR CLIENTS
|
||||
if (uiState.settings.networkRole == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.CLIENT && !uiState.isLocked) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("📋 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
if (uiState.discoveredMasters.isEmpty()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
"Suche nach der Meldestelle...",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
"Bitte warten Sie, bis der Hauptrechner (Master) bereit ist.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uiState.discoveredMasters.forEach { master ->
|
||||
val isSelected = uiState.selectedMaster?.name == master.name
|
||||
Surface(
|
||||
onClick = { viewModel.selectMaster(master) },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||
alpha = 0.5f
|
||||
),
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("🖥️", fontSize = 24.sp)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(master.name, style = MaterialTheme.typography.labelLarge)
|
||||
Text("Erreichbar unter ${master.host}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.showRoleChangeWarning) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.dismissRoleChangeWarning() },
|
||||
title = { Text("Netzwerk-Rolle ändern?") },
|
||||
text = { Text("Das Ändern der Netzwerk-Rolle kann Ihre bisherigen Eingaben beeinflussen. Wollen Sie fortfahren?") },
|
||||
text = { Text("Das Ändern der Netzwerk-Rolle kann Ihre bisherigen Eingaben in Schritt 2 beeinflussen. Wollen Sie fortfahren?") },
|
||||
confirmButton = {
|
||||
Button(onClick = { viewModel.confirmNetworkRoleChange() }) { Text("Ja, Ändern") }
|
||||
},
|
||||
@@ -313,21 +90,52 @@ fun DeviceInitializationScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!uiState.isLocked) {
|
||||
Button(
|
||||
onClick = { viewModel.nextStep() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.focusRequester(nextButtonFocus)
|
||||
.onKeyEvent {
|
||||
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||
viewModel.nextStep()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
Text("Weiter")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.nextStep() },
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("Zur Konfiguration")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Konfiguration
|
||||
} else {
|
||||
// PHASE 2 & Review
|
||||
DeviceInitializationConfig(
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
deviceNameFocus = deviceNameFocus
|
||||
viewModel = viewModel
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = { viewModel.previousStep() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Zurück zur Rollenauswahl")
|
||||
}
|
||||
|
||||
if (uiState.isLocked) {
|
||||
var showUnlockWarning by remember { mutableStateOf(false) }
|
||||
if (showUnlockWarning) {
|
||||
@@ -351,20 +159,18 @@ fun DeviceInitializationScreen(
|
||||
onClick = { showUnlockWarning = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
)
|
||||
) {
|
||||
Text("Konfiguration bearbeiten")
|
||||
Icon(Icons.Default.Edit, null, Modifier.padding(start = 8.dp).size(18.dp))
|
||||
Icon(Icons.Default.Edit, null, Modifier.padding(start = 8.dp))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.completeInitialization() },
|
||||
enabled = DeviceInitializationValidator.canContinue(uiState.settings),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
enabled = DeviceInitializationValidator.canContinue(uiState.settings)
|
||||
) {
|
||||
Text("Konfiguration finalisieren")
|
||||
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp).size(18.dp))
|
||||
Text("Konfiguration finalisieren & Sperren")
|
||||
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,6 +182,5 @@ fun DeviceInitializationScreen(
|
||||
@Composable
|
||||
expect fun DeviceInitializationConfig(
|
||||
uiState: DeviceInitializationUiState,
|
||||
viewModel: DeviceInitializationViewModel,
|
||||
deviceNameFocus: FocusRequester
|
||||
viewModel: DeviceInitializationViewModel
|
||||
)
|
||||
|
||||
+1
-10
@@ -6,21 +6,12 @@ import at.mocode.frontend.core.network.discovery.DiscoveredService
|
||||
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
|
||||
|
||||
data class DeviceInitializationUiState(
|
||||
val currentStep: Int = 0,
|
||||
val settings: DeviceInitializationSettings = DeviceInitializationSettings(),
|
||||
val discoveredMasters: List<DiscoveredService> = emptyList(),
|
||||
val selectedMaster: DiscoveredService? = null,
|
||||
val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
|
||||
val isProcessing: Boolean = false,
|
||||
val error: String? = null,
|
||||
val isLocked: Boolean = false,
|
||||
val showRoleChangeWarning: Boolean = false,
|
||||
val pendingRole: at.mocode.frontend.features.device.initialization.domain.model.NetworkRole? = null
|
||||
)
|
||||
|
||||
enum class ConnectionStatus {
|
||||
DISCONNECTED,
|
||||
SEARCHING,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
+26
-99
@@ -5,104 +5,54 @@ package at.mocode.frontend.features.device.initialization.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.mocode.frontend.core.network.backup.BackupService
|
||||
import at.mocode.frontend.core.network.discovery.DiscoveredService
|
||||
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
|
||||
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
|
||||
import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient
|
||||
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class DeviceInitializationViewModel(
|
||||
private val discoveryService: NetworkDiscoveryService,
|
||||
private val backupServiceProvider: (String) -> BackupService
|
||||
private val onInitializationComplete: (DeviceInitializationSettings) -> Unit
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(DeviceInitializationUiState())
|
||||
val uiState: StateFlow<DeviceInitializationUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _initializationCompleteEvent = MutableSharedFlow<DeviceInitializationSettings>()
|
||||
val initializationCompleteEvent: SharedFlow<DeviceInitializationSettings> =
|
||||
_initializationCompleteEvent.asSharedFlow()
|
||||
|
||||
init {
|
||||
val existingSettings =
|
||||
at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager.loadSettings()
|
||||
val existingSettings = at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager.loadSettings()
|
||||
if (existingSettings != null) {
|
||||
println("[DeviceInit] Bestehende Einstellungen geladen.")
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
settings = existingSettings,
|
||||
isLocked = false // Immer offen für Bearbeitung beim Start des Wizards
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(
|
||||
settings = existingSettings,
|
||||
isLocked = existingSettings.isConfigured,
|
||||
currentStep = 1 // Direkt zu Schritt 2 (Konfig), da Rolle schon gewählt
|
||||
) }
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
discoveryService.discoveredServices.collect { services ->
|
||||
println("[DeviceInit] Discovery Update: ${services.size} Dienste gefunden.")
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
discoveredMasters = services,
|
||||
connectionStatus = if (services.isEmpty() && it.settings.networkRole != NetworkRole.MASTER) {
|
||||
ConnectionStatus.SEARCHING
|
||||
} else {
|
||||
it.connectionStatus
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectMaster(master: DiscoveredService) {
|
||||
println("[DeviceInit] Master ausgewählt: ${master.name}")
|
||||
_uiState.update { it.copy(selectedMaster = master) }
|
||||
}
|
||||
|
||||
fun connectToMaster() {
|
||||
val master = uiState.value.selectedMaster
|
||||
val key = uiState.value.settings.sharedKey
|
||||
|
||||
if (master == null || key.isBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(connectionStatus = ConnectionStatus.CONNECTING) }
|
||||
println("[DeviceInit] Verbindungsaufbau zu ${master.name} mit Key...")
|
||||
|
||||
// Simulierter Handshake für den PoC
|
||||
kotlinx.coroutines.delay(1500.milliseconds)
|
||||
|
||||
if (key == "1234") { // Demo-Key
|
||||
_uiState.update { it.copy(connectionStatus = ConnectionStatus.CONNECTED) }
|
||||
println("[DeviceInit] Verbindung erfolgreich hergestellt!")
|
||||
} else {
|
||||
_uiState.update { it.copy(connectionStatus = ConnectionStatus.FAILED, error = "Sicherheitsschlüssel ungültig!") }
|
||||
println("[DeviceInit] Verbindung fehlgeschlagen: Falscher Key.")
|
||||
_uiState.update { it.copy(discoveredMasters = services) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startDiscovery() {
|
||||
val selectedInterface = uiState.value.settings.networkInterface
|
||||
val ip = if (selectedInterface.contains("(") && selectedInterface.contains(")")) {
|
||||
selectedInterface.substringAfter("(").substringBefore(")")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
println("[DeviceInit] Starte/Restart Discovery für IP: $ip (Interface: $selectedInterface)")
|
||||
discoveryService.stopDiscovery()
|
||||
discoveryService.startDiscovery(ip)
|
||||
|
||||
// Falls wir ein Master sind, registrieren wir uns auch direkt, damit andere uns finden
|
||||
if (uiState.value.settings.networkRole == NetworkRole.MASTER) {
|
||||
discoveryService.registerService(8080, ip, uiState.value.settings.deviceName)
|
||||
}
|
||||
discoveryService.startDiscovery()
|
||||
}
|
||||
|
||||
fun nextStep() {
|
||||
println("[DeviceInit] Übergang zu Schritt ${uiState.value.currentStep + 1}")
|
||||
_uiState.update { it.copy(currentStep = it.currentStep + 1) }
|
||||
}
|
||||
|
||||
fun previousStep() {
|
||||
println("[DeviceInit] Zurück zu Schritt ${(uiState.value.currentStep - 1).coerceAtLeast(0)}")
|
||||
_uiState.update { it.copy(currentStep = (it.currentStep - 1).coerceAtLeast(0)) }
|
||||
}
|
||||
|
||||
fun updateSettings(update: (DeviceInitializationSettings) -> DeviceInitializationSettings) {
|
||||
_uiState.update {
|
||||
@@ -133,11 +83,10 @@ class DeviceInitializationViewModel(
|
||||
_uiState.update { it.copy(showRoleChangeWarning = false, pendingRole = null) }
|
||||
}
|
||||
|
||||
fun addExpectedClient() {
|
||||
val name = "Neuer Client ${uiState.value.settings.expectedClients.size + 1}"
|
||||
println("[DeviceInit] Erwarteter Client hinzugefügt: $name")
|
||||
fun addExpectedClient(name: String, role: NetworkRole) {
|
||||
println("[DeviceInit] Erwarteter Client hinzugefügt: $name ($role)")
|
||||
updateSettings {
|
||||
it.copy(expectedClients = it.expectedClients + ExpectedClient(name, NetworkRole.RICHTER))
|
||||
it.copy(expectedClients = it.expectedClients + ExpectedClient(name, role))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,32 +99,10 @@ class DeviceInitializationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun testUsbBackup() {
|
||||
val settings = uiState.value.settings
|
||||
if (settings.backupPath.isBlank() || settings.sharedKey.isBlank()) {
|
||||
println("[DeviceInit] Backup-Pfad oder Shared Key fehlt.")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val service = backupServiceProvider(settings.deviceName)
|
||||
val testData = "PoC Testdaten - ${settings.deviceName} - ${Clock.System.now()}"
|
||||
val result = service.exportDelta(testData, settings.backupPath, settings.sharedKey)
|
||||
|
||||
if (result.isSuccess) {
|
||||
println("[DeviceInit] USB-Backup Test erfolgreich.")
|
||||
} else {
|
||||
println("[DeviceInit] USB-Backup Test fehlgeschlagen: ${result.exceptionOrNull()?.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun completeInitialization() {
|
||||
println("[DeviceInit] Konfiguration wird finalisiert...")
|
||||
_uiState.update { it.copy(isLocked = true) }
|
||||
viewModelScope.launch {
|
||||
_initializationCompleteEvent.emit(_uiState.value.settings)
|
||||
}
|
||||
onInitializationComplete(_uiState.value.settings)
|
||||
}
|
||||
|
||||
fun unlockConfiguration() {
|
||||
|
||||
+10
-12
@@ -2,7 +2,6 @@
|
||||
|
||||
package at.mocode.frontend.features.device.initialization.presentation
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
@@ -22,14 +21,14 @@ fun NetworkRoleSelector(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
NetworkRoleCard(
|
||||
title = "Master (Host)",
|
||||
description = "Zentrale Datenbank & Sync-Koordination.",
|
||||
description = "Verwaltet die zentrale Datenbank und koordiniert den Sync.",
|
||||
isSelected = selectedRole == NetworkRole.MASTER,
|
||||
onClick = { if (enabled) onRoleSelected(NetworkRole.MASTER) },
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f).onKeyEvent {
|
||||
modifier = Modifier.onKeyEvent {
|
||||
if (enabled && (it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||
onRoleSelected(NetworkRole.MASTER)
|
||||
true
|
||||
@@ -39,11 +38,11 @@ fun NetworkRoleSelector(
|
||||
|
||||
NetworkRoleCard(
|
||||
title = "Client",
|
||||
description = "Verbindung zum Master im LAN.",
|
||||
description = "Verbindet sich mit einem Master-Gerät im lokalen Netzwerk.",
|
||||
isSelected = selectedRole == NetworkRole.CLIENT,
|
||||
onClick = { if (enabled) onRoleSelected(NetworkRole.CLIENT) },
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f).onKeyEvent {
|
||||
modifier = Modifier.onKeyEvent {
|
||||
if (enabled && (it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||
onRoleSelected(NetworkRole.CLIENT)
|
||||
true
|
||||
@@ -67,14 +66,13 @@ private fun NetworkRoleCard(
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = when {
|
||||
isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
|
||||
!enabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
||||
!enabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = modifier
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = null,
|
||||
|
||||
+1
-11
@@ -8,17 +8,7 @@ import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
actual object DeviceInitializationSettingsManager {
|
||||
private val settingsFile: File by lazy {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
val appName = "Meldestelle"
|
||||
val baseDir = when {
|
||||
os.contains("win") -> File(System.getenv("APPDATA"), appName)
|
||||
os.contains("mac") -> File(System.getProperty("user.home"), "Library/Application Support/$appName")
|
||||
else -> File(System.getProperty("user.home"), ".config/$appName")
|
||||
}
|
||||
if (!baseDir.exists()) baseDir.mkdirs()
|
||||
File(baseDir, "settings.json")
|
||||
}
|
||||
private val settingsFile = File("settings.json")
|
||||
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
|
||||
|
||||
actual fun saveSettings(settings: DeviceInitializationSettings) {
|
||||
|
||||
+234
-195
@@ -2,13 +2,11 @@
|
||||
|
||||
package at.mocode.frontend.features.device.initialization.presentation
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
@@ -23,273 +21,314 @@ import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component4
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component5
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
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.components.MsEnumDropdown
|
||||
import at.mocode.frontend.core.designsystem.components.MsFilePicker
|
||||
import at.mocode.frontend.core.designsystem.components.MsStringDropdown
|
||||
import at.mocode.frontend.core.designsystem.components.MsTextField
|
||||
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator
|
||||
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
|
||||
import java.net.NetworkInterface
|
||||
import javax.print.PrintServiceLookup
|
||||
|
||||
@Composable
|
||||
actual fun DeviceInitializationConfig(
|
||||
uiState: DeviceInitializationUiState,
|
||||
viewModel: DeviceInitializationViewModel,
|
||||
deviceNameFocus: FocusRequester
|
||||
viewModel: DeviceInitializationViewModel
|
||||
) {
|
||||
val settings = uiState.settings
|
||||
val focusManager = LocalFocusManager.current
|
||||
val (_, sharedKeyFocus, backupPathFocus, clientNameFocus, clientRoleFocus) = remember { FocusRequester.createRefs() }
|
||||
val (deviceNameFocus, sharedKeyFocus, backupPathFocus, clientNameFocus, clientRoleFocus) = remember { FocusRequester.createRefs() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (settings.deviceName.isEmpty()) {
|
||||
deviceNameFocus.requestFocus()
|
||||
}
|
||||
deviceNameFocus.requestFocus()
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("⚙️ Geräte-Details", style = MaterialTheme.typography.titleLarge)
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("⚙️ Geräte-Konfiguration (${settings.networkRole})", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
MsTextField(
|
||||
value = settings.deviceName,
|
||||
onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } },
|
||||
label = "Gerätename",
|
||||
helpDescription = "Ein eindeutiger Name für diesen PC (z.B. 'Richter-Springplatz').",
|
||||
placeholder = "z.B. Meldestelle-PC-1",
|
||||
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
|
||||
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
|
||||
modifier = Modifier.focusRequester(deviceNameFocus),
|
||||
enabled = !uiState.isLocked,
|
||||
compact = true
|
||||
enabled = !uiState.isLocked
|
||||
)
|
||||
|
||||
// NETZWERK-INTERFACES (EXPERTEN-MODUS)
|
||||
val interfaces = remember {
|
||||
NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { it.isUp && !it.isLoopback && it.inetAddresses.hasMoreElements() }
|
||||
.map { ni ->
|
||||
val friendlyName = when {
|
||||
ni.displayName.contains("wlan", ignoreCase = true) || ni.displayName.contains("wi-fi", ignoreCase = true) || ni.name.contains("wlan", ignoreCase = true) -> "🌐 WLAN"
|
||||
ni.displayName.contains("eth", ignoreCase = true) || ni.displayName.contains("ethernet", ignoreCase = true) || ni.name.contains("eth", ignoreCase = true) || ni.name.contains("en", ignoreCase = true) -> "🔌 Ethernet"
|
||||
else -> "💻 " + ni.displayName
|
||||
}
|
||||
val address = ni.inetAddresses.asSequence().firstOrNull { !it.isLinkLocalAddress && it.hostAddress.indexOf(':') == -1 }?.hostAddress
|
||||
?: ni.inetAddresses.nextElement().hostAddress
|
||||
|
||||
val isConnected = !ni.isLoopback && ni.isUp && ni.interfaceAddresses.any {
|
||||
it.address.isSiteLocalAddress || it.address.hostAddress.startsWith("192.168") || it.address.hostAddress.startsWith("10.")
|
||||
}
|
||||
|
||||
InterfaceInfo(id = "$friendlyName ($address)", name = friendlyName, address = address, hardwareName = ni.name, isConnected = isConnected)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(interfaces) {
|
||||
if (settings.networkInterface.isEmpty() && interfaces.isNotEmpty()) {
|
||||
val bestMatch = interfaces.find { it.isConnected } ?: interfaces.first()
|
||||
viewModel.updateSettings { s -> s.copy(networkInterface = bestMatch.id) }
|
||||
}
|
||||
}
|
||||
|
||||
var showInterfaces by remember { mutableStateOf(false) }
|
||||
OutlinedButton(
|
||||
onClick = { showInterfaces = !showInterfaces },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Text(if (showInterfaces) "⬆️ Netzwerk-Einstellungen verbergen" else "⬇️ Netzwerk-Einstellungen (Experten)")
|
||||
}
|
||||
|
||||
if (showInterfaces) {
|
||||
interfaces.forEach { info ->
|
||||
val isSelected = settings.networkInterface == info.id
|
||||
Surface(
|
||||
onClick = { if (!uiState.isLocked) viewModel.updateSettings { s -> s.copy(networkInterface = info.id) } },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(10.dp).background(if (info.isConnected) Color(0xFF4CAF50) else Color(0xFFF44336), CircleShape))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(info.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
Text("IP: ${info.address}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (isSelected) Icon(Icons.Default.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SICHERHEITSSCHLÜSSEL
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
MsTextField(
|
||||
value = settings.sharedKey,
|
||||
onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } },
|
||||
label = "Sicherheitsschlüssel (Sync-Key)",
|
||||
helpDescription = "Das 'Turnier-Passwort'. Muss auf allen Geräten gleich sein.",
|
||||
placeholder = "Mindestens 8 Zeichen",
|
||||
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
|
||||
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
|
||||
visualTransformation = if (passwordVisible || uiState.isLocked) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Next) }
|
||||
),
|
||||
modifier = Modifier.focusRequester(sharedKeyFocus),
|
||||
trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
onTrailingIconClick = { passwordVisible = !passwordVisible },
|
||||
enabled = !uiState.isLocked,
|
||||
compact = true
|
||||
enabled = !uiState.isLocked
|
||||
)
|
||||
|
||||
// CLIENT-VERBINDUNG-FEEDBACK
|
||||
if (settings.networkRole == NetworkRole.CLIENT && !uiState.isLocked) {
|
||||
val masterSelected = uiState.selectedMaster != null
|
||||
val canConnect = masterSelected && settings.sharedKey.isNotBlank()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
color = when (uiState.connectionStatus) {
|
||||
ConnectionStatus.CONNECTED -> Color(0xFFE8F5E9)
|
||||
ConnectionStatus.FAILED -> Color(0xFFFFEBEE)
|
||||
else -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f)
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
border = BorderStroke(1.dp, when (uiState.connectionStatus) {
|
||||
ConnectionStatus.CONNECTED -> Color(0xFF4CAF50)
|
||||
ConnectionStatus.FAILED -> Color(0xFFF44336)
|
||||
else -> MaterialTheme.colorScheme.outlineVariant
|
||||
})
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
when (uiState.connectionStatus) {
|
||||
ConnectionStatus.CONNECTING -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
ConnectionStatus.CONNECTED -> Icon(Icons.Default.CheckCircle, null, tint = Color(0xFF4CAF50))
|
||||
ConnectionStatus.FAILED -> Icon(Icons.Default.Error, null, tint = Color(0xFFF44336))
|
||||
else -> Icon(Icons.Default.Link, null)
|
||||
}
|
||||
Text(
|
||||
text = when (uiState.connectionStatus) {
|
||||
ConnectionStatus.SEARCHING -> "Warte auf Master-Auswahl..."
|
||||
ConnectionStatus.CONNECTING -> "Verbindung wird aufgebaut..."
|
||||
ConnectionStatus.CONNECTED -> "Verbunden mit ${uiState.selectedMaster?.name}"
|
||||
ConnectionStatus.FAILED -> "Verbindung fehlgeschlagen!"
|
||||
else -> "Bereit zum Verbinden"
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.connectionStatus != ConnectionStatus.CONNECTED) {
|
||||
Button(
|
||||
onClick = { viewModel.connectToMaster() },
|
||||
enabled = canConnect && uiState.connectionStatus != ConnectionStatus.CONNECTING,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (uiState.connectionStatus == ConnectionStatus.CONNECTING) "Verbinde..." else "Jetzt verbinden")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BACKUP & DRUCKER
|
||||
MsFilePicker(
|
||||
label = "Backup-Verzeichnis (Plan-USB)",
|
||||
label = "Backup-Verzeichnis (Pfad)",
|
||||
selectedPath = settings.backupPath,
|
||||
onFileSelected = { viewModel.updateSettings { s -> s.copy(backupPath = it) } },
|
||||
onFileSelected = { selectedPath ->
|
||||
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
||||
},
|
||||
directoryOnly = true,
|
||||
modifier = Modifier.focusRequester(backupPathFocus),
|
||||
enabled = !uiState.isLocked
|
||||
)
|
||||
|
||||
if (settings.networkRole == NetworkRole.MASTER) {
|
||||
val printers = remember { PrintServiceLookup.lookupPrintServices(null, null).map { it.name } }
|
||||
MsStringDropdown(
|
||||
label = "Standard-Drucker",
|
||||
options = printers,
|
||||
selectedOption = settings.defaultPrinter,
|
||||
onOptionSelected = { viewModel.updateSettings { s -> s.copy(defaultPrinter = it) } },
|
||||
enabled = !uiState.isLocked
|
||||
)
|
||||
val printers = remember {
|
||||
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }.sorted()
|
||||
}
|
||||
|
||||
// MASTER: ERWARTETE CLIENTS
|
||||
if (settings.networkRole == NetworkRole.MASTER && !uiState.isLocked) {
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall)
|
||||
TextButton(onClick = { viewModel.addExpectedClient() }) {
|
||||
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Hinzufügen")
|
||||
}
|
||||
MsStringDropdown(
|
||||
label = "Standard-Drucker",
|
||||
options = printers,
|
||||
selectedOption = settings.defaultPrinter,
|
||||
onOptionSelected = { viewModel.updateSettings { s -> s.copy(defaultPrinter = it) } },
|
||||
placeholder = "Drucker auswählen...",
|
||||
enabled = !uiState.isLocked,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
if (settings.networkRole == NetworkRole.MASTER) {
|
||||
Text("Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.labelMedium)
|
||||
Slider(
|
||||
value = settings.syncInterval.toFloat(),
|
||||
onValueChange = { viewModel.updateSettings { s -> s.copy(syncInterval = it.toInt()) } },
|
||||
valueRange = 1f..60f,
|
||||
steps = 59,
|
||||
enabled = !uiState.isLocked
|
||||
)
|
||||
} else if (!uiState.isLocked) {
|
||||
// Button zum Abschließen für Clients, da diese keinen Slider/Clients haben
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { viewModel.completeInitialization() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = DeviceInitializationValidator.canContinue(settings)
|
||||
) {
|
||||
Text("Konfiguration abschließen")
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.networkRole == NetworkRole.MASTER && !uiState.isLocked) {
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
settings.expectedClients.forEachIndexed { index, client ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
MsTextField(
|
||||
value = client.name,
|
||||
onValueChange = { newName ->
|
||||
viewModel.updateSettings { s ->
|
||||
val newList = s.expectedClients.toMutableList()
|
||||
newList[index] = newList[index].copy(name = newName)
|
||||
s.copy(expectedClients = newList)
|
||||
}
|
||||
},
|
||||
label = null,
|
||||
compact = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
client.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = { Text(client.role.name) },
|
||||
colors = SuggestionChipDefaults.suggestionChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
labelColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
NetworkRole.entries.filter { it != NetworkRole.MASTER }.forEach { role ->
|
||||
val isSelected = client.role == role
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
viewModel.updateSettings { s ->
|
||||
val newList = s.expectedClients.toMutableList()
|
||||
newList[index] = newList[index].copy(role = role)
|
||||
s.copy(expectedClients = newList)
|
||||
}
|
||||
},
|
||||
label = { Text(role.name, style = MaterialTheme.typography.labelSmall) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
if (client.isOnline) "Verbunden" else "Offline",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (client.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = { viewModel.removeExpectedClient(index) }) {
|
||||
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp))
|
||||
IconButton(onClick = {
|
||||
val clientName = settings.expectedClients[index].name
|
||||
viewModel.removeExpectedClient(index)
|
||||
println("[DeviceInit] Client entfernt: $clientName")
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Löschen",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
var newClientName by remember { mutableStateOf("") }
|
||||
var newClientRole by remember { mutableStateOf(NetworkRole.RICHTER) }
|
||||
var showAddClient by remember { mutableStateOf(false) }
|
||||
|
||||
if (showAddClient) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LaunchedEffect(Unit) { clientNameFocus.requestFocus() }
|
||||
ClientEntryRow(
|
||||
name = newClientName,
|
||||
onNameChange = { newClientName = it },
|
||||
role = newClientRole,
|
||||
onRoleChange = { newClientRole = it },
|
||||
focusManager = focusManager,
|
||||
clientNameFocus = clientNameFocus,
|
||||
clientRoleFocus = clientRoleFocus,
|
||||
onEnter = {
|
||||
if (newClientName.isNotBlank()) {
|
||||
viewModel.addExpectedClient(newClientName, newClientRole)
|
||||
println("[DeviceInit] Client hinzugefügt: $newClientName ($newClientRole)")
|
||||
newClientName = ""
|
||||
showAddClient = false
|
||||
}
|
||||
}
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
showAddClient = false
|
||||
newClientName = ""
|
||||
}) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (newClientName.isNotBlank()) {
|
||||
viewModel.addExpectedClient(newClientName, newClientRole)
|
||||
println("[DeviceInit] Client hinzugefügt: $newClientName ($newClientRole)")
|
||||
newClientName = ""
|
||||
showAddClient = false
|
||||
}
|
||||
},
|
||||
enabled = newClientName.isNotBlank()
|
||||
) {
|
||||
Text("Client speichern")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TextButton(onClick = { showAddClient = true }) {
|
||||
Icon(Icons.Default.Add, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Client hinzufügen")
|
||||
}
|
||||
}
|
||||
} else if (settings.networkRole != NetworkRole.MASTER && !uiState.isLocked) {
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
Text("🔍 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
if (uiState.discoveredMasters.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Text("Suche nach Master...", modifier = Modifier.padding(start = 40.dp))
|
||||
}
|
||||
}
|
||||
|
||||
uiState.discoveredMasters.forEach { service ->
|
||||
ListItem(
|
||||
headlineContent = { Text(service.name) },
|
||||
supportingContent = { Text("${service.host}:${service.port}") },
|
||||
trailingContent = {
|
||||
Button(onClick = {
|
||||
viewModel.updateSettings { s -> s.copy(sharedKey = service.metadata["key"] ?: s.sharedKey) }
|
||||
}) {
|
||||
Text("Verbinden")
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"Hinweis: Als Client wird dieses Gerät automatisch versuchen, den Master im Netzwerk zu finden.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (settings.networkRole == NetworkRole.MASTER && uiState.isLocked && settings.expectedClients.isNotEmpty()) {
|
||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||
Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall)
|
||||
settings.expectedClients.forEach { client ->
|
||||
ListItem(
|
||||
headlineContent = { Text(client.name) },
|
||||
trailingContent = {
|
||||
SuggestionChip(onClick = {}, label = { Text(client.role.name) })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class InterfaceInfo(val id: String, val name: String, val address: String, val hardwareName: String, val isConnected: Boolean)
|
||||
@Composable
|
||||
private fun ClientEntryRow(
|
||||
name: String,
|
||||
onNameChange: (String) -> Unit,
|
||||
role: NetworkRole,
|
||||
onRoleChange: (NetworkRole) -> Unit,
|
||||
focusManager: androidx.compose.ui.focus.FocusManager,
|
||||
clientNameFocus: FocusRequester,
|
||||
clientRoleFocus: FocusRequester,
|
||||
onEnter: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MsTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
label = "Gerätename des Clients",
|
||||
modifier = Modifier.weight(1f).focusRequester(clientNameFocus),
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||
)
|
||||
|
||||
MsEnumDropdown(
|
||||
label = "Rolle",
|
||||
options = NetworkRole.entries.filter { it != NetworkRole.MASTER }.toTypedArray(),
|
||||
selectedOption = role,
|
||||
onOptionSelected = onRoleChange,
|
||||
modifier = Modifier.weight(0.5f).focusRequester(clientRoleFocus).onKeyEvent {
|
||||
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||
onEnter()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-9
@@ -12,8 +12,6 @@ import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -22,8 +20,7 @@ import at.mocode.frontend.features.device.initialization.domain.DeviceInitializa
|
||||
@Composable
|
||||
actual fun DeviceInitializationConfig(
|
||||
uiState: DeviceInitializationUiState,
|
||||
viewModel: DeviceInitializationViewModel,
|
||||
deviceNameFocus: FocusRequester
|
||||
viewModel: DeviceInitializationViewModel
|
||||
) {
|
||||
val settings = uiState.settings
|
||||
|
||||
@@ -37,8 +34,7 @@ actual fun DeviceInitializationConfig(
|
||||
label = "Gerätename",
|
||||
placeholder = "z.B. Web-Client",
|
||||
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
|
||||
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
|
||||
modifier = Modifier.focusRequester(deviceNameFocus)
|
||||
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich."
|
||||
)
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
@@ -78,15 +74,14 @@ private fun MsSettingsField(
|
||||
isError: Boolean,
|
||||
errorText: String,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
trailingIcon: @Composable (() -> Unit)? = null
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
placeholder = { Text(placeholder) },
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = isError,
|
||||
visualTransformation = visualTransformation,
|
||||
trailingIcon = trailingIcon,
|
||||
|
||||
@@ -35,7 +35,6 @@ kotlin {
|
||||
implementation(projects.frontend.core.navigation)
|
||||
implementation(projects.frontend.features.billingFeature)
|
||||
implementation(projects.frontend.features.nennungFeature)
|
||||
implementation(projects.frontend.core.wizard)
|
||||
implementation(projects.core.znsParser)
|
||||
|
||||
implementation(compose.foundation)
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
package at.mocode.frontend.features.turnier.wizard
|
||||
|
||||
import at.mocode.core.domain.model.ReglementE
|
||||
import at.mocode.core.domain.model.SparteE
|
||||
import at.mocode.core.domain.model.TurnierkategorieE
|
||||
import at.mocode.frontend.core.wizard.dsl.flow
|
||||
import at.mocode.frontend.core.wizard.runtime.StepId
|
||||
|
||||
/**
|
||||
* Definiert die Schritte für den Turnier-Anlage-Wizard.
|
||||
* Orientiert sich an der ÖTO-Logik und dem SCS-Rahmen.
|
||||
*/
|
||||
sealed interface TurnierAnlageStep : StepId {
|
||||
/** 1. Basisdaten (Name, Nummer, Datum, Sparte) */
|
||||
data object Basisdaten : TurnierAnlageStep
|
||||
/** 2. Kategorie & Reglement (CSN-C, CDN, etc. / ÖTO vs FEI) */
|
||||
data object KategorieReglement : TurnierAnlageStep
|
||||
/** 3. Funktionäre (TB, Parcoursbauer, etc.) */
|
||||
data object Funktionaere : TurnierAnlageStep
|
||||
/** 4. Nenn-Konfiguration (Nennschluss, Gebühren, Tauschbörse) */
|
||||
data object NennKonfig : TurnierAnlageStep
|
||||
/** 5. Zusammenfassung & Validierung (ÖTO-Warnungen prüfen) */
|
||||
data object Summary : TurnierAnlageStep
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulator für den Turnier-Wizard.
|
||||
* Sammelt die Daten, bevor sie als [DomTurnier] ans Backend gesendet werden.
|
||||
*/
|
||||
data class TurnierAnlageAcc(
|
||||
val name: String = "",
|
||||
val turnierNummer: String = "",
|
||||
val sparte: SparteE = SparteE.SPRINGEN,
|
||||
val kategorie: TurnierkategorieE = TurnierkategorieE.C,
|
||||
val reglement: ReglementE = ReglementE.OETO,
|
||||
val datum: String? = null, // ISO LocalDate
|
||||
val tbId: String? = null,
|
||||
val pbId: String? = null,
|
||||
val nennschluss: String? = null, // ISO Instant
|
||||
val nachnenngebuehrVerlangt: Boolean = false,
|
||||
val nenntauschboerseAktiv: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Der Wizard-Flow für die Turnier-Anlage.
|
||||
*/
|
||||
val TurnierAnlageFlow = flow<TurnierAnlageStep, TurnierAnlageAcc>(start = TurnierAnlageStep.Basisdaten) {
|
||||
step(TurnierAnlageStep.Basisdaten) {
|
||||
otherwise(TurnierAnlageStep.KategorieReglement)
|
||||
}
|
||||
step(TurnierAnlageStep.KategorieReglement) {
|
||||
otherwise(TurnierAnlageStep.Funktionaere)
|
||||
}
|
||||
step(TurnierAnlageStep.Funktionaere) {
|
||||
otherwise(TurnierAnlageStep.NennKonfig)
|
||||
}
|
||||
step(TurnierAnlageStep.NennKonfig) {
|
||||
otherwise(TurnierAnlageStep.Summary)
|
||||
}
|
||||
}
|
||||
+4
-73
@@ -1,76 +1,7 @@
|
||||
package at.mocode.veranstaltung.feature.wizard
|
||||
|
||||
import at.mocode.frontend.core.navigation.AppScreen
|
||||
import at.mocode.frontend.core.wizard.dsl.flow
|
||||
import at.mocode.frontend.core.wizard.runtime.Guard
|
||||
import at.mocode.frontend.core.wizard.runtime.StepId
|
||||
import at.mocode.frontend.core.wizard.runtime.WizardContext
|
||||
import at.mocode.frontend.core.wizard.runtime.WizardState
|
||||
// Platzhalter für den Event-Flow.
|
||||
// Hinweis: Der echte Flow lebt zunächst als Demo in :frontend:core:wizard (samples),
|
||||
// bis die VM-Delegation hinter dem Feature-Flag integriert wird.
|
||||
|
||||
sealed interface EventWizardStep : StepId {
|
||||
data object ZnsCheck : EventWizardStep
|
||||
data object VeranstalterSelection : EventWizardStep
|
||||
data object AnsprechpersonMapping : EventWizardStep
|
||||
data object MetaData : EventWizardStep
|
||||
data object TurnierKonfiguration : EventWizardStep
|
||||
data object BewerbKonfiguration : EventWizardStep
|
||||
data object AbteilungKonfiguration : EventWizardStep
|
||||
data object Summary : EventWizardStep
|
||||
}
|
||||
|
||||
data class EventWizardAcc(
|
||||
val veranstalterId: String? = null,
|
||||
val veranstalterNr: String = ""
|
||||
)
|
||||
|
||||
object EventWizardGuards {
|
||||
val hasZns: Guard<EventWizardStep, EventWizardAcc> = { ctx, _ ->
|
||||
val stats = ctx.stats
|
||||
if (stats == null) false
|
||||
else {
|
||||
val hasData = stats.vereinCount > 0
|
||||
hasData && !stats.lastImport.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
val needsContactPerson: Guard<EventWizardStep, EventWizardAcc> = { _, acc ->
|
||||
acc.veranstalterId == null || acc.veranstalterNr.startsWith("ORG-")
|
||||
}
|
||||
|
||||
val hasSelectedVeranstalter: Guard<EventWizardStep, EventWizardAcc> = { _, acc ->
|
||||
!acc.veranstalterId.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
val EventWizardFlow = flow<EventWizardStep, EventWizardAcc>(start = EventWizardStep.ZnsCheck) {
|
||||
step(EventWizardStep.ZnsCheck) {
|
||||
whenGuard("hasZns", EventWizardGuards.hasZns, go = EventWizardStep.VeranstalterSelection)
|
||||
otherwise(EventWizardStep.VeranstalterSelection)
|
||||
}
|
||||
step(EventWizardStep.VeranstalterSelection) {
|
||||
whenGuard("notSelected", { ctx, acc -> !EventWizardGuards.hasSelectedVeranstalter(ctx, acc) }, go = EventWizardStep.VeranstalterSelection)
|
||||
whenGuard("needsContactPerson", EventWizardGuards.needsContactPerson, go = EventWizardStep.AnsprechpersonMapping)
|
||||
otherwise(EventWizardStep.MetaData)
|
||||
}
|
||||
step(EventWizardStep.AnsprechpersonMapping) {
|
||||
otherwise(EventWizardStep.MetaData)
|
||||
}
|
||||
step(EventWizardStep.MetaData) {
|
||||
otherwise(EventWizardStep.TurnierKonfiguration)
|
||||
}
|
||||
step(EventWizardStep.TurnierKonfiguration) {
|
||||
otherwise(EventWizardStep.BewerbKonfiguration)
|
||||
}
|
||||
step(EventWizardStep.BewerbKonfiguration) {
|
||||
otherwise(EventWizardStep.AbteilungKonfiguration)
|
||||
}
|
||||
step(EventWizardStep.AbteilungKonfiguration) {
|
||||
otherwise(EventWizardStep.Summary)
|
||||
}
|
||||
step(EventWizardStep.Summary) {
|
||||
// End-Step
|
||||
}
|
||||
}
|
||||
|
||||
fun eventWizardStartState(origin: AppScreen, acc: EventWizardAcc = EventWizardAcc()): WizardState<EventWizardStep, EventWizardAcc> =
|
||||
WizardState(current = EventWizardStep.ZnsCheck, acc = acc)
|
||||
object EventWizardPlaceholder
|
||||
|
||||
+2
-13
@@ -1,6 +1,5 @@
|
||||
package at.mocode.frontend.shell.desktop
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -26,16 +25,7 @@ import org.koin.compose.viewmodel.koinViewModel
|
||||
*/
|
||||
@Composable
|
||||
fun DesktopApp() {
|
||||
val deviceInitViewModel: at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel = koinViewModel()
|
||||
val deviceSettings by deviceInitViewModel.uiState.collectAsState()
|
||||
|
||||
val isDark = when(deviceSettings.settings.appTheme) {
|
||||
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> isSystemInDarkTheme()
|
||||
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> false
|
||||
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> true
|
||||
}
|
||||
|
||||
AppTheme(darkTheme = isDark) {
|
||||
AppTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
@@ -90,8 +80,7 @@ fun DesktopApp() {
|
||||
currentScreen is AppScreen.ConnectivityCheck ||
|
||||
currentScreen is AppScreen.Dashboard ||
|
||||
currentScreen is AppScreen.Profile ||
|
||||
currentScreen is AppScreen.ProfileOnboarding ||
|
||||
currentScreen is AppScreen.Chat
|
||||
currentScreen is AppScreen.ProfileOnboarding
|
||||
|
||||
if (!authState.isAuthenticated && !isAllowedScreen) {
|
||||
LaunchedEffect(currentScreen) {
|
||||
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
package at.mocode.frontend.shell.desktop.screens.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
val sender: String,
|
||||
val text: String,
|
||||
val time: String,
|
||||
val isFromMe: Boolean
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var messageText by remember { mutableStateOf("") }
|
||||
val messages = remember { mutableStateListOf<ChatMessage>() }
|
||||
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||
|
||||
// Mock initial messages
|
||||
LaunchedEffect(Unit) {
|
||||
if (messages.isEmpty()) {
|
||||
messages.add(ChatMessage("1", "Richter-Turm 1", "Startliste für Bewerb 5 ist fertig?", "10:45", false))
|
||||
messages.add(ChatMessage("2", "Meldestelle", "Ja, wird gerade gedruckt.", "10:46", true))
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) {
|
||||
// Header
|
||||
Surface(
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(Dimens.SpacingM),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"Veranstaltungs-Chat",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
"LAN-Kanal: aktiv (3 Teilnehmer)",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = AppColors.Success
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chat Messages
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = Dimens.SpacingM),
|
||||
contentPadding = PaddingValues(vertical = Dimens.SpacingM),
|
||||
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
|
||||
) {
|
||||
items(messages) { msg ->
|
||||
ChatBubble(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Input Area
|
||||
Surface(
|
||||
tonalElevation = 4.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(Dimens.SpacingM),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = messageText,
|
||||
onValueChange = { messageText = it },
|
||||
placeholder = { Text("Nachricht schreiben...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
maxLines = 3
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (messageText.isNotBlank()) {
|
||||
messages.add(
|
||||
ChatMessage(
|
||||
id = messages.size.toString(),
|
||||
sender = "Meldestelle",
|
||||
text = messageText,
|
||||
time = LocalTime.now().format(timeFormatter),
|
||||
isFromMe = true
|
||||
)
|
||||
)
|
||||
messageText = ""
|
||||
}
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
),
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Senden")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatBubble(msg: ChatMessage) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = if (msg.isFromMe) Alignment.End else Alignment.Start
|
||||
) {
|
||||
if (!msg.isFromMe) {
|
||||
Text(
|
||||
msg.sender,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = if (msg.isFromMe) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(
|
||||
topStart = 12.dp,
|
||||
topEnd = 12.dp,
|
||||
bottomStart = if (msg.isFromMe) 12.dp else 0.dp,
|
||||
bottomEnd = if (msg.isFromMe) 0.dp else 12.dp
|
||||
),
|
||||
modifier = Modifier.widthIn(max = 400.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Text(msg.text, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
msg.time,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = 9.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
),
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -86,8 +86,7 @@ fun DesktopMainLayout(
|
||||
HorizontalDivider(thickness = Dimens.BorderThin, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
DesktopFooterBar(
|
||||
settings = onboardingSettings,
|
||||
onSetupClick = { onNavigate(AppScreen.DeviceInitialization) },
|
||||
onNavigate = onNavigate
|
||||
onSetupClick = { onNavigate(AppScreen.DeviceInitialization) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-13
@@ -42,7 +42,6 @@ import at.mocode.frontend.features.veranstalter.presentation.VeranstaltungKonfig
|
||||
import at.mocode.frontend.features.verein.presentation.VereinScreen
|
||||
import at.mocode.frontend.features.verein.presentation.VereinViewModel
|
||||
import at.mocode.frontend.features.zns.import.presentation.StammdatenImportScreen
|
||||
import at.mocode.frontend.shell.desktop.screens.chat.ChatScreen
|
||||
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterAuswahl
|
||||
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterDetail
|
||||
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterVerwaltungScreen
|
||||
@@ -67,19 +66,17 @@ fun DesktopContentArea(
|
||||
// DeviceInitialization (Geräte-Setup)
|
||||
is AppScreen.DeviceInitialization -> {
|
||||
println("[Screen] Rendering DeviceInitialization")
|
||||
val viewModel = koinViewModel<DeviceInitializationViewModel>()
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.initializationCompleteEvent.collect { finalSettings ->
|
||||
val viewModel = koinViewModel<DeviceInitializationViewModel> {
|
||||
parametersOf({ finalSettings: DeviceInitializationSettings ->
|
||||
DeviceInitializationSettingsManager.saveSettings(finalSettings)
|
||||
// Vision_04: Sicherheitsschlüssel als Token setzen, damit Cloud-Suche funktioniert
|
||||
val authTokenManager = org.koin.core.context.GlobalContext.get().get<AuthTokenManager>()
|
||||
authTokenManager.setToken(finalSettings.sharedKey)
|
||||
onSettingsChange(finalSettings)
|
||||
// nav.navigateToScreen(...) wird hier nicht direkt gerufen, sondern onNavigate
|
||||
onNavigate(AppScreen.EventVerwaltung)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
DeviceInitializationScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
@@ -342,12 +339,6 @@ fun DesktopContentArea(
|
||||
)
|
||||
}
|
||||
|
||||
is AppScreen.Chat -> {
|
||||
ChatScreen(
|
||||
onBack = onBack
|
||||
)
|
||||
}
|
||||
|
||||
is AppScreen.EntryManagement -> {
|
||||
val viewModel = koinViewModel<NennungViewModel>()
|
||||
NennungManagementScreen(viewModel = viewModel)
|
||||
|
||||
+2
-24
@@ -3,7 +3,6 @@ package at.mocode.frontend.shell.desktop.screens.layout.components
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.CloudDone
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.Dataset
|
||||
@@ -19,7 +18,6 @@ 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.domain.zns.ZnsImportProvider
|
||||
import at.mocode.frontend.core.navigation.AppScreen
|
||||
import at.mocode.frontend.core.network.ConnectivityTracker
|
||||
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
|
||||
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
|
||||
@@ -30,8 +28,7 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
@Composable
|
||||
fun DesktopFooterBar(
|
||||
settings: DeviceInitializationSettings,
|
||||
onSetupClick: () -> Unit = {},
|
||||
onNavigate: (AppScreen) -> Unit = {}
|
||||
onSetupClick: () -> Unit = {}
|
||||
) {
|
||||
val connectivityTracker = koinInject<ConnectivityTracker>()
|
||||
val discoveryService = koinInject<NetworkDiscoveryService>()
|
||||
@@ -105,26 +102,7 @@ fun DesktopFooterBar(
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
|
||||
) {
|
||||
// Chat Trigger
|
||||
Button(
|
||||
onClick = { onNavigate(AppScreen.Chat) },
|
||||
contentPadding = PaddingValues(horizontal = Dimens.SpacingS, vertical = 0.dp),
|
||||
modifier = Modifier.height(22.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null, modifier = Modifier.size(12.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Chat", style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp))
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "v2.4.0-rc1 | Desktop-Alpha",
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
-44
@@ -180,46 +180,6 @@ fun Erfolgsscreen(email: String, onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadDesktopAppCard() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = AppColors.PrimaryContainer),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Meldestelle Desktop",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = AppColors.OnPrimaryContainer
|
||||
)
|
||||
Text(
|
||||
"Laden Sie die professionelle Desktop-App für die Offline-Verwaltung Ihres Turniers herunter.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = AppColors.OnPrimaryContainer.copy(alpha = 0.8f),
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { /* In POC: Zeigt Hinweis oder simuliert Download */ },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Primary),
|
||||
modifier = Modifier.height(56.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Description, contentDescription = null) // Verwende Description als Ersatz für Download
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text("Desktop-App laden", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LandingPage(
|
||||
onVeranstaltungClick: (Long) -> Unit,
|
||||
@@ -245,10 +205,6 @@ fun LandingPage(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
item {
|
||||
DownloadDesktopAppCard()
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
"Willkommen bei der Meldestelle Online",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user