Compare commits
189 Commits
backup/rem
...
0528334eb4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0528334eb4 | ||
|
|
e033d716dd | ||
|
|
e195d5d189 | ||
|
|
533e89c405 | ||
|
|
083244bc83 | ||
|
|
9def0fdc32 | ||
|
|
512756cb48 | ||
|
|
19d2f21fc3 | ||
|
|
5074e8f8f8 | ||
|
|
18a08b0e7a | ||
|
|
026e4ba3e4 | ||
|
|
58fd7fa5c6 | ||
| 755442fb70 | |||
| d31515bc79 | |||
|
|
1710c9349d | ||
|
|
cd025b1f92 | ||
|
|
2c681cf65c | ||
|
|
92099685e6 | ||
|
|
7e0c92368e | ||
|
|
817f5e02ca | ||
|
|
d3be0a269f | ||
|
|
8e318b0b52 | ||
| b729e90e38 | |||
|
|
acfcf773f7 | ||
|
|
1ea7f7a63f | ||
|
|
7289adb7a0 | ||
|
|
19d7aeefb0 | ||
| 92c0610dcb | |||
|
|
5fce08ab75 | ||
|
|
1c9dff0932 | ||
|
|
fd83b18642 | ||
| 6db05c1dc6 | |||
|
|
84536956c4 | ||
|
|
459da00820 | ||
|
|
f883d45452 | ||
|
|
11ff823fe2 | ||
|
|
cfd9365d07 | ||
|
|
f2f76dec56 | ||
|
|
2d42ef3ecd | ||
|
|
e19158558d | ||
|
|
bf4db389ff | ||
|
|
964a68cdfd | ||
|
|
21b39d4e5c | ||
| 68b6ab593a | |||
|
|
3658589d94 | ||
| 4e42ddfee4 | |||
|
|
a80ea7b892 | ||
| 6a6b8e0a1b | |||
|
|
f7c6caebc1 | ||
| ddb170cffc | |||
|
|
79e2ae9b87 | ||
| 66f2718714 | |||
|
|
4891e965bb | ||
| f2f5ddb8ce | |||
|
|
2fab6be58a | ||
|
|
549f4a1510 | ||
| 4d13ea5de6 | |||
|
|
02ee4af49d | ||
|
|
cc253c24db | ||
| 45d343b1c1 | |||
|
|
2eeed60387 | ||
|
|
63a1034e3d | ||
| 7b9bdb9a36 | |||
|
|
1561e1b861 | ||
|
|
3f7149d622 | ||
|
|
bc9cc265e1 | ||
| a48c65bfb8 | |||
|
|
fdf72bdb96 | ||
|
|
dbcc55d7fc | ||
|
|
f8a0370910 | ||
| 9a59fa5525 | |||
|
|
41374da6ea | ||
| 70acfe6d5e | |||
|
|
e5c247f703 | ||
| 01f7d075e9 | |||
|
|
2503eb92af | ||
| bb2785ba56 | |||
|
|
4e918870f5 | ||
| e871790ee8 | |||
|
|
0849c625cb | ||
| b19a9f6b8e | |||
|
|
c78adc0d52 | ||
|
|
806a4be2f9 | ||
|
|
5f96f719c1 | ||
|
|
28b1001826 | ||
|
|
dcc4055eca | ||
|
|
4521ce002e | ||
|
|
842a516ce6 | ||
|
|
48f8b46e57 | ||
|
|
8ae7dcdbf1 | ||
| 667b65bf29 | |||
|
|
dba2747883 | ||
| 21752fedcc | |||
|
|
a30692a053 | ||
| 742687f82b | |||
|
|
fddde56076 | ||
|
|
c385df4a0c | ||
| 7152b54b68 | |||
|
|
e44d3c5c74 | ||
|
|
c409fa6d4b | ||
| 21b4e8bc9f | |||
|
|
0fa19493c5 | ||
|
|
c145a723ed | ||
| 91fb3d79c5 | |||
|
|
d0b15f3e83 | ||
|
|
e60c0f4481 | ||
|
|
27a096546f | ||
| aee8705fa3 | |||
|
|
20a1cdd7f2 | ||
|
|
e3825ad217 | ||
| 99c03dccf2 | |||
|
|
a12f1f7815 | ||
| d450175871 | |||
|
|
6fea2749e0 | ||
|
|
18da725567 | ||
|
|
4d5fb43ebc | ||
|
|
986b2056cd | ||
|
|
337c172d07 | ||
|
|
15b8f3c4c1 | ||
|
|
510cfd39f9 | ||
|
|
e0bad51764 | ||
|
|
c1de0c1671 | ||
|
|
2bedbee08d | ||
|
|
9c54b6907e | ||
|
|
edfab28fd3 | ||
|
|
5f79d220cf | ||
|
|
0a82b33afc | ||
|
|
1922e85184 | ||
|
|
0fb8052a77 | ||
|
|
ef2d9353f5 | ||
|
|
1aae808e5f | ||
|
|
75e6d66d25 | ||
|
|
daabeec33c | ||
|
|
0fb58af194 | ||
|
|
8ffd267dfc | ||
|
|
5eee7df7e4 | ||
|
|
7dea265eef | ||
|
|
381ec55fd1 | ||
|
|
c30911daed | ||
|
|
bdc9eef707 | ||
|
|
f7701d698f | ||
|
|
49e7255062 | ||
|
|
74246e6b08 | ||
|
|
6230c96bc9 | ||
|
|
3fb40bd87d | ||
|
|
46c2c14ae8 | ||
|
|
ff8c1970df | ||
|
|
8347a86727 | ||
|
|
9a6d32dcb3 | ||
|
|
161618f6fb | ||
|
|
0b3fba44a4 | ||
|
|
d35e1c9a3e | ||
|
|
528353132a | ||
|
|
cd5e5cd781 | ||
|
|
ebbffcc5c4 | ||
|
|
5c760d7fa8 | ||
|
|
d40073ac7b | ||
|
|
b25cf13d3c | ||
|
|
3287102761 | ||
|
|
08624cabbe | ||
|
|
d37f182928 | ||
|
|
79c45be7c7 | ||
|
|
d52f3ffc8d | ||
|
|
64baaf8535 | ||
|
|
e665495003 | ||
|
|
8f444c59eb | ||
|
|
8117335af9 | ||
|
|
85ec99b08c | ||
|
|
04571e6444 | ||
|
|
5799f97570 | ||
|
|
8ab08f4c09 | ||
|
|
fcf3168692 | ||
|
|
cfd209d7ee | ||
|
|
ee1709ffb2 | ||
|
|
8bb02b6e4a | ||
|
|
7a20af2772 | ||
|
|
3e610e68b6 | ||
|
|
485b21c13e | ||
|
|
08b1edc354 | ||
|
|
6e297c682c | ||
|
|
3d3e22bb1b | ||
|
|
d18b671532 | ||
|
|
af3c0164ef | ||
|
|
c681194462 | ||
|
|
141a15a6cb | ||
|
|
ce5915a3bc | ||
|
|
677140bd33 | ||
|
|
8a1e309eba | ||
|
|
0d533710cd |
@@ -1,9 +1,8 @@
|
||||
name: Code Analysis (JS/Vue)
|
||||
name: Code Analysis and Production Deploy
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, dev ]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -12,6 +11,12 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Workspace sanity check
|
||||
run: |
|
||||
echo "PWD: $(pwd)"
|
||||
@@ -25,6 +30,15 @@ jobs:
|
||||
node -v
|
||||
npm -v
|
||||
|
||||
- name: Check package.json version vs production
|
||||
if: "github.ref == 'refs/heads/main'"
|
||||
env:
|
||||
PROD_HOST: ${{ vars.PROD_HOST }}
|
||||
PROD_USER: ${{ vars.PROD_USER }}
|
||||
PROD_PORT: ${{ vars.PROD_PORT }}
|
||||
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
|
||||
run: bash scripts/check-version-against-prod.sh
|
||||
|
||||
- name: gitleaks (Secrets Scanning)
|
||||
run: |
|
||||
# Try to get the latest release asset URL
|
||||
@@ -82,3 +96,65 @@ jobs:
|
||||
./osv-scanner --version
|
||||
test -f ./package-lock.json
|
||||
./osv-scanner --lockfile ./package-lock.json
|
||||
|
||||
deploy-production:
|
||||
needs: analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"echo SSH OK"
|
||||
|
||||
- name: Run production deployment script
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
||||
|
||||
deploy-test:
|
||||
needs: analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
steps:
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"echo SSH OK"
|
||||
|
||||
- name: Run test deployment script
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
||||
|
||||
11
.gitea/workflows/version-gate.yml
Normal file
11
.gitea/workflows/version-gate.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
name: Require Package Version Change (disabled)
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Disabled
|
||||
run: echo "version-gate workflow disabled — gating is handled in code-analysis.yml"
|
||||
28
.github/workflows/android-ci.yml
vendored
Normal file
28
.github/workflows/android-ci.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Android CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper/
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
- name: Build
|
||||
run: |
|
||||
./gradlew :app:assembleDebug
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -88,6 +88,12 @@ out
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Android / Gradle generated and machine-local files
|
||||
/android-app/.gradle/
|
||||
/android-app/.kotlin/
|
||||
/android-app/**/build/
|
||||
/android-app/local.properties
|
||||
|
||||
# Build output (but keep production data!)
|
||||
.output
|
||||
!.output/.gitkeep
|
||||
@@ -143,6 +149,8 @@ Thumbs.db
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
temp/webpage-downloads/data/
|
||||
temp/webpage-downloads/*.html
|
||||
|
||||
# Security tooling artifacts (CI downloads)
|
||||
gitleaks
|
||||
@@ -154,3 +162,7 @@ server/data/**
|
||||
!server/data/.gitkeep
|
||||
public/data/**
|
||||
public/uploads/**
|
||||
backups/*
|
||||
public/data
|
||||
server/data
|
||||
public/uploads
|
||||
|
||||
36
ANDROID_ARCHITECTURE.md
Normal file
36
ANDROID_ARCHITECTURE.md
Normal file
@@ -0,0 +1,36 @@
|
||||
Android Architektur (Kotlin + Jetpack Compose) — Vorschlag
|
||||
|
||||
Packages/Module Struktur:
|
||||
- app/ (Android-App module)
|
||||
- src/main/java/de/harheimertc/
|
||||
- ui/
|
||||
- navigation/ (NavGraph, Routes)
|
||||
- screens/ (HomeScreen, TermineScreen, SpielplanScreen, GalleryScreen, ContactScreen, AuthScreens, CMS Screens)
|
||||
- components/ (TopBar, BottomNav, Cards, Modals)
|
||||
- theme/ (Color.kt, Typography.kt, Theme.kt)
|
||||
- data/
|
||||
- api/ (Retrofit interfaces, DTOs)
|
||||
- repository/ (Repositories für Domain-Modelle)
|
||||
- local/ (Room DAOs, Entities)
|
||||
- di/ (Hilt Modules)
|
||||
- domain/ (UseCases, Business-Logic)
|
||||
- util/ (Extensions, DateUtils, ImageUtils)
|
||||
- auth/ (AuthManager, Passkeys helper)
|
||||
|
||||
Wichtige Dateien:
|
||||
- `MainActivity.kt` — Hosts Compose NavHost
|
||||
- `AppTheme.kt` — Compose Material3 Theme mit Token-Mapping
|
||||
- `NetworkModule` (Hilt) — Retrofit + OkHttp + Auth Interceptor
|
||||
- `Repository` Layer — entkoppelt UI von Netz
|
||||
- `Room` Entities — für Caching von Termine/News/Galerie
|
||||
|
||||
Auth-Strategie:
|
||||
- AuthRepository verwaltet Login/Logout, `checkAuth()` (mirroring `/api/auth/status`).
|
||||
- Token/Cookie-Speicherung: `EncryptedSharedPreferences` für Tokens oder `CookieJar` mit OKHttp-Client.
|
||||
- Passkeys: `Fido2Client` wrapper + Bridge zu Server-API (Formate prüfen).
|
||||
|
||||
Build / Module Tipps:
|
||||
- Start mit Single Module `app/` und später evtl. `:data`, `:domain` Trennung.
|
||||
- Verwende Gradle Kotlin DSL (build.gradle.kts).
|
||||
|
||||
Diese Architekturdatei wurde generiert; ich kann nun ein initiales Gradle-Kotlin-Scaffold erzeugen. Soll ich das direkt in `android-app/` ablegen? (Ja/Nein)
|
||||
211
ANDROID_KOTLIN_PLAN.md
Normal file
211
ANDROID_KOTLIN_PLAN.md
Normal file
@@ -0,0 +1,211 @@
|
||||
Android App — Kotlin (Jetpack Compose) Plan und Abhakliste
|
||||
|
||||
Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web-UI 1:1 abbildet (Farben, Typografie, Funktionalitäten). Diese Datei enthält Architekturentscheidungen, empfohlene Bibliotheken und eine detaillierte Abhakliste (schrittweise).
|
||||
|
||||
1) Zusammenfassung der Entscheidungen
|
||||
- Plattform: Native Android
|
||||
- Sprache: Kotlin
|
||||
- UI-Toolkit: Jetpack Compose (Compose Material3)
|
||||
- Architektur: MVVM mit `ViewModel` + Kotlin Coroutines + Flow
|
||||
- DI: Hilt
|
||||
- HTTP-Client: Ktor Client oder Retrofit + OkHttp (empfohlen: Retrofit für breite Community-Docs)
|
||||
- Bild-Loading: Coil
|
||||
- Lokale DB / Caching: Room + DataStore (Preferences)
|
||||
- Background/Sync: WorkManager
|
||||
- Auth: kurzlebiges JWT-Access-Token plus rotierendes, widerrufbares Refresh-Token pro Android-Gerätesitzung; Speicherung in `EncryptedSharedPreferences`/Android Keystore; Unterstützung für Passkeys (Android Passkeys / WebAuthn Interop über FIDO2 APIs)
|
||||
- Auth-Sicherheitsentscheidung: kein statischer App-Key bzw. kein in der APK hinterlegtes Client-Secret. Native Apps können ein gemeinsames Secret nicht vertraulich halten. Optional später: Refresh-Sitzung an ein pro Installation im Android Keystore erzeugtes Schlüsselpaar binden.
|
||||
- Rich-Text: WebView-basierte Anzeige; Editoren: ggf. hybride Lösung (Server-side HTML editor + WebView) oder `RichEditor`-Libs
|
||||
- Crash-Reporting & Monitoring: Firebase Crashlytics oder Sentry
|
||||
|
||||
2) Design & Farben
|
||||
- Material Theme (Material3) mit Farben aus `tailwind.config.js` (Primary + Accent).
|
||||
- Fonts: Inter & Montserrat via Google Fonts (Download/Bundle oder Play-Services-Download at runtime).
|
||||
- Mapping: Tailwind-Token → `colors.xml` / Compose `Color` tokens.
|
||||
|
||||
3) Empfohlene Abhängigkeiten (erste Implementierung)
|
||||
- androidx.compose.* (ui, material3, navigation)
|
||||
- androidx.lifecycle:lifecycle-viewmodel-ktx
|
||||
- com.google.dagger:hilt-android
|
||||
- retrofit2 + converter-moshi / kotlinx-serialization
|
||||
- io.coil-kt:coil-compose
|
||||
- androidx.room:room-runtime + room-ktx
|
||||
- androidx.work:work-runtime-ktx
|
||||
- androidx.datastore:datastore-preferences
|
||||
- com.google.android.gms:play-services-auth (für passkeys falls nötig)
|
||||
- io.sentry:sentry-android (optional)
|
||||
|
||||
4) Detaillierte Abhakliste (Schritte)
|
||||
[x] 1. Repo-Analyse: Liste der externen Endpunkte und Auth-Anforderungen exportieren
|
||||
[x] 2. Projekt-Scaffold: Android Studio Projekt mit Kotlin + Compose anlegen
|
||||
[x] 3. App-Architektur: Module / Packages anlegen (ui, data, domain, di, util)
|
||||
[x] 4. CI-Build: Gradle-Config und GitHub Actions Skeleton
|
||||
[x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen
|
||||
[x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts)
|
||||
[x] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
|
||||
[x] 7a. Umgebungen: Android-Varianten fuer lokal, Test-Instanz und Produktion mit eigener API-Basis konfigurieren
|
||||
[x] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation
|
||||
[x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden
|
||||
[x] 7d. Tablet-Navigation: öffentliche Haupt- und Subnavigation der Web-UI mit Portierungszielen abbilden
|
||||
[x] 7e. Navigation: dynamische Mannschaftslinks, Galerie-Sichtbarkeit und rollenabhängiges `Intern` wie in der Web-UI anbinden
|
||||
[x] 8. Start-Screen: `HomeScreen` webnah mit Hero, Navigation, Termine, Spielen, News und Aktionen umsetzen
|
||||
[x] 9. Komponenten: NavBar, Footer, Cards, ImageGrid und News-Dialog implementieren
|
||||
[x] 10. Öffentliche Screens aus der Web-Navigation portieren
|
||||
- [x] `/` Startseite
|
||||
- [x] `/termine`: öffentliche Terminliste mit Lade-, Leer- und Fehlerzustand
|
||||
- [x] `/mannschaften/spielplaene`: Saison-, Wettbewerbs- und Mannschaftsfilter mit Spielkarten
|
||||
- [x] `/verein/galerie`: Anzeige-Screen vorhanden
|
||||
- [x] `/kontakt`: Formular-Screen vorhanden
|
||||
- [x] `/mitgliedschaft`: Antrag, Validierung, PDF-Erzeugung und PDF-Öffnen
|
||||
- [x] `/verein/ueber-uns`: CMS-Inhalt aus der öffentlichen Konfiguration
|
||||
- [x] `/vorstand`: öffentliche Vorstandsangaben aus der Konfiguration
|
||||
- [x] `/verein/geschichte`: CMS-Inhalt aus der öffentlichen Konfiguration
|
||||
- [x] `/verein/satzung`: CMS-Inhalt und PDF-Aufruf aus der öffentlichen Konfiguration
|
||||
- [x] `/vereinsmeisterschaften`: Ergebnisliste mit Jahresfilter und Statistik
|
||||
- [x] `/links`: strukturierte CMS-Links mit Fallback-Verweisen
|
||||
- [x] `/mannschaften`: Übersicht aus saisonaler Mannschafts-CSV
|
||||
- [x] `/mannschaften/[slug]`: dynamische Mannschaftsdetails mit aktuellem Spielplan und Umschaltung `Matches`/`Tabelle`
|
||||
- [x] `/spielsysteme`: Spielsystemkarten mit Kategoriefilter aus CSV
|
||||
- [x] `/training`: Trainingsort und gruppierte Trainingszeiten aus der Konfiguration
|
||||
- [x] `/training/trainer`
|
||||
- [x] `/training/anfaenger`
|
||||
- [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf
|
||||
[x] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
|
||||
- [x] `/impressum`
|
||||
- [x] Legacy-/Doppelrouten geklärt: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
|
||||
[x] 10b. Newsletter-Screens portieren
|
||||
- [x] `/newsletter/subscribe`
|
||||
- [x] `/newsletter/unsubscribe`
|
||||
- [x] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
|
||||
[x] 10c. Auth-Screens portieren
|
||||
- [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung
|
||||
- [x] `/registrieren`
|
||||
- [x] `/passwort-vergessen`
|
||||
[x] 10d. Mitgliederbereich portieren
|
||||
- [x] `/mitgliederbereich`: Übersicht
|
||||
- [x] `/mitgliederbereich/mitglieder`
|
||||
- [x] `/mitgliederbereich/news`
|
||||
- [x] `/mitgliederbereich/profil`
|
||||
- [x] `/mitgliederbereich/api`
|
||||
[x] 10e. CMS-Screens nach Rollenberechtigung portieren
|
||||
- [x] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften`
|
||||
- [x] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen`
|
||||
- [x] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer`
|
||||
[ ] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh)
|
||||
- [x] Retrofit/OkHttp/Moshi und Hilt-Verdrahtung
|
||||
- [x] Öffentliche Endpunkte für Startseite, Termine, Spielplan, Galerie und Kontakt
|
||||
- [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider`
|
||||
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
|
||||
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
|
||||
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-Leseendpunkte erledigt)
|
||||
- [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
|
||||
- [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
|
||||
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
||||
- [x] Login/Logout und verschlüsselte Token-Speicherung
|
||||
- [x] Registrierung und Passwort-Reset
|
||||
- [x] Backend: Android-JWT-Access-Token auf ca. 15 Minuten reduzieren; bestehende Web-Cookie-Sitzungen bis zur Web-Refresh-Integration kompatibel weiterführen
|
||||
- [x] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
|
||||
- [x] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
|
||||
- [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
|
||||
- [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
|
||||
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
|
||||
[x] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
|
||||
- [x] Passkey-Login über Android Credential Manager an bestehende WebAuthn-Endpunkte anbinden
|
||||
- [x] Passkey-Erstellung und -Entfernung im nativen Profil ergänzen
|
||||
- [x] Backend-Passkey-Endpunkte für Android-Bearer-Token und Android-Refresh-Sitzungen erweitern
|
||||
- [ ] Für Produktion `/.well-known/assetlinks.json` mit Package `de.harheimertc` und Release-Zertifikat-Fingerprint veröffentlichen
|
||||
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
||||
[x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
||||
- [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt
|
||||
- [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ
|
||||
- [ ] Rich-Text-Editor für CMS-Bearbeitung als WebView-Bridge prüfen, falls CMS-Editieren nativ ausgebaut wird
|
||||
[x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
|
||||
- [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt
|
||||
- [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld
|
||||
[ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
|
||||
[ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
|
||||
[ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
|
||||
[ ] 20. Tests: Unit-Tests für ViewModels + UI-Tests mit Compose Testing
|
||||
[ ] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten)
|
||||
[ ] 22. Analytics: Firebase / Matomo Integration (je nach Datenschutz)
|
||||
[ ] 23. Crash-Reporting: Sentry / Crashlytics integrieren
|
||||
[ ] 24. Build/Release: App signing, Release-Notes, Play-Store-Metadaten vorbereiten
|
||||
[ ] 25. Dokumentation: `README-android.md` mit Setup, Architektur und Release-Anleitung
|
||||
|
||||
5) Kurzzeit-MVP (Priorität für erste Version)
|
||||
- [x] A. Auth (Login/Logout)
|
||||
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
|
||||
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
|
||||
- [x] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
|
||||
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
|
||||
- [x] C. Kontaktformular (absenden)
|
||||
- [ ] D. Bildanzeige + Caching
|
||||
- [x] E. Theme & Fonts
|
||||
|
||||
6) Nächste Aktionen (sofort)
|
||||
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
|
||||
- Release-Zertifikat erzeugen und Digital Asset Links für native Android-Passkeys veröffentlichen.
|
||||
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
||||
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
||||
- Weitere Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der jeweils verwendeten Backend-Endpunkte ergänzen.
|
||||
|
||||
7) Umsetzungsprotokoll
|
||||
- 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt.
|
||||
- 2026-05-27: `Termine` und `Spielplan` als native Screens umgesetzt; Spielplan unterstützt Saison, Wettbewerb, Mannschaft, Ergebnis und zweizeilige Gruppeninformation.
|
||||
- 2026-05-27: `Mitgliedschaft` mit Antrag/PDF-Abruf sowie Passwort-`Login`/`Logout` umgesetzt; offene Auth-Härtung separat ausgewiesen.
|
||||
- 2026-05-27: Tokens verschlüsselt persistiert; Session-Wiederherstellung sowie Logout per Bearer-Token in den Auth-Endpunkten ermöglicht.
|
||||
- 2026-05-27: Registrierung und Passwort-Reset an die vorhandenen Auth-Endpunkte angebunden.
|
||||
- 2026-05-27: Product-Flavors `local`, `instantTest` und `production` eingerichtet; lokale Basis-URL ist per Gradle-Parameter überschreibbar.
|
||||
- 2026-05-27: Gradle-Heap/Worker für Flavor-Builds festgelegt, nachdem paralleles D8/KSP mit dem 512-MiB-Standardheap nicht ausreichend Speicher hatte.
|
||||
- 2026-05-27: Lokales Testsetup gegen Emulator geprüft; bei IPv6-gebundenem Nuxt-Dev-Server wird die von Nuxt ausgegebene Network-URL per `LOCAL_API_BASE_URL` verwendet.
|
||||
- 2026-05-27: Adaptive Navigation umgesetzt; Tablet-Layouts ab `600dp` zeigen Header und Hauptmenue dauerhaft, Smartphone-Layouts behalten die vorhandene Navigation.
|
||||
- 2026-05-27: Platzhalterlogo in der Android-Navigation durch das vorhandene Harheimer-TC-Weblogo als skalierte lokale PNG-Ressource ersetzt.
|
||||
- 2026-05-27: Web-Navigation und `pages/` vollständig inventarisiert; Tablet-Haupt-/Subnavigation für die öffentlichen Bereiche strukturell angeglichen und alle fehlenden Screens einzeln in die Portierungsliste aufgenommen.
|
||||
- 2026-05-27: Tablet-Header auf Web-Verhalten angepasst (Bereichswechsel öffnet Startseite und Submenü) und die native ActionBar zugunsten des App-Headers entfernt.
|
||||
- 2026-05-27: Navigation mit Live-Mannschaftslinks, öffentlicher Galerie-Sichtbarkeit und rollenabhängigem `Intern` ergänzt; Mannschaftsübersicht/-detail sowie Training, Trainer und Anfänger nativ portiert.
|
||||
- 2026-05-27: Mannschaftsdetail um die Web-Untertabs `Matches` und `Tabelle` erweitert; Tabellenzeilen werden aus `/api/spielplan/table` geladen und die eigene Mannschaft hervorgehoben.
|
||||
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
|
||||
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
|
||||
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
|
||||
- 2026-05-27: Dauerhaftes Android-Login umgesetzt: Android-Logins erhalten 15-Minuten-Access-Tokens und rotierende Refresh-Tokens; Token-Hashes, Wiederverwendungswiderruf, Logout-/Reset-/Deaktivierungswiderruf sowie verschlüsselte App-Speicherung und automatischer OkHttp-Refresh sind implementiert.
|
||||
- 2026-05-27: Native Profilseite des Mitgliederbereichs umgesetzt; `/api/profile` akzeptiert nun Bearer-Tokens, die App kann Profil-/Sichtbarkeitsdaten bearbeiten und Passwortänderungen auslösen.
|
||||
- 2026-05-27: Login leitet in der Android-App nun direkt zur Mitgliederbereich-Übersicht weiter; `/mitgliederbereich` ist nativ mit Kacheln und Geburtstagsliste umgesetzt, `/api/birthdays` akzeptiert Bearer-Tokens.
|
||||
- 2026-05-28: 10d und 10e abgeschlossen: Mitgliederliste, interne News, API-Doku sowie alle CMS-Routen sind nativ angebunden; relevante Mitglieder-/News-/CMS-Endpunkte akzeptieren Bearer-Tokens.
|
||||
- 2026-05-28: 10a und 10b abgeschlossen: Impressum, Newsletter-An-/Abmeldung und Newsletter-Statusseiten sind nativ umgesetzt; Legacy-/Doppelrouten im Android-NavGraph auf native Screens abgebildet. Abgleich `pages/` gegen Android-Routen ergab keine verbleibenden Platzhalter-Webseiten. `/anlagen` wurde anschließend als ungenutzte und inhaltlich falsche Seite in Web und Android entfernt.
|
||||
- 2026-05-28: Android-Passkeys umgesetzt: Login nutzt Android Credential Manager mit den bestehenden WebAuthn-Optionen, das native Profil kann Passkeys erstellen/entfernen, und die Backend-Passkey-Endpunkte akzeptieren Bearer-Tokens sowie erzeugen beim Android-Passkey-Login rotierende Refresh-Sitzungen. Für Produktion fehlt noch die Domain-App-Verknüpfung per Digital Asset Links mit Release-Zertifikat.
|
||||
- 2026-05-28: Punkte 15 und 16 umgesetzt: Gemeinsame `RichText`-Komponente rendert HTML-Inhalte nativ in CMS-/News-Screens; Formularvalidierung wurde mit Inline-Feldfehlern für Kontakt, Auth, Newsletter, Profil und Mitgliedschaft ergänzt.
|
||||
|
||||
8) Android-Testumgebungen
|
||||
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
||||
- Lokal, wenn `10.0.2.2` nicht erreichbar ist: `./gradlew :app:installLocalDebug -PLOCAL_API_BASE_URL=http://<NUXT-NETWORK-HOST>:3100/`; die passende URL steht in der `npm run dev`-Ausgabe (hier `http://torstens:3100/`).
|
||||
- Test-Instanz: `./gradlew :app:installInstantTestDebug` verwendet `https://harheimertc.tsschulz.de/` und die App-ID `de.harheimertc.test`.
|
||||
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
|
||||
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
||||
|
||||
9) Dauerhaftes Android-Login: Architektur und Umsetzung
|
||||
- Stand der Umsetzung: Android-Logins erhalten ein ca. 15 Minuten gültiges JWT und eine serverseitig prüfbare Refresh-Sitzung; Access-Tokens mit Sitzungs-ID werden bei widerrufener Gerätesitzung abgelehnt. Web-Logins verwenden weiterhin das bisherige Cookie-JWT, bis ein browserseitiger Refresh-Flow ergänzt ist.
|
||||
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
|
||||
- Token-Modell:
|
||||
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
|
||||
- Refresh-Token: kryptografisch zufälliges, undurchsichtiges Token mit längerer Laufzeit, Zielwert z. B. 90 Tage mit Erneuerung bei aktiver Nutzung.
|
||||
- Server speichert ausschließlich den Hash des Refresh-Tokens zusammen mit `sessionId`, `userId`, `createdAt`, `lastUsedAt`, `expiresAt`, `revokedAt` und optional Gerätebezeichnung.
|
||||
- Backend-Arbeitspaket:
|
||||
- Login-Antwort um `accessToken`, `refreshToken`, `sessionId` und Ablaufmetadaten erweitern; bestehendes `token` nur befristet kompatibel halten.
|
||||
- `POST /api/auth/refresh` implementieren: gültiges Refresh-Token konsumieren, rotieren und ein neues Token-Paar zurückgeben.
|
||||
- Token-Wiederverwendung erkennen: Wird ein rotiertes Refresh-Token erneut präsentiert, die betroffene Token-Familie bzw. Gerätesitzung widerrufen.
|
||||
- `POST /api/auth/logout` auf Widerruf der Gerätesitzung erweitern; optional Endpunkte zum Anzeigen und Widerrufen eigener Geräte-Sitzungen vorsehen.
|
||||
- Kontodeaktivierung und Passwortänderung müssen sämtliche Refresh-Sitzungen des Benutzers widerrufen.
|
||||
- Rate-Limits und Audit-Events für Login, Refresh-Erfolg/-Fehlschlag, Wiederverwendung und Widerruf ergänzen.
|
||||
- Android-Arbeitspaket:
|
||||
- `AuthRepository` auf Access-Token, Refresh-Token und Session-ID erweitern; Speicherung weiter über Keystore-geschützte Preferences.
|
||||
- `ApiService`/DTOs um Refresh-Request und Token-Paar-Antwort ergänzen.
|
||||
- Einen OkHttp-`Authenticator` einsetzen, der auf `401` einmalig ein Access-Token erneuert, parallele Refreshes synchronisiert und den ursprünglichen Request wiederholt.
|
||||
- Beim App-Start zunächst Access-Token prüfen und bei Ablauf transparent mit dem Refresh-Token erneuern; nur bei fehlgeschlagenem Refresh zum Login zurückkehren.
|
||||
- Beim Logout lokale Tokens auch bei Netzwerkfehler entfernen; serverseitiger Widerruf erfolgt best effort bzw. bei nächster Konnektivität.
|
||||
- Sicherheitsregeln:
|
||||
- Kein gemeinsamer App-Key und kein statisches Client-Secret in Sourcecode, `BuildConfig` oder APK.
|
||||
- Refresh-Tokens nie im Klartext serverseitig speichern oder protokollieren.
|
||||
- Nur HTTPS für Test-/Produktionsumgebungen; Token-Werte nicht in Logging-Interceptors ausgeben.
|
||||
- Optional nach MVP: App erzeugt pro Installation ein Keystore-Schlüsselpaar; Backend bindet Refresh-Sitzungen an den öffentlichen Schlüssel und prüft signierte Refresh-Anfragen.
|
||||
|
||||
---
|
||||
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)
|
||||
84
ANDROID_PORT_TODO.md
Normal file
84
ANDROID_PORT_TODO.md
Normal file
@@ -0,0 +1,84 @@
|
||||
ANDROID App - 1:1 Portierung der Web-UI (TODO)
|
||||
|
||||
Ziel: Die Web-UI des Projekts 1:1 in eine native (oder cross-platform) Android-App überführen, inklusive Farben, Designsystem und aller Funktionalitäten.
|
||||
|
||||
1) Analyse Codebasis & Assets
|
||||
- Analysiere `package.json`, `nuxt.config.js`, `tailwind.config.js` und zentrale Server-/API-Endpunkte.
|
||||
- Liste alle verwendeten Farben, CSS-Variablen, Tailwind-Konfigurationen.
|
||||
- Sammle alle statischen Assets: Bilder, Icons, SVGs, Fonts, PDF-Dokumente.
|
||||
- Identifiziere dynamische Komponenten: Formulare, Rich-Text-Editor, Uploads, Kalender, Navigation.
|
||||
|
||||
2) Projektziele und Scope
|
||||
- Entscheide: Native Android (Kotlin/Jetpack Compose) oder Cross-Platform (React Native, Flutter, Kotlin Multiplatform).
|
||||
- Priorisiere Features für MVP vs. Post-Launch.
|
||||
|
||||
3) Designsystem und Farben extrahieren
|
||||
- Extrahiere Farbpalette, Typografie, Abstände, Buttons, Karten, Form-Controls.
|
||||
- Erstelle eine Design-Token-Liste (Hex/RGBA, Namen, Einsatzbereiche).
|
||||
|
||||
4) Technologie-Stack wählen
|
||||
- Empfohlene Optionen: Kotlin + Jetpack Compose (native), Flutter (UI-First), React Native (Wiederverwendung von JS/nuxt-Logik).
|
||||
- Bibliotheken: Navigation, HTTP-Client, Bild-Handling, Auth (WebAuthn falls nötig), Local DB.
|
||||
|
||||
5) Android-Projekt aufsetzen
|
||||
- Erstelle Projekt-Scaffold, CI-Build, Signing-Config.
|
||||
|
||||
6) Theme & Farben implementieren
|
||||
- Implementiere App-Theme mit Farben/Typografie-Token.
|
||||
|
||||
7) Navigation-Struktur implementieren
|
||||
- Bottom/Navigations-Drawer/Stack wie Web-Navigation abbilden.
|
||||
|
||||
8) Screens für Seiten anlegen
|
||||
- Erstelle Screens für: Startseite, Termine, Spielplan, Galerie, Kontakt, News, Mitgliedschaft, Login, CMS-Bereiche.
|
||||
|
||||
9) UI-Komponenten portieren
|
||||
- Navbar, Footer, Cards, Image-Grid, Modal/Dialog, Rich-Text-Viewer/Editor, Date-Picker, Tabellen.
|
||||
|
||||
10) Formulare & Validierung implementieren
|
||||
- Registrieren, Login, Passwort vergessen, Mitgliedschaftsformulare mit Client- und Server-Validierung.
|
||||
|
||||
11) Authentifizierungs-Flow implementieren
|
||||
- JWT / Session, OAuth oder WebAuthn falls benötigt; Token-Handling sicher speichern.
|
||||
|
||||
12) API-Client implementieren
|
||||
- Einheitlicher HTTP-Client, Error-Handling, Retry-Strategien, Pagination.
|
||||
|
||||
13) Bilderupload & Storage einrichten
|
||||
- Multi-part Upload, Progress, Bildkompression, lokale Cache-Strategie.
|
||||
|
||||
14) Offline-Support und Caching
|
||||
- Caching von API-Responses, Bild-Caching, Sync-Strategie für Formulare.
|
||||
|
||||
15) Lokalisierung und Texte prüfen
|
||||
- Alle statischen Texte extrahieren, deutsche Strings prüfen und in Resource-Files ablegen.
|
||||
|
||||
16) Accessibility-Prüfung und Anpassungen
|
||||
- Farbkontrast, Touch-Targets, Screenreader-Labels.
|
||||
|
||||
17) Unit- und UI-Tests schreiben
|
||||
- Komponenten- und Integrations-Tests, E2E (falls möglich).
|
||||
|
||||
18) Performance-Optimierung durchführen
|
||||
- Bilder, Netzwerk, Render-Perf.
|
||||
|
||||
19) CI/CD für Builds einrichten
|
||||
- GitHub Actions / GitLab CI: Build, Test, Lint, Release.
|
||||
|
||||
20) Play Store Release vorbereiten
|
||||
- App-Icons, Screenshots, Privacy-Policy, Datensparsamkeit.
|
||||
|
||||
21) Monitoring & Crash-Reporting einrichten
|
||||
- Sentry / Firebase Crashlytics, Analytics.
|
||||
|
||||
22) Dokumentation: Setup & Architektur
|
||||
- README, Architekturdiagramm, API-Spec, Onboarding-Guide.
|
||||
|
||||
23) Design Review und Abnahme
|
||||
- UX/Design-Review mit Stakeholdern.
|
||||
|
||||
24) Launch und Feedbackrunde durchführen
|
||||
- Release-Notes, Feedback-Formular, Bug-Fixing-Plan.
|
||||
|
||||
|
||||
Datei erstellt: Bitte bestätige, wenn ich mit der in-depth Analyse der Codebasis und Assets beginnen soll (Suche nach Farben, verwendeten Komponenten, Images, Fonts, relevanten Scripts).
|
||||
58
ANDROID_REPO_ENDPOINTS.md
Normal file
58
ANDROID_REPO_ENDPOINTS.md
Normal file
@@ -0,0 +1,58 @@
|
||||
Repo API Endpoints — Übersicht
|
||||
|
||||
Hinweis: Viele Frontend-Requests verwenden relative Pfade (`/api/...`) und Nuxt's `NUXT_PUBLIC_BASE_URL`.
|
||||
|
||||
Öffentliche/Frontend-Endpunkte (häufig genutzt):
|
||||
- GET /api/config
|
||||
- GET /api/news-public
|
||||
- GET /api/news
|
||||
- GET /api/termine
|
||||
- GET /api/spielplaene
|
||||
- GET /api/spielplan
|
||||
- GET /api/mannschaften
|
||||
- GET /api/galerie
|
||||
- GET /api/media/galerie/{id}
|
||||
- GET /api/personen/{filename}?width=...&height=...
|
||||
- POST /api/contact
|
||||
- POST /api/news (CMS)
|
||||
|
||||
Galerie / Media:
|
||||
- POST /api/galerie/upload
|
||||
- GET /api/galerie/list
|
||||
- GET /api/galerie/[id]
|
||||
- DELETE /api/galerie/[id]
|
||||
|
||||
Authentifizierung:
|
||||
- POST /api/auth/login
|
||||
- POST /api/auth/logout
|
||||
- POST /api/auth/register
|
||||
- POST /api/auth/reset-password
|
||||
- GET /api/auth/status
|
||||
- POST /api/auth/passkeys/authentication-options (Passkeys start: server returns WebAuthn options)
|
||||
- POST /api/auth/passkeys/login (Passkeys finish: credential verification)
|
||||
|
||||
CMS / geschützte Endpunkte (erfordern Auth):
|
||||
- GET /api/cms/* (z.B. /api/cms/users/list, /api/cms/contact-requests)
|
||||
- POST /api/cms/save-csv
|
||||
- POST /api/cms/upload-spielplan-pdf
|
||||
- POST /api/cms/satzung-upload
|
||||
- POST /api/members, DELETE /api/members, POST /api/members/bulk
|
||||
- POST /api/membership/update-status
|
||||
- POST /api/termine-manage, DELETE /api/termine-manage, GET /api/termine-manage
|
||||
|
||||
Weitere (Datei-Uploads, Personen):
|
||||
- POST /api/personen/upload
|
||||
- GET /api/app/version
|
||||
- Various CMS-specific routes under /api/cms
|
||||
|
||||
Auth-Anforderungen & Hinweise:
|
||||
- Frontend nutzt `$fetch('/api/...')` (Nuxt) — serverseitig vermutlich Session-Cookie oder JWT.
|
||||
- `stores/auth.js` verwendet `/api/auth/status` to check login state and `passkeyLogin()` which calls `/api/auth/passkeys/*`.
|
||||
- Passkeys-Flow verwendet `@simplewebauthn/browser` on web; Android port should support FIDO2 / Passkeys (Google Passkeys API) or provide password fallback.
|
||||
- CMS- und Manage-Endpunkte require authentication and role checks (admin/vorstand etc.).
|
||||
|
||||
Empfehlung für Android-Client:
|
||||
- Nutze Retrofit/OkHttp mit anpassbarem Auth-Interceptor (Cookie-jar or token storage). Prüfe, ob Server bevorzugt Cookies (then use CookieJar) or JWT Authorization header.
|
||||
- Implementiere Passkeys via Android FIDO2 / Passkeys APIs as optional fast-login path; for servers expecting WebAuthn payloads adapt encoding accordingly.
|
||||
|
||||
Datei automatisch erzeugt — wenn du möchtest, kann ich nun alle Dateien in `public/` und `assets/` auflisten und exportieren (Bilder, Fonts, PDFs).
|
||||
116
android-app/app/build.gradle.kts
Normal file
116
android-app/app/build.gradle.kts
Normal file
@@ -0,0 +1,116 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("com.google.devtools.ksp")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("com.google.dagger.hilt.android")
|
||||
}
|
||||
|
||||
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
||||
.orElse("http://10.0.2.2:3100/")
|
||||
.get()
|
||||
|
||||
android {
|
||||
namespace = "de.harheimertc"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.harheimertc"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
flavorDimensions += "environment"
|
||||
productFlavors {
|
||||
create("local") {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".local"
|
||||
versionNameSuffix = "-local"
|
||||
buildConfigField("String", "API_BASE_URL", "\"$localApiBaseUrl\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"LOCAL\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
}
|
||||
create("instantTest") {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".test"
|
||||
versionNameSuffix = "-test"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.tsschulz.de/\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"TEST\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
}
|
||||
create("production") {
|
||||
dimension = "environment"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.de/\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
implementation("androidx.core:core-ktx:1.10.1")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
||||
// Compose
|
||||
implementation("androidx.compose.ui:ui:1.5.0")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview:1.5.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling:1.5.0")
|
||||
implementation("androidx.compose.material3:material3:1.1.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.6.0")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
|
||||
|
||||
// Lifecycle
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
|
||||
|
||||
// Hilt
|
||||
implementation("com.google.dagger:hilt-android:2.59.2")
|
||||
ksp("com.google.dagger:hilt-compiler:2.59.2")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
||||
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.11.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
|
||||
|
||||
// Passkeys / Credential Manager
|
||||
implementation("androidx.credentials:credentials:1.6.0")
|
||||
implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
|
||||
|
||||
// Coil
|
||||
implementation("io.coil-kt:coil-compose:2.4.0")
|
||||
|
||||
// Room
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
|
||||
// WorkManager, DataStore
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Testing (skeleton)
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
28
android-app/app/src/main/AndroidManifest.xml
Normal file
28
android-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".HarheimerApplication"
|
||||
android:label="HarheimerTC"
|
||||
android:theme="@style/Theme.HarheimerTC"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity android:name="de.harheimertc.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class HarheimerApplication : Application()
|
||||
35
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file
35
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file
@@ -0,0 +1,35 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.harheimertc.ui.navigation.NavGraph
|
||||
import de.harheimertc.ui.theme.HarheimerTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
HarheimerTheme {
|
||||
val navController = rememberNavController()
|
||||
NavGraph(navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewMain() {
|
||||
App()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AccessTokenAuthenticator @Inject constructor(
|
||||
private val sessionRefresher: SessionRefresher,
|
||||
) : Authenticator {
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (responseCount(response) >= 2) return null
|
||||
if (response.request.url.encodedPath in setOf("/api/auth/login", "/api/auth/logout", "/api/auth/refresh")) {
|
||||
return null
|
||||
}
|
||||
|
||||
val currentAccessToken = response.request.header("Authorization")
|
||||
?.removePrefix("Bearer ")
|
||||
?.takeIf(String::isNotBlank)
|
||||
val refreshedToken = sessionRefresher.refreshAccessTokenBlocking(currentAccessToken) ?: return null
|
||||
|
||||
return response.request.newBuilder()
|
||||
.header("Authorization", "Bearer $refreshedToken")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var current: Response? = response
|
||||
var count = 0
|
||||
while (current != null) {
|
||||
count += 1
|
||||
current = current.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
535
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file
535
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file
@@ -0,0 +1,535 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Url
|
||||
import retrofit2.http.Streaming
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.RequestBody
|
||||
|
||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||
data class ContactResponse(val ok: Boolean, val id: String? = null)
|
||||
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||
data class TerminDto(
|
||||
val datum: String = "",
|
||||
val uhrzeit: String? = null,
|
||||
val titel: String = "",
|
||||
val beschreibung: String? = null,
|
||||
val kategorie: String? = null,
|
||||
)
|
||||
data class SpielplanResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val data: List<SpielDto> = emptyList(),
|
||||
val headers: List<String> = emptyList(),
|
||||
val season: String? = null,
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
)
|
||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||
data class SpielDto(
|
||||
@param:Json(name = "Termin") val termin: String = "",
|
||||
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
||||
@param:Json(name = "GastMannschaft") val gastMannschaft: String = "",
|
||||
@param:Json(name = "HeimMannschaftAltersklasse") val heimAltersklasse: String = "",
|
||||
@param:Json(name = "GastMannschaftAltersklasse") val gastAltersklasse: String = "",
|
||||
@param:Json(name = "Altersklasse") val altersklasse: String = "",
|
||||
@param:Json(name = "Liga") val liga: String = "",
|
||||
@param:Json(name = "Staffel") val staffel: String = "",
|
||||
@param:Json(name = "Runde") val runde: String? = null,
|
||||
@param:Json(name = "SpieleHeim") val spieleHeim: String = "",
|
||||
@param:Json(name = "SpieleGast") val spieleGast: String = "",
|
||||
)
|
||||
data class TeamTableResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val season: String? = null,
|
||||
val table: TeamTableDto? = null,
|
||||
)
|
||||
data class TeamTableDto(
|
||||
val teamName: String = "",
|
||||
val leagueName: String = "",
|
||||
val table: LeagueTableDto? = null,
|
||||
)
|
||||
data class LeagueTableDto(
|
||||
val leagueTable: List<LeagueTableRowDto> = emptyList(),
|
||||
)
|
||||
data class LeagueTableRowDto(
|
||||
@param:Json(name = "table_rank") val rank: Int? = null,
|
||||
@param:Json(name = "team_name") val teamName: String = "",
|
||||
@param:Json(name = "meetings_count") val meetings: Int? = null,
|
||||
@param:Json(name = "meetings_won") val won: Int? = null,
|
||||
@param:Json(name = "meetings_tie") val tied: Int? = null,
|
||||
@param:Json(name = "meetings_lost") val lost: Int? = null,
|
||||
@param:Json(name = "sets_won") val setsWon: Int? = null,
|
||||
@param:Json(name = "sets_lost") val setsLost: Int? = null,
|
||||
@param:Json(name = "games_won") val gamesWon: Int? = null,
|
||||
@param:Json(name = "games_lost") val gamesLost: Int? = null,
|
||||
@param:Json(name = "points_won") val pointsWon: Int? = null,
|
||||
@param:Json(name = "points_lost") val pointsLost: Int? = null,
|
||||
@param:Json(name = "rise_fall_state") val movement: String? = null,
|
||||
)
|
||||
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
|
||||
data class NewsDto(
|
||||
val id: Int? = null,
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val created: String? = null,
|
||||
val updated: String? = null,
|
||||
val author: String? = null,
|
||||
val isPublic: Boolean = false,
|
||||
val isHidden: Boolean = false,
|
||||
val expiresAt: String? = null,
|
||||
)
|
||||
data class NewsResponse(
|
||||
val success: Boolean = false,
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
)
|
||||
data class NewsSaveRequest(
|
||||
val id: Int? = null,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val isPublic: Boolean = false,
|
||||
val isHidden: Boolean = false,
|
||||
val expiresAt: String? = null,
|
||||
)
|
||||
data class PublicGalleryImageDto(
|
||||
val filename: String = "",
|
||||
val title: String = "",
|
||||
)
|
||||
data class MembershipRequest(
|
||||
val vorname: String,
|
||||
val nachname: String,
|
||||
val strasse: String,
|
||||
val plz: String,
|
||||
val ort: String,
|
||||
val geburtsdatum: String,
|
||||
val email: String,
|
||||
val telefon_privat: String? = null,
|
||||
val telefon_mobil: String? = null,
|
||||
val mitgliedschaftsart: String,
|
||||
val lastschrift_erlaubt: Boolean,
|
||||
val kontoinhaber: String,
|
||||
val iban: String,
|
||||
val bic: String? = null,
|
||||
val bank: String? = null,
|
||||
val datenschutz_einverstanden: Boolean,
|
||||
val satzung_anerkannt: Boolean,
|
||||
)
|
||||
data class MembershipResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val downloadUrl: String? = null,
|
||||
)
|
||||
data class LoginRequest(
|
||||
val email: String,
|
||||
val password: String,
|
||||
val client: String = "android",
|
||||
val deviceName: String = "Harheimer TC Android-App",
|
||||
)
|
||||
data class AuthUserDto(
|
||||
val id: String? = null,
|
||||
val email: String = "",
|
||||
val name: String? = null,
|
||||
val roles: List<String> = emptyList(),
|
||||
)
|
||||
data class LoginResponse(
|
||||
val success: Boolean = false,
|
||||
val token: String? = null,
|
||||
val accessToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val user: AuthUserDto? = null,
|
||||
val role: String? = null,
|
||||
)
|
||||
data class RefreshRequest(val refreshToken: String)
|
||||
data class LogoutRequest(val refreshToken: String? = null)
|
||||
data class AuthStatusResponse(
|
||||
val isLoggedIn: Boolean = false,
|
||||
val user: AuthUserDto? = null,
|
||||
val roles: List<String> = emptyList(),
|
||||
val role: String? = null,
|
||||
)
|
||||
data class ResetPasswordRequest(val email: String)
|
||||
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
|
||||
data class PasskeyAuthenticationOptionsRequest(
|
||||
val email: String? = null,
|
||||
val client: String = "android",
|
||||
)
|
||||
data class PasskeyRegistrationOptionsRequest(
|
||||
val preferredAuthenticatorType: String? = null,
|
||||
val client: String = "android",
|
||||
)
|
||||
data class PasskeyDto(
|
||||
val id: String = "",
|
||||
val credentialId: String = "",
|
||||
val createdAt: String? = null,
|
||||
val lastUsedAt: String? = null,
|
||||
val name: String = "",
|
||||
)
|
||||
data class PasskeysResponse(
|
||||
val success: Boolean = false,
|
||||
val passkeys: List<PasskeyDto> = emptyList(),
|
||||
)
|
||||
data class RemovePasskeyRequest(val credentialId: String)
|
||||
data class ProfileVisibilityDto(
|
||||
val showEmail: Boolean = true,
|
||||
val showPhone: Boolean = true,
|
||||
val showAddress: Boolean = false,
|
||||
val showBirthday: Boolean = true,
|
||||
)
|
||||
data class ProfileUserDto(
|
||||
val id: String? = null,
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val phone: String = "",
|
||||
val geburtsdatum: String = "",
|
||||
val visibility: ProfileVisibilityDto = ProfileVisibilityDto(),
|
||||
val roles: List<String> = emptyList(),
|
||||
val role: String? = null,
|
||||
)
|
||||
data class ProfileResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val user: ProfileUserDto? = null,
|
||||
)
|
||||
data class ProfileUpdateRequest(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
val geburtsdatum: String? = null,
|
||||
val visibility: ProfileVisibilityDto,
|
||||
val currentPassword: String? = null,
|
||||
val newPassword: String? = null,
|
||||
)
|
||||
data class BirthdayDto(
|
||||
val name: String = "",
|
||||
val dayMonth: String = "",
|
||||
val inDays: Int = 0,
|
||||
)
|
||||
data class BirthdaysResponse(
|
||||
val success: Boolean = false,
|
||||
val birthdays: List<BirthdayDto> = emptyList(),
|
||||
)
|
||||
data class MemberDto(
|
||||
val id: String? = null,
|
||||
val name: String = "",
|
||||
val firstName: String = "",
|
||||
val lastName: String = "",
|
||||
val email: String? = null,
|
||||
val phone: String? = null,
|
||||
val address: String? = null,
|
||||
val birthday: String? = null,
|
||||
val geburtsdatum: String? = null,
|
||||
val source: String = "",
|
||||
val notes: String = "",
|
||||
val hasLogin: Boolean = false,
|
||||
val editable: Boolean = false,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
val loginRoles: List<String> = emptyList(),
|
||||
)
|
||||
data class MembersResponse(
|
||||
val success: Boolean = false,
|
||||
val members: List<MemberDto> = emptyList(),
|
||||
)
|
||||
data class RegistrationVisibility(val showBirthday: Boolean)
|
||||
data class RegistrationRequest(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
val password: String,
|
||||
val geburtsdatum: String,
|
||||
val visibility: RegistrationVisibility,
|
||||
)
|
||||
data class TrainingLocationDto(
|
||||
val name: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
)
|
||||
data class TrainingTimeDto(
|
||||
val id: String = "",
|
||||
val tag: String = "",
|
||||
val von: String = "",
|
||||
val bis: String = "",
|
||||
val gruppe: String = "",
|
||||
val info: String? = null,
|
||||
)
|
||||
data class TrainingDto(
|
||||
val ort: TrainingLocationDto = TrainingLocationDto(),
|
||||
val zeiten: List<TrainingTimeDto> = emptyList(),
|
||||
)
|
||||
data class TrainerDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val lizenz: String = "",
|
||||
val schwerpunkt: String = "",
|
||||
val zusatz: String? = null,
|
||||
val imageFilename: String? = null,
|
||||
)
|
||||
data class BoardMemberDto(
|
||||
val vorname: String = "",
|
||||
val nachname: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
val telefon: String = "",
|
||||
val email: String = "",
|
||||
val imageFilename: String? = null,
|
||||
)
|
||||
data class VorstandDto(
|
||||
val vorsitzender: BoardMemberDto = BoardMemberDto(),
|
||||
val stellvertreter: BoardMemberDto = BoardMemberDto(),
|
||||
val kassenwart: BoardMemberDto = BoardMemberDto(),
|
||||
val schriftfuehrer: BoardMemberDto = BoardMemberDto(),
|
||||
val sportwart: BoardMemberDto = BoardMemberDto(),
|
||||
val jugendwart: BoardMemberDto = BoardMemberDto(),
|
||||
)
|
||||
data class VereinDto(
|
||||
val name: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
val useVorsitzenderAddress: Boolean = false,
|
||||
)
|
||||
data class WebsiteResponsibleDto(
|
||||
val vorname: String = "",
|
||||
val nachname: String = "",
|
||||
val email: String = "",
|
||||
)
|
||||
data class WebsiteDto(
|
||||
val verantwortlicher: WebsiteResponsibleDto = WebsiteResponsibleDto(),
|
||||
)
|
||||
data class SatzungDto(
|
||||
val pdfUrl: String = "",
|
||||
val content: String = "",
|
||||
)
|
||||
data class LinkItemDto(
|
||||
val label: String = "",
|
||||
val href: String = "",
|
||||
val description: String = "",
|
||||
)
|
||||
data class LinkSectionDto(
|
||||
val title: String = "",
|
||||
val items: List<LinkItemDto> = emptyList(),
|
||||
)
|
||||
data class SeitenDto(
|
||||
val ueberUns: String = "",
|
||||
val geschichte: String = "",
|
||||
val ttRegeln: String = "",
|
||||
val satzung: SatzungDto = SatzungDto(),
|
||||
val links: String = "",
|
||||
val linksStructured: List<LinkSectionDto> = emptyList(),
|
||||
)
|
||||
data class ConfigResponse(
|
||||
val training: TrainingDto = TrainingDto(),
|
||||
val trainer: List<TrainerDto> = emptyList(),
|
||||
val verein: VereinDto = VereinDto(),
|
||||
val vorstand: VorstandDto = VorstandDto(),
|
||||
val website: WebsiteDto = WebsiteDto(),
|
||||
val seiten: SeitenDto = SeitenDto(),
|
||||
)
|
||||
data class CmsUserDto(
|
||||
val id: String = "",
|
||||
val email: String? = null,
|
||||
val name: String = "",
|
||||
val roles: List<String> = emptyList(),
|
||||
val role: String? = null,
|
||||
val phone: String = "",
|
||||
val active: Boolean = true,
|
||||
val created: String? = null,
|
||||
val lastLogin: String? = null,
|
||||
)
|
||||
data class CmsUsersResponse(val users: List<CmsUserDto> = emptyList())
|
||||
data class ContactRequestDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val phone: String? = null,
|
||||
val message: String = "",
|
||||
val status: String = "",
|
||||
val createdAt: String? = null,
|
||||
val repliedAt: String? = null,
|
||||
)
|
||||
data class NewsletterDto(
|
||||
val id: String = "",
|
||||
val subject: String = "",
|
||||
val title: String = "",
|
||||
val createdAt: String? = null,
|
||||
val sentAt: String? = null,
|
||||
val status: String? = null,
|
||||
)
|
||||
data class NewsletterListResponse(
|
||||
val success: Boolean = false,
|
||||
val newsletters: List<NewsletterDto> = emptyList(),
|
||||
)
|
||||
data class NewsletterGroupDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val description: String = "",
|
||||
val subscribers: List<String> = emptyList(),
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
data class NewsletterGroupsResponse(
|
||||
val success: Boolean = false,
|
||||
val groups: List<NewsletterGroupDto> = emptyList(),
|
||||
)
|
||||
data class NewsletterSubscriptionRequest(
|
||||
val groupId: String,
|
||||
val email: String,
|
||||
val name: String? = null,
|
||||
)
|
||||
data class PasswordResetStepDto(
|
||||
val ts: String? = null,
|
||||
val step: String = "",
|
||||
val status: String = "",
|
||||
val reason: String? = null,
|
||||
val errorCode: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
data class PasswordResetAttemptDto(
|
||||
val requestId: String = "",
|
||||
val startedAt: String? = null,
|
||||
val emailMasked: String? = null,
|
||||
val ip: String? = null,
|
||||
val failed: Boolean = false,
|
||||
val steps: List<PasswordResetStepDto> = emptyList(),
|
||||
)
|
||||
data class PasswordResetDiagnosticsResponse(
|
||||
val retentionHours: Int = 0,
|
||||
val attempts: List<PasswordResetAttemptDto> = emptyList(),
|
||||
)
|
||||
|
||||
interface ApiService {
|
||||
@POST("/api/contact")
|
||||
suspend fun postContact(@Body req: ContactRequest): Response<ContactResponse>
|
||||
|
||||
@GET("/api/galerie/list")
|
||||
suspend fun galerieList(): Response<List<String>>
|
||||
|
||||
@GET("/api/galerie")
|
||||
suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>>
|
||||
|
||||
@GET("/api/termine")
|
||||
suspend fun termine(): Response<TermineResponse>
|
||||
|
||||
@GET("/api/spielplan")
|
||||
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
||||
|
||||
@GET("/api/spielplan/table")
|
||||
suspend fun spielplanTable(
|
||||
@Query("team") team: String,
|
||||
@Query("season") season: String? = null,
|
||||
): Response<TeamTableResponse>
|
||||
|
||||
@GET("/api/news-public")
|
||||
suspend fun publicNews(): Response<NewsPublicResponse>
|
||||
|
||||
@GET("/api/news")
|
||||
suspend fun memberNews(): Response<NewsResponse>
|
||||
|
||||
@POST("/api/news")
|
||||
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/news")
|
||||
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/mannschaften")
|
||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||
|
||||
@GET("/api/config")
|
||||
suspend fun config(): Response<ConfigResponse>
|
||||
|
||||
@GET("/data/spielsysteme.csv")
|
||||
suspend fun spielsysteme(): Response<ResponseBody>
|
||||
|
||||
@GET("/api/vereinsmeisterschaften")
|
||||
suspend fun vereinsmeisterschaften(): Response<ResponseBody>
|
||||
|
||||
@POST("/api/membership/generate-pdf")
|
||||
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
||||
|
||||
@Streaming
|
||||
@GET
|
||||
suspend fun downloadMembershipPdf(@Url downloadUrl: String): Response<ResponseBody>
|
||||
|
||||
@POST("/api/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||
|
||||
@POST("/api/auth/logout")
|
||||
suspend fun logout(@Body request: LogoutRequest): Response<Unit>
|
||||
|
||||
@POST("/api/auth/refresh")
|
||||
suspend fun refresh(@Body request: RefreshRequest): Response<LoginResponse>
|
||||
|
||||
@GET("/api/auth/status")
|
||||
suspend fun authStatus(): Response<AuthStatusResponse>
|
||||
|
||||
@POST("/api/auth/reset-password")
|
||||
suspend fun resetPassword(@Body request: ResetPasswordRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/auth/register")
|
||||
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/auth/passkeys/authentication-options")
|
||||
suspend fun passkeyAuthenticationOptions(@Body request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody>
|
||||
|
||||
@POST("/api/auth/passkeys/login")
|
||||
suspend fun passkeyLogin(@Body request: RequestBody): Response<LoginResponse>
|
||||
|
||||
@GET("/api/auth/passkeys/list")
|
||||
suspend fun passkeys(): Response<PasskeysResponse>
|
||||
|
||||
@POST("/api/auth/passkeys/registration-options")
|
||||
suspend fun passkeyRegistrationOptions(@Body request: PasskeyRegistrationOptionsRequest): Response<ResponseBody>
|
||||
|
||||
@POST("/api/auth/passkeys/register")
|
||||
suspend fun registerPasskey(@Body request: RequestBody): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/auth/passkeys/remove")
|
||||
suspend fun removePasskey(@Body request: RemovePasskeyRequest): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/profile")
|
||||
suspend fun profile(): Response<ProfileResponse>
|
||||
|
||||
@retrofit2.http.PUT("/api/profile")
|
||||
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
||||
|
||||
@GET("/api/birthdays")
|
||||
suspend fun birthdays(): Response<BirthdaysResponse>
|
||||
|
||||
@GET("/api/members")
|
||||
suspend fun members(): Response<MembersResponse>
|
||||
|
||||
@GET("/api/cms/users/list")
|
||||
suspend fun cmsUsers(): Response<CmsUsersResponse>
|
||||
|
||||
@GET("/api/cms/contact-requests")
|
||||
suspend fun contactRequests(): Response<List<ContactRequestDto>>
|
||||
|
||||
@GET("/api/newsletter/list")
|
||||
suspend fun newsletters(): Response<NewsletterListResponse>
|
||||
|
||||
@GET("/api/newsletter/groups/list")
|
||||
suspend fun newsletterGroups(): Response<NewsletterGroupsResponse>
|
||||
|
||||
@GET("/api/newsletter/groups/public-list")
|
||||
suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse>
|
||||
|
||||
@POST("/api/newsletter/subscribe")
|
||||
suspend fun subscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/newsletter/unsubscribe-by-email")
|
||||
suspend fun unsubscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/newsletter/confirm")
|
||||
suspend fun confirmNewsletter(@Query("token") token: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/cms/password-reset-diagnostics")
|
||||
suspend fun passwordResetDiagnostics(): Response<PasswordResetDiagnosticsResponse>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
class AuthInterceptor @Inject constructor(private val authRepository: AuthRepository) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
val token = authRepository.getToken()
|
||||
if (!token.isNullOrBlank()) {
|
||||
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||
}
|
||||
return chain.proceed(requestBuilder.build())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
|
||||
object MediaTypes {
|
||||
val json = "application/json; charset=utf-8".toMediaType()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import de.harheimertc.BuildConfig
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import javax.inject.Singleton
|
||||
import java.net.CookieManager
|
||||
import java.net.CookiePolicy
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor, accessTokenAuthenticator: AccessTokenAuthenticator): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor()
|
||||
logging.level = HttpLoggingInterceptor.Level.BASIC
|
||||
val cookies = CookieManager().apply {
|
||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.cookieJar(JavaNetCookieJar(cookies))
|
||||
.addInterceptor(authInterceptor)
|
||||
.authenticator(accessTokenAuthenticator)
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(moshi: Moshi, client: OkHttpClient): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.client(client)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SessionRefresher @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
moshi: Moshi,
|
||||
) {
|
||||
private val lock = Any()
|
||||
private val client = OkHttpClient.Builder().build()
|
||||
private val requestAdapter = moshi.adapter(RefreshRequest::class.java)
|
||||
private val responseAdapter = moshi.adapter(LoginResponse::class.java)
|
||||
|
||||
suspend fun refreshAccessToken(): Boolean = withContext(Dispatchers.IO) {
|
||||
refreshAccessTokenBlocking() != null
|
||||
}
|
||||
|
||||
fun refreshAccessTokenBlocking(requestToken: String? = null): String? = synchronized(lock) {
|
||||
val currentToken = authRepository.getToken()
|
||||
if (!requestToken.isNullOrBlank() && !currentToken.isNullOrBlank() && currentToken != requestToken) {
|
||||
return@synchronized currentToken
|
||||
}
|
||||
|
||||
val refreshToken = authRepository.getRefreshToken()?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized null
|
||||
val payload = requestAdapter.toJson(RefreshRequest(refreshToken))
|
||||
val request = Request.Builder()
|
||||
.url(BuildConfig.API_BASE_URL + "api/auth/refresh")
|
||||
.post(payload.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
authRepository.clearSession()
|
||||
return@synchronized null
|
||||
}
|
||||
if (!response.isSuccessful) return@synchronized null
|
||||
|
||||
val tokens = response.body?.string()?.let(responseAdapter::fromJson)
|
||||
?: return@synchronized null
|
||||
val accessToken = (tokens.accessToken ?: tokens.token)?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized null
|
||||
val nextRefreshToken = tokens.refreshToken?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized null
|
||||
authRepository.setSession(accessToken, nextRefreshToken, tokens.sessionId)
|
||||
accessToken
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.harheimertc.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.AuthRepositoryImpl
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
interface AuthRepository {
|
||||
fun getToken(): String?
|
||||
fun getRefreshToken(): String?
|
||||
fun getSessionId(): String?
|
||||
fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
|
||||
fun clearSession()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
|
||||
private val tokenKey = "auth_token"
|
||||
private val refreshTokenKey = "auth_refresh_token"
|
||||
private val sessionIdKey = "auth_session_id"
|
||||
private val preferences by lazy {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_auth",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
||||
|
||||
override fun getRefreshToken(): String? = preferences.getString(refreshTokenKey, null)
|
||||
|
||||
override fun getSessionId(): String? = preferences.getString(sessionIdKey, null)
|
||||
|
||||
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
|
||||
preferences.edit().apply {
|
||||
if (accessToken == null) remove(tokenKey) else putString(tokenKey, accessToken)
|
||||
if (refreshToken == null) remove(refreshTokenKey) else putString(refreshTokenKey, refreshToken)
|
||||
if (sessionId == null) remove(sessionIdKey) else putString(sessionIdKey, sessionId)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
preferences.edit()
|
||||
.remove(tokenKey)
|
||||
.remove(refreshTokenKey)
|
||||
.remove(sessionIdKey)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.CmsUsersResponse
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.NewsletterGroupsResponse
|
||||
import de.harheimertc.data.NewsletterListResponse
|
||||
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||
import javax.inject.Inject
|
||||
|
||||
class CmsRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun config(): Result<ConfigResponse> = runCatching {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun users(): Result<CmsUsersResponse> = runCatching {
|
||||
val response = api.cmsUsers()
|
||||
if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun contactRequests(): Result<List<ContactRequestDto>> = runCatching {
|
||||
val response = api.contactRequests()
|
||||
if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.")
|
||||
response.body() ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun newsletters(): Result<NewsletterListResponse> = runCatching {
|
||||
val response = api.newsletters()
|
||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun newsletterGroups(): Result<NewsletterGroupsResponse> = runCatching {
|
||||
val response = api.newsletterGroups()
|
||||
if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun passwordResetDiagnostics(): Result<PasswordResetDiagnosticsResponse> = runCatching {
|
||||
val response = api.passwordResetDiagnostics()
|
||||
if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.ContactRequest
|
||||
import de.harheimertc.data.ContactResponse
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ContactRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun sendContact(req: ContactRequest): Response<ContactResponse> = api.postContact(req)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class GalleryRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||
val response = api.publicGalleryImages()
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body().orEmpty().isNotEmpty()
|
||||
}
|
||||
|
||||
suspend fun fetchImages(): Result<List<String>> {
|
||||
return try {
|
||||
val resp = api.galerieList()
|
||||
if (resp.isSuccessful) {
|
||||
Result.success(resp.body() ?: emptyList())
|
||||
} else {
|
||||
Result.failure(Exception("HTTP ${resp.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class HomeData(
|
||||
val termine: List<TerminDto>,
|
||||
val spiele: List<SpielDto>,
|
||||
val news: List<NewsDto>,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
||||
val termine = api.termine().body()?.termine.orEmpty()
|
||||
val spiele = api.spielplan().body()?.data.orEmpty()
|
||||
val news = api.publicNews().body()?.news.orEmpty()
|
||||
HomeData(termine, spiele, news)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.LogoutRequest
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.ResetPasswordRequest
|
||||
import de.harheimertc.data.SessionRefresher
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class LoginRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
private val authRepository: AuthRepository,
|
||||
private val sessionRefresher: SessionRefresher,
|
||||
) {
|
||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||
val response = api.login(LoginRequest(email.trim(), password))
|
||||
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
}
|
||||
|
||||
suspend fun logout(): Result<Unit> = runCatching {
|
||||
try {
|
||||
api.logout(LogoutRequest(authRepository.getRefreshToken()))
|
||||
} finally {
|
||||
authRepository.clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun status(): Result<AuthStatusResponse> = runCatching {
|
||||
if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
|
||||
return@runCatching AuthStatusResponse()
|
||||
}
|
||||
|
||||
var response = api.authStatus()
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
var status = response.body() ?: AuthStatusResponse()
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
||||
response = api.authStatus()
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
status = response.body() ?: AuthStatusResponse()
|
||||
}
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
||||
status
|
||||
}
|
||||
|
||||
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
||||
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.register(request)
|
||||
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class Mannschaft(
|
||||
val mannschaft: String,
|
||||
val liga: String,
|
||||
val staffelleiter: String,
|
||||
val telefon: String,
|
||||
val heimspieltag: String,
|
||||
val spielsystem: String,
|
||||
val mannschaftsfuehrer: String,
|
||||
val spieler: List<String>,
|
||||
val informationenLink: String,
|
||||
val letzteAktualisierung: String,
|
||||
) {
|
||||
val slug: String
|
||||
get() = mannschaft.lowercase(Locale.GERMANY).replace(Regex("\\s+"), "-")
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
||||
val response = api.mannschaften(season)
|
||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty())
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
||||
.filter(String::isNotBlank)
|
||||
.drop(1)
|
||||
.mapNotNull { row ->
|
||||
val fields = parseCsvRow(row)
|
||||
if (fields.size < 10 || fields[0].isBlank()) return@mapNotNull null
|
||||
Mannschaft(
|
||||
mannschaft = fields[0],
|
||||
liga = fields[1],
|
||||
staffelleiter = fields[2],
|
||||
telefon = fields[3],
|
||||
heimspieltag = fields[4],
|
||||
spielsystem = fields[5],
|
||||
mannschaftsfuehrer = fields[6],
|
||||
spieler = fields[7].split(';').map(String::trim).filter(String::isNotBlank),
|
||||
informationenLink = fields[8],
|
||||
letzteAktualisierung = fields[9],
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
private fun parseCsvRow(row: String): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var inQuotes = false
|
||||
var index = 0
|
||||
while (index < row.length) {
|
||||
val character = row[index]
|
||||
when {
|
||||
character == '"' && inQuotes && row.getOrNull(index + 1) == '"' -> {
|
||||
current.append('"')
|
||||
index++
|
||||
}
|
||||
character == '"' -> inQuotes = !inQuotes
|
||||
character == ',' && !inQuotes -> {
|
||||
values += current.toString().trim()
|
||||
current.clear()
|
||||
}
|
||||
else -> current.append(character)
|
||||
}
|
||||
index++
|
||||
}
|
||||
values += current.toString().trim()
|
||||
return values
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.BirthdaysResponse
|
||||
import de.harheimertc.data.MembersResponse
|
||||
import de.harheimertc.data.NewsResponse
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import javax.inject.Inject
|
||||
|
||||
class MemberAreaRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun birthdays(): Result<BirthdaysResponse> = runCatching {
|
||||
val response = api.birthdays()
|
||||
if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun members(): Result<MembersResponse> = runCatching {
|
||||
val response = api.members()
|
||||
if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun news(): Result<NewsResponse> = runCatching {
|
||||
val response = api.memberNews()
|
||||
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun saveNews(request: NewsSaveRequest): Result<Unit> = runCatching {
|
||||
val response = api.saveNews(request)
|
||||
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.FileProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.MembershipRequest
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class MembershipDocument(val message: String, val uri: String)
|
||||
|
||||
@Singleton
|
||||
class MembershipRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
|
||||
val response = api.generateMembershipPdf(request)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
|
||||
val downloadUrl = body.downloadUrl ?: error("PDF-Download fehlt.")
|
||||
val documentResponse = api.downloadMembershipPdf(downloadUrl)
|
||||
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
|
||||
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
|
||||
val file = File(directory, "beitrittserklaerung.pdf")
|
||||
documentResponse.body()?.byteStream()?.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: error("Leere PDF-Antwort")
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||
MembershipDocument(
|
||||
message = body.message ?: "Beitrittsformular erfolgreich erstellt.",
|
||||
uri = uri.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.NewsletterGroupsResponse
|
||||
import de.harheimertc.data.NewsletterSubscriptionRequest
|
||||
import javax.inject.Inject
|
||||
|
||||
class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
|
||||
val response = api.publicNewsletterGroups()
|
||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.subscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim(), name?.trim().orEmpty()))
|
||||
if (!response.isSuccessful) error("Newsletter-Anmeldung fehlgeschlagen.")
|
||||
response.body() ?: AuthMessageResponse(success = true, message = "Eine Bestätigungsmail wurde versendet.")
|
||||
}
|
||||
|
||||
suspend fun unsubscribe(groupId: String, email: String): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.unsubscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim()))
|
||||
if (!response.isSuccessful) error("Newsletter-Abmeldung fehlgeschlagen.")
|
||||
response.body() ?: AuthMessageResponse(success = true, message = "Sie wurden abgemeldet.")
|
||||
}
|
||||
|
||||
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.confirmNewsletter(token)
|
||||
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
||||
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import androidx.credentials.CreatePublicKeyCredentialRequest
|
||||
import androidx.credentials.CreatePublicKeyCredentialResponse
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.credentials.GetCredentialRequest
|
||||
import androidx.credentials.GetPublicKeyCredentialOption
|
||||
import androidx.credentials.PublicKeyCredential
|
||||
import androidx.credentials.exceptions.CreateCredentialCancellationException
|
||||
import androidx.credentials.exceptions.GetCredentialCancellationException
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.MediaTypes
|
||||
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
|
||||
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
|
||||
import de.harheimertc.data.PasskeysResponse
|
||||
import de.harheimertc.data.RemovePasskeyRequest
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PasskeyRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
|
||||
val optionsResponse = api.passkeyAuthenticationOptions(
|
||||
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
||||
)
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.getCredential(
|
||||
context = context,
|
||||
request = GetCredentialRequest(
|
||||
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
||||
),
|
||||
)
|
||||
val credential = credentialResponse.credential as? PublicKeyCredential
|
||||
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
||||
|
||||
val response = api.passkeyLogin(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credential.authenticationResponseJson))
|
||||
.put("client", "android")
|
||||
.put("deviceName", "Harheimer TC Android-App")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
|
||||
|
||||
suspend fun list(): Result<PasskeysResponse> = runCatching {
|
||||
val response = api.passkeys()
|
||||
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
|
||||
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.createCredential(
|
||||
context = context,
|
||||
request = CreatePublicKeyCredentialRequest(optionsJson),
|
||||
) as? CreatePublicKeyCredentialResponse
|
||||
?: error("Der erstellte Zugang ist kein Passkey.")
|
||||
|
||||
val response = api.registerPasskey(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
||||
.put("name", name)
|
||||
.put("client", "android")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
|
||||
|
||||
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.removePasskey(RemovePasskeyRequest(credentialId))
|
||||
if (!response.isSuccessful) error("Passkey konnte nicht entfernt werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
|
||||
private fun String.extractJsonObject(key: String): String? {
|
||||
val root = JSONObject(this)
|
||||
return root.optJSONObject(key)?.toString()
|
||||
}
|
||||
|
||||
private fun <T> Result<T>.recoverCredentialCancellation(message: String): Result<T> =
|
||||
recoverCatching { error ->
|
||||
when (error) {
|
||||
is GetCredentialCancellationException,
|
||||
is CreateCredentialCancellationException -> throw IllegalStateException(message)
|
||||
else -> throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.ProfileResponse
|
||||
import de.harheimertc.data.ProfileUpdateRequest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ProfileRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun load(): Result<ProfileResponse> = runCatching {
|
||||
val response = api.profile()
|
||||
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
|
||||
val response = api.updateProfile(request)
|
||||
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.LinkItemDto
|
||||
import de.harheimertc.data.LinkSectionDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class Spielsystem(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val teamSize: String,
|
||||
val category: String,
|
||||
val sequence: String,
|
||||
val gameCount: String,
|
||||
val features: String,
|
||||
)
|
||||
|
||||
data class MeisterschaftResult(
|
||||
val year: String,
|
||||
val category: String,
|
||||
val rank: String,
|
||||
val playerOne: String,
|
||||
val playerTwo: String,
|
||||
val note: String,
|
||||
val imageOne: String,
|
||||
val imageTwo: String,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
|
||||
val response = api.spielsysteme()
|
||||
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 8) return@mapNotNull null
|
||||
Spielsystem(
|
||||
name = values[0],
|
||||
description = values[1],
|
||||
teamSize = values[2],
|
||||
category = values[3],
|
||||
sequence = values[5],
|
||||
gameCount = values[6],
|
||||
features = values[7],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
||||
val response = api.vereinsmeisterschaften()
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 6) return@mapNotNull null
|
||||
MeisterschaftResult(
|
||||
year = values[0],
|
||||
category = values[1],
|
||||
rank = values[2],
|
||||
playerOne = values[3],
|
||||
playerTwo = values[4],
|
||||
note = values[5],
|
||||
imageOne = values.getOrElse(6) { "" },
|
||||
imageTwo = values.getOrElse(7) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<List<String>> =
|
||||
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
||||
|
||||
private fun parseCsvLine(line: String): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val value = StringBuilder()
|
||||
var quoted = false
|
||||
var index = 0
|
||||
while (index < line.length) {
|
||||
when (val char = line[index]) {
|
||||
'"' -> {
|
||||
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
|
||||
value.append('"')
|
||||
index++
|
||||
} else {
|
||||
quoted = !quoted
|
||||
}
|
||||
}
|
||||
',' -> if (quoted) value.append(char) else {
|
||||
values += value.toString().trim()
|
||||
value.clear()
|
||||
}
|
||||
else -> value.append(char)
|
||||
}
|
||||
index++
|
||||
}
|
||||
values += value.toString().trim()
|
||||
return values
|
||||
}
|
||||
|
||||
fun ConfigResponse.linkSections(): List<LinkSectionDto> =
|
||||
seiten.linksStructured.filter { it.title.isNotBlank() && it.items.isNotEmpty() }
|
||||
.ifEmpty {
|
||||
parseLinkSections(seiten.links).ifEmpty { defaultLinkSections }
|
||||
}
|
||||
|
||||
private fun parseLinkSections(html: String): List<LinkSectionDto> {
|
||||
if (html.isBlank()) return emptyList()
|
||||
val sectionRegex = Regex("""<h2[^>]*>(.*?)</h2>(.*?)(?=<h2[^>]*>|$)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
val itemRegex = Regex("""<li[^>]*>(.*?)</li>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
val anchorRegex = Regex("""<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
return sectionRegex.findAll(html).mapNotNull { section ->
|
||||
val title = stripHtml(section.groupValues[1])
|
||||
val items = itemRegex.findAll(section.groupValues[2]).mapNotNull { item ->
|
||||
val match = anchorRegex.find(item.groupValues[1]) ?: return@mapNotNull null
|
||||
LinkItemDto(
|
||||
href = match.groupValues[1].trim(),
|
||||
label = stripHtml(match.groupValues[2]),
|
||||
description = stripHtml(item.groupValues[1].replace(match.value, "")),
|
||||
)
|
||||
}.toList()
|
||||
title.takeIf { it.isNotBlank() && items.isNotEmpty() }?.let { LinkSectionDto(it, items) }
|
||||
}.toList()
|
||||
}
|
||||
|
||||
private fun stripHtml(html: String): String = html
|
||||
.replace(Regex("<[^>]*>"), "")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
|
||||
private val defaultLinkSections = listOf(
|
||||
LinkSectionDto("Ergebnisse & Portale", listOf(
|
||||
LinkItemDto("MyTischtennis.de", "http://www.mytischtennis.de/public/home", "(offizielle QTTR-Werte)"),
|
||||
LinkItemDto("Click-tt Ergebnisse", "http://httv.click-tt.de/", "(offizieller Ergebnisdienst HTTV)"),
|
||||
LinkItemDto("Tischtennis Pur", "https://www.tischtennis-pur.de/", "(Informationen, Blogs und Tipps)"),
|
||||
LinkItemDto("Liveticker 2. und 3. TT-Bundesliga", "https://ticker.tt-news.com/"),
|
||||
)),
|
||||
LinkSectionDto("Verbände", listOf(
|
||||
LinkItemDto("Hessischer Tischtennisverband (HTTV)", "http://www.httv.de/"),
|
||||
LinkItemDto("Deutscher Tischtennisbund (DTTB)", "http://www.tischtennis.de/aktuelles/"),
|
||||
LinkItemDto("European Table Tennis Union (ETTU)", "http://www.ettu.org/"),
|
||||
LinkItemDto("International Table Tennis Federation (ITTF)", "https://www.ittf.com/"),
|
||||
)),
|
||||
LinkSectionDto("Regionale Links", listOf(
|
||||
LinkItemDto("Stadt Frankfurt", "http://www.frankfurt.de/"),
|
||||
LinkItemDto("Vereinsring Harheim", "http://www.harheim.com/"),
|
||||
)),
|
||||
LinkSectionDto("Partner & Vereine", listOf(
|
||||
LinkItemDto("TTC OE Bad Homburg", "http://www.ttcoe.de/"),
|
||||
LinkItemDto("SpVgg Steinkirchen e.V.", "https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis"),
|
||||
LinkItemDto("Ergebnisse SpVgg Steinkirchen", "https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/"),
|
||||
)),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TeamTableResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
|
||||
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
|
||||
val response = api.spielplanTable(team, season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.TerminDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TermineRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
|
||||
val response = api.termine()
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body()?.termine.orEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.navigation.NavigationUiState
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
private enum class MenuSection {
|
||||
VEREIN,
|
||||
MANNSCHAFTEN,
|
||||
TRAINING,
|
||||
NEWSLETTER,
|
||||
INTERN,
|
||||
}
|
||||
|
||||
private data class MenuTarget(val label: String, val route: String)
|
||||
|
||||
@Composable
|
||||
fun AppNavigationHeader(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
webTabletNavigation: Boolean = false,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Brush.horizontalGradient(listOf(Accent900, Primary900, Accent900)))
|
||||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (webTabletNavigation) {
|
||||
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
|
||||
} else {
|
||||
CompactNavigation(selectedRoute, onNavigate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactNavigation(selectedRoute: String?, onNavigate: (String) -> Unit) {
|
||||
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WebTabletNavigation(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
navigationState: NavigationUiState,
|
||||
) {
|
||||
val section = menuSection(selectedRoute)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Brand()
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { onNavigate(Destinations.Home.route) })
|
||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = { onNavigate(Destinations.VereinAbout.route) })
|
||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { onNavigate(Destinations.Mannschaften.route) })
|
||||
MainLink("Training", section == MenuSection.TRAINING, onClick = { onNavigate(Destinations.Training.route) })
|
||||
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { onNavigate(Destinations.Membership.route) })
|
||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { onNavigate(Destinations.Termine.route) })
|
||||
if (navigationState.showGallery) {
|
||||
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
|
||||
}
|
||||
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) })
|
||||
if (navigationState.loggedIn) {
|
||||
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
|
||||
}
|
||||
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { onNavigate(Destinations.Contact.route) })
|
||||
TextButton(onClick = { onNavigate(Destinations.Login.route) }) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
val subItems = submenu(section, navigationState)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(top = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
subItems.forEach { item ->
|
||||
SubLink(item.label, item.route == selectedRoute) { onNavigate(item.route) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrandRow(onLogin: () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Brand()
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Brand() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.harheimer_tc_logo),
|
||||
contentDescription = "Harheimer TC Logo",
|
||||
modifier = Modifier.size(42.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Harheimer ", color = Color.White, style = MaterialTheme.typography.titleLarge)
|
||||
Text("TC", color = Color(0xFFF87171), style = MaterialTheme.typography.titleLarge)
|
||||
if (BuildConfig.ENVIRONMENT_NAME.isNotBlank()) {
|
||||
Text(
|
||||
BuildConfig.ENVIRONMENT_NAME,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.background(Primary600, RoundedCornerShape(5.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainLink(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
primary: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = if (selected || primary) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactLink(
|
||||
label: String,
|
||||
route: String,
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
color = if (route == selectedRoute) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.clickable { onNavigate(route) },
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = if (selected) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) Color.White else Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun menuSection(route: String?): MenuSection? = when (route) {
|
||||
Destinations.VereinAbout.route,
|
||||
Destinations.Vorstand.route,
|
||||
Destinations.Geschichte.route,
|
||||
Destinations.Satzung.route,
|
||||
Destinations.Vereinsmeisterschaften.route,
|
||||
Destinations.Links.route,
|
||||
Destinations.Impressum.route,
|
||||
Destinations.Gallery.route -> MenuSection.VEREIN
|
||||
|
||||
Destinations.Mannschaften.route,
|
||||
Destinations.Spielplan.route,
|
||||
Destinations.Spielsysteme.route -> MenuSection.MANNSCHAFTEN
|
||||
|
||||
Destinations.Training.route,
|
||||
Destinations.Trainer.route,
|
||||
Destinations.Anfaenger.route,
|
||||
Destinations.Regeln.route -> MenuSection.TRAINING
|
||||
|
||||
Destinations.NewsletterSubscribe.route,
|
||||
Destinations.NewsletterUnsubscribe.route,
|
||||
Destinations.NewsletterConfirm.route,
|
||||
Destinations.NewsletterConfirmed.route,
|
||||
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
||||
Destinations.MemberArea.route,
|
||||
Destinations.Members.route,
|
||||
Destinations.MemberNews.route,
|
||||
Destinations.Profile.route,
|
||||
Destinations.MemberApi.route,
|
||||
Destinations.CmsStartseite.route,
|
||||
Destinations.CmsInhalte.route,
|
||||
Destinations.CmsVereinsmeisterschaften.route,
|
||||
Destinations.CmsSportbetrieb.route,
|
||||
Destinations.CmsMitgliederverwaltung.route,
|
||||
Destinations.CmsNewsletter.route,
|
||||
Destinations.CmsContactRequests.route,
|
||||
Destinations.CmsEinstellungen.route,
|
||||
Destinations.CmsBenutzer.route,
|
||||
Destinations.CmsPasswordResetDiagnostics.route,
|
||||
Destinations.Cms.route -> MenuSection.INTERN
|
||||
else -> null
|
||||
}.let { section ->
|
||||
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
|
||||
}
|
||||
|
||||
private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuTarget> = when (section) {
|
||||
MenuSection.VEREIN -> listOf(
|
||||
MenuTarget("Über uns", Destinations.VereinAbout.route),
|
||||
MenuTarget("Vorstand", Destinations.Vorstand.route),
|
||||
MenuTarget("Geschichte", Destinations.Geschichte.route),
|
||||
MenuTarget("Satzung", Destinations.Satzung.route),
|
||||
MenuTarget("Vereinsmeisterschaften", Destinations.Vereinsmeisterschaften.route),
|
||||
MenuTarget("Galerie", Destinations.Gallery.route),
|
||||
MenuTarget("Links", Destinations.Links.route),
|
||||
MenuTarget("Impressum", Destinations.Impressum.route),
|
||||
)
|
||||
MenuSection.MANNSCHAFTEN -> listOf(
|
||||
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
||||
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
|
||||
MenuTarget("Spielpläne", Destinations.Spielplan.route),
|
||||
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
|
||||
)
|
||||
MenuSection.TRAINING -> listOf(
|
||||
MenuTarget("Trainingszeiten", Destinations.Training.route),
|
||||
MenuTarget("Trainer", Destinations.Trainer.route),
|
||||
MenuTarget("Anfänger", Destinations.Anfaenger.route),
|
||||
MenuTarget("TT-Regeln", Destinations.Regeln.route),
|
||||
)
|
||||
MenuSection.NEWSLETTER -> listOf(
|
||||
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
||||
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
||||
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
|
||||
)
|
||||
MenuSection.INTERN -> buildList {
|
||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
|
||||
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
||||
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
||||
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
|
||||
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
|
||||
}
|
||||
null -> emptyList()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
@Composable
|
||||
fun ValidatedTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier.fillMaxWidth(),
|
||||
error: String? = null,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
isError = error != null,
|
||||
supportingText = error?.let { { Text(it) } },
|
||||
keyboardOptions = keyboardOptions,
|
||||
visualTransformation = visualTransformation,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FormMessages(error: String?, message: String?) {
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
message?.let { Text(it, color = Color(0xFF166534)) }
|
||||
}
|
||||
|
||||
internal fun isValidEmail(value: String): Boolean {
|
||||
val trimmed = value.trim()
|
||||
return trimmed.length in 5..254 &&
|
||||
trimmed.count { it == '@' } == 1 &&
|
||||
trimmed.substringBefore('@').isNotBlank() &&
|
||||
trimmed.substringAfter('@').contains('.') &&
|
||||
!trimmed.any(Char::isWhitespace)
|
||||
}
|
||||
|
||||
internal fun isValidIsoDate(value: String): Boolean =
|
||||
value.trim().matches(Regex("\\d{4}-\\d{2}-\\d{2}"))
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
@Composable
|
||||
fun HeroComponent(
|
||||
imageUrl: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
ctaText: String,
|
||||
onPrimaryCta: () -> Unit,
|
||||
heightDp: Int = 280
|
||||
) {
|
||||
Box(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heightDp.dp)) {
|
||||
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = "Hero Image",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heightDp.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Box(modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color(0x66000000), Color(0x00000000), Color(0x88000000))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Box(modifier = Modifier
|
||||
.matchParentSize()
|
||||
.padding(20.dp)) {
|
||||
Column(modifier = Modifier.align(Alignment.CenterStart)) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text(text = subtitle, style = MaterialTheme.typography.bodyMedium, color = Color.White)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Button(
|
||||
onClick = onPrimaryCta,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier
|
||||
) {
|
||||
Text(ctaText, color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.padding(horizontal = 12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ImageGrid(images: List<String>, modifier: Modifier = Modifier) {
|
||||
val selected = remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = modifier.padding(8.dp)) {
|
||||
items(images) { img ->
|
||||
Card(modifier = Modifier.padding(4.dp), elevation = CardDefaults.cardElevation(4.dp)) {
|
||||
AsyncImage(
|
||||
model = img,
|
||||
contentDescription = "Gallery image",
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clickable { selected.value = img },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.value != null) {
|
||||
Dialog(onDismissRequest = { selected.value = null }) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
AsyncImage(model = selected.value, contentDescription = "Full image", modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit)
|
||||
Button(onClick = { selected.value = null }, modifier = Modifier.align(Alignment.TopEnd), colors = ButtonDefaults.buttonColors()) {
|
||||
Text("Schließen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
@Composable
|
||||
fun PendingPage(
|
||||
navController: NavController,
|
||||
title: String,
|
||||
webPath: String,
|
||||
showBackNavigation: Boolean,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 30.dp, bottom = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
|
||||
Text("Webseite: $webPath", color = Accent500)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
|
||||
Text(
|
||||
"Die native Android-Seite wird in einem der nächsten Portierungsschritte umgesetzt.",
|
||||
color = Primary900,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.text.HtmlCompat
|
||||
|
||||
@Composable
|
||||
fun RichText(
|
||||
html: String,
|
||||
modifier: Modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
textSize = 17f
|
||||
setTextColor(android.graphics.Color.rgb(63, 63, 70))
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
setLineSpacing(0f, 1.2f)
|
||||
}
|
||||
},
|
||||
update = { textView ->
|
||||
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.harheimertc.ui.navigation
|
||||
|
||||
sealed class Destinations(val route: String) {
|
||||
object Home : Destinations("home")
|
||||
object VereinAbout : Destinations("verein/about")
|
||||
object Vorstand : Destinations("verein/vorstand")
|
||||
object Geschichte : Destinations("verein/geschichte")
|
||||
object Satzung : Destinations("verein/satzung")
|
||||
object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften")
|
||||
object Links : Destinations("verein/links")
|
||||
object Impressum : Destinations("impressum")
|
||||
object Mannschaften : Destinations("mannschaften")
|
||||
object MannschaftDetail : Destinations("mannschaften/{slug}") {
|
||||
fun create(slug: String): String = "mannschaften/$slug"
|
||||
}
|
||||
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
|
||||
fun create(slug: String): String = "mannschaft/$slug"
|
||||
}
|
||||
object Termine : Destinations("termine")
|
||||
object Spielplan : Destinations("spielplan")
|
||||
object Spielsysteme : Destinations("mannschaften/spielsysteme")
|
||||
object Training : Destinations("training")
|
||||
object Trainer : Destinations("training/trainer")
|
||||
object Anfaenger : Destinations("training/anfaenger")
|
||||
object Regeln : Destinations("training/regeln")
|
||||
object Gallery : Destinations("gallery")
|
||||
object NewsletterSubscribe : Destinations("newsletter/subscribe")
|
||||
object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe")
|
||||
object NewsletterConfirm : Destinations("newsletter/confirm")
|
||||
object NewsletterConfirmed : Destinations("newsletter/confirmed")
|
||||
object NewsletterUnsubscribed : Destinations("newsletter/unsubscribed")
|
||||
object Contact : Destinations("contact")
|
||||
object Membership : Destinations("membership")
|
||||
object Login : Destinations("login")
|
||||
object PasswordReset : Destinations("passwordReset")
|
||||
object Register : Destinations("register")
|
||||
object MemberArea : Destinations("intern")
|
||||
object Members : Destinations("intern/mitglieder")
|
||||
object MemberNews : Destinations("intern/news")
|
||||
object Profile : Destinations("intern/profil")
|
||||
object MemberApi : Destinations("intern/api")
|
||||
object CmsStartseite : Destinations("cms/startseite")
|
||||
object CmsInhalte : Destinations("cms/inhalte")
|
||||
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
||||
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
||||
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
||||
object CmsNewsletter : Destinations("cms/newsletter")
|
||||
object CmsContactRequests : Destinations("cms/kontaktanfragen")
|
||||
object CmsEinstellungen : Destinations("cms/einstellungen")
|
||||
object CmsBenutzer : Destinations("cms/benutzer")
|
||||
object CmsPasswordResetDiagnostics : Destinations("cms/passwort-reset-diagnose")
|
||||
object Cms : Destinations("cms")
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package de.harheimertc.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
|
||||
@Composable
|
||||
fun NavGraph(
|
||||
navController: NavHostController,
|
||||
startDestination: String = Destinations.Home.route,
|
||||
navigationViewModel: NavigationViewModel = hiltViewModel(),
|
||||
) {
|
||||
val backStackEntry = navController.currentBackStackEntryAsState().value
|
||||
val route = backStackEntry?.destination?.route
|
||||
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
||||
backStackEntry.arguments?.getString("slug")?.let(Destinations.MannschaftDetail::create)
|
||||
} else route
|
||||
val navigationState by navigationViewModel.state.collectAsState()
|
||||
LaunchedEffect(currentRoute) {
|
||||
navigationViewModel.refreshSession()
|
||||
}
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val persistentNavigation = maxWidth >= 600.dp
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (persistentNavigation) {
|
||||
AppNavigationHeader(
|
||||
selectedRoute = currentRoute,
|
||||
onNavigate = navController::navigateTopLevel,
|
||||
webTabletNavigation = true,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
composable(Destinations.Home.route) {
|
||||
de.harheimertc.ui.screens.home.HomeScreen(
|
||||
navController = navController,
|
||||
showNavigationHeader = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.VereinAbout.route) {
|
||||
de.harheimertc.ui.screens.publicpages.AboutScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Vorstand.route) {
|
||||
de.harheimertc.ui.screens.publicpages.VorstandScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Geschichte.route) {
|
||||
de.harheimertc.ui.screens.publicpages.GeschichteScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Satzung.route) {
|
||||
de.harheimertc.ui.screens.publicpages.SatzungScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Vereinsmeisterschaften.route) {
|
||||
de.harheimertc.ui.screens.publicpages.VereinsmeisterschaftenScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Links.route) {
|
||||
de.harheimertc.ui.screens.publicpages.LinksScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Impressum.route) {
|
||||
de.harheimertc.ui.screens.publicpages.ImpressumScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Mannschaften.route) {
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable("mannschaften/herren") {
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable("mannschaften/damen") {
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable("mannschaften/jugend") {
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.MannschaftDetail.route) { entry ->
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Termine.route) {
|
||||
de.harheimertc.ui.screens.termine.TermineScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Spielplan.route) {
|
||||
de.harheimertc.ui.screens.spielplan.SpielplanScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Spielsysteme.route) {
|
||||
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable("spielsysteme") {
|
||||
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.Training.route) {
|
||||
de.harheimertc.ui.screens.training.TrainingScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Trainer.route) {
|
||||
de.harheimertc.ui.screens.training.TrainerScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Anfaenger.route) {
|
||||
de.harheimertc.ui.screens.training.AnfaengerScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Regeln.route) {
|
||||
de.harheimertc.ui.screens.publicpages.RegelnScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable("tt-regeln") {
|
||||
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable("verein/tt-regeln") {
|
||||
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.Gallery.route) {
|
||||
de.harheimertc.ui.screens.gallery.GalleryScreen()
|
||||
}
|
||||
composable("galerie") {
|
||||
de.harheimertc.ui.screens.gallery.GalleryScreen()
|
||||
}
|
||||
composable(Destinations.NewsletterSubscribe.route) {
|
||||
de.harheimertc.ui.screens.newsletter.NewsletterSubscribeScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NewsletterUnsubscribe.route) {
|
||||
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribeScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NewsletterConfirm.route) {
|
||||
de.harheimertc.ui.screens.newsletter.NewsletterConfirmScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
token = null,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NewsletterConfirmed.route) {
|
||||
de.harheimertc.ui.screens.newsletter.NewsletterConfirmedScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NewsletterUnsubscribed.route) {
|
||||
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribedScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Contact.route) {
|
||||
de.harheimertc.ui.screens.contact.ContactScreen()
|
||||
}
|
||||
composable(Destinations.Membership.route) {
|
||||
de.harheimertc.ui.screens.membership.MembershipScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Login.route) {
|
||||
de.harheimertc.ui.screens.login.LoginScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.PasswordReset.route) {
|
||||
de.harheimertc.ui.screens.login.PasswordResetScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Register.route) {
|
||||
de.harheimertc.ui.screens.login.RegisterScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable("ueber-uns") {
|
||||
de.harheimertc.ui.screens.publicpages.AboutScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable("geschichte") {
|
||||
de.harheimertc.ui.screens.publicpages.GeschichteScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable("satzung") {
|
||||
de.harheimertc.ui.screens.publicpages.SatzungScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.MemberArea.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Members.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MembersScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MemberNews.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Profile.route) {
|
||||
de.harheimertc.ui.screens.profile.ProfileScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MemberApi.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.CmsStartseite.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsInhalte.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsSportbetrieb.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsMitgliederverwaltung.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsNewsletter.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsContactRequests.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsEinstellungen.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsBenutzer.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsPasswordResetDiagnostics.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.Cms.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavHostController.navigateTopLevel(route: String) {
|
||||
val isTeamDetail = route.startsWith("mannschaften/") &&
|
||||
route != Destinations.Spielsysteme.route
|
||||
navigate(route) {
|
||||
launchSingleTop = !isTeamDetail
|
||||
restoreState = !isTeamDetail
|
||||
popUpTo(Destinations.Home.route) {
|
||||
saveState = !isTeamDetail
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.harheimertc.ui.navigation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.repositories.GalleryRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class NavigationUiState(
|
||||
val teams: List<Mannschaft> = emptyList(),
|
||||
val hasGalleryImages: Boolean = false,
|
||||
val loggedIn: Boolean = false,
|
||||
val roles: Set<String> = emptySet(),
|
||||
) {
|
||||
val isAdmin: Boolean get() = "admin" in roles
|
||||
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
|
||||
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
|
||||
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class NavigationViewModel @Inject constructor(
|
||||
private val mannschaftenRepository: MannschaftenRepository,
|
||||
private val galleryRepository: GalleryRepository,
|
||||
private val loginRepository: LoginRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NavigationUiState())
|
||||
val state: StateFlow<NavigationUiState> = _state
|
||||
|
||||
init {
|
||||
loadNavigationData()
|
||||
}
|
||||
|
||||
fun loadNavigationData() {
|
||||
viewModelScope.launch {
|
||||
val teams = async { mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()) }
|
||||
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
|
||||
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
||||
val status = auth.await()
|
||||
_state.value = NavigationUiState(
|
||||
teams = teams.await(),
|
||||
hasGalleryImages = gallery.await(),
|
||||
loggedIn = status.isLoggedIn,
|
||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSession() {
|
||||
viewModelScope.launch {
|
||||
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
||||
_state.value = _state.value.copy(
|
||||
loggedIn = status.isLoggedIn,
|
||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.CmsUserDto
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.NewsletterDto
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
item { CmsSummaryGrid(navController, state) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsConfigPage(navController, showBackNavigation, "Startseite", "Öffentliche Startseiteninhalte", state.config) {
|
||||
InfoRow("Öffentliche News", "${state.newsletters.count { it.sentAt != null }} versendete Newsletter")
|
||||
InfoRow("Kontaktanfragen", "${state.contactRequests.size} Einträge")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsConfigPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte", state.config) { config ->
|
||||
InfoRow("Über uns", textState(config.seiten.ueberUns))
|
||||
InfoRow("Geschichte", textState(config.seiten.geschichte))
|
||||
InfoRow("Satzung", if (config.seiten.satzung.pdfUrl.isNotBlank()) config.seiten.satzung.pdfUrl else textState(config.seiten.satzung.content))
|
||||
InfoRow("Links", "${config.seiten.linksStructured.size} strukturierte Gruppen")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsConfigPage(navController, showBackNavigation, "Vereinsmeisterschaften", "CSV- und Personenbild-Inhalte", state.config) {
|
||||
InfoRow("Datenquelle", "/api/vereinsmeisterschaften")
|
||||
InfoRow("Hinweis", "Die Ergebnisliste ist nativ im öffentlichen Bereich portiert.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsConfigPage(navController, showBackNavigation, "Sportbetrieb", "Training, Trainer und Mannschaftsinhalte", state.config) { config ->
|
||||
InfoRow("Trainingszeiten", "${config.training.zeiten.size} Einträge")
|
||||
InfoRow("Trainer", "${config.trainer.size} Personen")
|
||||
InfoRow("Spielsysteme", "/data/spielsysteme.csv")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsUserListPage(navController, showBackNavigation, "Mitgliederverwaltung", state.users)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
|
||||
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index]) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsNewsletterScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
item { SectionTitle("Newsletter") }
|
||||
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
|
||||
items(state.newsletters.size) { index -> NewsletterCard(state.newsletters[index]) }
|
||||
item { SectionTitle("Gruppen") }
|
||||
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
|
||||
items(state.newsletterGroups.size) { index -> NewsletterGroupCard(state.newsletterGroups[index]) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsConfigPage(navController, showBackNavigation, "Einstellungen", "Konfiguration und Systemstatus", state.config) { config ->
|
||||
InfoRow("Vorstand", listOf(config.vorstand.vorsitzender, config.vorstand.stellvertreter, config.vorstand.kassenwart).count { it.email.isNotBlank() }.toString())
|
||||
InfoRow("Trainer", config.trainer.size.toString())
|
||||
InfoRow("Trainingsort", config.training.ort.name.ifBlank { "Nicht gesetzt" })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsBenutzerScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsUserListPage(navController, showBackNavigation, "Benutzer", state.users)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "Passwort-Reset-Diagnose", "Fehlgeschlagene Reset-Versuche") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
if (!state.loading && state.passwordResetAttempts.isEmpty()) item { EmptyCard("Keine Diagnoseeinträge gefunden.") }
|
||||
items(state.passwordResetAttempts.size) { index -> PasswordAttemptCard(state.passwordResetAttempts[index]) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
|
||||
val cards = listOf(
|
||||
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
||||
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
|
||||
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
||||
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
||||
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
||||
Triple("Newsletter", "${state.newsletters.size} Newsletter", Destinations.CmsNewsletter.route),
|
||||
Triple("Einstellungen", "Systemkonfiguration", Destinations.CmsEinstellungen.route),
|
||||
Triple("Benutzer", "Rollen und Zugänge", Destinations.CmsBenutzer.route),
|
||||
Triple("Vereinsmeisterschaften", "Ergebnisdaten", Destinations.CmsVereinsmeisterschaften.route),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
cards.forEach { (title, subtitle, route) ->
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp, modifier = Modifier.fillMaxWidth().clickable { navController.navigate(route) }) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CmsPage(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< CMS", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CmsConfigPage(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
config: ConfigResponse?,
|
||||
content: @Composable (ConfigResponse) -> Unit,
|
||||
) {
|
||||
CmsPage(navController, showBackNavigation, title, subtitle) {
|
||||
if (config == null) {
|
||||
item { CircularProgressIndicator(color = Primary600) }
|
||||
} else {
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
content(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CmsUserListPage(navController: NavController, showBackNavigation: Boolean, title: String, users: List<CmsUserDto>) {
|
||||
CmsPage(navController, showBackNavigation, title, "Registrierte Zugänge und Rollen") {
|
||||
if (users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") }
|
||||
items(users.size) { index -> UserCard(users[index]) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserCard(user: CmsUserDto) {
|
||||
DataCard(user.name.ifBlank { user.email.orEmpty() }) {
|
||||
InfoRow("E-Mail", user.email ?: "-")
|
||||
InfoRow("Rollen", user.roles.ifEmpty { listOfNotNull(user.role) }.joinToString(", ").ifBlank { "mitglied" })
|
||||
InfoRow("Status", if (user.active) "Aktiv" else "Inaktiv")
|
||||
InfoRow("Letzter Login", user.lastLogin ?: "-")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactRequestCard(request: ContactRequestDto) {
|
||||
DataCard(request.name.ifBlank { request.email }) {
|
||||
InfoRow("E-Mail", request.email)
|
||||
InfoRow("Status", request.status.ifBlank { "offen" })
|
||||
InfoRow("Nachricht", request.message)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterCard(newsletter: NewsletterDto) {
|
||||
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
|
||||
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
|
||||
InfoRow("Erstellt", newsletter.createdAt ?: "-")
|
||||
InfoRow("Versendet", newsletter.sentAt ?: "-")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterGroupCard(group: NewsletterGroupDto) {
|
||||
DataCard(group.name.ifBlank { group.id }) {
|
||||
InfoRow("Beschreibung", group.description.ifBlank { "-" })
|
||||
InfoRow("Abonnenten", group.subscribers.size.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordAttemptCard(attempt: PasswordResetAttemptDto) {
|
||||
DataCard(attempt.emailMasked ?: attempt.requestId) {
|
||||
InfoRow("Gestartet", attempt.startedAt ?: "-")
|
||||
InfoRow("Status", if (attempt.failed) "Fehler" else "OK")
|
||||
InfoRow("Schritte", attempt.steps.size.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DataCard(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, color = Accent500, modifier = Modifier.weight(0.8f))
|
||||
Text(value, color = Accent900, modifier = Modifier.weight(1.2f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(title: String) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyCard(message: String) {
|
||||
Surface(color = Accent100, shape = RoundedCornerShape(12.dp)) {
|
||||
Text(message, color = Accent700, modifier = Modifier.fillMaxWidth().padding(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun textState(value: String): String = if (value.isBlank()) "Nicht gesetzt" else "${value.length} Zeichen"
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.CmsUserDto
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.NewsletterDto
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class CmsUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val config: ConfigResponse? = null,
|
||||
val users: List<CmsUserDto> = emptyList(),
|
||||
val contactRequests: List<ContactRequestDto> = emptyList(),
|
||||
val newsletters: List<NewsletterDto> = emptyList(),
|
||||
val newsletterGroups: List<NewsletterGroupDto> = emptyList(),
|
||||
val passwordResetAttempts: List<PasswordResetAttemptDto> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class CmsViewModel @Inject constructor(
|
||||
private val repository: CmsRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(CmsUiState())
|
||||
val state: StateFlow<CmsUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
val config = async { repository.config().getOrNull() }
|
||||
val users = async { repository.users().getOrNull()?.users.orEmpty() }
|
||||
val requests = async { repository.contactRequests().getOrNull().orEmpty() }
|
||||
val newsletters = async { repository.newsletters().getOrNull()?.newsletters.orEmpty() }
|
||||
val groups = async { repository.newsletterGroups().getOrNull()?.groups.orEmpty() }
|
||||
val diagnostics = async { repository.passwordResetDiagnostics().getOrNull()?.attempts.orEmpty() }
|
||||
|
||||
_state.value = CmsUiState(
|
||||
loading = false,
|
||||
config = config.await(),
|
||||
users = users.await(),
|
||||
contactRequests = requests.await(),
|
||||
newsletters = newsletters.await(),
|
||||
newsletterGroups = groups.await(),
|
||||
passwordResetAttempts = diagnostics.await(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.harheimertc.ui.screens.contact
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
|
||||
@Composable
|
||||
fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
|
||||
val name by viewModel.name.collectAsState()
|
||||
val email by viewModel.email.collectAsState()
|
||||
val message by viewModel.message.collectAsState()
|
||||
val sending by viewModel.sending.collectAsState()
|
||||
val result by viewModel.result.collectAsState()
|
||||
val fieldErrors by viewModel.fieldErrors.collectAsState()
|
||||
|
||||
Surface(modifier = Modifier.padding(16.dp)) {
|
||||
Column {
|
||||
ValidatedTextField(name, viewModel::onName, "Name", error = fieldErrors["name"])
|
||||
ValidatedTextField(email, viewModel::onEmail, "E-Mail", error = fieldErrors["email"])
|
||||
ValidatedTextField(
|
||||
value = message,
|
||||
onValueChange = viewModel::onMessage,
|
||||
label = "Nachricht",
|
||||
error = fieldErrors["message"],
|
||||
singleLine = false,
|
||||
minLines = 4,
|
||||
)
|
||||
Button(onClick = { viewModel.send() }, enabled = !sending, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text(if (sending) "Sende…" else "Absenden")
|
||||
}
|
||||
if (result != null) {
|
||||
Text(text = result!!, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package de.harheimertc.ui.screens.contact
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ContactRequest
|
||||
import de.harheimertc.repositories.ContactRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ContactViewModel @Inject constructor(private val repo: ContactRepository) : ViewModel() {
|
||||
private val _name = MutableStateFlow("")
|
||||
val name: StateFlow<String> = _name
|
||||
|
||||
private val _email = MutableStateFlow("")
|
||||
val email: StateFlow<String> = _email
|
||||
|
||||
private val _message = MutableStateFlow("")
|
||||
val message: StateFlow<String> = _message
|
||||
|
||||
private val _sending = MutableStateFlow(false)
|
||||
val sending: StateFlow<Boolean> = _sending
|
||||
|
||||
private val _result = MutableStateFlow<String?>(null)
|
||||
val result: StateFlow<String?> = _result
|
||||
|
||||
private val _fieldErrors = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val fieldErrors: StateFlow<Map<String, String>> = _fieldErrors
|
||||
|
||||
fun onName(v: String) {
|
||||
_name.value = v
|
||||
clearFieldError("name")
|
||||
}
|
||||
|
||||
fun onEmail(v: String) {
|
||||
_email.value = v
|
||||
clearFieldError("email")
|
||||
}
|
||||
|
||||
fun onMessage(v: String) {
|
||||
_message.value = v
|
||||
clearFieldError("message")
|
||||
}
|
||||
|
||||
fun send() {
|
||||
val n = _name.value.trim()
|
||||
val e = _email.value.trim()
|
||||
val m = _message.value.trim()
|
||||
val errors = buildMap {
|
||||
if (n.isEmpty()) put("name", "Bitte geben Sie Ihren Namen ein.")
|
||||
if (e.isEmpty()) put("email", "Bitte geben Sie Ihre E-Mail-Adresse ein.")
|
||||
else if (!isValidEmail(e)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||
if (m.isEmpty()) put("message", "Bitte geben Sie eine Nachricht ein.")
|
||||
else if (m.length < 10) put("message", "Die Nachricht sollte mindestens 10 Zeichen haben.")
|
||||
}
|
||||
if (errors.isNotEmpty()) {
|
||||
_fieldErrors.value = errors
|
||||
_result.value = "Bitte prüfen Sie die markierten Felder."
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_sending.value = true
|
||||
try {
|
||||
val resp = repo.sendContact(ContactRequest(n, e, m))
|
||||
if (resp.isSuccessful) {
|
||||
_result.value = "Nachricht gesendet"
|
||||
_fieldErrors.value = emptyMap()
|
||||
_name.value = ""
|
||||
_email.value = ""
|
||||
_message.value = ""
|
||||
} else {
|
||||
_result.value = "Fehler: ${resp.code()}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_result.value = "Netzwerkfehler"
|
||||
} finally {
|
||||
_sending.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearFieldError(field: String) {
|
||||
if (_fieldErrors.value.containsKey(field)) {
|
||||
_fieldErrors.value = _fieldErrors.value - field
|
||||
}
|
||||
_result.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.components.ImageGrid
|
||||
|
||||
@Composable
|
||||
fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
|
||||
val images by viewModel.images.collectAsState()
|
||||
val loading by viewModel.loading.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator()
|
||||
} else if (error != null) {
|
||||
Text(text = "Fehler: $error")
|
||||
} else {
|
||||
ImageGrid(images = images)
|
||||
}
|
||||
}
|
||||
|
||||
// load on first composition
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.repositories.GalleryRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class GalleryViewModel @Inject constructor(private val repo: GalleryRepository) : ViewModel() {
|
||||
private val _images = MutableStateFlow<List<String>>(emptyList())
|
||||
val images: StateFlow<List<String>> = _images
|
||||
|
||||
private val _loading = MutableStateFlow(false)
|
||||
val loading: StateFlow<Boolean> = _loading
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_loading.value = true
|
||||
_error.value = null
|
||||
repo.fetchImages()
|
||||
.onSuccess { _images.value = it }
|
||||
.onFailure { _error.value = it.message ?: "Fehler" }
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package de.harheimertc.ui.screens.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
import de.harheimertc.ui.components.RichText
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent200
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
navController: NavController,
|
||||
showNavigationHeader: Boolean = true,
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
|
||||
|
||||
selectedNews?.let { item ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { selectedNews = null },
|
||||
title = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Text(item.title, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
text = { RichText(item.content) },
|
||||
confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } },
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color.White),
|
||||
) {
|
||||
if (showNavigationHeader) {
|
||||
item {
|
||||
AppNavigationHeader(
|
||||
selectedRoute = Destinations.Home.route,
|
||||
onNavigate = navController::navigate,
|
||||
)
|
||||
}
|
||||
}
|
||||
item { WebHero() }
|
||||
item {
|
||||
HomeTermineSection(
|
||||
termine = state.termine,
|
||||
loading = state.loading,
|
||||
onAll = { navController.navigate(Destinations.Termine.route) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeGamesSection(
|
||||
spiele = state.spiele,
|
||||
loading = state.loading,
|
||||
onAll = { navController.navigate(Destinations.Spielplan.route) },
|
||||
)
|
||||
}
|
||||
if (state.news.isNotEmpty()) {
|
||||
item {
|
||||
HomeNewsSection(
|
||||
news = state.news,
|
||||
onOpen = { selectedNews = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
HomeActionSection(
|
||||
onMembership = { navController.navigate(Destinations.Membership.route) },
|
||||
onContact = { navController.navigate(Destinations.Contact.route) },
|
||||
)
|
||||
}
|
||||
item { HomeFooter() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WebHero() {
|
||||
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 390.dp)
|
||||
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = "${BuildConfig.API_BASE_URL}images/club_about_us.png",
|
||||
contentDescription = null,
|
||||
modifier = Modifier.matchParentSize().alpha(0.10f),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Text(
|
||||
"Willkommen beim",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = Accent900,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"Harheimer TC",
|
||||
style = MaterialTheme.typography.displayLarge.copy(fontSize = 40.sp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Primary600,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"Tradition trifft Moderne - Ihr Tischtennisverein in Frankfurt-Harheim seit $years Jahren",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Accent700,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeTermineSection(termine: List<TerminDto>, loading: Boolean, onAll: () -> Unit) {
|
||||
HomeSection(title = "Kommende Termine", background = Color(0xFFFAFAFA)) {
|
||||
if (loading) {
|
||||
LoadingRow("Termine werden geladen...")
|
||||
} else if (termine.isEmpty()) {
|
||||
EmptyRow("Keine kommenden Termine")
|
||||
} else {
|
||||
termine.forEach { termin -> AppointmentCard(termin) }
|
||||
}
|
||||
PrimaryAction("Alle Termine anzeigen", onAll)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeGamesSection(spiele: List<SpielDto>, loading: Boolean, onAll: () -> Unit) {
|
||||
HomeSection(title = "Nächste Spiele", background = Color.White) {
|
||||
if (loading) {
|
||||
LoadingRow("Spielplan wird geladen...")
|
||||
} else if (spiele.isEmpty()) {
|
||||
EmptyRow("Derzeit sind keine Spiele geplant.")
|
||||
} else {
|
||||
spiele.forEach { spiel -> MatchCard(spiel) }
|
||||
}
|
||||
PrimaryAction("Alle Spiele anzeigen", onAll)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeNewsSection(news: List<NewsDto>, onOpen: (NewsDto) -> Unit) {
|
||||
HomeSection(
|
||||
title = "Aktuelles",
|
||||
subtitle = "Die neuesten Nachrichten aus unserem Verein",
|
||||
background = Color.White,
|
||||
) {
|
||||
news.forEach { item ->
|
||||
Surface(
|
||||
color = Color(0xFFFAFAFA),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp).clickable { onOpen(item) },
|
||||
) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
RichText(item.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeActionSection(onMembership: () -> Unit, onContact: () -> Unit) {
|
||||
HomeSection(title = null, background = Color(0xFFFAFAFA)) {
|
||||
ActionCard(
|
||||
title = "Mitglied werden",
|
||||
body = "Werden Sie Teil unserer Tischtennisfamilie und profitieren Sie von Training, Wettkämpfen und Gemeinschaft.",
|
||||
action = "Mehr erfahren",
|
||||
onClick = onMembership,
|
||||
)
|
||||
ActionCard(
|
||||
title = "Kontakt aufnehmen",
|
||||
body = "Haben Sie Fragen oder möchten ein kostenloses Probetraining vereinbaren? Wir freuen uns auf Ihre Nachricht!",
|
||||
action = "Jetzt kontaktieren",
|
||||
onClick = onContact,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeSection(
|
||||
title: String?,
|
||||
subtitle: String? = null,
|
||||
background: Color,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().background(background).padding(horizontal = 18.dp, vertical = 38.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
title?.let {
|
||||
Text(it, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(Modifier.width(74.dp).height(4.dp).background(Primary600))
|
||||
subtitle?.let { text ->
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyLarge, color = Accent500, textAlign = TextAlign.Center)
|
||||
}
|
||||
Spacer(Modifier.height(26.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppointmentCard(termin: TerminDto) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(9.dp),
|
||||
color = Color(0xFFF4F4F5),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.width(65.dp).padding(vertical = 8.dp, horizontal = 3.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(formatDate(termin.datum, "dd"), color = Color.White, fontWeight = FontWeight.Bold, fontSize = 19.sp)
|
||||
Text(formatDate(termin.datum, "MMM yyyy"), color = Color.White, style = MaterialTheme.typography.labelSmall)
|
||||
termin.uhrzeit?.let { Text("$it Uhr", color = Color.White, style = MaterialTheme.typography.labelSmall) }
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(start = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(termin.titel, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
termin.beschreibung?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent700, maxLines = 2)
|
||||
}
|
||||
}
|
||||
termin.kategorie?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
it,
|
||||
color = Accent700,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.background(Primary100, RoundedCornerShape(14.dp)).padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MatchCard(spiel: SpielDto) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White,
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatMatchDate(spiel.termin), fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
Text(spiel.termin.substringAfter(' ', "-"), style = MaterialTheme.typography.bodyMedium, color = Accent500)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TeamLabel("Heim", spiel.heimMannschaft, Modifier.weight(1f))
|
||||
Box(
|
||||
Modifier.size(34.dp).background(Primary100, RoundedCornerShape(20.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("vs", color = Primary600, fontWeight = FontWeight.Bold) }
|
||||
TeamLabel("Gast", spiel.gastMannschaft, Modifier.weight(1f), right = true)
|
||||
}
|
||||
spiel.runde?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamLabel(label: String, value: String, modifier: Modifier, right: Boolean = false) {
|
||||
Column(modifier.padding(horizontal = 8.dp), horizontalAlignment = if (right) Alignment.End else Alignment.Start) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Text(value, fontWeight = FontWeight.SemiBold, color = Accent900, textAlign = if (right) TextAlign.End else TextAlign.Start)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionCard(title: String, body: String, action: String, onClick: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White,
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 15.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
Column(Modifier.padding(22.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier.size(48.dp).background(Primary100, RoundedCornerShape(10.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("HTC", color = Primary600, fontWeight = FontWeight.Bold, fontSize = 11.sp) }
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
}
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium, color = Accent700)
|
||||
Text("$action >", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrimaryAction(label: String, onClick: () -> Unit) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) { Text("$label >", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyRow(text: String) {
|
||||
Surface(color = Accent100, shape = RoundedCornerShape(9.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 30.dp, horizontal = 12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingRow(text: String) {
|
||||
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(28.dp), color = Primary600)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(text, color = Accent500)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeFooter() {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().background(Accent900).padding(horizontal = 18.dp, vertical = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Harheimer TC", style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text("Tischtennis in Frankfurt-Harheim seit 1954", color = Accent200, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(value: String, pattern: String): String = runCatching {
|
||||
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
|
||||
SimpleDateFormat(pattern, Locale.GERMANY).format(source.parse(value)!!)
|
||||
}.getOrDefault(value)
|
||||
|
||||
private fun formatMatchDate(value: String): String = runCatching {
|
||||
val source = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY)
|
||||
val date = source.parse(value.substringBefore(' '))!!
|
||||
SimpleDateFormat("EEE dd.MM.yyyy", Locale.GERMANY).format(date)
|
||||
}.getOrDefault(value.substringBefore(' '))
|
||||
|
||||
private fun formatNewsDate(value: String?): String {
|
||||
if (value.isNullOrBlank()) return ""
|
||||
return runCatching {
|
||||
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
|
||||
SimpleDateFormat("dd. MMMM yyyy", Locale.GERMANY).format(source.parse(value.take(10))!!)
|
||||
}.getOrDefault(value.take(10))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.harheimertc.ui.screens.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.repositories.HomeRepository
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class HomeUiState(
|
||||
val loading: Boolean = true,
|
||||
val termine: List<TerminDto> = emptyList(),
|
||||
val spiele: List<SpielDto> = emptyList(),
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
val error: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(private val repository: HomeRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(HomeUiState())
|
||||
val state: StateFlow<HomeUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = false)
|
||||
repository.fetchHomeData()
|
||||
.onSuccess { data ->
|
||||
_state.value = HomeUiState(
|
||||
loading = false,
|
||||
termine = data.termine
|
||||
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
|
||||
.sortedBy { it.asDateTime() }
|
||||
.take(3),
|
||||
spiele = data.spiele
|
||||
.filter { game ->
|
||||
game.asDate()?.let { date ->
|
||||
!date.isBefore(LocalDate.now()) &&
|
||||
!date.isAfter(LocalDate.now().plusDays(7))
|
||||
} == true
|
||||
}
|
||||
.sortedBy { it.asDate() }
|
||||
.take(3),
|
||||
news = data.news.take(3),
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = HomeUiState(loading = false, error = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TerminDto.asDateTime(): LocalDateTime? = runCatching {
|
||||
val time = uhrzeit?.takeIf { it.matches(Regex("\\d{2}:\\d{2}")) } ?: "00:00"
|
||||
LocalDateTime.parse("$datum $time", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||
}.getOrNull()
|
||||
|
||||
fun SpielDto.asDate(): LocalDate? = runCatching {
|
||||
LocalDate.parse(termin.substringBefore(' '), DateTimeFormatter.ofPattern("dd.MM.yyyy"))
|
||||
}.getOrNull()
|
||||
@@ -0,0 +1,153 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.loggedIn, state.restoring) {
|
||||
if (state.loggedIn && !state.restoring) {
|
||||
navController.navigate(Destinations.MemberArea.route) {
|
||||
launchSingleTop = true
|
||||
popUpTo(Destinations.Login.route) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
|
||||
Text(
|
||||
"Mitglieder-Login",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = Accent900,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
"Melden Sie sich an, um auf den Mitgliederbereich zuzugreifen.",
|
||||
color = Accent500,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 9.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
if (state.restoring) {
|
||||
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
|
||||
Text("Sitzung wird geprüft...", color = Accent500)
|
||||
} else if (!state.loggedIn) {
|
||||
ValidatedTextField(
|
||||
value = state.email,
|
||||
onValueChange = viewModel::setEmail,
|
||||
label = "E-Mail-Adresse",
|
||||
error = state.fieldErrors["email"],
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true,
|
||||
)
|
||||
ValidatedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::setPassword,
|
||||
label = "Passwort",
|
||||
error = state.fieldErrors["password"],
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(onClick = viewModel::login, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||
if (state.loading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 4.dp))
|
||||
Text(if (state.loading) "Anmeldung läuft..." else "Anmelden")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.passkeyLogin(context) },
|
||||
enabled = !state.loading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Mit Passkey anmelden")
|
||||
}
|
||||
TextButton(onClick = { navController.navigate(Destinations.PasswordReset.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Passwort vergessen?")
|
||||
}
|
||||
TextButton(onClick = { navController.navigate(Destinations.Register.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Registrierung beantragen")
|
||||
}
|
||||
} else {
|
||||
Text("Angemeldet", style = MaterialTheme.typography.titleLarge, color = Color(0xFF166534))
|
||||
Text(state.userName.orEmpty(), color = Accent900)
|
||||
if (state.roles.isNotEmpty()) Text(state.roles.joinToString(", "), color = Accent500)
|
||||
OutlinedButton(onClick = viewModel::logout, modifier = Modifier.fillMaxWidth()) { Text("Abmelden") }
|
||||
}
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
state.message?.let { Text(it, color = Color(0xFF166534)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
|
||||
Text(
|
||||
"Nur für Vereinsmitglieder. Kein Zugang? Kontaktieren Sie den Vorstand.",
|
||||
color = Primary900,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.PasskeyRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class LoginUiState(
|
||||
val email: String = "",
|
||||
val password: String = "",
|
||||
val fieldErrors: Map<String, String> = emptyMap(),
|
||||
val loading: Boolean = false,
|
||||
val restoring: Boolean = true,
|
||||
val loggedIn: Boolean = false,
|
||||
val userName: String? = null,
|
||||
val roles: List<String> = emptyList(),
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val repository: LoginRepository,
|
||||
private val passkeyRepository: PasskeyRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(LoginUiState())
|
||||
val state: StateFlow<LoginUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.status()
|
||||
.onSuccess { status ->
|
||||
_state.value = _state.value.copy(
|
||||
restoring = false,
|
||||
loggedIn = status.isLoggedIn,
|
||||
userName = status.user?.name ?: status.user?.email,
|
||||
roles = status.roles.ifEmpty { status.user?.roles.orEmpty() },
|
||||
)
|
||||
}
|
||||
.onFailure { _state.value = _state.value.copy(restoring = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setEmail(value: String) {
|
||||
_state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null)
|
||||
}
|
||||
|
||||
fun setPassword(value: String) {
|
||||
_state.value = _state.value.copy(password = value, fieldErrors = _state.value.fieldErrors - "password", error = null)
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val current = _state.value
|
||||
val fieldErrors = buildMap {
|
||||
if (!isValidEmail(current.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||
if (current.password.isBlank()) put("password", "Bitte geben Sie Ihr Passwort ein.")
|
||||
}
|
||||
if (fieldErrors.isNotEmpty()) {
|
||||
_state.value = current.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = current.copy(loading = true, error = null, message = null)
|
||||
repository.login(current.email, current.password)
|
||||
.onSuccess { response ->
|
||||
_state.value = current.copy(
|
||||
password = "",
|
||||
loading = false,
|
||||
restoring = false,
|
||||
loggedIn = true,
|
||||
userName = response.user?.name ?: response.user?.email,
|
||||
roles = response.user?.roles.orEmpty(),
|
||||
message = "Anmeldung erfolgreich.",
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = current.copy(loading = false, error = it.message ?: "Anmeldung fehlgeschlagen.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun passkeyLogin(context: Context) {
|
||||
val current = _state.value
|
||||
viewModelScope.launch {
|
||||
_state.value = current.copy(loading = true, error = null, message = null)
|
||||
passkeyRepository.login(context, current.email)
|
||||
.onSuccess { response ->
|
||||
_state.value = current.copy(
|
||||
password = "",
|
||||
loading = false,
|
||||
restoring = false,
|
||||
loggedIn = true,
|
||||
userName = response.user?.name ?: response.user?.email,
|
||||
roles = response.user?.roles.orEmpty(),
|
||||
message = "Passkey-Anmeldung erfolgreich.",
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = current.copy(
|
||||
loading = false,
|
||||
restoring = false,
|
||||
error = it.message ?: "Passkey-Anmeldung fehlgeschlagen.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
repository.logout()
|
||||
_state.value = LoginUiState(restoring = false, message = "Sie wurden abgemeldet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
@Composable
|
||||
fun PasswordResetScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: PasswordResetViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
AuthFormPage(
|
||||
title = "Passwort zurücksetzen",
|
||||
subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.",
|
||||
onBack = { navController.navigate(Destinations.Login.route) },
|
||||
showBackNavigation = showBackNavigation,
|
||||
) {
|
||||
ValidatedTextField(
|
||||
value = state.email,
|
||||
onValueChange = viewModel::setEmail,
|
||||
label = "E-Mail-Adresse",
|
||||
error = state.fieldErrors["email"],
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true,
|
||||
)
|
||||
MessageLines(state.error, state.message)
|
||||
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (state.loading) "Wird gesendet..." else "Passwort zurücksetzen")
|
||||
}
|
||||
TextButton(onClick = { navController.navigate(Destinations.Login.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Zurück zum Login")
|
||||
}
|
||||
AuthNotice("Sie erhalten eine E-Mail mit einem temporären Passwort, sofern ein Konto vorhanden ist.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: RegisterViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val form = state.form
|
||||
AuthFormPage(
|
||||
title = "Registrierung",
|
||||
subtitle = "Beantragen Sie einen Zugang zum Mitgliederbereich.",
|
||||
onBack = { navController.navigate(Destinations.Login.route) },
|
||||
showBackNavigation = showBackNavigation,
|
||||
) {
|
||||
ValidatedTextField(form.name, { viewModel.update(form.copy(name = it)) }, "Name *", error = state.fieldErrors["name"])
|
||||
ValidatedTextField(
|
||||
value = form.email,
|
||||
onValueChange = { viewModel.update(form.copy(email = it)) },
|
||||
label = "E-Mail-Adresse *",
|
||||
error = state.fieldErrors["email"],
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
)
|
||||
ValidatedTextField(
|
||||
value = form.phone,
|
||||
onValueChange = { viewModel.update(form.copy(phone = it)) },
|
||||
label = "Telefon",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
)
|
||||
ValidatedTextField(
|
||||
value = form.birthDate,
|
||||
onValueChange = { viewModel.update(form.copy(birthDate = it)) },
|
||||
label = "Geburtsdatum * (JJJJ-MM-TT)",
|
||||
error = state.fieldErrors["birthDate"],
|
||||
)
|
||||
ValidatedTextField(
|
||||
value = form.password,
|
||||
onValueChange = { viewModel.update(form.copy(password = it)) },
|
||||
label = "Passwort *",
|
||||
error = state.fieldErrors["password"],
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
ValidatedTextField(
|
||||
value = form.passwordRepeat,
|
||||
onValueChange = { viewModel.update(form.copy(passwordRepeat = it)) },
|
||||
label = "Passwort wiederholen *",
|
||||
error = state.fieldErrors["passwordRepeat"],
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(form.showBirthday, { viewModel.update(form.copy(showBirthday = it)) })
|
||||
Text("Geburtstag im Mitgliederbereich anzeigen")
|
||||
}
|
||||
MessageLines(state.error, state.message)
|
||||
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (state.loading) "Wird gesendet..." else "Registrierung beantragen")
|
||||
}
|
||||
AuthNotice("Ihre Registrierung muss vor der Anmeldung vom Vorstand freigegeben werden.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthFormPage(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onBack: () -> Unit,
|
||||
showBackNavigation: Boolean,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = onBack) { Text("< Login", color = Primary600, fontWeight = FontWeight.SemiBold) }
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 22.dp))
|
||||
Text(subtitle, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 9.dp, bottom = 14.dp))
|
||||
}
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(15.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageLines(error: String?, message: String?) {
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
message?.let { Text(it, color = Color(0xFF166534)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthNotice(text: String) {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
|
||||
Text(text, color = Primary900, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(13.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.RegistrationVisibility
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import de.harheimertc.ui.components.isValidIsoDate
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PasswordResetUiState(
|
||||
val email: String = "",
|
||||
val fieldErrors: Map<String, String> = emptyMap(),
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class PasswordResetViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(PasswordResetUiState())
|
||||
val state: StateFlow<PasswordResetUiState> = _state
|
||||
|
||||
fun setEmail(value: String) {
|
||||
_state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val email = _state.value.email.trim()
|
||||
if (!isValidEmail(email)) {
|
||||
_state.value = _state.value.copy(
|
||||
fieldErrors = mapOf("email" to "Bitte eine gültige E-Mail-Adresse eingeben."),
|
||||
error = "Bitte prüfen Sie die markierten Felder.",
|
||||
)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null, message = null)
|
||||
repository.resetPassword(email)
|
||||
.onSuccess { response ->
|
||||
_state.value = PasswordResetUiState(message = response.message ?: "Anfrage wurde gesendet.")
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(loading = false, error = "Anfrage konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RegisterFormState(
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val phone: String = "",
|
||||
val birthDate: String = "",
|
||||
val password: String = "",
|
||||
val passwordRepeat: String = "",
|
||||
val showBirthday: Boolean = true,
|
||||
)
|
||||
|
||||
data class RegisterUiState(
|
||||
val form: RegisterFormState = RegisterFormState(),
|
||||
val fieldErrors: Map<String, String> = emptyMap(),
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class RegisterViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(RegisterUiState())
|
||||
val state: StateFlow<RegisterUiState> = _state
|
||||
|
||||
fun update(form: RegisterFormState) {
|
||||
_state.value = _state.value.copy(form = form, fieldErrors = validateFields(form, onlyTouched = true), error = null)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val form = _state.value.form
|
||||
val fieldErrors = validateFields(form)
|
||||
if (fieldErrors.isNotEmpty()) {
|
||||
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null, message = null)
|
||||
repository.register(
|
||||
RegistrationRequest(
|
||||
name = form.name.trim(),
|
||||
email = form.email.trim(),
|
||||
phone = form.phone.trim().takeIf(String::isNotBlank),
|
||||
password = form.password,
|
||||
geburtsdatum = form.birthDate.trim(),
|
||||
visibility = RegistrationVisibility(showBirthday = form.showBirthday),
|
||||
),
|
||||
).onSuccess { response ->
|
||||
_state.value = RegisterUiState(message = response.message ?: "Registrierung wurde eingereicht.")
|
||||
}.onFailure {
|
||||
_state.value = _state.value.copy(loading = false, error = it.message ?: "Registrierung fehlgeschlagen.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateFields(form: RegisterFormState, onlyTouched: Boolean = false): Map<String, String> = buildMap {
|
||||
fun shouldValidate(value: String) = !onlyTouched || value.isNotBlank()
|
||||
if (!onlyTouched && form.name.isBlank()) put("name", "Bitte geben Sie Ihren Namen ein.")
|
||||
if (shouldValidate(form.email) && !isValidEmail(form.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||
if (shouldValidate(form.birthDate) && !isValidIsoDate(form.birthDate)) put("birthDate", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
|
||||
if (shouldValidate(form.password) && form.password.length < 8) put("password", "Das Passwort muss mindestens 8 Zeichen lang sein.")
|
||||
if (form.passwordRepeat.isNotBlank() && form.password != form.passwordRepeat) put("passwordRepeat", "Die Passwörter stimmen nicht überein.")
|
||||
if (!onlyTouched && form.passwordRepeat.isBlank()) put("passwordRepeat", "Bitte wiederholen Sie das Passwort.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package de.harheimertc.ui.screens.mannschaften
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.LeagueTableRowDto
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary700
|
||||
|
||||
@Composable
|
||||
fun MannschaftenScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: MannschaftenViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
item {
|
||||
BackLink(navController, showBackNavigation)
|
||||
Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
|
||||
Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
when {
|
||||
state.loading -> item { Loading() }
|
||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
|
||||
else -> items(state.teams) { team ->
|
||||
TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug)) }
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Spielpläne & Ergebnisse", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text("Alle aktuellen Spielpläne und Ergebnisse unserer Mannschaften.", color = Accent500)
|
||||
Button(
|
||||
onClick = { navController.navigate(Destinations.Spielplan.route) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
) { Text("Zu den Spielplänen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
|
||||
Surface(
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shadowElevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen),
|
||||
) {
|
||||
Column {
|
||||
Column(Modifier.fillMaxWidth().background(Brush.horizontalGradient(listOf(Primary600, Primary700))).padding(16.dp)) {
|
||||
Text(team.mannschaft, style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text(team.liga, color = Primary100, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TeamInfo("Staffelleiter", team.staffelleiter)
|
||||
TeamInfo("Heimspieltag", team.heimspieltag)
|
||||
TeamInfo("Spielsystem", team.spielsystem)
|
||||
Text("${team.spieler.size} Spieler - Details anzeigen", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MannschaftDetailScreen(
|
||||
slug: String,
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: MannschaftDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedTab by rememberSaveable(slug) { mutableStateOf(DetailTab.Matches) }
|
||||
LaunchedEffect(slug) { viewModel.load(slug) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item { BackLink(navController, showBackNavigation) }
|
||||
if (state.loading) {
|
||||
item { Loading() }
|
||||
} else {
|
||||
state.team?.let { team ->
|
||||
item { TeamHeader(team) }
|
||||
item {
|
||||
InfoCard("Liga-Informationen") {
|
||||
TeamInfo("Staffelleiter", team.staffelleiter)
|
||||
TeamInfo("Telefon", team.telefon)
|
||||
TeamInfo("Heimspieltag", team.heimspieltag)
|
||||
TeamInfo("Spielsystem", team.spielsystem)
|
||||
}
|
||||
}
|
||||
item {
|
||||
InfoCard("Mannschaftsaufstellung") {
|
||||
team.spieler.forEach { player ->
|
||||
val captain = player == team.mannschaftsfuehrer
|
||||
Surface(color = if (captain) Primary100 else Accent100, shape = RoundedCornerShape(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(player, color = Accent900)
|
||||
if (captain) Text("Mannschaftsführer", color = Primary600, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
SchedulePanelHeader(
|
||||
season = state.season,
|
||||
selectedTab = selectedTab,
|
||||
hasTable = team.informationenLink.isNotBlank(),
|
||||
onSelected = { selectedTab = it },
|
||||
)
|
||||
}
|
||||
when (selectedTab) {
|
||||
DetailTab.Matches -> {
|
||||
if (state.matchesError != null) item { Text(state.matchesError.orEmpty(), color = Primary700) }
|
||||
if (state.matches.isEmpty() && state.matchesError == null) {
|
||||
item { Text("Für diese Mannschaft sind aktuell keine Spiele vorhanden.", color = Accent500) }
|
||||
} else {
|
||||
items(state.matches) { MatchCard(it) }
|
||||
}
|
||||
}
|
||||
DetailTab.Table -> {
|
||||
when {
|
||||
state.tableLoading -> item { Loading() }
|
||||
state.tableError != null -> item { Text(state.tableError.orEmpty(), color = Primary700) }
|
||||
state.tableRows.isEmpty() -> item { Text("Für diese Mannschaft ist aktuell keine Tabelle hinterlegt.", color = Accent500) }
|
||||
else -> {
|
||||
item { TableLegend() }
|
||||
items(state.tableRows) { TableRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class DetailTab { Matches, Table }
|
||||
|
||||
@Composable
|
||||
private fun SchedulePanelHeader(
|
||||
season: String?,
|
||||
selectedTab: DetailTab,
|
||||
hasTable: Boolean,
|
||||
onSelected: (DetailTab) -> Unit,
|
||||
) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Aktueller Spielplan", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
season?.let { Text("Saison ${seasonLabel(it)}", color = Accent500) }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
DetailTabButton("Matches", selectedTab == DetailTab.Matches) { onSelected(DetailTab.Matches) }
|
||||
if (hasTable) {
|
||||
DetailTabButton("Tabelle", selectedTab == DetailTab.Table) { onSelected(DetailTab.Table) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailTabButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = if (selected) Color.White else Accent100,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
shadowElevation = if (selected) 2.dp else 0.dp,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) Primary700 else Accent500,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 15.dp, vertical = 9.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamHeader(team: Mannschaft) {
|
||||
Column(Modifier.fillMaxWidth().background(Primary600, RoundedCornerShape(8.dp)).padding(20.dp)) {
|
||||
Text(team.mannschaft, style = MaterialTheme.typography.displayLarge, color = Color.White)
|
||||
Text(team.liga, color = Primary100, modifier = Modifier.padding(top = 5.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoCard(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamInfo(label: String, value: String) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text("$label:", color = Accent500, modifier = Modifier.weight(0.38f))
|
||||
Text(value.ifBlank { "-" }, color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(0.62f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MatchCard(game: SpielDto) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 1.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(13.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(game.termin, color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
val result = if (game.spieleHeim.isNotBlank() || game.spieleGast.isNotBlank()) "${game.spieleHeim}:${game.spieleGast}" else "-"
|
||||
Text(result, color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Text("${game.heimMannschaft} - ${game.gastMannschaft}", color = Accent900)
|
||||
Text("${game.altersklasse} / ${game.staffel.removePrefix("E")}", color = Accent500, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableLegend() {
|
||||
Surface(color = Accent100, shape = RoundedCornerShape(6.dp)) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
val compact = maxWidth < 560.dp
|
||||
if (compact) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Platz / Mannschaft", color = Accent500, style = MaterialTheme.typography.labelSmall)
|
||||
TableMetricsHeader()
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Platz", color = Accent500, style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(55.dp))
|
||||
Text("Mannschaft", color = Accent500, style = MaterialTheme.typography.labelSmall, modifier = Modifier.weight(1f))
|
||||
TableMetricsHeader()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableRow(row: LeagueTableRowDto) {
|
||||
val ourTeam = row.teamName.contains("Harheimer TC", ignoreCase = true)
|
||||
Surface(
|
||||
color = if (ourTeam) Primary100 else Color.White,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shadowElevation = 1.dp,
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
val compact = maxWidth < 560.dp
|
||||
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
if (compact) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text("${row.rank ?: "-"}.", color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
Text(row.teamName.ifBlank { "-" }, color = Accent900, fontWeight = if (ourTeam) FontWeight.Bold else FontWeight.Normal)
|
||||
Movement(row.movement)
|
||||
}
|
||||
TableMetrics(row)
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("${row.rank ?: "-"}.", color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(55.dp))
|
||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text(row.teamName.ifBlank { "-" }, color = Accent900, fontWeight = if (ourTeam) FontWeight.Bold else FontWeight.Normal)
|
||||
Movement(row.movement)
|
||||
}
|
||||
TableStanding(row)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Sätze ${formatSets(row)} Bälle ${formatGames(row)}",
|
||||
color = Accent500,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Movement(value: String?) {
|
||||
when (value) {
|
||||
"rise" -> Text("↑", color = Color(0xFF15803D))
|
||||
"fall" -> Text("↓", color = Primary700)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableMetricsHeader() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TableCell("Sp.", 48.dp, Accent500)
|
||||
TableCell("S", 38.dp, Accent500)
|
||||
TableCell("U", 38.dp, Accent500)
|
||||
TableCell("N", 38.dp, Accent500)
|
||||
TableCell("Punkte", 66.dp, Accent500)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableStanding(row: LeagueTableRowDto) = TableMetrics(row)
|
||||
|
||||
@Composable
|
||||
private fun TableMetrics(row: LeagueTableRowDto) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TableCell((row.meetings ?: "-").toString(), 48.dp, Accent900)
|
||||
TableCell((row.won ?: 0).toString(), 38.dp, Accent900)
|
||||
TableCell((row.tied ?: 0).toString(), 38.dp, Accent900)
|
||||
TableCell((row.lost ?: 0).toString(), 38.dp, Accent900)
|
||||
TableCell(formatPoints(row), 66.dp, Accent900)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableCell(value: String, width: androidx.compose.ui.unit.Dp, color: Color) {
|
||||
Text(
|
||||
value,
|
||||
color = color,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.width(width),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackLink(navController: NavController, visible: Boolean) {
|
||||
if (visible) TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorPanel(message: String, retry: () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(message, color = Primary700)
|
||||
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun seasonLabel(value: String): String =
|
||||
Regex("^(\\d{2})--(\\d{2})$").matchEntire(value)?.let { "20${it.groupValues[1]}/${it.groupValues[2]}" } ?: value
|
||||
|
||||
private fun formatSets(row: LeagueTableRowDto): String = "${row.setsWon ?: 0}:${row.setsLost ?: 0}"
|
||||
private fun formatGames(row: LeagueTableRowDto): String = "${row.gamesWon ?: 0}:${row.gamesLost ?: 0}"
|
||||
private fun formatPoints(row: LeagueTableRowDto): String = "${row.pointsWon ?: 0}:${row.pointsLost ?: 0}"
|
||||
@@ -0,0 +1,139 @@
|
||||
package de.harheimertc.ui.screens.mannschaften
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.LeagueTableRowDto
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.SpielplanRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class MannschaftenUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val teams: List<Mannschaft> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MannschaftenViewModel @Inject constructor(
|
||||
private val repository: MannschaftenRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MannschaftenUiState())
|
||||
val state: StateFlow<MannschaftenUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = MannschaftenUiState(loading = true)
|
||||
repository.fetchMannschaften()
|
||||
.onSuccess { _state.value = MannschaftenUiState(loading = false, teams = it) }
|
||||
.onFailure { _state.value = MannschaftenUiState(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MannschaftDetailUiState(
|
||||
val loading: Boolean = true,
|
||||
val matchesError: String? = null,
|
||||
val team: Mannschaft? = null,
|
||||
val matches: List<SpielDto> = emptyList(),
|
||||
val season: String? = null,
|
||||
val tableLoading: Boolean = false,
|
||||
val tableError: String? = null,
|
||||
val tableRows: List<LeagueTableRowDto> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MannschaftDetailViewModel @Inject constructor(
|
||||
private val mannschaftenRepository: MannschaftenRepository,
|
||||
private val spielplanRepository: SpielplanRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MannschaftDetailUiState())
|
||||
val state: StateFlow<MannschaftDetailUiState> = _state
|
||||
private var loadedSlug: String? = null
|
||||
|
||||
fun load(slug: String) {
|
||||
if (loadedSlug == slug) return
|
||||
loadedSlug = slug
|
||||
viewModelScope.launch {
|
||||
_state.value = MannschaftDetailUiState(loading = true)
|
||||
val team = mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()).find { it.slug == slug }
|
||||
if (team == null) {
|
||||
_state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.")
|
||||
return@launch
|
||||
}
|
||||
spielplanRepository.fetchSpielplan()
|
||||
.onSuccess { plan ->
|
||||
_state.value = MannschaftDetailUiState(
|
||||
loading = false,
|
||||
team = team,
|
||||
matches = plan.data.filter { matchesTeam(it, team.mannschaft) },
|
||||
season = plan.season,
|
||||
)
|
||||
if (team.informationenLink.isNotBlank()) {
|
||||
loadTable(team, plan.season)
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = MannschaftDetailUiState(
|
||||
loading = false,
|
||||
team = team,
|
||||
matchesError = "Der aktuelle Spielplan konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadTable(team: Mannschaft, season: String?) {
|
||||
_state.value = _state.value.copy(tableLoading = true, tableError = null)
|
||||
spielplanRepository.fetchTeamTable(team.mannschaft, season)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
tableLoading = false,
|
||||
tableRows = response.table?.table?.leagueTable.orEmpty(),
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(
|
||||
tableLoading = false,
|
||||
tableError = "Tabelle konnte nicht geladen werden.",
|
||||
tableRows = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesTeam(game: SpielDto, cmsName: String): Boolean {
|
||||
val variant = when (cmsName) {
|
||||
"Erwachsene 1" -> "harheimer tc"
|
||||
"Erwachsene 2" -> "harheimer tc ii"
|
||||
"Erwachsene 3" -> "harheimer tc iii"
|
||||
"Erwachsene 4" -> "harheimer tc iv"
|
||||
"Erwachsene 5" -> "harheimer tc v"
|
||||
"Jugendmannschaft", "Jugend I" -> "harheimer tc"
|
||||
else -> return false
|
||||
}
|
||||
fun exact(value: String): Boolean =
|
||||
if (variant == "harheimer tc") {
|
||||
value == variant || (value.startsWith("$variant ") && !Regex("harheimer tc\\s+[ivx]+").containsMatchIn(value))
|
||||
} else value == variant || value.startsWith("$variant ")
|
||||
|
||||
val home = game.heimMannschaft.lowercase()
|
||||
val away = game.gastMannschaft.lowercase()
|
||||
if (!exact(home) && !exact(away)) return false
|
||||
return if (cmsName.startsWith("Erwachsene")) {
|
||||
(exact(home) && game.heimAltersklasse.contains("Erwachsene", true)) ||
|
||||
(exact(away) && game.gastAltersklasse.contains("Erwachsene", true))
|
||||
} else {
|
||||
game.heimAltersklasse.contains("Jugend", true) || game.gastAltersklasse.contains("Jugend", true) ||
|
||||
home.contains("jugend") || away.contains("jugend")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package de.harheimertc.ui.screens.memberarea
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.MemberDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.ui.components.RichText
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
@Composable
|
||||
fun MembersScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: MembersViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val query = state.query.trim()
|
||||
val members = state.members
|
||||
.filter { member ->
|
||||
query.isBlank() ||
|
||||
member.name.contains(query, ignoreCase = true) ||
|
||||
member.email.orEmpty().contains(query, ignoreCase = true)
|
||||
}
|
||||
.sortedWith(compareBy<MemberDto> { it.lastName.ifBlank { it.name } }.thenBy { it.firstName })
|
||||
|
||||
MemberAreaPage(navController, showBackNavigation, "Mitgliederliste", "Kontaktdaten der Vereinsmitglieder") {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.query,
|
||||
onValueChange = viewModel::updateQuery,
|
||||
label = { Text("Suchen") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
when {
|
||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||
members.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) }
|
||||
else -> items(members.size) { index -> MemberCard(members[index]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MemberNewsScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: MemberNewsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
MemberAreaPage(navController, showBackNavigation, "News", "Neuigkeiten und Ankündigungen im Mitgliederbereich") {
|
||||
when {
|
||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||
state.news.isEmpty() -> item { Text("Noch keine News vorhanden.", color = Accent700) }
|
||||
else -> items(state.news.size) { index -> NewsCard(state.news[index]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) {
|
||||
val groups = listOf(
|
||||
"Authentifizierung" to listOf("POST /api/auth/login", "POST /api/auth/refresh", "GET /api/auth/status", "POST /api/auth/logout"),
|
||||
"Mitgliederbereich" to listOf("GET /api/members", "GET /api/news", "GET /api/profile", "PUT /api/profile"),
|
||||
"CMS" to listOf("GET /api/cms/users/list", "GET /api/cms/contact-requests", "GET /api/newsletter/list", "GET /api/config"),
|
||||
)
|
||||
MemberAreaPage(navController, showBackNavigation, "API-Dokumentation", "Kurzüberblick der wichtigsten App-Endpunkte") {
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) {
|
||||
Text(
|
||||
"Android nutzt Authorization: Bearer <Access-Token>. Abgelaufene Tokens werden über /api/auth/refresh automatisch erneuert.",
|
||||
color = Primary900,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(groups.size) { index ->
|
||||
val (title, endpoints) = groups[index]
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
endpoints.forEach { endpoint ->
|
||||
Surface(color = Accent100, shape = RoundedCornerShape(8.dp)) {
|
||||
Text(endpoint, color = Accent900, modifier = Modifier.fillMaxWidth().padding(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberAreaPage(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberCard(member: MemberDto) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600)
|
||||
if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700)
|
||||
if (!member.birthday.isNullOrBlank()) Text("Geburtstag: ${member.birthday}", color = Accent500)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Badge(if (member.hasLogin) "Login" else member.source.ifBlank { "Mitglied" })
|
||||
if (member.isMannschaftsspieler) Badge("Mannschaft")
|
||||
if (member.hasHallKey) Badge("Hallenschlüssel")
|
||||
}
|
||||
if (member.email.isNullOrBlank() && member.phone.isNullOrBlank()) {
|
||||
Text("Kontaktdaten sind für dich nicht freigegeben.", color = Accent500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsCard(item: NewsDto) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(listOfNotNull(item.author, item.created).joinToString(" | "), color = Accent500)
|
||||
if (item.isPublic || item.isHidden) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (item.isPublic) Badge("Öffentlich")
|
||||
if (item.isHidden) Badge("Ausgeblendet")
|
||||
}
|
||||
}
|
||||
RichText(item.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Badge(label: String) {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(20.dp)) {
|
||||
Text(label, color = Primary600, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorCard(message: String, onRetry: () -> Unit) {
|
||||
Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(12.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(message, color = Color(0xFF991B1B))
|
||||
Button(onClick = onRetry) { Text("Erneut laden") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.harheimertc.ui.screens.memberarea
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.MemberDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.repositories.MemberAreaRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class MembersUiState(
|
||||
val members: List<MemberDto> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val query: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MembersViewModel @Inject constructor(
|
||||
private val repository: MemberAreaRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MembersUiState())
|
||||
val state: StateFlow<MembersUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun updateQuery(query: String) {
|
||||
_state.value = _state.value.copy(query = query)
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
repository.members()
|
||||
.onSuccess { response -> _state.value = _state.value.copy(members = response.members, loading = false) }
|
||||
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Mitglieder konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MemberNewsUiState(
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MemberNewsViewModel @Inject constructor(
|
||||
private val repository: MemberAreaRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MemberNewsUiState())
|
||||
val state: StateFlow<MemberNewsUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
repository.news()
|
||||
.onSuccess { response -> _state.value = _state.value.copy(news = response.news, loading = false) }
|
||||
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "News konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package de.harheimertc.ui.screens.memberarea
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.BirthdayDto
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
fun MemberAreaScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: MemberAreaViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text("Mitgliederbereich", style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Text("Alles Wichtige für Vereinsmitglieder.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
MemberAreaCardGrid(navController)
|
||||
}
|
||||
|
||||
item {
|
||||
BirthdayCard(
|
||||
birthdays = state.birthdays,
|
||||
loading = state.loadingBirthdays,
|
||||
error = state.birthdayError,
|
||||
onRetry = viewModel::loadBirthdays,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberAreaCardGrid(navController: NavController) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
MemberAreaCard(
|
||||
title = "Mein Profil",
|
||||
description = "Persönliche Daten und Passwort verwalten",
|
||||
marker = "P",
|
||||
onClick = { navController.navigate(Destinations.Profile.route) },
|
||||
)
|
||||
MemberAreaCard(
|
||||
title = "Mitglieder",
|
||||
description = "Kontaktdaten der Vereinsmitglieder",
|
||||
marker = "M",
|
||||
onClick = { navController.navigate(Destinations.Members.route) },
|
||||
)
|
||||
MemberAreaCard(
|
||||
title = "News",
|
||||
description = "Neuigkeiten und Ankündigungen",
|
||||
marker = "N",
|
||||
onClick = { navController.navigate(Destinations.MemberNews.route) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberAreaCard(title: String, description: String, marker: String, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(18.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(48.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(marker, color = Primary600, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(description, color = Accent700, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BirthdayCard(
|
||||
birthdays: List<BirthdayDto>,
|
||||
loading: Boolean,
|
||||
error: String?,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(color = Color(0xFFFCE7F3), shape = RoundedCornerShape(10.dp), modifier = Modifier.size(48.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("G", color = Color(0xFFDB2777), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Text("Geburtstage (nächste 4 Wochen)", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
}
|
||||
|
||||
when {
|
||||
loading -> {
|
||||
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
|
||||
Text("Lade...", color = Accent500)
|
||||
}
|
||||
error != null -> {
|
||||
Text(error, color = MaterialTheme.colorScheme.error)
|
||||
TextButton(onClick = onRetry) { Text("Erneut laden") }
|
||||
}
|
||||
birthdays.isEmpty() -> Text("Keine Geburtstage in den nächsten 4 Wochen.", color = Accent700)
|
||||
else -> birthdays.forEach { birthday -> BirthdayRow(birthday) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BirthdayRow(birthday: BirthdayDto) {
|
||||
Surface(color = Accent100, shape = RoundedCornerShape(9.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(birthday.name, color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
Text(birthday.dayMonth, color = Accent500, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
Text(relativeBirthdayLabel(birthday.inDays), color = Accent500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun relativeBirthdayLabel(inDays: Int): String = when (inDays) {
|
||||
0 -> "Heute"
|
||||
1 -> "Morgen"
|
||||
else -> "in $inDays Tagen"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.harheimertc.ui.screens.memberarea
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.BirthdayDto
|
||||
import de.harheimertc.repositories.MemberAreaRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class MemberAreaUiState(
|
||||
val birthdays: List<BirthdayDto> = emptyList(),
|
||||
val loadingBirthdays: Boolean = true,
|
||||
val birthdayError: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MemberAreaViewModel @Inject constructor(
|
||||
private val repository: MemberAreaRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MemberAreaUiState())
|
||||
val state: StateFlow<MemberAreaUiState> = _state
|
||||
|
||||
init {
|
||||
loadBirthdays()
|
||||
}
|
||||
|
||||
fun loadBirthdays() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loadingBirthdays = true, birthdayError = null)
|
||||
repository.birthdays()
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
birthdays = response.birthdays,
|
||||
loadingBirthdays = false,
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(
|
||||
loadingBirthdays = false,
|
||||
birthdayError = it.message ?: "Geburtstage konnten nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package de.harheimertc.ui.screens.membership
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
@Composable
|
||||
fun MembershipScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: MembershipViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val form = state.form
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text("Mitgliedschaft", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 14.dp))
|
||||
Text(
|
||||
"Werden Sie Teil unserer Tischtennis-Familie.",
|
||||
color = Accent500,
|
||||
modifier = Modifier.padding(top = 7.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
item {
|
||||
InfoCard(
|
||||
title = "Vereinssatzung",
|
||||
text = "Unsere aktuelle Vereinssatzung und der Mitgliedsantrag stehen auf der Website als PDF bereit.",
|
||||
)
|
||||
}
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(13.dp)) {
|
||||
Text("Beitrittserklärung", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
FormHeading("Persönliche Daten")
|
||||
TextInput("Vorname *", form.vorname, error = state.fieldErrors["vorname"]) { viewModel.update(form.copy(vorname = it)) }
|
||||
TextInput("Nachname *", form.nachname, error = state.fieldErrors["nachname"]) { viewModel.update(form.copy(nachname = it)) }
|
||||
TextInput("Straße und Hausnummer *", form.strasse, error = state.fieldErrors["strasse"]) { viewModel.update(form.copy(strasse = it)) }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number, state.fieldErrors["plz"]) { viewModel.update(form.copy(plz = it)) }
|
||||
TextInput("Wohnort *", form.ort, Modifier.weight(0.62f), error = state.fieldErrors["ort"]) { viewModel.update(form.copy(ort = it)) }
|
||||
}
|
||||
TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum, error = state.fieldErrors["geburtsdatum"]) { viewModel.update(form.copy(geburtsdatum = it)) }
|
||||
TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email, error = state.fieldErrors["email"]) { viewModel.update(form.copy(email = it)) }
|
||||
TextInput("Telefon (Mobil)", form.telefon, keyboard = KeyboardType.Phone) { viewModel.update(form.copy(telefon = it)) }
|
||||
FormHeading("Mitgliedschaftsart")
|
||||
ChoiceRow("Aktives Mitglied", form.art == "aktiv") { viewModel.update(form.copy(art = "aktiv")) }
|
||||
ChoiceRow("Passives Mitglied", form.art == "passiv") { viewModel.update(form.copy(art = "passiv")) }
|
||||
FeeInfo()
|
||||
AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift, state.fieldErrors["lastschrift"]) {
|
||||
viewModel.update(form.copy(lastschrift = it))
|
||||
}
|
||||
FormHeading("Bankdaten für SEPA-Lastschrift")
|
||||
TextInput("Kontoinhaber *", form.kontoinhaber, error = state.fieldErrors["kontoinhaber"]) { viewModel.update(form.copy(kontoinhaber = it)) }
|
||||
TextInput("IBAN *", form.iban, error = state.fieldErrors["iban"]) { viewModel.update(form.copy(iban = it)) }
|
||||
TextInput("BIC", form.bic) { viewModel.update(form.copy(bic = it)) }
|
||||
TextInput("Kreditinstitut", form.bank) { viewModel.update(form.copy(bank = it)) }
|
||||
FormHeading("Datenschutz und Vereinssatzung")
|
||||
AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz, state.fieldErrors["datenschutz"]) {
|
||||
viewModel.update(form.copy(datenschutz = it))
|
||||
}
|
||||
AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung, state.fieldErrors["satzung"]) {
|
||||
viewModel.update(form.copy(satzung = it))
|
||||
}
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
state.message?.let { Text(it, color = Color(0xFF166534), fontWeight = FontWeight.SemiBold) }
|
||||
Button(onClick = viewModel::submit, enabled = !state.sending, modifier = Modifier.fillMaxWidth()) {
|
||||
if (state.sending) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = Color.White)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(if (state.sending) "Formular wird erstellt..." else "Beitrittsformular erstellen")
|
||||
}
|
||||
state.pdfUri?.let { uri ->
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(Uri.parse(uri), "application/pdf")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Erstelltes PDF öffnen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(14.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Noch Fragen zur Mitgliedschaft?", color = Color.White, style = MaterialTheme.typography.titleLarge)
|
||||
Text("Kontaktieren Sie uns - wir beraten Sie gerne persönlich.", color = Primary100, modifier = Modifier.padding(vertical = 12.dp))
|
||||
OutlinedButton(onClick = { navController.navigate(Destinations.Contact.route) }) {
|
||||
Text("Jetzt Kontakt aufnehmen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormHeading(text: String) {
|
||||
Text(text, fontWeight = FontWeight.SemiBold, color = Accent900, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextInput(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier.fillMaxWidth(),
|
||||
keyboard: KeyboardType = KeyboardType.Text,
|
||||
error: String? = null,
|
||||
onChange: (String) -> Unit,
|
||||
) {
|
||||
ValidatedTextField(
|
||||
value = value,
|
||||
onValueChange = onChange,
|
||||
label = label,
|
||||
error = error,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboard),
|
||||
modifier = modifier,
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceRow(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
Text(label, color = Accent700)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgreementRow(label: String, selected: Boolean, error: String? = null, onChange: (Boolean) -> Unit) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Checkbox(checked = selected, onCheckedChange = onChange)
|
||||
Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeeInfo() {
|
||||
Surface(color = Color(0xFFF4F4F5), shape = RoundedCornerShape(8.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Jährlicher Mitgliedsbeitrag", fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
Text("120 EUR Erwachsene | 72 EUR Jugendliche | 30 EUR passive Mitglieder", color = Accent700)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoCard(title: String, text: String) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(text, color = Accent500, modifier = Modifier.padding(top = 7.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package de.harheimertc.ui.screens.membership
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.MembershipRequest
|
||||
import de.harheimertc.repositories.MembershipRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import de.harheimertc.ui.components.isValidIsoDate
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class MembershipFormState(
|
||||
val vorname: String = "",
|
||||
val nachname: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
val geburtsdatum: String = "",
|
||||
val email: String = "",
|
||||
val telefon: String = "",
|
||||
val art: String = "aktiv",
|
||||
val kontoinhaber: String = "",
|
||||
val iban: String = "",
|
||||
val bic: String = "",
|
||||
val bank: String = "",
|
||||
val lastschrift: Boolean = false,
|
||||
val datenschutz: Boolean = false,
|
||||
val satzung: Boolean = false,
|
||||
)
|
||||
|
||||
data class MembershipUiState(
|
||||
val form: MembershipFormState = MembershipFormState(),
|
||||
val fieldErrors: Map<String, String> = emptyMap(),
|
||||
val sending: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null,
|
||||
val pdfUri: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MembershipViewModel @Inject constructor(private val repository: MembershipRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MembershipUiState())
|
||||
val state: StateFlow<MembershipUiState> = _state
|
||||
|
||||
fun update(form: MembershipFormState) {
|
||||
_state.value = _state.value.copy(form = form, fieldErrors = _state.value.fieldErrors - changedKeys(_state.value.form, form), error = null)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val form = _state.value.form
|
||||
val fieldErrors = validateFields(form)
|
||||
if (fieldErrors.isNotEmpty()) {
|
||||
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sending = true, error = null, message = null)
|
||||
val request = MembershipRequest(
|
||||
vorname = form.vorname.trim(),
|
||||
nachname = form.nachname.trim(),
|
||||
strasse = form.strasse.trim(),
|
||||
plz = form.plz.trim(),
|
||||
ort = form.ort.trim(),
|
||||
geburtsdatum = form.geburtsdatum.trim(),
|
||||
email = form.email.trim(),
|
||||
telefon_mobil = form.telefon.trim().takeIf(String::isNotBlank),
|
||||
mitgliedschaftsart = form.art,
|
||||
lastschrift_erlaubt = form.lastschrift,
|
||||
kontoinhaber = form.kontoinhaber.trim(),
|
||||
iban = form.iban.trim(),
|
||||
bic = form.bic.trim().takeIf(String::isNotBlank),
|
||||
bank = form.bank.trim().takeIf(String::isNotBlank),
|
||||
datenschutz_einverstanden = form.datenschutz,
|
||||
satzung_anerkannt = form.satzung,
|
||||
)
|
||||
repository.submit(request)
|
||||
.onSuccess { document ->
|
||||
_state.value = _state.value.copy(sending = false, fieldErrors = emptyMap(), message = document.message, pdfUri = document.uri)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(sending = false, error = "Beitrittsformular konnte nicht erstellt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateFields(form: MembershipFormState): Map<String, String> = buildMap {
|
||||
if (form.vorname.isBlank()) put("vorname", "Bitte geben Sie den Vornamen ein.")
|
||||
if (form.nachname.isBlank()) put("nachname", "Bitte geben Sie den Nachnamen ein.")
|
||||
if (form.strasse.isBlank()) put("strasse", "Bitte geben Sie Straße und Hausnummer ein.")
|
||||
if (!form.plz.matches(Regex("\\d{5}"))) put("plz", "Bitte eine gültige fünfstellige PLZ eingeben.")
|
||||
if (form.ort.isBlank()) put("ort", "Bitte geben Sie den Wohnort ein.")
|
||||
if (!isValidIsoDate(form.geburtsdatum)) put("geburtsdatum", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
|
||||
if (!isValidEmail(form.email)) put("email", "Bitte eine gültige E-Mail-Adresse eingeben.")
|
||||
if (form.kontoinhaber.isBlank()) put("kontoinhaber", "Bitte geben Sie den Kontoinhaber ein.")
|
||||
if (form.iban.filterNot(Char::isWhitespace).length < 15) put("iban", "Bitte geben Sie eine gültige IBAN ein.")
|
||||
if (!form.lastschrift) put("lastschrift", "Das SEPA-Lastschriftmandat ist erforderlich.")
|
||||
if (!form.datenschutz) put("datenschutz", "Die Datenschutzeinwilligung ist erforderlich.")
|
||||
if (!form.satzung) put("satzung", "Die Anerkennung der Satzung ist erforderlich.")
|
||||
}
|
||||
|
||||
private fun changedKeys(previous: MembershipFormState, next: MembershipFormState): Set<String> = buildSet {
|
||||
if (previous.vorname != next.vorname) add("vorname")
|
||||
if (previous.nachname != next.nachname) add("nachname")
|
||||
if (previous.strasse != next.strasse) add("strasse")
|
||||
if (previous.plz != next.plz) add("plz")
|
||||
if (previous.ort != next.ort) add("ort")
|
||||
if (previous.geburtsdatum != next.geburtsdatum) add("geburtsdatum")
|
||||
if (previous.email != next.email) add("email")
|
||||
if (previous.kontoinhaber != next.kontoinhaber) add("kontoinhaber")
|
||||
if (previous.iban != next.iban) add("iban")
|
||||
if (previous.lastschrift != next.lastschrift) add("lastschrift")
|
||||
if (previous.datenschutz != next.datenschutz) add("datenschutz")
|
||||
if (previous.satzung != next.satzung) add("satzung")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package de.harheimertc.ui.screens.newsletter
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
@Composable
|
||||
fun NewsletterSubscribeScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: NewsletterViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
NewsletterFormScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = showBackNavigation,
|
||||
title = "Newsletter abonnieren",
|
||||
buttonLabel = "Newsletter abonnieren",
|
||||
state = state,
|
||||
onUpdate = viewModel::update,
|
||||
onSubmit = viewModel::subscribe,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewsletterUnsubscribeScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: NewsletterViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
NewsletterFormScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = showBackNavigation,
|
||||
title = "Newsletter abmelden",
|
||||
buttonLabel = "Newsletter abmelden",
|
||||
state = state,
|
||||
onUpdate = viewModel::update,
|
||||
onSubmit = viewModel::unsubscribe,
|
||||
showName = false,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewsletterConfirmScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
token: String?,
|
||||
viewModel: NewsletterConfirmViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
LaunchedEffect(token) {
|
||||
viewModel.confirm(token.orEmpty())
|
||||
}
|
||||
NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigen") {
|
||||
when {
|
||||
state.loading -> {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
Text("Newsletter-Anmeldung wird bestätigt...", color = Accent700)
|
||||
}
|
||||
state.error != null -> {
|
||||
Text("Fehler", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(state.error.orEmpty(), color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center)
|
||||
Button(onClick = { navController.navigate(Destinations.NewsletterSubscribe.route) }) { Text("Zur Anmeldung") }
|
||||
}
|
||||
else -> {
|
||||
Text("Anmeldung bestätigt", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(state.message ?: "Vielen Dank. Ihre Newsletter-Anmeldung wurde bestätigt.", color = Accent700, textAlign = TextAlign.Center)
|
||||
Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewsletterConfirmedScreen(navController: NavController, showBackNavigation: Boolean) {
|
||||
NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigt") {
|
||||
Text("Anmeldung bestätigt", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text("Vielen Dank. Sie erhalten ab sofort unseren Newsletter.", color = Accent700, textAlign = TextAlign.Center)
|
||||
Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewsletterUnsubscribedScreen(navController: NavController, showBackNavigation: Boolean) {
|
||||
NewsletterStatusPage(navController, showBackNavigation, "Newsletter abgemeldet") {
|
||||
Text("Erfolgreich abgemeldet", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text("Sie erhalten keine weiteren Newsletter dieser Auswahl.", color = Accent700, textAlign = TextAlign.Center)
|
||||
Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterFormScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
buttonLabel: String,
|
||||
state: NewsletterUiState,
|
||||
onUpdate: (NewsletterFormState) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
showName: Boolean = true,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Text("Wählen Sie einen Newsletter und geben Sie Ihre E-Mail-Adresse ein.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
if (state.loading) {
|
||||
item { CircularProgressIndicator(color = Primary600) }
|
||||
} else {
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("Newsletter auswählen", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
state.groups.forEach { group ->
|
||||
NewsletterGroupOption(group, selected = group.id == state.form.selectedGroupId) {
|
||||
onUpdate(state.form.copy(selectedGroupId = group.id))
|
||||
}
|
||||
}
|
||||
state.fieldErrors["selectedGroupId"]?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
ValidatedTextField(
|
||||
value = state.form.email,
|
||||
onValueChange = { onUpdate(state.form.copy(email = it)) },
|
||||
label = "E-Mail-Adresse",
|
||||
error = state.fieldErrors["email"],
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true,
|
||||
)
|
||||
if (showName) {
|
||||
ValidatedTextField(
|
||||
value = state.form.name,
|
||||
onValueChange = { onUpdate(state.form.copy(name = it)) },
|
||||
label = "Name (optional)",
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
state.message?.let { Text(it, color = Color(0xFF166534)) }
|
||||
Button(onClick = onSubmit, enabled = !state.submitting && state.groups.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (state.submitting) "Wird verarbeitet..." else buttonLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterGroupOption(group: NewsletterGroupDto, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = if (selected) Primary100 else Accent100,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(if (selected) "✓" else "○", color = Primary600)
|
||||
Text(group.name, color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
if (group.description.isNotBlank()) Text(group.description, color = Accent700)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterStatusPage(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
}
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
|
||||
Text("Harheimer TC Newsletter", color = Primary900, modifier = Modifier.fillMaxWidth().padding(16.dp), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.harheimertc.ui.screens.newsletter
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.repositories.NewsletterRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class NewsletterFormState(
|
||||
val selectedGroupId: String = "",
|
||||
val email: String = "",
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
data class NewsletterUiState(
|
||||
val groups: List<NewsletterGroupDto> = emptyList(),
|
||||
val form: NewsletterFormState = NewsletterFormState(),
|
||||
val fieldErrors: Map<String, String> = emptyMap(),
|
||||
val loading: Boolean = true,
|
||||
val submitting: Boolean = false,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class NewsletterViewModel @Inject constructor(
|
||||
private val repository: NewsletterRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NewsletterUiState())
|
||||
val state: StateFlow<NewsletterUiState> = _state
|
||||
|
||||
init {
|
||||
loadGroups()
|
||||
}
|
||||
|
||||
fun loadGroups() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
repository.groups()
|
||||
.onSuccess { response -> _state.value = _state.value.copy(groups = response.groups, loading = false) }
|
||||
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Newsletter konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun update(form: NewsletterFormState) {
|
||||
_state.value = _state.value.copy(
|
||||
form = form,
|
||||
fieldErrors = _state.value.fieldErrors.filterKeys { key ->
|
||||
when (key) {
|
||||
"selectedGroupId" -> form.selectedGroupId.isBlank()
|
||||
"email" -> form.email.isBlank() || !isValidEmail(form.email)
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
error = null,
|
||||
message = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun subscribe() = submit { groupId, email, name -> repository.subscribe(groupId, email, name) }
|
||||
|
||||
fun unsubscribe() = submit { groupId, email, _ -> repository.unsubscribe(groupId, email) }
|
||||
|
||||
private fun submit(action: suspend (String, String, String) -> Result<de.harheimertc.data.AuthMessageResponse>) {
|
||||
val current = _state.value
|
||||
val form = current.form
|
||||
val fieldErrors = buildMap {
|
||||
if (form.selectedGroupId.isBlank()) put("selectedGroupId", "Bitte Newsletter auswählen.")
|
||||
if (!isValidEmail(form.email)) put("email", "Bitte gültige E-Mail-Adresse eingeben.")
|
||||
}
|
||||
if (fieldErrors.isNotEmpty()) {
|
||||
_state.value = current.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = current.copy(submitting = true, error = null, message = null)
|
||||
action(form.selectedGroupId, form.email, form.name)
|
||||
.onSuccess { response ->
|
||||
_state.value = current.copy(
|
||||
form = NewsletterFormState(),
|
||||
fieldErrors = emptyMap(),
|
||||
submitting = false,
|
||||
loading = false,
|
||||
groups = current.groups,
|
||||
message = response.message ?: "Vorgang erfolgreich.",
|
||||
)
|
||||
}
|
||||
.onFailure { _state.value = current.copy(submitting = false, error = it.message ?: "Vorgang fehlgeschlagen.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NewsletterConfirmUiState(
|
||||
val loading: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class NewsletterConfirmViewModel @Inject constructor(
|
||||
private val repository: NewsletterRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NewsletterConfirmUiState())
|
||||
val state: StateFlow<NewsletterConfirmUiState> = _state
|
||||
|
||||
fun confirm(token: String) {
|
||||
if (token.isBlank()) {
|
||||
_state.value = NewsletterConfirmUiState(error = "Bestätigungstoken fehlt.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = NewsletterConfirmUiState(loading = true)
|
||||
repository.confirm(token)
|
||||
.onSuccess { _state.value = NewsletterConfirmUiState(message = it.message ?: "Newsletter-Anmeldung bestätigt.") }
|
||||
.onFailure { _state.value = NewsletterConfirmUiState(error = it.message ?: "Bestätigung fehlgeschlagen.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package de.harheimertc.ui.screens.profile
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: ProfileViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val form = state.form
|
||||
val context = LocalContext.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text("Mein Profil", style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Text("Kontaktdaten, Sichtbarkeit und Passwort verwalten.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
item {
|
||||
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
ProfileCard("Persönliche Daten") {
|
||||
ValidatedTextField(
|
||||
value = form.name,
|
||||
onValueChange = { viewModel.update(form.copy(name = it)) },
|
||||
label = "Name",
|
||||
error = state.fieldErrors["name"],
|
||||
singleLine = true,
|
||||
)
|
||||
ValidatedTextField(
|
||||
value = form.email,
|
||||
onValueChange = { viewModel.update(form.copy(email = it)) },
|
||||
label = "E-Mail",
|
||||
error = state.fieldErrors["email"],
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true,
|
||||
)
|
||||
ValidatedTextField(
|
||||
value = form.phone,
|
||||
onValueChange = { viewModel.update(form.copy(phone = it)) },
|
||||
label = "Telefon",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
singleLine = true,
|
||||
)
|
||||
ValidatedTextField(
|
||||
value = form.birthDate,
|
||||
onValueChange = { viewModel.update(form.copy(birthDate = it)) },
|
||||
label = "Geburtsdatum (JJJJ-MM-TT)",
|
||||
error = state.fieldErrors["birthDate"],
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ProfileCard("Sichtbarkeit im Mitgliederbereich") {
|
||||
VisibilityRow("E-Mail anzeigen", form.showEmail) { viewModel.update(form.copy(showEmail = it)) }
|
||||
VisibilityRow("Telefon anzeigen", form.showPhone) { viewModel.update(form.copy(showPhone = it)) }
|
||||
VisibilityRow("Adresse anzeigen", form.showAddress) { viewModel.update(form.copy(showAddress = it)) }
|
||||
VisibilityRow("Geburtstag anzeigen", form.showBirthday) { viewModel.update(form.copy(showBirthday = it)) }
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ProfileCard("Passwort ändern") {
|
||||
ValidatedTextField(
|
||||
value = form.currentPassword,
|
||||
onValueChange = { viewModel.update(form.copy(currentPassword = it)) },
|
||||
label = "Aktuelles Passwort",
|
||||
error = state.fieldErrors["currentPassword"],
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
)
|
||||
ValidatedTextField(
|
||||
value = form.newPassword,
|
||||
onValueChange = { viewModel.update(form.copy(newPassword = it)) },
|
||||
label = "Neues Passwort",
|
||||
error = state.fieldErrors["newPassword"],
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
)
|
||||
ValidatedTextField(
|
||||
value = form.confirmPassword,
|
||||
onValueChange = { viewModel.update(form.copy(confirmPassword = it)) },
|
||||
label = "Neues Passwort wiederholen",
|
||||
error = state.fieldErrors["confirmPassword"],
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
)
|
||||
Text("Leer lassen, wenn das Passwort unverändert bleiben soll.", color = Accent500)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ProfileCard("Passkeys") {
|
||||
Text(
|
||||
"Passkeys ermöglichen eine Anmeldung ohne Passwort über den Android Credential Manager.",
|
||||
color = Accent500,
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.addPasskey(context) },
|
||||
enabled = !state.passkeyLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.passkeyLoading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 8.dp))
|
||||
Text(if (state.passkeyLoading) "Passkey wird erstellt..." else "Passkey hinzufügen")
|
||||
}
|
||||
if (state.passkeys.isEmpty()) {
|
||||
Text("Noch kein Passkey hinterlegt.", color = Accent500)
|
||||
} else {
|
||||
state.passkeys.forEach { passkey ->
|
||||
Surface(color = Color(0xFFF4F4F5), shape = RoundedCornerShape(9.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(passkey.name.ifBlank { "Passkey" }, color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
passkey.createdAt?.let { Text("Erstellt: $it", color = Accent500) }
|
||||
passkey.lastUsedAt?.let { Text("Zuletzt genutzt: $it", color = Accent500) }
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.removePasskey(passkey.credentialId) },
|
||||
enabled = !state.passkeyLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Entfernen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Button(
|
||||
onClick = viewModel::save,
|
||||
enabled = !state.saving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.saving) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 8.dp))
|
||||
Text(if (state.saving) "Speichert..." else "Profil speichern")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let { message ->
|
||||
item { Text(message, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
state.message?.let { message ->
|
||||
item { Text(message, color = Color(0xFF166534)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileCard(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VisibilityRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
|
||||
Text(label, color = Accent900)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package de.harheimertc.ui.screens.profile
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.harheimertc.data.PasskeyDto
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ProfileUpdateRequest
|
||||
import de.harheimertc.data.ProfileVisibilityDto
|
||||
import de.harheimertc.repositories.ProfileRepository
|
||||
import de.harheimertc.repositories.PasskeyRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import de.harheimertc.ui.components.isValidIsoDate
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ProfileFormState(
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val phone: String = "",
|
||||
val birthDate: String = "",
|
||||
val showEmail: Boolean = true,
|
||||
val showPhone: Boolean = true,
|
||||
val showAddress: Boolean = false,
|
||||
val showBirthday: Boolean = true,
|
||||
val currentPassword: String = "",
|
||||
val newPassword: String = "",
|
||||
val confirmPassword: String = "",
|
||||
)
|
||||
|
||||
data class ProfileUiState(
|
||||
val form: ProfileFormState = ProfileFormState(),
|
||||
val passkeys: List<PasskeyDto> = emptyList(),
|
||||
val fieldErrors: Map<String, String> = emptyMap(),
|
||||
val loading: Boolean = true,
|
||||
val saving: Boolean = false,
|
||||
val passkeyLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ProfileViewModel @Inject constructor(
|
||||
private val repository: ProfileRepository,
|
||||
private val passkeyRepository: PasskeyRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(ProfileUiState())
|
||||
val state: StateFlow<ProfileUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null, message = null)
|
||||
repository.load()
|
||||
.onSuccess { response ->
|
||||
val user = response.user
|
||||
val visibility = user?.visibility ?: ProfileVisibilityDto()
|
||||
_state.value = ProfileUiState(
|
||||
loading = false,
|
||||
form = ProfileFormState(
|
||||
name = user?.name.orEmpty(),
|
||||
email = user?.email.orEmpty(),
|
||||
phone = user?.phone.orEmpty(),
|
||||
birthDate = user?.geburtsdatum.orEmpty(),
|
||||
showEmail = visibility.showEmail,
|
||||
showPhone = visibility.showPhone,
|
||||
showAddress = visibility.showAddress,
|
||||
showBirthday = visibility.showBirthday,
|
||||
),
|
||||
)
|
||||
loadPasskeys()
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = it.message ?: "Profil konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPasskeys() {
|
||||
viewModelScope.launch {
|
||||
passkeyRepository.list()
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(passkeys = response.passkeys, passkeyLoading = false)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(
|
||||
passkeyLoading = false,
|
||||
error = it.message ?: "Passkeys konnten nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addPasskey(context: Context) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(passkeyLoading = true, error = null, message = null)
|
||||
passkeyRepository.add(context)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
passkeyLoading = false,
|
||||
message = response.message ?: "Passkey hinzugefügt.",
|
||||
)
|
||||
loadPasskeys()
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(
|
||||
passkeyLoading = false,
|
||||
error = it.message ?: "Passkey konnte nicht hinzugefügt werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removePasskey(credentialId: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(passkeyLoading = true, error = null, message = null)
|
||||
passkeyRepository.remove(credentialId)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
passkeyLoading = false,
|
||||
message = response.message ?: "Passkey entfernt.",
|
||||
)
|
||||
loadPasskeys()
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(
|
||||
passkeyLoading = false,
|
||||
error = it.message ?: "Passkey konnte nicht entfernt werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun update(form: ProfileFormState) {
|
||||
_state.value = _state.value.copy(form = form, fieldErrors = emptyMap(), error = null, message = null)
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val form = _state.value.form
|
||||
val fieldErrors = buildMap {
|
||||
if (form.name.isBlank()) put("name", "Bitte geben Sie Ihren Namen ein.")
|
||||
if (!isValidEmail(form.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||
if (form.birthDate.isNotBlank() && !isValidIsoDate(form.birthDate)) put("birthDate", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
|
||||
if (form.currentPassword.isNotBlank() || form.newPassword.isNotBlank() || form.confirmPassword.isNotBlank()) {
|
||||
if (form.currentPassword.isBlank()) put("currentPassword", "Bitte geben Sie Ihr aktuelles Passwort ein.")
|
||||
if (form.newPassword.isBlank()) put("newPassword", "Bitte geben Sie ein neues Passwort ein.")
|
||||
else if (form.newPassword.length < 6) put("newPassword", "Das neue Passwort muss mindestens 6 Zeichen lang sein.")
|
||||
if (form.newPassword != form.confirmPassword) put("confirmPassword", "Die neuen Passwörter stimmen nicht überein.")
|
||||
}
|
||||
}
|
||||
if (fieldErrors.isNotEmpty()) {
|
||||
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.", message = null)
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.save(
|
||||
ProfileUpdateRequest(
|
||||
name = form.name.trim(),
|
||||
email = form.email.trim(),
|
||||
phone = form.phone.trim().takeIf(String::isNotBlank),
|
||||
geburtsdatum = form.birthDate.trim().takeIf(String::isNotBlank),
|
||||
visibility = ProfileVisibilityDto(
|
||||
showEmail = form.showEmail,
|
||||
showPhone = form.showPhone,
|
||||
showAddress = form.showAddress,
|
||||
showBirthday = form.showBirthday,
|
||||
),
|
||||
currentPassword = form.currentPassword.takeIf(String::isNotBlank),
|
||||
newPassword = form.newPassword.takeIf(String::isNotBlank),
|
||||
),
|
||||
).onSuccess { response ->
|
||||
val current = _state.value
|
||||
val next = response.user
|
||||
val visibility = next?.visibility ?: ProfileVisibilityDto(
|
||||
form.showEmail,
|
||||
form.showPhone,
|
||||
form.showAddress,
|
||||
form.showBirthday,
|
||||
)
|
||||
_state.value = ProfileUiState(
|
||||
loading = false,
|
||||
message = response.message ?: "Profil erfolgreich aktualisiert.",
|
||||
passkeys = current.passkeys,
|
||||
fieldErrors = emptyMap(),
|
||||
form = form.copy(
|
||||
name = next?.name ?: form.name.trim(),
|
||||
email = next?.email ?: form.email.trim(),
|
||||
phone = next?.phone ?: form.phone.trim(),
|
||||
birthDate = next?.geburtsdatum ?: form.birthDate.trim(),
|
||||
showEmail = visibility.showEmail,
|
||||
showPhone = visibility.showPhone,
|
||||
showAddress = visibility.showAddress,
|
||||
showBirthday = visibility.showBirthday,
|
||||
currentPassword = "",
|
||||
newPassword = "",
|
||||
confirmPassword = "",
|
||||
),
|
||||
)
|
||||
}.onFailure {
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
error = it.message ?: "Profil konnte nicht gespeichert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.harheimertc.ui.screens.publicpages
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
fun ImpressumScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
PublicPage(navController, showBackNavigation, "Impressum") {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
|
||||
state.config != null -> {
|
||||
val config = state.config
|
||||
item { ImpressumContent(config!!, onOpenSatzung = { navController.navigate(de.harheimertc.ui.navigation.Destinations.Satzung.route) }, onMail = { context.openPublicUri("mailto:$it") }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImpressumContent(config: ConfigResponse, onOpenSatzung: () -> Unit, onMail: (String) -> Unit) {
|
||||
val address = if (config.verein.useVorsitzenderAddress) {
|
||||
listOf(config.vorstand.vorsitzender.strasse, "${config.vorstand.vorsitzender.plz} ${config.vorstand.vorsitzender.ort}".trim())
|
||||
} else {
|
||||
listOf(config.verein.strasse, "${config.verein.plz} ${config.verein.ort}".trim())
|
||||
}.filter(String::isNotBlank)
|
||||
|
||||
PublicCard("Angaben gemäß § 5 TMG") {
|
||||
Text(config.verein.name.ifBlank { "Harheimer Tischtennis-Club 1954 e. V. (HTC)" }, color = Accent700)
|
||||
address.forEach { Text(it, color = Accent700) }
|
||||
}
|
||||
PublicCard("Kontakt") {
|
||||
config.vorstand.vorsitzender.telefon.takeIf(String::isNotBlank)?.let { Text("Telefon: $it", color = Accent700) }
|
||||
config.vorstand.vorsitzender.email.takeIf(String::isNotBlank)?.let { email ->
|
||||
Button(onClick = { onMail(email) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text(email) }
|
||||
}
|
||||
Text("Internet: www.harheimertc.de", color = Accent700)
|
||||
}
|
||||
PublicCard("Vertretungsberechtigter Vorstand") {
|
||||
boardRows(config).forEach { Text(it, color = Accent700) }
|
||||
}
|
||||
PublicCard("Registereintrag") {
|
||||
Text("lsb h-Vereinsnummer: 24091", color = Accent700)
|
||||
Text("Registereintrag: Amtsgericht Frankfurt am Main, Registergericht", color = Accent700)
|
||||
Text("Registernummer: VR 6835", color = Accent700)
|
||||
}
|
||||
PublicCard("Vereinssatzung") {
|
||||
Text("Unsere aktuelle Vereinssatzung können Sie online einsehen.", color = Accent700)
|
||||
Button(onClick = onOpenSatzung, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Satzung öffnen") }
|
||||
}
|
||||
PublicCard("Verantwortlich für die Website") {
|
||||
val person = config.website.verantwortlicher
|
||||
Text("${person.vorname} ${person.nachname}".trim().ifBlank { "-" }, color = Accent700)
|
||||
person.email.takeIf(String::isNotBlank)?.let { email ->
|
||||
Button(onClick = { onMail(email) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text(email) }
|
||||
}
|
||||
}
|
||||
PublicCard("Haftungsausschluss und Datenschutz") {
|
||||
SectionText("Haftung für Inhalte", "Als Diensteanbieter sind wir für eigene Inhalte nach den allgemeinen Gesetzen verantwortlich. Eine Haftung für externe oder fehlerhafte Informationen ist erst ab Kenntnis einer konkreten Rechtsverletzung möglich.")
|
||||
SectionText("Haftung für Links", "Für Inhalte externer Websites sind deren Betreiber verantwortlich. Bei Bekanntwerden von Rechtsverletzungen entfernen wir entsprechende Links.")
|
||||
SectionText("Urheberrecht", "Die Inhalte dieser App und Website unterliegen dem deutschen Urheberrecht.")
|
||||
SectionText("Datenschutz", "Personenbezogene Daten werden vertraulich und entsprechend der Datenschutzvorschriften behandelt.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionText(title: String, text: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
Text(text, color = Accent700)
|
||||
}
|
||||
}
|
||||
|
||||
private fun boardRows(config: ConfigResponse): List<String> = listOf(
|
||||
"Vorsitzender" to config.vorstand.vorsitzender,
|
||||
"Stellvertreter" to config.vorstand.stellvertreter,
|
||||
"Kassenwart" to config.vorstand.kassenwart,
|
||||
"Schriftführer" to config.vorstand.schriftfuehrer,
|
||||
"Sportwart" to config.vorstand.sportwart,
|
||||
"Jugendwart" to config.vorstand.jugendwart,
|
||||
).mapNotNull { (role, member) ->
|
||||
val name = "${member.vorname} ${member.nachname}".trim()
|
||||
name.takeIf(String::isNotBlank)?.let { "$it, $role" }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.harheimertc.ui.screens.publicpages
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import de.harheimertc.repositories.PublicPagesRepository
|
||||
import de.harheimertc.repositories.Spielsystem
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PublicConfigUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val config: ConfigResponse? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class PublicConfigViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(PublicConfigUiState())
|
||||
val state: StateFlow<PublicConfigUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = PublicConfigUiState()
|
||||
repository.fetchConfig()
|
||||
.onSuccess { _state.value = PublicConfigUiState(loading = false, config = it) }
|
||||
.onFailure { _state.value = PublicConfigUiState(loading = false, error = "Inhalte konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SpielsystemeUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val systems: List<Spielsystem> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SpielsystemeViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SpielsystemeUiState())
|
||||
val state: StateFlow<SpielsystemeUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = SpielsystemeUiState()
|
||||
repository.fetchSpielsysteme()
|
||||
.onSuccess { _state.value = SpielsystemeUiState(loading = false, systems = it) }
|
||||
.onFailure { _state.value = SpielsystemeUiState(loading = false, error = "Spielsysteme konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class VereinsmeisterschaftenUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val results: List<MeisterschaftResult> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class VereinsmeisterschaftenViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(VereinsmeisterschaftenUiState())
|
||||
val state: StateFlow<VereinsmeisterschaftenUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = VereinsmeisterschaftenUiState()
|
||||
repository.fetchVereinsmeisterschaften()
|
||||
.onSuccess { _state.value = VereinsmeisterschaftenUiState(loading = false, results = it) }
|
||||
.onFailure { _state.value = VereinsmeisterschaftenUiState(loading = false, error = "Vereinsmeisterschaften konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package de.harheimertc.ui.screens.publicpages
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.ui.components.RichText
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
internal fun PublicPage(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
|
||||
subtitle?.let { Text(it, color = Accent500, modifier = Modifier.padding(top = 8.dp)) }
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PublicCard(title: String? = null, content: @Composable () -> Unit) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(17.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
title?.let { Text(it, style = MaterialTheme.typography.titleLarge, color = Accent900) }
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PublicLoading() {
|
||||
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PublicError(message: String, retry: () -> Unit) {
|
||||
PublicCard {
|
||||
Text(message, color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HtmlContent(html: String) {
|
||||
RichText(html)
|
||||
}
|
||||
|
||||
internal fun Context.openPublicUri(value: String) {
|
||||
val uri = if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("mailto:")) {
|
||||
value
|
||||
} else {
|
||||
BuildConfig.API_BASE_URL.trimEnd('/') + "/" + value.trimStart('/')
|
||||
}
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)))
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package de.harheimertc.ui.screens.publicpages
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import de.harheimertc.repositories.Spielsystem
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
fun SpielsystemeScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: SpielsystemeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedCategory by rememberSaveable { mutableStateOf("Alle Kategorien") }
|
||||
val categories = state.systems.map(Spielsystem::category).filter(String::isNotBlank).distinct().sorted()
|
||||
val displayed = if (selectedCategory == "Alle Kategorien") state.systems else state.systems.filter { it.category == selectedCategory }
|
||||
PublicPage(
|
||||
navController,
|
||||
showBackNavigation,
|
||||
"Spielsysteme",
|
||||
"Übersicht der verschiedenen Mannschafts-Spielsysteme im Tischtennis",
|
||||
) {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
|
||||
else -> {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
(categories + "Alle Kategorien").forEach { category ->
|
||||
CategoryButton(category, category == selectedCategory) { selectedCategory = category }
|
||||
}
|
||||
}
|
||||
}
|
||||
displayed.forEach { system -> item { SpielsystemCard(system) } }
|
||||
item {
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text(
|
||||
"Die Spielsysteme werden je nach Liga und Verband unterschiedlich eingesetzt. Regionale Ligen verwenden meist das Bundessystem oder das Braunschweiger System.",
|
||||
color = Primary100,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = if (selected) Primary600 else Color.White, contentColor = if (selected) Color.White else Accent700),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
) { Text(label) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpielsystemCard(system: Spielsystem) {
|
||||
PublicCard {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(system.name, style = MaterialTheme.typography.titleLarge, color = Accent900, modifier = Modifier.weight(1f))
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(20.dp)) {
|
||||
Text(system.category, color = Primary600, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp))
|
||||
}
|
||||
}
|
||||
Text(system.teamSize, color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
Text(system.description, color = Accent700)
|
||||
Text("Spielabfolge: ${system.sequence}", color = Accent500)
|
||||
Text("Anzahl Spiele: ${system.gameCount}", color = Accent500)
|
||||
Text("Besonderheiten: ${system.features}", color = Accent500)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VereinsmeisterschaftenScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: VereinsmeisterschaftenViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedYear by rememberSaveable { mutableStateOf("Alle Jahre") }
|
||||
var selectedPortrait by rememberSaveable { mutableStateOf<Pair<String, String>?>(null) }
|
||||
val years = state.results.map(MeisterschaftResult::year).filter(String::isNotBlank).distinct().sortedDescending()
|
||||
val displayed = if (selectedYear == "Alle Jahre") state.results else state.results.filter { it.year == selectedYear }
|
||||
PublicPage(
|
||||
navController,
|
||||
showBackNavigation,
|
||||
"Vereinsmeisterschaften",
|
||||
"Die Ergebnisse unserer Vereinsmeisterschaften der letzten Jahre",
|
||||
) {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
|
||||
else -> {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
(years + "Alle Jahre").forEach { year ->
|
||||
CategoryButton(year, selectedYear == year) { selectedYear = year }
|
||||
}
|
||||
}
|
||||
}
|
||||
displayed.groupBy(MeisterschaftResult::year).toSortedMap(compareByDescending { it }).forEach { (year, results) ->
|
||||
item { ChampionshipYearCard(year, results) { image, name -> selectedPortrait = image to name } }
|
||||
}
|
||||
item {
|
||||
val singleWinners = state.results.count { it.rank == "1" && it.category.contains("Einzel") }
|
||||
val doublesWinners = state.results.count { it.rank == "1" && it.category == "Doppel" }
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(18.dp), horizontalArrangement = Arrangement.SpaceAround) {
|
||||
Statistic("${years.size}", "Jahre")
|
||||
Statistic("$singleWinners", "Einzelgewinner")
|
||||
Statistic("$doublesWinners", "Doppelgewinner")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedPortrait?.let { (image, name) ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { selectedPortrait = null },
|
||||
confirmButton = {
|
||||
Button(onClick = { selectedPortrait = null }) { Text("Schließen") }
|
||||
},
|
||||
title = { Text(name) },
|
||||
text = {
|
||||
AsyncImage(
|
||||
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$image",
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChampionshipYearCard(year: String, results: List<MeisterschaftResult>, onImage: (String, String) -> Unit) {
|
||||
PublicCard(year) {
|
||||
results.map(MeisterschaftResult::note).firstOrNull(String::isNotBlank)?.let { note ->
|
||||
Surface(color = Color(0xFFFEF3C7), shape = RoundedCornerShape(6.dp)) {
|
||||
Text(note, color = Color(0xFF92400E), modifier = Modifier.fillMaxWidth().padding(10.dp))
|
||||
}
|
||||
}
|
||||
results.filter { it.category.isNotBlank() }.groupBy(MeisterschaftResult::category).forEach { (category, placements) ->
|
||||
Text(category, color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 6.dp))
|
||||
placements.sortedBy { it.rank.toIntOrNull() ?: Int.MAX_VALUE }.forEach { result ->
|
||||
val names = listOf(result.playerOne, result.playerTwo).filter(String::isNotBlank).joinToString(" / ")
|
||||
Surface(color = if (result.rank == "1") Color(0xFFFEF3C7) else Accent100, shape = RoundedCornerShape(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("${result.rank}.", color = Accent900, fontWeight = FontWeight.Bold)
|
||||
result.imageOne.takeIf(String::isNotBlank)?.let { image ->
|
||||
Portrait(image, result.playerOne) { onImage(image, result.playerOne) }
|
||||
}
|
||||
Text(names, color = Accent900, modifier = Modifier.weight(1f))
|
||||
result.imageTwo.takeIf(String::isNotBlank)?.let { image ->
|
||||
Portrait(image, result.playerTwo) { onImage(image, result.playerTwo) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Portrait(filename: String, name: String, onClick: () -> Unit) {
|
||||
AsyncImage(
|
||||
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$filename?width=48&height=48",
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(32.dp).clip(CircleShape).clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Statistic(value: String, label: String) {
|
||||
Column {
|
||||
Text(value, color = Color.White, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text(label, color = Primary100, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RegelnScreen(navController: NavController, showBackNavigation: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val dttbUrl = "https://www.tischtennis.de/dttb/regeln-satzung/satzung-ordnungen.html"
|
||||
PublicPage(
|
||||
navController,
|
||||
showBackNavigation,
|
||||
"Tischtennis-Regeln",
|
||||
"Offizielle Regeln und Bestimmungen für den Tischtennissport",
|
||||
) {
|
||||
item {
|
||||
PublicCard("Offizielles ITTF-Reglement") {
|
||||
Text("Die offiziellen Regeln des Internationalen Tischtennis-Verbands gelten weltweit für Wettkämpfe und Turniere.", color = Accent700)
|
||||
Button(onClick = { context.openPublicUri(dttbUrl) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) {
|
||||
Text("Offizielle Regeln öffnen")
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
PublicCard("Tischtennis-Regeln Light") {
|
||||
Text("Eine kompakte Übersicht der wichtigsten Regeln für Einsteiger und Hobbyspieler.", color = Accent700)
|
||||
Button(
|
||||
onClick = { context.openPublicUri("/documents/Tischtennisregeln%20light.pdf") },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
) { Text("Regeln Light als PDF öffnen") }
|
||||
}
|
||||
}
|
||||
item { Text("Grundregeln im Überblick", style = MaterialTheme.typography.titleLarge, color = Accent900) }
|
||||
listOf(
|
||||
"Spielfeld" to "Tisch: 2,74 m x 1,525 m, Höhe: 76 cm. Netz: 15,25 cm hoch.",
|
||||
"Ball" to "Durchmesser: 40 mm. Gewicht: 2,7 g.",
|
||||
"Schläger" to "Belag: schwarz und farbig. Holz: mindestens 85 Prozent.",
|
||||
"Aufschlag" to "Ball muss sichtbar mindestens 16 cm hochgeworfen werden.",
|
||||
"Satz" to "Gewinn bei 11 Punkten mit mindestens 2 Punkten Vorsprung.",
|
||||
"Spiel" to "Best of 5 oder 7 Sätze; Aufschlagwechsel alle 2 Punkte.",
|
||||
).forEach { rule ->
|
||||
item { PublicCard(rule.first) { Text(rule.second, color = Accent700) } }
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text("Für regionale Turniere können ergänzende Bestimmungen gelten.", color = Primary100)
|
||||
Button(onClick = { context.openPublicUri(dttbUrl) }) { Text("DTTB-Regeln und Ordnungen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package de.harheimertc.ui.screens.publicpages
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.BoardMemberDto
|
||||
import de.harheimertc.data.LinkSectionDto
|
||||
import de.harheimertc.repositories.linkSections
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
fun AboutScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsHtmlScreen(navController, showBackNavigation, "Über uns", state.config?.seiten?.ueberUns, state.loading, state.error, viewModel::load)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GeschichteScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsHtmlScreen(navController, showBackNavigation, "Geschichte", state.config?.seiten?.geschichte, state.loading, state.error, viewModel::load)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CmsHtmlScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
html: String?,
|
||||
loading: Boolean,
|
||||
error: String?,
|
||||
retry: () -> Unit,
|
||||
) {
|
||||
PublicPage(navController, showBackNavigation, title) {
|
||||
when {
|
||||
loading -> item { PublicLoading() }
|
||||
error != null -> item { PublicError(error, retry) }
|
||||
html.isNullOrBlank() -> item { Text("Für diese Seite ist derzeit kein Inhalt hinterlegt.", color = Accent500) }
|
||||
else -> item { PublicCard { HtmlContent(html) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SatzungScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
PublicPage(navController, showBackNavigation, "Satzung") {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
|
||||
else -> state.config?.seiten?.satzung?.let { satzung ->
|
||||
item {
|
||||
PublicCard {
|
||||
if (satzung.content.isNotBlank()) HtmlContent(satzung.content)
|
||||
if (satzung.pdfUrl.isNotBlank()) {
|
||||
Button(
|
||||
onClick = { context.openPublicUri(satzung.pdfUrl) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
) { Text("Satzung als PDF öffnen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VorstandScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
PublicPage(
|
||||
navController,
|
||||
showBackNavigation,
|
||||
"Vorstand",
|
||||
"Unser Vorstandsteam leitet den Harheimer TC.",
|
||||
) {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
|
||||
else -> {
|
||||
val vorstand = state.config?.vorstand
|
||||
listOf(
|
||||
"Vorsitzender" to vorstand?.vorsitzender,
|
||||
"Stellvertreter" to vorstand?.stellvertreter,
|
||||
"Kassenwart" to vorstand?.kassenwart,
|
||||
"Schriftführer" to vorstand?.schriftfuehrer,
|
||||
"Sportwart" to vorstand?.sportwart,
|
||||
"Jugendwart" to vorstand?.jugendwart,
|
||||
).filter { !it.second?.vorname.isNullOrBlank() }.forEach { (role, member) ->
|
||||
item { BoardMemberCard(role, member!!, onMail = { context.openPublicUri("mailto:$it") }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoardMemberCard(role: String, member: BoardMemberDto, onMail: (String) -> Unit) {
|
||||
PublicCard {
|
||||
member.imageFilename?.takeIf(String::isNotBlank)?.let { filename ->
|
||||
AsyncImage(
|
||||
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$filename?width=120&height=120",
|
||||
contentDescription = "${member.vorname} ${member.nachname}",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(64.dp).clip(CircleShape),
|
||||
)
|
||||
}
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(6.dp)) {
|
||||
Text(role, color = Primary600, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp))
|
||||
}
|
||||
Text("${member.vorname} ${member.nachname}", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
member.strasse.takeIf(String::isNotBlank)?.let { Text(it, color = Accent700) }
|
||||
if (member.plz.isNotBlank() || member.ort.isNotBlank()) Text("${member.plz} ${member.ort}".trim(), color = Accent700)
|
||||
member.telefon.takeIf(String::isNotBlank)?.let { Text("Tel. $it", color = Accent700) }
|
||||
member.email.takeIf(String::isNotBlank)?.let { email ->
|
||||
Button(
|
||||
onClick = { onMail(email) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
) { Text(email) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LinksScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
PublicPage(
|
||||
navController,
|
||||
showBackNavigation,
|
||||
"Links",
|
||||
"Nützliche Verweise rund um Tischtennis, Verbände, Ergebnisse und Partner.",
|
||||
) {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
else -> (state.config ?: ConfigResponse()).linkSections().forEach { section ->
|
||||
item { LinkSectionCard(section) { context.openPublicUri(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkSectionCard(section: LinkSectionDto, onOpen: (String) -> Unit) {
|
||||
PublicCard(section.title) {
|
||||
section.items.forEach { item ->
|
||||
Surface(color = Accent100, shape = RoundedCornerShape(6.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(11.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row {
|
||||
Text(
|
||||
item.label,
|
||||
color = Primary600,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { onOpen(item.href) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
) { Text("Öffnen") }
|
||||
}
|
||||
item.description.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package de.harheimertc.ui.screens.spielplan
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent200
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun SpielplanScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: SpielplanViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text("Spielpläne", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 15.dp))
|
||||
Text("Alle Spielpläne der Mannschaften", color = Accent500, modifier = Modifier.padding(top = 5.dp, bottom = 15.dp))
|
||||
}
|
||||
item {
|
||||
FilterPanel(
|
||||
state = state,
|
||||
onSeason = viewModel::selectSeason,
|
||||
onCompetition = viewModel::selectWettbewerb,
|
||||
onTeam = viewModel::selectTeam,
|
||||
onReload = { viewModel.load() },
|
||||
)
|
||||
}
|
||||
if (state.loading) {
|
||||
item { LoadingPlan() }
|
||||
} else if (state.error != null) {
|
||||
item {
|
||||
StatusPanel("Fehler beim Laden", state.error.orEmpty())
|
||||
Button(onClick = { viewModel.load() }, modifier = Modifier.fillMaxWidth()) { Text("Erneut versuchen") }
|
||||
}
|
||||
} else if (state.spiele.isEmpty()) {
|
||||
item { StatusPanel("Keine Spielpläne verfügbar", "Es wurden noch keine Spielplandaten hochgeladen.") }
|
||||
} else {
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(9.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Text("Spielplan", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
Text(
|
||||
"${state.selectedWettbewerb.label} - ${state.filtered.size} von ${state.spiele.size} Einträgen",
|
||||
color = Accent500,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.filtered.isEmpty()) {
|
||||
item { StatusPanel("Keine Einträge", "Für die gewählte Filterung sind keine Spiele vorhanden.") }
|
||||
} else {
|
||||
items(state.filtered) { game -> MatchRow(game) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterPanel(
|
||||
state: SpielplanUiState,
|
||||
onSeason: (String) -> Unit,
|
||||
onCompetition: (Wettbewerb) -> Unit,
|
||||
onTeam: (String) -> Unit,
|
||||
onReload: () -> Unit,
|
||||
) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(15.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (state.seasons.isNotEmpty()) {
|
||||
SelectMenu(
|
||||
label = "Saison",
|
||||
selected = state.seasons.firstOrNull { it.slug == state.selectedSeason }?.label ?: state.selectedSeason.orEmpty(),
|
||||
options = state.seasons,
|
||||
text = { it.label },
|
||||
onSelected = { onSeason(it.slug) },
|
||||
)
|
||||
}
|
||||
SelectMenu(
|
||||
label = "Wettbewerb",
|
||||
selected = state.selectedWettbewerb.label,
|
||||
options = Wettbewerb.entries.toList(),
|
||||
text = { it.label },
|
||||
onSelected = onCompetition,
|
||||
)
|
||||
SelectMenu(
|
||||
label = "Mannschaft",
|
||||
selected = state.selectedTeam,
|
||||
options = state.teams,
|
||||
text = { it },
|
||||
onSelected = onTeam,
|
||||
)
|
||||
Button(
|
||||
onClick = onReload,
|
||||
enabled = !state.loading,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Spielplan laden") }
|
||||
Text(
|
||||
"${state.selectedWettbewerb.label} - ${state.selectedTeam} (${state.filtered.size} von ${state.spiele.size} Einträgen)",
|
||||
color = Accent500,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> SelectMenu(
|
||||
label: String,
|
||||
selected: String,
|
||||
options: List<T>,
|
||||
text: (T) -> String,
|
||||
onSelected: (T) -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent700)
|
||||
Box {
|
||||
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(selected.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
|
||||
Text("v")
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text(option)) },
|
||||
onClick = {
|
||||
open = false
|
||||
onSelected(option)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MatchRow(game: SpielDto) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(9.dp), shadowElevation = 1.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatTermin(game.termin), fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
if (game.spieleHeim.isNotBlank() || game.spieleGast.isNotBlank()) {
|
||||
Text("${game.spieleHeim}:${game.spieleGast}", color = Primary600, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(game.heimMannschaft.ifBlank { "-" }, modifier = Modifier.weight(1f), color = Accent900)
|
||||
Text(" - ", color = Accent500)
|
||||
Text(
|
||||
game.gastMannschaft.ifBlank { "-" },
|
||||
modifier = Modifier.weight(1f),
|
||||
color = Accent900,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(game.runde.orEmpty(), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(game.altersklasse.ifBlank { "-" }, style = MaterialTheme.typography.labelSmall, color = Accent700)
|
||||
Text(formatStaffel(game.staffel), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingPlan() {
|
||||
Column(Modifier.fillMaxWidth().padding(40.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
Text("Spielpläne werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusPanel(title: String, body: String) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 34.dp, horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
Text(body, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTermin(value: String): String = runCatching {
|
||||
val source = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMANY)
|
||||
SimpleDateFormat("EEE dd.MM.yyyy, HH:mm", Locale.GERMANY).format(source.parse(value)!!)
|
||||
}.getOrDefault(value)
|
||||
|
||||
private fun formatStaffel(value: String): String =
|
||||
value.trim().removePrefix("E").trimStart()
|
||||
@@ -0,0 +1,102 @@
|
||||
package de.harheimertc.ui.screens.spielplan
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.repositories.SpielplanRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class Wettbewerb(val label: String) {
|
||||
Punktrunde("Punktrunde"),
|
||||
Pokal("Pokal"),
|
||||
Alle("Alle"),
|
||||
}
|
||||
|
||||
data class SpielplanUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val spiele: List<SpielDto> = emptyList(),
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
val selectedSeason: String? = null,
|
||||
val selectedWettbewerb: Wettbewerb = Wettbewerb.Punktrunde,
|
||||
val selectedTeam: String = "Gesamt",
|
||||
) {
|
||||
val teams: List<String>
|
||||
get() = listOf("Gesamt", "Erwachsene", "Nachwuchs") +
|
||||
spiele.flatMap { listOf(it.heimMannschaft, it.gastMannschaft) }
|
||||
.filter { it.contains("Harheimer TC", ignoreCase = true) }
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
val filtered: List<SpielDto>
|
||||
get() = spiele.filter(::matchesCompetition).filter(::matchesTeam)
|
||||
|
||||
private fun matchesCompetition(game: SpielDto): Boolean {
|
||||
val text = "${game.runde.orEmpty()} ${game.staffel} ${game.liga}".lowercase()
|
||||
val pokal = "pokal" in text
|
||||
return when (selectedWettbewerb) {
|
||||
Wettbewerb.Punktrunde -> !pokal
|
||||
Wettbewerb.Pokal -> pokal
|
||||
Wettbewerb.Alle -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesTeam(game: SpielDto): Boolean {
|
||||
val homeHtc = game.heimMannschaft.contains("Harheimer TC", ignoreCase = true)
|
||||
val awayHtc = game.gastMannschaft.contains("Harheimer TC", ignoreCase = true)
|
||||
return when (selectedTeam) {
|
||||
"Gesamt" -> true
|
||||
"Erwachsene" -> (homeHtc && game.heimAltersklasse.contains("Erwachsene", ignoreCase = true)) ||
|
||||
(awayHtc && game.gastAltersklasse.contains("Erwachsene", ignoreCase = true))
|
||||
"Nachwuchs" -> (homeHtc && isYouth(game.heimAltersklasse, game.heimMannschaft)) ||
|
||||
(awayHtc && isYouth(game.gastAltersklasse, game.gastMannschaft))
|
||||
else -> game.heimMannschaft == selectedTeam || game.gastMannschaft == selectedTeam
|
||||
}
|
||||
}
|
||||
|
||||
private fun isYouth(age: String, team: String): Boolean =
|
||||
age.contains("Jugend", ignoreCase = true) || team.contains("Jugend", ignoreCase = true)
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class SpielplanViewModel @Inject constructor(private val repository: SpielplanRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SpielplanUiState())
|
||||
val state: StateFlow<SpielplanUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load(season: String? = _state.value.selectedSeason) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
repository.fetchSpielplan(season)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
spiele = response.data,
|
||||
seasons = response.seasons.ifEmpty { _state.value.seasons },
|
||||
selectedSeason = response.season ?: season,
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(loading = false, error = "Spielplan konnte nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSeason(slug: String) = load(slug)
|
||||
|
||||
fun selectWettbewerb(value: Wettbewerb) {
|
||||
_state.value = _state.value.copy(selectedWettbewerb = value)
|
||||
}
|
||||
|
||||
fun selectTeam(value: String) {
|
||||
_state.value = _state.value.copy(selectedTeam = value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package de.harheimertc.ui.screens.termine
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TermineScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: TermineViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
PageHeader(onBack = { navController.popBackStack() })
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 30.dp, bottom = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("Termine & Events", style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Spacer(Modifier.size(13.dp))
|
||||
Box(Modifier.width(74.dp).size(width = 74.dp, height = 4.dp).background(Primary600))
|
||||
Text(
|
||||
"Alle kommenden Termine und Veranstaltungen des Harheimer TC",
|
||||
color = Accent500,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 17.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.loading) {
|
||||
item { LoadingPanel() }
|
||||
} else if (state.error != null) {
|
||||
item {
|
||||
EmptyPanel(state.error.orEmpty())
|
||||
Button(onClick = viewModel::load, modifier = Modifier.fillMaxWidth()) { Text("Erneut versuchen") }
|
||||
}
|
||||
} else if (state.termine.isEmpty()) {
|
||||
item { EmptyPanel("Aktuell sind keine Termine geplant. Schauen Sie bald wieder vorbei!") }
|
||||
} else {
|
||||
items(state.termine) { termin -> TerminCard(termin) }
|
||||
}
|
||||
item { NoticePanel() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageHeader(onBack: () -> Unit) {
|
||||
TextButton(onClick = onBack) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TerminCard(termin: TerminDto) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
|
||||
Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.Top) {
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(10.dp)) {
|
||||
Column(Modifier.width(64.dp).padding(vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(datePart(termin.datum, "dd"), color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge)
|
||||
Text(datePart(termin.datum, "MMM"), color = Color.White, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 13.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(termin.titel, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
termin.beschreibung?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent700) }
|
||||
Text(fullDate(termin), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
}
|
||||
termin.kategorie?.takeIf(String::isNotBlank)?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Primary900,
|
||||
modifier = Modifier.background(Primary100, RoundedCornerShape(16.dp)).padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingPanel() {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 38.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
Text("Termine werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyPanel(message: String) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Keine kommenden Termine", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(message, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoticePanel() {
|
||||
Surface(color = Color(0xFFFEF2F2), shape = RoundedCornerShape(12.dp), modifier = Modifier.padding(top = 16.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp)) {
|
||||
Text("Hinweis", fontWeight = FontWeight.SemiBold, color = Primary900)
|
||||
Text(
|
||||
"Alle Termine sind vorbehaltlich kurzfristiger Änderungen. Bei Fragen zu einzelnen Veranstaltungen kontaktieren Sie uns gerne.",
|
||||
color = Primary900,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun datePart(value: String, pattern: String): String = runCatching {
|
||||
val parsed = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY).parse(value)!!
|
||||
SimpleDateFormat(pattern, Locale.GERMANY).format(parsed)
|
||||
}.getOrDefault(value)
|
||||
|
||||
private fun fullDate(termin: TerminDto): String {
|
||||
val date = datePart(termin.datum, "EEEE, d. MMMM yyyy")
|
||||
return termin.uhrzeit?.takeIf(String::isNotBlank)?.let { "$date - $it Uhr" } ?: date
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.harheimertc.ui.screens.termine
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.repositories.TermineRepository
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class TermineUiState(
|
||||
val loading: Boolean = true,
|
||||
val termine: List<TerminDto> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class TermineViewModel @Inject constructor(private val repository: TermineRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(TermineUiState())
|
||||
val state: StateFlow<TermineUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = TermineUiState(loading = true)
|
||||
repository.fetchTermine()
|
||||
.onSuccess { termine ->
|
||||
_state.value = TermineUiState(
|
||||
loading = false,
|
||||
termine = termine
|
||||
.filter { it.eventDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
|
||||
.sortedBy { it.eventDateTime() },
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = TermineUiState(loading = false, error = "Termine konnten nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TerminDto.eventDateTime(): LocalDateTime? = runCatching {
|
||||
val time = uhrzeit?.takeIf { it.matches(Regex("\\d{2}:\\d{2}")) } ?: "00:00"
|
||||
LocalDateTime.parse("$datum $time", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||
}.getOrNull()
|
||||
@@ -0,0 +1,207 @@
|
||||
package de.harheimertc.ui.screens.training
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.TrainingTimeDto
|
||||
import de.harheimertc.data.TrainerDto
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
fun TrainingScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: TrainingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
Page(
|
||||
navController = navController,
|
||||
showBackNavigation = showBackNavigation,
|
||||
title = "Trainingszeiten",
|
||||
) {
|
||||
when {
|
||||
state.loading -> item { Loading() }
|
||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||
else -> state.config?.let { config ->
|
||||
item {
|
||||
ContentCard("Trainingsort") {
|
||||
Text(config.training.ort.name, color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
Text(config.training.ort.strasse, color = Accent700)
|
||||
Text("${config.training.ort.plz} ${config.training.ort.ort}", color = Accent700)
|
||||
}
|
||||
}
|
||||
item { Text("Trainingszeiten", style = MaterialTheme.typography.titleLarge, color = Accent900) }
|
||||
val groups = config.training.zeiten.groupBy { it.gruppe }
|
||||
items(groups.entries.toList()) { group ->
|
||||
ContentCard(group.key) {
|
||||
group.value.forEach { time ->
|
||||
Text("${time.tag}: ${time.von} - ${time.bis} Uhr", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
time.info?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Interessiert?", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text("Komm einfach zum Schnuppertraining vorbei oder kontaktiere uns für weitere Informationen.", color = Accent700)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = { navController.navigate(Destinations.Anfaenger.route) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) {
|
||||
Text("Infos für Anfänger")
|
||||
}
|
||||
OutlinedButton(onClick = { navController.navigate(Destinations.Contact.route) }) { Text("Kontakt") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrainerScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: TrainingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
Page(navController, showBackNavigation, "Unsere Trainer") {
|
||||
item { Text("Erfahrene und qualifizierte Trainer für alle Leistungsstufen", color = Accent500) }
|
||||
when {
|
||||
state.loading -> item { Loading() }
|
||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||
else -> items(state.config?.trainer.orEmpty()) { trainer -> TrainerCard(trainer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnfaengerScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: TrainingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
Page(navController, showBackNavigation, "Tischtennis für Anfänger") {
|
||||
item { Text("Du möchtest mit Tischtennis anfangen? Bei uns bist du richtig.", color = Accent500) }
|
||||
item {
|
||||
ContentCard("Was du wissen solltest") {
|
||||
listOf(
|
||||
"Keine Vorkenntnisse nötig",
|
||||
"Schläger und Material werden gestellt",
|
||||
"Sportkleidung und Hallenschuhe mitbringen",
|
||||
"3x kostenlos Probetraining",
|
||||
"Einstieg jederzeit möglich",
|
||||
).forEach { Text("+ $it", color = Accent700) }
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.loading -> item { Loading() }
|
||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||
else -> {
|
||||
val groups = state.config?.training?.zeiten.orEmpty().groupBy(TrainingTimeDto::gruppe)
|
||||
item { Text("Anfängergruppen", style = MaterialTheme.typography.titleLarge, color = Accent900) }
|
||||
items(groups.entries.toList()) { group ->
|
||||
ContentCard(group.key) {
|
||||
group.value.forEach { Text("${it.tag}, ${it.von} - ${it.bis} Uhr", color = Accent700) }
|
||||
}
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = { navController.navigate(Destinations.Contact.route) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Zum Probetraining anmelden") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Page(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentCard(title: String, content: @Composable () -> Unit) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(17.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrainerCard(trainer: TrainerDto) {
|
||||
ContentCard(trainer.lizenz) {
|
||||
Text(trainer.name, color = Accent700, fontWeight = FontWeight.SemiBold)
|
||||
Text("Schwerpunkt: ${trainer.schwerpunkt}", color = Accent500)
|
||||
trainer.zusatz?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorPanel(message: String, retry: () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(message, color = Accent700)
|
||||
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.harheimertc.ui.screens.training
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.repositories.TrainingRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class TrainingUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val config: ConfigResponse? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class TrainingViewModel @Inject constructor(private val repository: TrainingRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(TrainingUiState())
|
||||
val state: StateFlow<TrainingUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = TrainingUiState(loading = true)
|
||||
repository.fetchConfig()
|
||||
.onSuccess { _state.value = TrainingUiState(loading = false, config = it) }
|
||||
.onFailure { _state.value = TrainingUiState(loading = false, error = "Trainingsinformationen konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.harheimertc.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Primary (Tailwind)
|
||||
val Primary50 = Color(0xFFFEF2F2)
|
||||
val Primary100 = Color(0xFFFEE2E2)
|
||||
val Primary200 = Color(0xFFFECACA)
|
||||
val Primary300 = Color(0xFFFCA5A5)
|
||||
val Primary400 = Color(0xFFF87171)
|
||||
val Primary500 = Color(0xFFEF4444)
|
||||
val Primary600 = Color(0xFFDC2626)
|
||||
val Primary700 = Color(0xFFB91C1C)
|
||||
val Primary800 = Color(0xFF991B1B)
|
||||
val Primary900 = Color(0xFF7F1D1D)
|
||||
|
||||
// Accent (Tailwind gray-ish palette)
|
||||
val Accent50 = Color(0xFFFAFAFA)
|
||||
val Accent100 = Color(0xFFF4F4F5)
|
||||
val Accent200 = Color(0xFFE4E4E7)
|
||||
val Accent300 = Color(0xFFD4D4D8)
|
||||
val Accent400 = Color(0xFFA1A1AA)
|
||||
val Accent500 = Color(0xFF71717A)
|
||||
val Accent600 = Color(0xFF52525B)
|
||||
val Accent700 = Color(0xFF3F3F46)
|
||||
val Accent800 = Color(0xFF27272A)
|
||||
val Accent900 = Color(0xFF18181B)
|
||||
|
||||
// Other semantic colors
|
||||
val AppBackground = Color(0xFFFFFFFF)
|
||||
val AppSurface = Accent50
|
||||
val AppOnPrimary = Color(0xFFFFFFFF)
|
||||
val AppError = Color(0xFFB00020)
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.harheimertc.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = Primary500,
|
||||
onPrimary = AppOnPrimary,
|
||||
primaryContainer = Primary100,
|
||||
secondary = Accent500,
|
||||
background = AppBackground,
|
||||
surface = AppSurface,
|
||||
error = AppError,
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Primary300,
|
||||
onPrimary = AppOnPrimary,
|
||||
primaryContainer = Primary700,
|
||||
secondary = Accent300,
|
||||
background = Accent900,
|
||||
surface = Accent800,
|
||||
error = AppError,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HarheimerTheme(
|
||||
darkTheme: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = if (darkTheme) DarkColors else LightColors
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
typography = AppTypography,
|
||||
shapes = MaterialTheme.shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.harheimertc.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.harheimertc.R
|
||||
|
||||
// Bundled variable fonts in res/font:
|
||||
val InterFamily = FontFamily(Font(R.font.inter_variable))
|
||||
val MontserratFamily = FontFamily(Font(R.font.montserrat_variable, FontWeight.SemiBold))
|
||||
|
||||
val AppTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = MontserratFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 30.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = MontserratFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 20.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
android-app/app/src/main/res/font/inter_variable.ttf
Normal file
BIN
android-app/app/src/main/res/font/inter_variable.ttf
Normal file
Binary file not shown.
BIN
android-app/app/src/main/res/font/montserrat_variable.ttf
Normal file
BIN
android-app/app/src/main/res/font/montserrat_variable.ttf
Normal file
Binary file not shown.
37
android-app/app/src/main/res/font_README.md
Normal file
37
android-app/app/src/main/res/font_README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
Fonts für die Android-App
|
||||
|
||||
Dieses Verzeichnis soll die gebündelten TTF-Dateien enthalten, damit die App die gleichen Schriften wie die Web-UI verwendet.
|
||||
|
||||
Automatischer Download (empfohlen):
|
||||
|
||||
1. Ausführbar machen:
|
||||
|
||||
```bash
|
||||
chmod +x android-app/scripts/download-fonts.sh
|
||||
```
|
||||
|
||||
2. Script ausführen:
|
||||
|
||||
```bash
|
||||
./android-app/scripts/download-fonts.sh
|
||||
```
|
||||
|
||||
Das Script lädt die variable Font-Dateien `Inter[opsz,wght].ttf` und `Montserrat[wght].ttf` in `app/src/main/res/font/` und benennt sie in `inter_variable.ttf` und `montserrat_variable.ttf`.
|
||||
|
||||
Manuelle Alternative:
|
||||
- Lade `Inter` und `Montserrat` vom Google Fonts Repo herunter und lege die TTFs in dieses Verzeichnis.
|
||||
- Benenne die Dateien wie oben.
|
||||
|
||||
Compose-Nutzung (Beispiel in Kotlin):
|
||||
|
||||
```kotlin
|
||||
val Inter = FontFamily(
|
||||
Font(R.font.inter_variable)
|
||||
)
|
||||
|
||||
val Montserrat = FontFamily(
|
||||
Font(R.font.montserrat_variable)
|
||||
)
|
||||
```
|
||||
|
||||
Hinweis: Das Herunterladen der Fonts erfolgt von GitHub (Raw URLs). Prüfe die Lizenzen (Google Fonts sind in der Regel OFL-lizenziert).
|
||||
6
android-app/app/src/main/res/values/colors.xml
Normal file
6
android-app/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primary_500">#ef4444</color>
|
||||
<color name="primary_600">#dc2626</color>
|
||||
<color name="accent_500">#71717a</color>
|
||||
</resources>
|
||||
8
android-app/app/src/main/res/values/themes.xml
Normal file
8
android-app/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.HarheimerTC" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">#18181B</item>
|
||||
<item name="android:navigationBarColor">#18181B</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
4
android-app/app/src/main/res/xml/file_paths.xml
Normal file
4
android-app/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="membership_documents" path="membership/" />
|
||||
</paths>
|
||||
16
android-app/build.gradle.kts
Normal file
16
android-app/build.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Root build.gradle.kts (skeleton)
|
||||
plugins {
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||
id("com.google.dagger.hilt.android") version "2.59.2" apply false
|
||||
id("com.google.devtools.ksp") version "2.3.7" apply false
|
||||
id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
|
||||
}
|
||||
|
||||
buildscript {
|
||||
val agp_version by extra("9.2.1")
|
||||
}
|
||||
|
||||
allprojects {
|
||||
}
|
||||
666
android-app/build/reports/problems/problems-report.html
Normal file
666
android-app/build/reports/problems/problems-report.html
Normal file
File diff suppressed because one or more lines are too long
3
android-app/gradle.properties
Normal file
3
android-app/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
# Using AGP 9.2.1 defaults
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
|
||||
org.gradle.workers.max=2
|
||||
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
android-app/gradlew
vendored
Executable file
251
android-app/gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
android-app/gradlew.bat
vendored
Normal file
94
android-app/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
BIN
android-app/java_pid672503.hprof
Normal file
BIN
android-app/java_pid672503.hprof
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user