Compare commits
218 Commits
backup/rem
...
530e544542
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530e544542 | ||
|
|
300dce9835 | ||
|
|
a98def915e | ||
|
|
7aa7970f2e | ||
|
|
e517720b03 | ||
|
|
402913d877 | ||
|
|
2014abe660 | ||
|
|
80834d8652 | ||
|
|
7bc98c03e4 | ||
|
|
bf1caefde4 | ||
|
|
6983186caf | ||
|
|
7c93966878 | ||
|
|
31d20f1bff | ||
|
|
6507afea5f | ||
|
|
387ce6e08e | ||
|
|
f822fc8a8e | ||
|
|
67c746f18b | ||
|
|
1e65cb47da | ||
|
|
ec96e21517 | ||
|
|
46f80df165 | ||
|
|
5c3d78245f | ||
|
|
f5045c3cf0 | ||
|
|
696c50f0fc | ||
|
|
b8bdbf0a8d | ||
|
|
1ea9596006 | ||
|
|
cdbe71eaec | ||
|
|
125a00819d | ||
|
|
b4c31374c0 | ||
|
|
c8b7f5ec2e | ||
|
|
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,16 +1,38 @@
|
|||||||
name: Code Analysis (JS/Vue)
|
name: Code Analysis and Production Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main, dev ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
clean: true
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Ensure clean workspace
|
||||||
|
run: |
|
||||||
|
git reset --hard HEAD
|
||||||
|
git clean -fdx
|
||||||
|
|
||||||
|
- name: Debug dependency files
|
||||||
|
run: |
|
||||||
|
echo "commit: $(git rev-parse HEAD)"
|
||||||
|
echo "branch ref: ${GITHUB_REF:-unknown}"
|
||||||
|
echo "package.json checksum:" && sha256sum package.json
|
||||||
|
echo "package-lock.json checksum:" && sha256sum package-lock.json
|
||||||
|
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
- name: Workspace sanity check
|
- name: Workspace sanity check
|
||||||
run: |
|
run: |
|
||||||
@@ -25,6 +47,15 @@ jobs:
|
|||||||
node -v
|
node -v
|
||||||
npm -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)
|
- name: gitleaks (Secrets Scanning)
|
||||||
run: |
|
run: |
|
||||||
# Try to get the latest release asset URL
|
# Try to get the latest release asset URL
|
||||||
@@ -58,7 +89,11 @@ jobs:
|
|||||||
rm -f gitleaks.tar.gz
|
rm -f gitleaks.tar.gz
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: |
|
||||||
|
if ! npm ci; then
|
||||||
|
echo "WARNING: npm ci fehlgeschlagen (Lockfile-Drift?). Fallback auf npm install."
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
@@ -82,3 +117,64 @@ jobs:
|
|||||||
./osv-scanner --version
|
./osv-scanner --version
|
||||||
test -f ./package-lock.json
|
test -f ./package-lock.json
|
||||||
./osv-scanner --lockfile ./package-lock.json
|
./osv-scanner --lockfile ./package-lock.json
|
||||||
|
|
||||||
|
deploy-production:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: 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
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -88,6 +88,13 @@ out
|
|||||||
.nuxt
|
.nuxt
|
||||||
dist
|
dist
|
||||||
|
|
||||||
|
# Android / Gradle generated and machine-local files
|
||||||
|
/android-app/.gradle/
|
||||||
|
/android-app/.kotlin/
|
||||||
|
/android-app/**/build/
|
||||||
|
/android-app/local.properties
|
||||||
|
/android-app/gradle-local.properties
|
||||||
|
|
||||||
# Build output (but keep production data!)
|
# Build output (but keep production data!)
|
||||||
.output
|
.output
|
||||||
!.output/.gitkeep
|
!.output/.gitkeep
|
||||||
@@ -143,6 +150,8 @@ Thumbs.db
|
|||||||
# Temporary files
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
temp/webpage-downloads/data/
|
||||||
|
temp/webpage-downloads/*.html
|
||||||
|
|
||||||
# Security tooling artifacts (CI downloads)
|
# Security tooling artifacts (CI downloads)
|
||||||
gitleaks
|
gitleaks
|
||||||
@@ -154,3 +163,7 @@ server/data/**
|
|||||||
!server/data/.gitkeep
|
!server/data/.gitkeep
|
||||||
public/data/**
|
public/data/**
|
||||||
public/uploads/**
|
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)
|
||||||
372
ANDROID_KOTLIN_PLAN.md
Normal file
372
ANDROID_KOTLIN_PLAN.md
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
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`
|
||||||
|
[x] 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
|
||||||
|
- [x] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-/Newsletter-/Galerie-Endpunkte erledigt); Web bleibt bewusst Cookie-basiert
|
||||||
|
- [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
|
||||||
|
[x] 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
|
||||||
|
- [x] Nativer Rich-Text-Editor für CMS-Inhalte ergänzt; Toolbar schreibt Quill-kompatible HTML-Fragmente und speichert denselben HTML-String wie die Web-UI
|
||||||
|
[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
|
||||||
|
[x] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
|
||||||
|
- [x] Zentraler OkHttp-Cache für öffentliche GET-Antworten ergänzt; Offline-Fallback nutzt gecachte Antworten bis 7 Tage
|
||||||
|
- [x] Zentraler Coil-ImageLoader mit gemeinsamem OkHttp-Client, Memory-Cache und 75-MB-Diskcache ergänzt
|
||||||
|
- [x] Verschlüsselte persistente Offline-Daten für geschützte Mitglieder-/CMS-Inhalte mit `EncryptedSharedPreferences` implementiert
|
||||||
|
[x] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
|
||||||
|
- [x] App-Name und neue Galerie-Upload-Texte in deutsche und englische Ressourcen ausgelagert
|
||||||
|
- [x] i18n-Check durchgeführt; ältere Compose-Harttexte bleiben als separate, risikoarme Nachmigration offen
|
||||||
|
[x] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
|
||||||
|
[ ] 20. Tests: Unit-Tests für ViewModels + UI-Tests mit Compose Testing
|
||||||
|
- [x] Erste JVM-Unit-Tests für gemeinsame Formularvalidierung ergänzt
|
||||||
|
- [x] ViewModel-Tests für Auth-/CMS-/Galerie-Flows ergänzen
|
||||||
|
- [ ] Compose-UI-Tests für kritische Screens ergänzen
|
||||||
|
- [x] Hilt androidTest dependencies und `kspAndroidTest` konfiguriert
|
||||||
|
- [x] `HiltTestApplication` in `androidTest`-Manifest gesetzt
|
||||||
|
- [x] `LoginScreenTest` zu `@HiltAndroidTest` migriert und `HiltAndroidRule` hinzugefügt
|
||||||
|
- [x] `TestHiltModules.kt` für androidTest hinzugefügt (Test‑Bindings bereitgestellt)
|
||||||
|
[x] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten)
|
||||||
|
[ ] 22. Analytics: Firebase / Matomo Integration (je nach Datenschutz)
|
||||||
|
[x] 23. Crash-Reporting: Sentry / Crashlytics integrieren
|
||||||
|
[ ] 24. Build/Release: App signing, Release-Notes, Play-Store-Metadaten vorbereiten
|
||||||
|
- [x] Technische Release-Basis vorbereitet: `ANDROID_VERSION_CODE` und `ANDROID_VERSION_NAME` als Gradle-Properties eingeführt (`android-app/gradle.properties`) und im App-Gradle verdrahtet.
|
||||||
|
- [x] Production-Release-Flavor auf Produktiv-Backend parametrisierbar gemacht (`PRODUCTION_API_BASE_URL`, Default `https://harheimertc.de/`).
|
||||||
|
- [x] Release-Signing per sicheren Gradle-Properties vorbereitet (`RELEASE_STORE_FILE`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`, `RELEASE_KEY_PASSWORD`) statt Hardcoding.
|
||||||
|
- [x] `:app:assembleProductionRelease` erfolgreich gebaut (Stand 2026-05-29).
|
||||||
|
- [x] Play-Store-Listing-Basis ergänzt: Datenschutzseite unter `/datenschutz` sowie Skripte für Icon/Feature-Graphic-Export und Screenshot-Anonymisierung inklusive Anleitung (`android-app/PLAYSTORE_ASSETS.md`).
|
||||||
|
- [x] Konto-Lösch-URL für Play Store ergänzt: öffentliche Seite unter `/konto-loeschen` inklusive Prozessbeschreibung.
|
||||||
|
- [ ] Offen: Finales Upload-Keystore + Credentials in CI/Build-Host hinterlegen, Play-Store-Release-Notes und Store-Metadaten pflegen.
|
||||||
|
[ ] 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)
|
||||||
|
- [x] 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 offene Punkte nach Priorität abarbeiten; die API-/Bearer-Basis für die portierten geschützten Android-Screens ist abgeschlossen.
|
||||||
|
|
||||||
|
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.
|
||||||
|
- 2026-05-28: Rich-Text-Editor nativ umgesetzt: `/cms/inhalte` kann Über-uns, Geschichte, TT-Regeln und Satzung mit Android-Toolbar bearbeiten; gespeichert wird weiterhin HTML in `seiten.*` über `PUT /api/config`, kompatibel zur Web-/Quill-Ausgabe.
|
||||||
|
- 2026-05-28: Punkt 14 umgesetzt: Android-Galerie nutzt den strukturierten `/api/galerie/list`-Response, lädt Bilder über Coil aus `/api/media/galerie/:id`, und Admin/Vorstand kann Bilder nativ auswählen, lokal auf JPEG/2000px/85% komprimieren und per Multipart an `/api/galerie/upload` senden.
|
||||||
|
- 2026-05-28: Punkt 18 umgesetzt: `strings.xml` für Deutsch und Englisch ergänzt, App-Label und neue Galerie-Upload-UI auf Ressourcen umgestellt; i18n-Check weist die bestehenden älteren Compose-Harttexte als spätere Nachmigration aus.
|
||||||
|
- 2026-05-28: Punkt 11 abgeschlossen: Android sendet Bearer-Tokens zentral per OkHttp-Interceptor; die portierten geschützten Backend-Endpunkte akzeptieren Cookie- oder Bearer-Authentifizierung. Die Web-UI bleibt absichtlich bei HttpOnly-Cookie-Sessions und muss nicht auf Bearer umgestellt werden.
|
||||||
|
- 2026-05-28: Caching-Teil von Punkt 17 und MVP-D umgesetzt: OkHttp cached öffentliche GET-Antworten und nutzt gecachte Antworten offline, Coil nutzt denselben authentifizierten Client plus Memory-/Diskcache. Geschützte Daten werden bewusst nicht unverschlüsselt im HTTP-Diskcache persistiert.
|
||||||
|
- 2026-05-28: Testbasis für Punkt 20 begonnen: JVM-Unit-Tests für E-Mail- und ISO-Datum-Validierung ergänzt; `:app:testLocalDebugUnitTest` läuft mit `compileSdk 35` grün.
|
||||||
|
- 2026-05-28: Punkte 17, 19, 21 und 23 weiter umgesetzt: geschützte Mitglieder-/CMS-Daten werden verschlüsselt in Keystore-gestützten Preferences gecacht und bei Ladefehlern genutzt; Galerie-Accessibility und Thumbnail-Decoding verbessert; Sentry-Android 8.42.0 über optionalen `SENTRY_DSN`-Gradle-Parameter integriert.
|
||||||
|
- 2026-05-29: Play-Store-Listing-Vorbereitung ergänzt: eigenständige Web-Datenschutzseite (`/datenschutz`) sowie Asset-/Anonymisierungs-Skripte und Anleitung in `android-app/PLAYSTORE_ASSETS.md` hinzugefügt.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
8a) Aktueller Teststatus & Troubleshooting (Stand: 2026-05-28)
|
||||||
|
|
||||||
|
- **Status:** `:app:assembleAndroidTest` läuft durch; `:app:connectedAndroidTest` ist derzeit instabil und schlägt bei Instrumentation-Läufen fehl.
|
||||||
|
- **Beobachtete Probleme:**
|
||||||
|
- Kompilationsfehler in `LoginScreenTest.kt` wegen `HiltTestActivity` (Unresolved reference). Workaround: `createAndroidComposeRule<ComponentActivity>()` + `setContent{}` verwenden, damit `assembleAndroidTest` durchläuft.
|
||||||
|
- Laufzeit-/Device-Probleme bei `connectedAndroidTest`: `com.android.ddmlib.SyncException: Remote object doesn't exist!` und `DELETE_FAILED_INTERNAL_ERROR` beim Deinstallieren von Test-APKs.
|
||||||
|
- `AndroidTestLogcatPlugin` wirft `FileNotFoundException` für erwartete Log-/Crash-Dateien, weil Gradle/UTP manche Device-Artefakte nicht zuverlässig pulled.
|
||||||
|
- Einzelne Instrumentation-Tests (z. B. `CmsActivateResendTest`, `GalleryScreenTest`) zeigen Assertion-Fehlschläge — diese sollten isoliert reproduziert werden.
|
||||||
|
- **Kurzfristige Empfehlungen (nicht ausführen):**
|
||||||
|
- Emulator neu starten und sicherstellen, dass keine veralteten Test-APKs installiert sind.
|
||||||
|
- Manuell: `adb uninstall` der Test-Pakete, dann frisches `adb install -r` des Test-APKs und gezielter Einzeltest via:
|
||||||
|
|
||||||
|
`adb shell am instrument -w -e class <test-class>#<testMethod> de.harheimertc.test/androidx.test.runner.AndroidJUnitRunner`
|
||||||
|
|
||||||
|
parallel `adb logcat -v time > /tmp/harheimertc_live_logcat.txt` laufen lassen, um vollständige Logs zu speichern.
|
||||||
|
- Falls UTP/ddmlib `SyncException` weiter auftritt: Gradle-Parallelität reduzieren, Test-Plugins (z. B. `AndroidTestLogcatPlugin`) temporär deaktivieren oder Tests in kleinere Gruppen splitten.
|
||||||
|
- **Offene Test‑To‑Dos:**
|
||||||
|
- Reproduzierbaren Einzeltest-Run mit vollständigem `logcat` erfassen (derzeit vom Nutzer pausiert).
|
||||||
|
- Flaky Tests isolieren und Hilt/KSP-Setup prüfen, damit `HiltTestActivity`-Importe nicht mehr fehlschlagen.
|
||||||
|
- Langfristig: Tests aufteilen, flaky tests markieren und CI-Job für androidTests gegen UTP-Transient-Fehler härten.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
**CMS-Verbesserungsplan (Analyse → Umsetzung)**
|
||||||
|
|
||||||
|
Ziel: Alle `cms/*`-Screens von rudimentärem Status zu vollständigen, getesteten Admin-Tools weiterentwickeln. Fokus: Datenintegrität, Berechtigungen, bessere UI/UX, Offline-Verhalten und Tests.
|
||||||
|
|
||||||
|
Kurzüberblick (3 Phasen):
|
||||||
|
- Phase A — Analyse (1-2 Tage): Inventar aller CMS-Endpunkte, fehlende CRUD-Workflows identifizieren, Prioritäten setzen (News, Benutzer, Kontaktanfragen, Newsletter, Config). Ergebnis: Aufgabenliste mit Aufwandsschätzung.
|
||||||
|
- Phase B — Implementierung MVP (1-2 Wochen): Kernfunktionen pro Bereich implementieren (News CRUD mit RichText-Vorschau, Benutzerliste + Rollen-Edit, Kontaktanfragen Detail & Antwort-Workflow, Newsletter-Gruppen-Management, Config-Editor inklusive Satzung-PDF-Feld). Unit- / Integrationstests für ViewModels.
|
||||||
|
- Phase C — Harden, UX & Tests (1 Woche): Validierung, Fehlermeldungen, Offline-Caching (verschlüsselt für geschützte Daten), Compose-UI-Tests, Accessibility-, Performance-Feinschliff.
|
||||||
|
|
||||||
|
Detaillierte Aufgaben (priorisiert):
|
||||||
|
- A1: Audit `CmsViewModel`-State vs. Backend-Responses — fehlen Felder/Fehlerfälle? (bereits teilweise umgesetzt)
|
||||||
|
- A2: Prüfen, ob API-Fehler (4xx/5xx) sauber an `FormMessages`/UI gemeldet werden — Standardisiere Fehlermeldungen.
|
||||||
|
- A3: Prüfen, ob `NativeRichTextEditor` HTML speichert, das Web-Editor-kompatibel bleibt (Quill/HTML). Schreibe Roundtrip-Tests.
|
||||||
|
- B1: News-Management
|
||||||
|
- B1.1: News-CRUD: Create/Update/Delete mit Vorschau (RichText-Preview) und Validierung (Titel Pflicht, Inhalt Mindestlänge)
|
||||||
|
- B1.2: Bulk-Aktionen: Sichtbar/Unsichtbar/ExpiresAt setzen
|
||||||
|
- B1.3: Unit-Tests für `NewsViewModel` + `CmsViewModel`-Integrationspfad
|
||||||
|
- B2: Benutzer-Management
|
||||||
|
- B2.1: Rollen-Edit (admin/vorstand/trainer/newsletter) in `CmsBenutzerScreen` (Inline-Action oder Detail-Dialog)
|
||||||
|
- B2.2: Aktiv/Inaktiv Toggle + Resend-Invite (falls API unterstützt)
|
||||||
|
- B2.3: Tests: `CmsViewModel.users()` Verhalten bei Pagination/Leeren Listen
|
||||||
|
- B3: Kontaktanfragen
|
||||||
|
- B3.1: Detailansicht mit Antwort-Option (falls Backend Mail-Sende-Endpunkt vorhanden)
|
||||||
|
- B3.2: Status-Filter (offen/beantwortet) und Bulk-Archiv
|
||||||
|
- B4: Newsletter
|
||||||
|
- B4.1: Entwurf -> Senden Flow mit Preview (falls Backend zulässt)
|
||||||
|
- B4.2: Gruppenverwaltung (CRUD) + Subscribe/Unsubscribe-Preview
|
||||||
|
- B5: Config / Seiten (Inhalte)
|
||||||
|
- B5.1: Sichern/Zurücksetzen von Seiteninhalten mit Undo-Hinweis
|
||||||
|
- B5.2: Satzung: PDF-Upload-Feld und native PDF-Viewer-Integration (falls serverseitig gespeichert)
|
||||||
|
- B5.6: Android-Startseite weiter ausbauen: Nutzer sollen Elemente und Reihenfolge der Startseite selbst zusammenstellen koennen; Detailkonzept und Feinschliff folgen spaeter
|
||||||
|
- B6: Diagnostics / Passwort-Reset-Diagnose
|
||||||
|
- B6.1: Detail-View mit exportierbaren Logs (bei Bedarf)
|
||||||
|
- C1: Offline-/Caching-Strategie
|
||||||
|
- C1.1: Verschlüsseltes lokales Caching für CMS-Daten (EncryptedSharedPreferences/Room)
|
||||||
|
- C1.2: Sync-Strategie: lokale Änderungen buffernd senden, Konflikt-UI
|
||||||
|
- C2: Tests & CI
|
||||||
|
- C2.1: ViewModel-Unit-Tests für alle CMS-Flows
|
||||||
|
- C2.2: Compose-UI-Tests für kritische Pfade (News erstellen, Benutzerrolle ändern, Config speichern)
|
||||||
|
- C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
|
||||||
|
|
||||||
|
Minor UX-Verbesserungen (parallel möglich):
|
||||||
|
- konsistente Buttons/Labels (`Speichern` vs `Inhalt speichern`), Ladezustand-UI, einzeilige Success-/Error-Banner, Inline-Validierungen.
|
||||||
|
|
||||||
|
Deliverables & Milestones:
|
||||||
|
- M1 (nach Analyse): Priorisierte Aufgabenliste + Schätzung (mehrere PRs)
|
||||||
|
- M2 (nach MVP-Implementierung): News + Benutzer + ContactRequests + Config Editor + Tests (smoke)
|
||||||
|
- M3 (Final): Offline, UI-Tests, Accessibility, Performance
|
||||||
|
|
||||||
|
Zeitplanung (empfohlen):
|
||||||
|
- Analyse: 2 Arbeitstage
|
||||||
|
- MVP-Implementierung: 7–10 Arbeitstage
|
||||||
|
- Hardening + Tests: 3–5 Arbeitstage
|
||||||
|
|
||||||
|
Wenn du willst, trage ich die einzelnen Subtickets in unserem lokalen Issue-Tracker (oder als separate TODOs) ein und beginne mit A1/A2.
|
||||||
|
|
||||||
|
**TODO (zum Abhaken) — CMS-Implementierung**
|
||||||
|
|
||||||
|
- [x] A1: Audit `CmsViewModel` vs Backend-Responses (Fehleraggregation implementiert)
|
||||||
|
- [x] A2: Standardisiere API-Fehlerdarstellung in UI (`FormMessages` / globale Errors)
|
||||||
|
- [x] A3: Roundtrip-Tests `NativeRichTextEditor` ↔ Backend-HTML (Kompatibilität / Quill)
|
||||||
|
- [x] B1: News-Management
|
||||||
|
- [x] B1.1: News-CRUD (Create/Update/Delete) mit RichText-Vorschau
|
||||||
|
- [x] B1.2: Bulk-Aktionen (sichtbar/unsichtbar, expiresAt)
|
||||||
|
- [x] B1.3: Unit-Tests für `NewsViewModel`
|
||||||
|
|
||||||
|
- [x] B2: Benutzer-Management
|
||||||
|
- [x] B2.1: Rollen-Edit (Inline oder Detail-Dialog)
|
||||||
|
- [x] B2.2: Aktiv/Inaktiv Toggle, Resend-Invite
|
||||||
|
- [x] B2.3: Tests für Pagination/Leere Listen
|
||||||
|
|
||||||
|
- [x] B3: Kontaktanfragen
|
||||||
|
- [x] B3.1: Detailansicht + Antwort-Option
|
||||||
|
- [x] B3.2: Status-Filter + Archiv
|
||||||
|
|
||||||
|
- [ ] B4: Newsletter
|
||||||
|
- [x] B4.1: Entwurf → Senden Flow mit Preview
|
||||||
|
- [x] B4.2: Gruppenverwaltung (CRUD)
|
||||||
|
|
||||||
|
- [x] B5: Config / Seiten
|
||||||
|
- Web‑Status: Die Web‑UI bietet bereits umfassende CMS‑UIs für `cms/startseite`, `cms/vereinsmeisterschaften`, `cms/sportbetrieb` und `cms/einstellungen` (Drag&Drop, CSV‑Import/Export, Tabbed‑UIs, ImageUpload, native‑like Modals). `cms/startseite` speichert `homepage.sections` via `PUT /api/config`, `vereinsmeisterschaften` arbeitet mit CSV‑Export/Import, `sportbetrieb` kapselt Termine/Mannschaften/Spielpläne in Tabs, `einstellungen` ist ein umfangreicher Config‑Editor.
|
||||||
|
- Android‑Status: Implementiert — die Android‑App enthält native CMS‑Screens (`CmsStartseiteScreen`, `CmsVereinsmeisterschaftenScreen`, `CmsSportbetriebScreen`, `CmsEinstellungenScreen`) mit Save/Load‑Flows via `CmsViewModel`.
|
||||||
|
- Umsetzung (B5.x):
|
||||||
|
- [x] B5.1: `cms/startseite` (Startseiten‑Layout) — Reorderable/Visibility + Save → `PUT /api/config` (via `CmsViewModel`).
|
||||||
|
- [x] B5.2: `cms/vereinsmeisterschaften` — CSV‑Parser/CSV‑Save integration and modal CRUD (native UI present).
|
||||||
|
- [x] B5.3: `cms/sportbetrieb` — Tabbed UI reusing `Termine`, `Mannschaften`, `Spielplan` components.
|
||||||
|
- [x] B5.4: `cms/einstellungen` — Tabbed config editor with Vereinsdaten/Training/Trainer/Mitgliedschaft and save.
|
||||||
|
- [x] B5.5: Roundtrip & Tests — basic ViewModel unit tests and roundtrip checks exist; Compose UI smoke tests remain for hardening.
|
||||||
|
- [x] B5.6: Startseite weiter ausgebaut — zusaetzliche Elemente (`training`, `links`, `vereinsmeisterschaften`) sind konfigurierbar; Android kann Reihenfolge/Sichtbarkeit lokal speichern und Web nutzt Marker (`cookie`, `eingeloggt`) mit marker-spezifischer Persistenz: `eingeloggt` wird als individuelles User-Setting serverseitig gespeichert, `cookie` wird ausschliesslich im Browser-Cookie gehalten. Neu umgesetzt: konfigurierbare Startseiten-Widgets vom Typ `spielplan_team` (Saison + Mannschaft beim Hinzufuegen waehlbar, spaeter jederzeit aenderbar, mehrfach pro Startseite moeglich, persistiert ueber `key` + `config`).
|
||||||
|
|
||||||
|
- [x] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail)
|
||||||
|
- Web‑Status: `cms/passwort-reset-diagnose` zeigt vollständige Diagnose‑UI mit Suche, Maskierung, Filter (nur Auffälligkeiten) und listbaren Reset‑Versuchen; Backend: `/api/cms/password-reset-diagnostics` liefert `matchingUsers`, `attempts`, `retentionHours`.
|
||||||
|
- Android‑Status: umgesetzt — native Diagnose‑UI mit Suche (`email`/Name), Filter `Nur Auffälligkeiten`, `matchingUsers`‑Liste mit Schnellfilter, detaillierter Schrittansicht je Versuch (Zeit/Schritt/Status/Grund), Refresh und Share‑Export der maskierten Logs.
|
||||||
|
- Konkrete Android‑ToDos (B6.x):
|
||||||
|
- [x] B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, Status‑Badges und Details.
|
||||||
|
- [x] B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: E‑Mail Maskierung beibehalten.
|
||||||
|
|
||||||
|
- [x] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten)
|
||||||
|
- Umgesetzt: EncryptedSharedPreferences-basierter Offline-Cache mit Zeitstempel/TTL pro Cache-Key (CMS standard 24h, Reset-Diagnose 6h).
|
||||||
|
- Umgesetzt: Fallback auf verschlüsselte Cache-Daten bei Ladefehlern nur innerhalb der TTL, um veraltete geschützte CMS-Daten zu begrenzen.
|
||||||
|
- Umgesetzt: Gezielte Cache-Invalidierung bei schreibenden CMS-Operationen (Konfiguration, Benutzerverwaltung, Kontaktanfragen, Newsletter, interne News), damit Offline-Daten nach Änderungen konsistent bleiben.
|
||||||
|
- Umgesetzt: Passwort-Reset-Diagnose-Cache wird nur für den Standardfilter (ohne Suchbegriff) verwendet, um falsche Treffer bei gefilterten Diagnosen zu vermeiden.
|
||||||
|
- [x] C2: Tests & CI
|
||||||
|
- [x] C2.1: ViewModel-Unit-Tests für CMS-Flows (`CmsViewModel.load()` / `saveConfig()`)
|
||||||
|
- Status: `:app:testLocalDebugUnitTest` läuft grün; `CmsViewModelTest` wurde auf aktuelle Repository-Signaturen und vollständige `load()`-Abhängigkeiten (inkl. `vereinsmeisterschaften`) aktualisiert.
|
||||||
|
- [x] C2.2: Compose-UI-Tests für kritische Flows
|
||||||
|
- Status: neuer Instrumentation-Test für `CmsPasswordResetDiagnosticsScreen` ergänzt (`diagnosticsScreen_showsFilterAndAttemptDetails`) und gezielt per `connectedLocalDebugAndroidTest` erfolgreich ausgeführt.
|
||||||
|
- [x] C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
|
||||||
|
- Status: androidTest-ApiService-Stubs und Hilt-Testmodul auf neue `passwordResetDiagnostics(email, failedOnly)`-Signatur erweitert; `:app:assembleLocalDebugAndroidTest` läuft grün.
|
||||||
|
|
||||||
|
Markiere die Items, wenn erledigt — ich kann die einzelnen Punkte jetzt in Branches/PRs umsetzen.
|
||||||
|
|
||||||
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).
|
||||||
92
android-app/PLAYSTORE_ASSETS.md
Normal file
92
android-app/PLAYSTORE_ASSETS.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Play Store Assets - Harheimer TC Android
|
||||||
|
|
||||||
|
## 1) Datenschutzerklaerung (Web-URL)
|
||||||
|
|
||||||
|
Empfohlene URL fuer Play Console:
|
||||||
|
- https://harheimertc.de/datenschutz
|
||||||
|
|
||||||
|
Die Seite ist in der Web-App als eigene Route vorhanden.
|
||||||
|
|
||||||
|
## 1b) Konto-Loeschung (Web-URL)
|
||||||
|
|
||||||
|
Empfohlene URL fuer Play Console:
|
||||||
|
- https://harheimertc.de/konto-loeschen
|
||||||
|
|
||||||
|
Die Seite beschreibt den Loeschprozess und Kontaktweg fuer App- und Webkonto.
|
||||||
|
|
||||||
|
## 2) Logo / Grafiken
|
||||||
|
|
||||||
|
### Pflicht
|
||||||
|
- App-Icon (Play): 512 x 512 PNG
|
||||||
|
|
||||||
|
### Optional, aber empfohlen
|
||||||
|
- Feature Graphic: 1024 x 500 PNG
|
||||||
|
|
||||||
|
### Generierung
|
||||||
|
|
||||||
|
Im Repo ist ein Script vorhanden, das aus dem Vereinslogo fertige Dateien erzeugt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/playstore-assets.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ausgabe in:
|
||||||
|
- android-app/playstore-assets/generated/playstore-icon-512.png
|
||||||
|
- android-app/playstore-assets/generated/playstore-feature-graphic-1024x500.png
|
||||||
|
|
||||||
|
## 3) Screenshots (anonymisiert)
|
||||||
|
|
||||||
|
### Zielgroessen fuer Store-Upload
|
||||||
|
- Telefon (Portrait): 1080 x 1920
|
||||||
|
- Medium 7" Tablet (Portrait): 1200 x 1920
|
||||||
|
- 10" Tablet (Portrait): 1600 x 2560
|
||||||
|
|
||||||
|
Alle Dateien als PNG oder JPEG.
|
||||||
|
|
||||||
|
### Anonymisierung
|
||||||
|
|
||||||
|
Script fuer schwarze halbtransparente Balken ueber sensible Bereiche:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/anonymize-playstore-screenshot.sh <input.png> <output.png> 'x,y,w,h;x,y,w,h'
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/anonymize-playstore-screenshot.sh \
|
||||||
|
android-app/playstore-assets/raw/screen1.png \
|
||||||
|
android-app/playstore-assets/anon/screen1-anon.png \
|
||||||
|
'68,118,520,72;70,706,560,98'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zielprofile erzeugen (Telefon, 7", 10")
|
||||||
|
|
||||||
|
Aus allen Dateien in `android-app/playstore-assets/anon` werden die drei Profile erzeugt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/playstore-screenshot-sizes.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional mit eigenen Ordnern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/playstore-screenshot-sizes.sh \
|
||||||
|
--input-dir android-app/playstore-assets/anon \
|
||||||
|
--output-dir android-app/playstore-assets/final
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- android-app/playstore-assets/final/phone
|
||||||
|
- android-app/playstore-assets/final/tablet-7
|
||||||
|
- android-app/playstore-assets/final/tablet-10
|
||||||
|
|
||||||
|
## 4) Upload in Play Console
|
||||||
|
|
||||||
|
- Datenschutzerklaerung: URL eintragen
|
||||||
|
- Konto-Loeschung: URL eintragen
|
||||||
|
- App-Icon: playstore-icon-512.png
|
||||||
|
- Feature Graphic: playstore-feature-graphic-1024x500.png
|
||||||
|
- Screenshots Telefon: Dateien aus `.../final/phone`
|
||||||
|
- Screenshots 7" Tablet: Dateien aus `.../final/tablet-7`
|
||||||
|
- Screenshots 10" Tablet: Dateien aus `.../final/tablet-10`
|
||||||
281
android-app/app/build.gradle.kts
Normal file
281
android-app/app/build.gradle.kts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
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("https://harheimertc.tsschulz.de/")
|
||||||
|
.get()
|
||||||
|
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
||||||
|
.orElse("https://harheimertc.de/")
|
||||||
|
.get()
|
||||||
|
val expectedProductionApiBaseUrl = "https://harheimertc.de/"
|
||||||
|
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||||
|
.orElse("")
|
||||||
|
.get()
|
||||||
|
val androidVersionCode = providers.gradleProperty("ANDROID_VERSION_CODE")
|
||||||
|
.orElse("2")
|
||||||
|
.get()
|
||||||
|
.toInt()
|
||||||
|
val androidVersionName = providers.gradleProperty("ANDROID_VERSION_NAME")
|
||||||
|
.orElse("1.0.0")
|
||||||
|
.get()
|
||||||
|
val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
|
||||||
|
.orElse("true")
|
||||||
|
.get()
|
||||||
|
.toBoolean()
|
||||||
|
|
||||||
|
val localSigningProperties = Properties().apply {
|
||||||
|
val localSigningFile = rootProject.file("gradle-local.properties")
|
||||||
|
if (localSigningFile.exists()) {
|
||||||
|
localSigningFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signingProperty(name: String): String? =
|
||||||
|
providers.gradleProperty(name).orNull
|
||||||
|
?: providers.environmentVariable(name).orNull
|
||||||
|
?: localSigningProperties.getProperty(name)
|
||||||
|
|
||||||
|
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
|
||||||
|
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
|
||||||
|
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
|
||||||
|
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
|
||||||
|
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
||||||
|
!releaseStorePassword.isNullOrBlank() &&
|
||||||
|
!releaseKeyAlias.isNullOrBlank() &&
|
||||||
|
!releaseKeyPassword.isNullOrBlank()
|
||||||
|
|
||||||
|
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
|
||||||
|
doFirst {
|
||||||
|
if (!hasReleaseSigning) {
|
||||||
|
throw GradleException(
|
||||||
|
"Production release signing is not configured. " +
|
||||||
|
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
|
||||||
|
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
|
||||||
|
doFirst {
|
||||||
|
if (productionApiBaseUrl != expectedProductionApiBaseUrl) {
|
||||||
|
throw GradleException(
|
||||||
|
"Production Play Store builds must use $expectedProductionApiBaseUrl, but PRODUCTION_API_BASE_URL is $productionApiBaseUrl."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "de.harheimertc"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "de.harheimertc"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = androidVersionCode
|
||||||
|
versionName = androidVersionName
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
storeFile = file(releaseStoreFile!!)
|
||||||
|
storePassword = releaseStorePassword
|
||||||
|
keyAlias = releaseKeyAlias
|
||||||
|
keyPassword = releaseKeyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = releaseMinifyEnabled
|
||||||
|
isShrinkResources = false
|
||||||
|
ndk {
|
||||||
|
// Generate a native debug symbols archive for Play Console uploads.
|
||||||
|
debugSymbolLevel = "SYMBOL_TABLE"
|
||||||
|
}
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flavorDimensions += "environment"
|
||||||
|
productFlavors {
|
||||||
|
create("local") {
|
||||||
|
dimension = "environment"
|
||||||
|
applicationIdSuffix = ".local"
|
||||||
|
versionNameSuffix = "-local"
|
||||||
|
buildConfigField("String", "API_BASE_URL", "\"$localApiBaseUrl\"")
|
||||||
|
buildConfigField("String", "SENTRY_DSN", "\"\"")
|
||||||
|
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", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||||
|
buildConfigField("String", "ENVIRONMENT_NAME", "\"TEST\"")
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
}
|
||||||
|
create("production") {
|
||||||
|
dimension = "environment"
|
||||||
|
buildConfigField("String", "API_BASE_URL", "\"$productionApiBaseUrl\"")
|
||||||
|
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||||
|
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.all {
|
||||||
|
// allow Byte Buddy experimental features for newer JVMs
|
||||||
|
it.jvmArgs = listOf("-Dnet.bytebuddy.experimental=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("packageNativeDebugSymbolsForProductionRelease") {
|
||||||
|
group = "distribution"
|
||||||
|
description = "Packages production release native libraries into a Play Console debug symbols zip."
|
||||||
|
dependsOn(":app:mergeProductionReleaseNativeLibs")
|
||||||
|
from(layout.buildDirectory.dir("intermediates/merged_native_libs/productionRelease/mergeProductionReleaseNativeLibs/out/lib"))
|
||||||
|
destinationDirectory.set(layout.buildDirectory.dir("outputs/native-debug-symbols/productionRelease"))
|
||||||
|
archiveFileName.set("native-debug-symbols.zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||||
|
group = "distribution"
|
||||||
|
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
||||||
|
dependsOn(ensureReleaseSigning)
|
||||||
|
dependsOn(ensureProductionApiBaseUrl)
|
||||||
|
dependsOn(":app:bundleProductionRelease")
|
||||||
|
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
val outputDir = layout.buildDirectory.dir("outputs/playstore/productionRelease").get().asFile
|
||||||
|
outputDir.mkdirs()
|
||||||
|
|
||||||
|
val bundleFile = layout.buildDirectory.file("outputs/bundle/productionRelease/app-production-release.aab").get().asFile
|
||||||
|
if (bundleFile.exists()) {
|
||||||
|
bundleFile.copyTo(outputDir.resolve(bundleFile.name), overwrite = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mappingFile = layout.buildDirectory.file("outputs/mapping/productionRelease/mapping.txt").get().asFile
|
||||||
|
if (mappingFile.exists()) {
|
||||||
|
mappingFile.copyTo(outputDir.resolve("mapping.txt"), overwrite = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val nativeSymbolsZip = layout.buildDirectory.file("outputs/native-debug-symbols/productionRelease/native-debug-symbols.zip").get().asFile
|
||||||
|
if (nativeSymbolsZip.exists()) {
|
||||||
|
nativeSymbolsZip.copyTo(outputDir.resolve("native-debug-symbols.zip"), overwrite = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Play Store artifacts prepared in: ${outputDir.absolutePath}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.matching {
|
||||||
|
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
|
||||||
|
}.configureEach {
|
||||||
|
dependsOn(ensureReleaseSigning)
|
||||||
|
dependsOn(ensureProductionApiBaseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Crash reporting
|
||||||
|
implementation("io.sentry:sentry-android:8.42.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")
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||||
|
testImplementation("io.mockk:mockk:1.13.7")
|
||||||
|
// Compose UI testing
|
||||||
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||||
|
// Hilt testing
|
||||||
|
androidTestImplementation("com.google.dagger:hilt-android-testing:2.59.2")
|
||||||
|
// Ensure Hilt runtime is available in the test APK so HiltTestApplication can be instantiated
|
||||||
|
androidTestImplementation("com.google.dagger:hilt-android:2.59.2")
|
||||||
|
kspAndroidTest("com.google.dagger:hilt-compiler:2.59.2")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.0")
|
||||||
|
}
|
||||||
BIN
android-app/app/instantTest/release/app-instantTest-release.aab
Normal file
BIN
android-app/app/instantTest/release/app-instantTest-release.aab
Normal file
Binary file not shown.
BIN
android-app/app/local/release/app-local-release.aab
Normal file
BIN
android-app/app/local/release/app-local-release.aab
Normal file
Binary file not shown.
BIN
android-app/app/production/release/app-production-release.aab
Normal file
BIN
android-app/app/production/release/app-production-release.aab
Normal file
Binary file not shown.
37
android-app/app/proguard-rules.pro
vendored
Normal file
37
android-app/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Project-specific R8/ProGuard rules for release builds.
|
||||||
|
|
||||||
|
# Keep reflection/generic metadata used by Retrofit + Moshi.
|
||||||
|
-keepattributes Signature,InnerClasses,EnclosingMethod
|
||||||
|
-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault
|
||||||
|
-keep class kotlin.Metadata { *; }
|
||||||
|
|
||||||
|
# Keep Retrofit service interfaces and HTTP method annotations.
|
||||||
|
-keep,allowobfuscation interface * {
|
||||||
|
@retrofit2.http.* <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retrofit + R8 full mode: keep interfaces with HTTP methods and suspend continuation generics.
|
||||||
|
-if interface * { @retrofit2.http.* <methods>; }
|
||||||
|
-keep,allowobfuscation interface <1>
|
||||||
|
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||||
|
|
||||||
|
# Avoid Retrofit generic signature loss on release builds for our API interface.
|
||||||
|
-keep interface de.harheimertc.data.ApiService { *; }
|
||||||
|
-keepclassmembers interface de.harheimertc.data.ApiService { *; }
|
||||||
|
|
||||||
|
# Keep app DTO/request/response models used via Moshi reflection.
|
||||||
|
-keep class de.harheimertc.data.*Dto { *; }
|
||||||
|
-keep class de.harheimertc.data.*Request { *; }
|
||||||
|
-keep class de.harheimertc.data.*Response { *; }
|
||||||
|
|
||||||
|
# Keep fields annotated with @Json names.
|
||||||
|
-keepclassmembers class * {
|
||||||
|
@com.squareup.moshi.Json <fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep WorkManager + Room generated classes used reflectively at startup.
|
||||||
|
-keep class * extends androidx.work.ListenableWorker {
|
||||||
|
<init>(android.content.Context, androidx.work.WorkerParameters);
|
||||||
|
}
|
||||||
|
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
7
android-app/app/src/androidTest/AndroidManifest.xml
Normal file
7
android-app/app/src/androidTest/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application android:allowBackup="false">
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Disabled TestBindingsModule — replaced by TestHiltModules.kt
|
||||||
|
// Kept as an empty placeholder to avoid accidental compilation of the previous
|
||||||
|
// broken test module. Refer to TestHiltModules.kt for test bindings.
|
||||||
|
package de.harheimertc.test
|
||||||
|
|
||||||
|
// Intentionally empty
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package de.harheimertc.test
|
||||||
|
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.hilt.testing.TestInstallIn
|
||||||
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.AuthStatusResponse
|
||||||
|
import de.harheimertc.data.LoginRequest
|
||||||
|
import de.harheimertc.data.LoginResponse
|
||||||
|
import de.harheimertc.data.AuthUserDto
|
||||||
|
import de.harheimertc.repositories.AuthRepository
|
||||||
|
import de.harheimertc.data.SessionRefresher
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import retrofit2.Response
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import java.lang.reflect.InvocationHandler
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.lang.reflect.Proxy
|
||||||
|
import de.harheimertc.repositories.LoginRepository
|
||||||
|
import de.harheimertc.repositories.PasskeyRepository
|
||||||
|
import de.harheimertc.repositories.AuthRepository as RepoAuthRepository
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@TestInstallIn(
|
||||||
|
components = [SingletonComponent::class],
|
||||||
|
replaces = [de.harheimertc.data.NetworkModule::class, de.harheimertc.di.RepositoryModule::class]
|
||||||
|
)
|
||||||
|
object TestHiltModules {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideMoshi(): Moshi = Moshi.Builder().build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideApiService(): ApiService {
|
||||||
|
val handler = InvocationHandler { _, method: Method, args: Array<Any>? ->
|
||||||
|
when (method.name) {
|
||||||
|
"login" -> Response.success(LoginResponse(success = true, accessToken = "test-token", refreshToken = "r", sessionId = "s", user = AuthUserDto(id = "1", email = "test@example.com", name = "Test")))
|
||||||
|
"authStatus" -> Response.success(AuthStatusResponse(isLoggedIn = false))
|
||||||
|
"publicNews" -> Response.success(de.harheimertc.data.NewsPublicResponse(news = listOf()))
|
||||||
|
"memberNews" -> Response.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||||
|
"passwordResetDiagnostics" -> Response.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||||
|
else -> throw UnsupportedOperationException("ApiService method not implemented in test double: ${method.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Proxy.newProxyInstance(
|
||||||
|
ApiService::class.java.classLoader,
|
||||||
|
arrayOf(ApiService::class.java),
|
||||||
|
handler,
|
||||||
|
) as ApiService
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthRepository(): AuthRepository = object : AuthRepository {
|
||||||
|
private var token: String? = "test-token"
|
||||||
|
private var refresh: String? = "r"
|
||||||
|
override fun getToken(): String? = token
|
||||||
|
override fun getRefreshToken(): String? = refresh
|
||||||
|
override fun getSessionId(): String? = "s"
|
||||||
|
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
|
||||||
|
token = accessToken
|
||||||
|
refresh = refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearSession() { token = null; refresh = null }
|
||||||
|
override fun ensureDeviceKey(): String? = null
|
||||||
|
override fun getDevicePublicKey(): String? = null
|
||||||
|
override fun signWithDeviceKey(data: ByteArray): ByteArray? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSessionRefresher(auth: AuthRepository, moshi: Moshi): SessionRefresher = SessionRefresher(auth, moshi)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideLoginRepository(api: ApiService, auth: AuthRepository, sessionRefresher: SessionRefresher): LoginRepository {
|
||||||
|
return LoginRepository(api, auth, sessionRefresher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePasskeyRepository(api: ApiService, auth: AuthRepository): PasskeyRepository {
|
||||||
|
return PasskeyRepository(api, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package de.harheimertc.ui
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
|
||||||
|
class TestActivity : ComponentActivity()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CmsActivateResendTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun activateAndResend_buttonsAreClickable() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Deaktivieren") }
|
||||||
|
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Invite erneut") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait until nodes appear to avoid race conditions on slower devices
|
||||||
|
fun waitForText(text: String, timeoutMs: Long = 15000L) {
|
||||||
|
try {
|
||||||
|
composeTestRule.waitUntil(timeoutMs) {
|
||||||
|
try {
|
||||||
|
composeTestRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
} catch (_: AssertionError) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
// dump semantics tree for debugging before failing
|
||||||
|
try {
|
||||||
|
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE")
|
||||||
|
} catch (_: Throwable) { /* best-effort logging */ }
|
||||||
|
throw AssertionError("Timed out waiting for text: '$text'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper: find the nearest parent node that has a click action
|
||||||
|
fun findClickableParent(text: String): SemanticsNodeInteraction {
|
||||||
|
val all = composeTestRule.onAllNodes(hasText(text))
|
||||||
|
if (all.fetchSemanticsNodes().isEmpty()) {
|
||||||
|
try {
|
||||||
|
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NOT-FOUND-$text")
|
||||||
|
} catch (_: Throwable) { }
|
||||||
|
throw AssertionError("No node found with text '$text'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log matches for debugging
|
||||||
|
try {
|
||||||
|
val matches = all.fetchSemanticsNodes()
|
||||||
|
Log.d("CmsActivateResendTest", "Found ${matches.size} node(s) for text '$text'")
|
||||||
|
matches.forEachIndexed { i, n -> Log.d("CmsActivateResendTest", "Match[$i]: ${n}") }
|
||||||
|
} catch (_: Throwable) { /* ignore logging failures */ }
|
||||||
|
|
||||||
|
var node = try {
|
||||||
|
// prefer the single-node API, but fall back to the first match if ambiguous
|
||||||
|
composeTestRule.onNode(hasText(text))
|
||||||
|
} catch (_: AssertionError) {
|
||||||
|
all[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// climb a few parents to find the clickable wrapper
|
||||||
|
repeat(8) {
|
||||||
|
try {
|
||||||
|
node.assert(hasClickAction())
|
||||||
|
try { Log.d("CmsActivateResendTest", "Clickable node found for '$text': ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
|
||||||
|
return node
|
||||||
|
} catch (_: AssertionError) {
|
||||||
|
try { Log.d("CmsActivateResendTest", "Node not clickable yet, current node: ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
|
||||||
|
node = node.onParent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NO-CLICK-$text")
|
||||||
|
} catch (_: Throwable) { }
|
||||||
|
throw AssertionError("No clickable parent found for text '$text'")
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForText("Deaktivieren")
|
||||||
|
val deactivateNode = findClickableParent("Deaktivieren")
|
||||||
|
deactivateNode.assertExists()
|
||||||
|
deactivateNode.assertIsDisplayed()
|
||||||
|
deactivateNode.assert(hasClickAction())
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
try {
|
||||||
|
deactivateNode.performClick()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-DEACTIVATE")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForText("Invite erneut")
|
||||||
|
val inviteNode = findClickableParent("Invite erneut")
|
||||||
|
inviteNode.assertExists()
|
||||||
|
inviteNode.assertIsDisplayed()
|
||||||
|
inviteNode.assert(hasClickAction())
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
try {
|
||||||
|
inviteNode.performClick()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-INVITE")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.AuthMessageResponse
|
||||||
|
import de.harheimertc.data.AuthStatusResponse
|
||||||
|
import de.harheimertc.data.BirthdaysResponse
|
||||||
|
import de.harheimertc.data.CmsUserDto
|
||||||
|
import de.harheimertc.data.CmsUsersResponse
|
||||||
|
import de.harheimertc.data.ConfigResponse
|
||||||
|
import de.harheimertc.data.ContactRequest
|
||||||
|
import de.harheimertc.data.ContactRequestDto
|
||||||
|
import de.harheimertc.data.ContactResponse
|
||||||
|
import de.harheimertc.data.LoginRequest
|
||||||
|
import de.harheimertc.data.LoginResponse
|
||||||
|
import de.harheimertc.data.LogoutRequest
|
||||||
|
import de.harheimertc.data.MembersResponse
|
||||||
|
import de.harheimertc.data.MembershipRequest
|
||||||
|
import de.harheimertc.data.MembershipResponse
|
||||||
|
import de.harheimertc.data.NewsletterCreateRequest
|
||||||
|
import de.harheimertc.data.NewsletterCreateResponse
|
||||||
|
import de.harheimertc.data.NewsletterDto
|
||||||
|
import de.harheimertc.data.NewsletterGroupDto
|
||||||
|
import de.harheimertc.data.NewsletterGroupsResponse
|
||||||
|
import de.harheimertc.data.NewsletterListResponse
|
||||||
|
import de.harheimertc.data.NewsletterSendResponse
|
||||||
|
import de.harheimertc.data.NewsletterSubscriptionRequest
|
||||||
|
import de.harheimertc.data.NewsPublicResponse
|
||||||
|
import de.harheimertc.data.NewsResponse
|
||||||
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
|
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
|
||||||
|
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
|
||||||
|
import de.harheimertc.data.PasskeysResponse
|
||||||
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
|
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||||
|
import de.harheimertc.data.ProfileResponse
|
||||||
|
import de.harheimertc.data.ProfileUpdateRequest
|
||||||
|
import de.harheimertc.data.PublicGalleryImageDto
|
||||||
|
import de.harheimertc.data.GalleryListResponse
|
||||||
|
import de.harheimertc.data.GalleryUploadResponse
|
||||||
|
import de.harheimertc.data.RefreshRequest
|
||||||
|
import de.harheimertc.data.RegistrationRequest
|
||||||
|
import de.harheimertc.data.RemovePasskeyRequest
|
||||||
|
import de.harheimertc.data.ResetPasswordRequest
|
||||||
|
import de.harheimertc.data.SaveCsvRequest
|
||||||
|
import de.harheimertc.data.SaveCsvResponse
|
||||||
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
|
import de.harheimertc.data.SpielplanResponse
|
||||||
|
import de.harheimertc.data.TeamTableResponse
|
||||||
|
import de.harheimertc.data.TermineResponse
|
||||||
|
import de.harheimertc.repositories.CmsRepository
|
||||||
|
import de.harheimertc.repositories.MeisterschaftResult
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CmsExistingScreensSmokeTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsDashboard_renders() {
|
||||||
|
val api = RecordingApiService()
|
||||||
|
val viewModel = createViewModel(api)
|
||||||
|
renderWithState(viewModel)
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsDashboardScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Vereinsmeisterschaften", substring = true).assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsStartseite_saveWorks() {
|
||||||
|
val api = RecordingApiService()
|
||||||
|
val viewModel = createViewModel(api)
|
||||||
|
renderWithState(viewModel)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertTrue(api.updateConfigCalls >= 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsInhalte_saveWorks() {
|
||||||
|
val api = RecordingApiService()
|
||||||
|
val viewModel = createViewModel(api)
|
||||||
|
renderWithState(viewModel)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsInhalteScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Inhalte speichern").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertTrue(api.updateConfigCalls >= 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsVereinsmeisterschaften_saveWorks() {
|
||||||
|
val api = RecordingApiService()
|
||||||
|
val viewModel = createViewModel(api)
|
||||||
|
renderWithState(viewModel)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsVereinsmeisterschaftenScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertEquals(1, api.saveCsvCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsSportbetrieb_saveWorks() {
|
||||||
|
val api = RecordingApiService()
|
||||||
|
val viewModel = createViewModel(api)
|
||||||
|
renderWithState(viewModel)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsSportbetriebScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertTrue(api.updateConfigCalls >= 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsEinstellungen_saveWorks() {
|
||||||
|
val api = RecordingApiService()
|
||||||
|
val viewModel = createViewModel(api)
|
||||||
|
renderWithState(viewModel)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsEinstellungenScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertTrue(api.updateConfigCalls >= 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsMitgliederverwaltung_clickFreischalten() {
|
||||||
|
val api = RecordingApiService()
|
||||||
|
val viewModel = createViewModel(api)
|
||||||
|
renderWithState(viewModel)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsMitgliederverwaltungScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Freischalten").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertEquals(1, api.updateUserActiveCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsBenutzer_clickRollenSpeichern() {
|
||||||
|
val api = RecordingApiService()
|
||||||
|
val viewModel = createViewModel(api)
|
||||||
|
renderWithState(viewModel)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsBenutzerScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Rollen").performClick()
|
||||||
|
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertEquals(1, api.updateUserRolesCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsContactRequests_replySenden() {
|
||||||
|
val api = RecordingApiService()
|
||||||
|
val viewModel = createViewModel(api)
|
||||||
|
renderWithState(viewModel)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsContactRequestsScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Antworten").performClick()
|
||||||
|
composeTestRule.onNode(hasSetTextAction()).performTextInput("Kurze Testantwort")
|
||||||
|
composeTestRule.onNodeWithText("Senden").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertEquals(1, api.replyContactCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsNewsletter_createAndSave() {
|
||||||
|
val api = RecordingApiService()
|
||||||
|
val viewModel = createViewModel(api)
|
||||||
|
renderWithState(viewModel)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsNewsletterScreen(nav, showBackNavigation = false, viewModel = viewModel, canWriteOverride = true)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Newsletter erstellen").performClick()
|
||||||
|
composeTestRule.onNode(hasSetTextAction()).performTextInput("Testnewsletter")
|
||||||
|
composeTestRule.onAllNodes(hasText("Speichern")).onFirst().performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
assertEquals(1, api.createNewsletterCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsPasswordResetDiagnostics_renders() {
|
||||||
|
val api = RecordingApiService()
|
||||||
|
val viewModel = createViewModel(api)
|
||||||
|
renderWithState(viewModel)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsPasswordResetDiagnosticsScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Passwort-Reset-Diagnose", substring = true).assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createViewModel(api: RecordingApiService): CmsViewModel {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val cache = SecureOfflineCache(context, Moshi.Builder().build())
|
||||||
|
val repository = CmsRepository(api, cache)
|
||||||
|
return CmsViewModel(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderWithState(viewModel: CmsViewModel) {
|
||||||
|
val readyState = CmsUiState(
|
||||||
|
loading = false,
|
||||||
|
saving = false,
|
||||||
|
error = null,
|
||||||
|
message = null,
|
||||||
|
config = ConfigResponse(),
|
||||||
|
users = listOf(
|
||||||
|
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
|
||||||
|
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
|
||||||
|
),
|
||||||
|
contactRequests = listOf(
|
||||||
|
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
|
||||||
|
),
|
||||||
|
newsletters = listOf(
|
||||||
|
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
|
||||||
|
),
|
||||||
|
newsletterGroups = listOf(
|
||||||
|
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
|
||||||
|
),
|
||||||
|
passwordResetAttempts = listOf(
|
||||||
|
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
|
||||||
|
),
|
||||||
|
news = emptyList(),
|
||||||
|
meisterschaften = listOf(
|
||||||
|
MeisterschaftResult(year = "2025", category = "Herren", rank = "1", playerOne = "Erika Muster", playerTwo = "", note = "Titel verteidigt", imageOne = "", imageTwo = ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val field = CmsViewModel::class.java.getDeclaredField("_state")
|
||||||
|
field.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(field.get(viewModel) as MutableStateFlow<CmsUiState>).value = readyState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingApiService : ApiService {
|
||||||
|
var updateConfigCalls = 0
|
||||||
|
var saveCsvCalls = 0
|
||||||
|
var updateUserActiveCalls = 0
|
||||||
|
var updateUserRolesCalls = 0
|
||||||
|
var replyContactCalls = 0
|
||||||
|
var createNewsletterCalls = 0
|
||||||
|
|
||||||
|
private val config = ConfigResponse()
|
||||||
|
private val users = listOf(
|
||||||
|
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
|
||||||
|
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
|
||||||
|
)
|
||||||
|
private val contactRequests = listOf(
|
||||||
|
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
|
||||||
|
)
|
||||||
|
private val newsletters = listOf(
|
||||||
|
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
|
||||||
|
)
|
||||||
|
private val groups = listOf(
|
||||||
|
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
|
||||||
|
)
|
||||||
|
private val diagnostics = listOf(
|
||||||
|
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
|
||||||
|
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
|
||||||
|
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
|
||||||
|
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
|
||||||
|
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
|
||||||
|
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
|
||||||
|
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
|
||||||
|
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
|
||||||
|
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
|
||||||
|
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
|
||||||
|
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun mannschaften(season: String?): Response<ResponseBody> = Response.success(null)
|
||||||
|
override suspend fun config(): Response<ConfigResponse> = Response.success(config)
|
||||||
|
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> {
|
||||||
|
updateConfigCalls++
|
||||||
|
return Response.success(request)
|
||||||
|
}
|
||||||
|
override suspend fun spielsysteme(): Response<ResponseBody> = Response.success(null)
|
||||||
|
override suspend fun vereinsmeisterschaften(): Response<ResponseBody> =
|
||||||
|
Response.success("Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2\n\"2025\",\"Herren\",\"1\",\"Erika Muster\",\"\",\"Titel verteidigt\",\"\",\"\"".toResponseBody(null))
|
||||||
|
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> {
|
||||||
|
saveCsvCalls++
|
||||||
|
return Response.success(SaveCsvResponse(success = true, message = "CSV gespeichert"))
|
||||||
|
}
|
||||||
|
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
|
||||||
|
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<ResponseBody> = Response.success(null)
|
||||||
|
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||||
|
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
|
||||||
|
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||||
|
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
|
||||||
|
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody> = Response.success(null)
|
||||||
|
override suspend fun passkeyLogin(request: RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
|
||||||
|
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
|
||||||
|
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<ResponseBody> = Response.success(null)
|
||||||
|
override suspend fun registerPasskey(request: RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||||
|
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||||
|
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
|
||||||
|
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
|
||||||
|
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
|
||||||
|
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
|
||||||
|
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse(users = users))
|
||||||
|
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> {
|
||||||
|
updateUserRolesCalls++
|
||||||
|
return Response.success(AuthMessageResponse(success = true, message = "Rollen aktualisiert"))
|
||||||
|
}
|
||||||
|
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> {
|
||||||
|
updateUserActiveCalls++
|
||||||
|
return Response.success(AuthMessageResponse(success = true, message = "Status aktualisiert"))
|
||||||
|
}
|
||||||
|
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(contactRequests)
|
||||||
|
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<ContactResponse> {
|
||||||
|
replyContactCalls++
|
||||||
|
return Response.success(ContactResponse(ok = true, message = "Antwort versendet"))
|
||||||
|
}
|
||||||
|
override suspend fun toggleContactRequestStatus(id: String): Response<ContactResponse> = Response.success(ContactResponse(ok = true, message = "Status aktualisiert"))
|
||||||
|
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = newsletters))
|
||||||
|
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
|
||||||
|
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> {
|
||||||
|
createNewsletterCalls++
|
||||||
|
return Response.success(NewsletterCreateResponse(success = true, message = "Newsletter gespeichert"))
|
||||||
|
}
|
||||||
|
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||||
|
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
|
||||||
|
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
|
||||||
|
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun passwordResetDiagnostics(
|
||||||
|
email: String?,
|
||||||
|
failedOnly: Boolean,
|
||||||
|
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse(attempts = diagnostics))
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.CmsUsersResponse
|
||||||
|
import de.harheimertc.data.ConfigResponse
|
||||||
|
import de.harheimertc.data.ContactRequestDto
|
||||||
|
import de.harheimertc.data.NewsDto
|
||||||
|
import de.harheimertc.data.NewsResponse
|
||||||
|
import de.harheimertc.data.NewsletterGroupsResponse
|
||||||
|
import de.harheimertc.data.NewsletterListResponse
|
||||||
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
|
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||||
|
import de.harheimertc.data.PasswordResetStepDto
|
||||||
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
|
import de.harheimertc.repositories.CmsRepository
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import retrofit2.Response
|
||||||
|
import java.lang.reflect.InvocationHandler
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.lang.reflect.Proxy
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CmsPasswordResetDiagnosticsScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun diagnosticsScreen_showsFilterAndAttemptDetails() {
|
||||||
|
val api = createDiagnosticsApiService()
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val cache = SecureOfflineCache(context, Moshi.Builder().build())
|
||||||
|
val repo = CmsRepository(api, cache)
|
||||||
|
val viewModel = CmsViewModel(repo)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
CmsPasswordResetDiagnosticsScreen(navController, showBackNavigation = false, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(15_000) {
|
||||||
|
try {
|
||||||
|
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
|
||||||
|
true
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
|
||||||
|
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).assertExists()
|
||||||
|
composeTestRule.onNodeWithText("Reset-Vorgänge", useUnmergedTree = true).assertExists()
|
||||||
|
composeTestRule.onNodeWithText("Aktualisieren", useUnmergedTree = true).assertExists()
|
||||||
|
|
||||||
|
// Trigger a manual refresh to validate the main interaction path.
|
||||||
|
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDiagnosticsApiService(): ApiService {
|
||||||
|
val attempt = PasswordResetAttemptDto(
|
||||||
|
requestId = "req-1",
|
||||||
|
startedAt = "2026-05-29T10:15:00Z",
|
||||||
|
emailMasked = "m***@example.com",
|
||||||
|
ip = "127.0.0.1",
|
||||||
|
failed = true,
|
||||||
|
steps = listOf(
|
||||||
|
PasswordResetStepDto(
|
||||||
|
ts = "2026-05-29T10:15:01Z",
|
||||||
|
step = "mail_configuration",
|
||||||
|
status = "failed",
|
||||||
|
reason = "smtp_credentials_missing",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val handler = InvocationHandler { _, method: Method, _ ->
|
||||||
|
when (method.name) {
|
||||||
|
"config" -> Response.success(ConfigResponse())
|
||||||
|
"users" -> Response.success(CmsUsersResponse())
|
||||||
|
"cmsUsers" -> Response.success(CmsUsersResponse())
|
||||||
|
"contactRequests" -> Response.success(listOf<ContactRequestDto>())
|
||||||
|
"newsletters" -> Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
|
||||||
|
"newsletterGroups" -> Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
|
||||||
|
"memberNews" -> Response.success(NewsResponse(success = true, news = listOf(NewsDto(id = 1, title = "N", content = "C"))))
|
||||||
|
"passwordResetDiagnostics" -> Response.success(
|
||||||
|
PasswordResetDiagnosticsResponse(
|
||||||
|
retentionHours = 72,
|
||||||
|
searchedEmail = "",
|
||||||
|
matchingUsers = listOf(
|
||||||
|
de.harheimertc.data.PasswordResetMatchingUserDto(
|
||||||
|
id = "u1",
|
||||||
|
name = "Max Muster",
|
||||||
|
email = "max@example.com",
|
||||||
|
active = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
attempts = listOf(attempt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"vereinsmeisterschaften" -> Response.success("Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung\n".toResponseBody(null))
|
||||||
|
else -> throw UnsupportedOperationException("Unhandled ApiService method in test: ${method.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Proxy.newProxyInstance(
|
||||||
|
ApiService::class.java.classLoader,
|
||||||
|
arrayOf(ApiService::class.java),
|
||||||
|
handler,
|
||||||
|
) as ApiService
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CmsRolesDialogTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
class FakeVm {
|
||||||
|
var calledId: String? = null
|
||||||
|
var calledRoles: List<String>? = null
|
||||||
|
fun updateUserRoles(id: String, roles: List<String>) {
|
||||||
|
calledId = id
|
||||||
|
calledRoles = roles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rolesDialog_callsUpdateUserRoles() {
|
||||||
|
val fake = FakeVm()
|
||||||
|
val initialRoles = listOf("admin")
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val show = remember { mutableStateOf(false) }
|
||||||
|
val selected = remember { mutableStateListOf<String>().apply { addAll(initialRoles) } }
|
||||||
|
Column {
|
||||||
|
Button(onClick = { show.value = true }) { Text("Rollen") }
|
||||||
|
if (show.value) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { show.value = false },
|
||||||
|
title = { Text("Rollen bearbeiten") },
|
||||||
|
text = {
|
||||||
|
Column(modifier = Modifier.padding(4.dp)) {
|
||||||
|
// simple checkbox row for admin only (representative)
|
||||||
|
Row {
|
||||||
|
Checkbox(checked = selected.contains("admin"), onCheckedChange = { checked ->
|
||||||
|
if (checked) selected.add("admin") else selected.remove("admin")
|
||||||
|
})
|
||||||
|
Text("admin", modifier = Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = {
|
||||||
|
fake.updateUserRoles("42", selected.toList())
|
||||||
|
show.value = false
|
||||||
|
}) { Text("Speichern") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { show.value = false }) { Text("Abbrechen") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open dialog
|
||||||
|
composeTestRule.onNodeWithText("Rollen").performClick()
|
||||||
|
// Save immediately (we keep admin preselected)
|
||||||
|
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||||
|
|
||||||
|
assert(fake.calledId == "42")
|
||||||
|
assert(fake.calledRoles?.contains("admin") == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CmsScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsScreen_placeholder() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
Text("CMS Placeholder")
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("CMS Placeholder").assertExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import retrofit2.Response
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import de.harheimertc.data.*
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import de.harheimertc.repositories.CmsRepository
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CmsStartseiteSmokeTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmsStartseite_rendersWithDefaultState() {
|
||||||
|
// prepare a minimal fake ApiService that returns empty/neutral responses
|
||||||
|
val fakeApi = object : ApiService {
|
||||||
|
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
|
||||||
|
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
|
||||||
|
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
|
||||||
|
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
|
||||||
|
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
|
||||||
|
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
|
||||||
|
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
|
||||||
|
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
|
||||||
|
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
|
||||||
|
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
|
||||||
|
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun mannschaften(season: String?): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||||
|
override suspend fun config(): Response<ConfigResponse> = Response.success(ConfigResponse())
|
||||||
|
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> = Response.success(request)
|
||||||
|
override suspend fun spielsysteme(): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||||
|
override suspend fun vereinsmeisterschaften(): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||||
|
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> = Response.success(SaveCsvResponse(success = true))
|
||||||
|
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
|
||||||
|
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||||
|
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||||
|
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
|
||||||
|
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||||
|
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
|
||||||
|
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||||
|
override suspend fun passkeyLogin(request: okhttp3.RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
|
||||||
|
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
|
||||||
|
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||||
|
override suspend fun registerPasskey(request: okhttp3.RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||||
|
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||||
|
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
|
||||||
|
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
|
||||||
|
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
|
||||||
|
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
|
||||||
|
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse())
|
||||||
|
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||||
|
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(emptyList())
|
||||||
|
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
|
||||||
|
override suspend fun toggleContactRequestStatus(id: String): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
|
||||||
|
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
|
||||||
|
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
|
||||||
|
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||||
|
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||||
|
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
|
||||||
|
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true))
|
||||||
|
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||||
|
override suspend fun passwordResetDiagnostics(
|
||||||
|
email: String?,
|
||||||
|
failedOnly: Boolean,
|
||||||
|
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||||
|
val moshi = Moshi.Builder().build()
|
||||||
|
val cache = SecureOfflineCache(context, moshi)
|
||||||
|
val repo = CmsRepository(fakeApi, cache)
|
||||||
|
val vm = de.harheimertc.ui.screens.cms.CmsViewModel(repo)
|
||||||
|
|
||||||
|
// set a ready state to avoid waiting for async network loads in Vm.init
|
||||||
|
val readyState = de.harheimertc.ui.screens.cms.CmsUiState(
|
||||||
|
loading = false,
|
||||||
|
saving = false,
|
||||||
|
error = null,
|
||||||
|
message = null,
|
||||||
|
config = ConfigResponse(),
|
||||||
|
users = emptyList(),
|
||||||
|
contactRequests = emptyList(),
|
||||||
|
newsletters = emptyList(),
|
||||||
|
newsletterGroups = emptyList(),
|
||||||
|
passwordResetAttempts = emptyList(),
|
||||||
|
news = emptyList(),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val field = de.harheimertc.ui.screens.cms.CmsViewModel::class.java.getDeclaredField("_state")
|
||||||
|
field.isAccessible = true
|
||||||
|
val current = field.get(vm) as? MutableStateFlow<*>
|
||||||
|
if (current is MutableStateFlow<*>) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(current as MutableStateFlow<de.harheimertc.ui.screens.cms.CmsUiState>).value = readyState
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) { /* best-effort, continue */ }
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = vm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dump semantics tree for debugging
|
||||||
|
try {
|
||||||
|
composeTestRule.onRoot().printToLog("CmsStartseiteSmokeTest-SEMTREE")
|
||||||
|
} catch (_: Throwable) { }
|
||||||
|
|
||||||
|
// wait for the main title and info rows to appear
|
||||||
|
fun waitForText(text: String, timeoutMs: Long = 20000L) {
|
||||||
|
composeTestRule.waitUntil(timeoutMs) {
|
||||||
|
try {
|
||||||
|
composeTestRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
} catch (_: AssertionError) { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForText("Startseite")
|
||||||
|
waitForText("Öffentliche")
|
||||||
|
|
||||||
|
// basic assertions (use substring matching)
|
||||||
|
assertTrue(composeTestRule.onAllNodes(hasText("Startseite", substring = true)).fetchSemanticsNodes().isNotEmpty())
|
||||||
|
assertTrue(composeTestRule.onAllNodes(hasText("Öffentliche", substring = true)).fetchSemanticsNodes().isNotEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.uiautomator.By
|
||||||
|
import androidx.test.uiautomator.UiDevice
|
||||||
|
import androidx.test.uiautomator.UiObject2
|
||||||
|
import androidx.test.uiautomator.Until
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CmsUiAutomatorClickTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clickThroughExistingCmsPages_andTrySave() {
|
||||||
|
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||||
|
val context = instrumentation.targetContext
|
||||||
|
val device = UiDevice.getInstance(instrumentation)
|
||||||
|
val packageName = "de.harheimertc.local"
|
||||||
|
|
||||||
|
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
?.apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
assertNotNull("Launch-Intent fuer de.harheimertc.local nicht gefunden", launchIntent)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
|
||||||
|
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000)
|
||||||
|
|
||||||
|
clickText(device, "Intern")
|
||||||
|
clickText(device, "CMS")
|
||||||
|
|
||||||
|
openCmsCard(device, "Startseite")
|
||||||
|
clickIfPresent(device, "Speichern")
|
||||||
|
backToCmsDashboard(device)
|
||||||
|
|
||||||
|
openCmsCard(device, "Inhalte")
|
||||||
|
clickIfPresent(device, "Inhalte speichern")
|
||||||
|
backToCmsDashboard(device)
|
||||||
|
|
||||||
|
openCmsCard(device, "Vereinsmeisterschaften")
|
||||||
|
clickIfPresent(device, "Speichern")
|
||||||
|
backToCmsDashboard(device)
|
||||||
|
|
||||||
|
openCmsCard(device, "Sportbetrieb")
|
||||||
|
clickIfPresent(device, "Speichern")
|
||||||
|
backToCmsDashboard(device)
|
||||||
|
|
||||||
|
openCmsCard(device, "Einstellungen")
|
||||||
|
clickIfPresent(device, "Speichern")
|
||||||
|
backToCmsDashboard(device)
|
||||||
|
|
||||||
|
openCmsCard(device, "Mitgliederverwaltung")
|
||||||
|
clickIfPresent(device, "Freischalten")
|
||||||
|
backToCmsDashboard(device)
|
||||||
|
|
||||||
|
openCmsCard(device, "Kontaktanfragen")
|
||||||
|
clickIfPresent(device, "Antworten")
|
||||||
|
clickIfPresent(device, "Abbrechen")
|
||||||
|
backToCmsDashboard(device)
|
||||||
|
|
||||||
|
openCmsCard(device, "Newsletter")
|
||||||
|
clickIfPresent(device, "Newsletter erstellen")
|
||||||
|
clickIfPresent(device, "Abbrechen")
|
||||||
|
backToCmsDashboard(device)
|
||||||
|
|
||||||
|
if (openCmsCardIfAvailable(device, "Benutzer")) {
|
||||||
|
clickIfPresent(device, "Rollen")
|
||||||
|
clickIfPresent(device, "Abbrechen")
|
||||||
|
backToCmsDashboard(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
openCmsCardIfAvailable(device, "Passwort-Reset-Diagnose")
|
||||||
|
|
||||||
|
// Wenn wir am Ende noch im App-Paket sind, ist der Flow nicht gecrasht.
|
||||||
|
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openCmsCard(device: UiDevice, label: String) {
|
||||||
|
if (!clickIfPresent(device, label, 2500) && !clickTextWithScroll(device, label)) {
|
||||||
|
clickText(device, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openCmsCardIfAvailable(device: UiDevice, label: String): Boolean {
|
||||||
|
if (clickIfPresent(device, label, 1500)) return true
|
||||||
|
return clickTextWithScroll(device, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun backToCmsDashboard(device: UiDevice) {
|
||||||
|
if (!clickIfPresent(device, "CMS", 3000)) {
|
||||||
|
device.pressBack()
|
||||||
|
device.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clickText(device: UiDevice, text: String, timeoutMs: Long = 10000): UiObject2 {
|
||||||
|
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
|
||||||
|
requireNotNull(obj) { "Text nicht gefunden: $text" }
|
||||||
|
obj.click()
|
||||||
|
device.waitForIdle()
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clickIfPresent(device: UiDevice, text: String, timeoutMs: Long = 1500): Boolean {
|
||||||
|
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
|
||||||
|
if (obj != null) {
|
||||||
|
obj.click()
|
||||||
|
device.waitForIdle()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clickTextWithScroll(device: UiDevice, text: String, maxSwipes: Int = 5): Boolean {
|
||||||
|
if (clickIfPresent(device, text, 1500)) return true
|
||||||
|
repeat(maxSwipes) {
|
||||||
|
device.swipe(
|
||||||
|
device.displayWidth / 2,
|
||||||
|
(device.displayHeight * 0.8).toInt(),
|
||||||
|
device.displayWidth / 2,
|
||||||
|
(device.displayHeight * 0.25).toInt(),
|
||||||
|
24,
|
||||||
|
)
|
||||||
|
device.waitForIdle()
|
||||||
|
if (clickIfPresent(device, text, 1200)) return true
|
||||||
|
}
|
||||||
|
repeat(maxSwipes) {
|
||||||
|
device.swipe(
|
||||||
|
device.displayWidth / 2,
|
||||||
|
(device.displayHeight * 0.25).toInt(),
|
||||||
|
device.displayWidth / 2,
|
||||||
|
(device.displayHeight * 0.8).toInt(),
|
||||||
|
24,
|
||||||
|
)
|
||||||
|
device.waitForIdle()
|
||||||
|
if (clickIfPresent(device, text, 1200)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.harheimertc.ui.screens.gallery
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class GalleryScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun galleryScreen_rendersPlaceholder() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
GalleryScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onRoot().assertExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package de.harheimertc.ui.screens.home
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class HomeScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun homeScreen_renders() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
HomeScreen(navController = navController, showNavigationHeader = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onRoot().assertExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.harheimertc.ui.screens.login
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LoginScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginScreen_showsFields() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
LoginScreen(navController = navController, showBackNavigation = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("E-Mail-Adresse", useUnmergedTree = true).assertExists()
|
||||||
|
composeTestRule.onNodeWithText("Passwort", useUnmergedTree = true).assertExists()
|
||||||
|
composeTestRule.onNodeWithText("Anmelden", useUnmergedTree = true).assertExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
8
android-app/app/src/debug/AndroidManifest.xml
Normal file
8
android-app/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<!-- Disable Sentry automatic initialization in debug/test builds -->
|
||||||
|
<meta-data android:name="io.sentry.auto-init" android:value="false" />
|
||||||
|
<meta-data android:name="io.sentry.dsn" android:value="" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
29
android-app/app/src/main/AndroidManifest.xml
Normal file
29
android-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<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="@string/app_name"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
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,49 @@
|
|||||||
|
package de.harheimertc
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.disk.DiskCache
|
||||||
|
import coil.memory.MemoryCache
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import io.sentry.Sentry
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import javax.inject.Inject
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class HarheimerApplication : Application(), ImageLoaderFactory {
|
||||||
|
@Inject
|
||||||
|
lateinit var okHttpClient: OkHttpClient
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
Log.d("HILT", "HarheimerApplication.onCreate called")
|
||||||
|
super.onCreate()
|
||||||
|
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||||
|
Sentry.init { options ->
|
||||||
|
options.dsn = BuildConfig.SENTRY_DSN
|
||||||
|
options.environment = BuildConfig.ENVIRONMENT_NAME.ifBlank { "production" }
|
||||||
|
options.release = "${BuildConfig.APPLICATION_ID}@${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}"
|
||||||
|
options.isEnableAutoSessionTracking = true
|
||||||
|
options.tracesSampleRate = 0.05
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newImageLoader(): ImageLoader =
|
||||||
|
ImageLoader.Builder(this)
|
||||||
|
.okHttpClient(okHttpClient)
|
||||||
|
.memoryCache {
|
||||||
|
MemoryCache.Builder(this)
|
||||||
|
.maxSizePercent(0.20)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.diskCache {
|
||||||
|
DiskCache.Builder()
|
||||||
|
.directory(cacheDir.resolve("image_cache"))
|
||||||
|
.maxSizeBytes(75L * 1024L * 1024L)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.crossfade(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
44
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file
44
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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 de.harheimertc.ui.navigation.NavGraph
|
||||||
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.ui.theme.HarheimerTheme
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
App()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun App() {
|
||||||
|
HarheimerTheme {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
val activity = ctx as? ComponentActivity
|
||||||
|
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
||||||
|
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||||
|
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
||||||
770
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file
770
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
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.Multipart
|
||||||
|
import retrofit2.http.PATCH
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Part
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.PUT
|
||||||
|
import retrofit2.http.Query
|
||||||
|
import retrofit2.http.Url
|
||||||
|
import retrofit2.http.Streaming
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
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, val message: 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 MannschaftenSeasonsResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val seasons: List<String> = emptyList(),
|
||||||
|
val currentSeason: String = "",
|
||||||
|
val defaultSeason: 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: String? = 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: String? = 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 GalleryImageDto(
|
||||||
|
val id: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val isPublic: Boolean = false,
|
||||||
|
val uploadedAt: String? = null,
|
||||||
|
val previewFilename: String? = null,
|
||||||
|
)
|
||||||
|
data class GalleryPaginationDto(
|
||||||
|
val page: Int = 1,
|
||||||
|
val perPage: Int = 10,
|
||||||
|
val total: Int = 0,
|
||||||
|
val totalPages: Int = 0,
|
||||||
|
)
|
||||||
|
data class GalleryListResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val images: List<GalleryImageDto> = emptyList(),
|
||||||
|
val pagination: GalleryPaginationDto = GalleryPaginationDto(),
|
||||||
|
)
|
||||||
|
data class GalleryUploadImageDto(
|
||||||
|
val id: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val isPublic: Boolean = false,
|
||||||
|
)
|
||||||
|
data class GalleryUploadResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
val image: GalleryUploadImageDto? = null,
|
||||||
|
)
|
||||||
|
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 SaveCsvRequest(
|
||||||
|
val filename: String,
|
||||||
|
val content: String,
|
||||||
|
)
|
||||||
|
data class SaveCsvResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
val writtenTo: List<String> = emptyList(),
|
||||||
|
val jsonWrittenTo: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
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 QttrSourceDto(
|
||||||
|
val url: String = "",
|
||||||
|
)
|
||||||
|
data class QttrRowDto(
|
||||||
|
val rank: Int? = null,
|
||||||
|
val playerNumber: Int? = null,
|
||||||
|
val gender: String? = null,
|
||||||
|
val playerName: String = "",
|
||||||
|
val clubName: String = "",
|
||||||
|
val currentQttr: Int? = null,
|
||||||
|
val previousQttr: Int? = null,
|
||||||
|
val birthdate: String? = null,
|
||||||
|
)
|
||||||
|
data class QttrValuesResponse(
|
||||||
|
val format: String = "",
|
||||||
|
val importedAt: String = "",
|
||||||
|
val source: QttrSourceDto = QttrSourceDto(),
|
||||||
|
val title: String? = null,
|
||||||
|
val headerCount: Int = 0,
|
||||||
|
val rowCount: Int = 0,
|
||||||
|
val rows: List<QttrRowDto> = 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 MembershipTierDto(
|
||||||
|
val id: String = "",
|
||||||
|
val typ: String = "",
|
||||||
|
val beschreibung: String? = null,
|
||||||
|
val preis: Int = 0,
|
||||||
|
val features: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
data class LinkItemDto(
|
||||||
|
val label: String = "",
|
||||||
|
val href: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val id: String = "",
|
||||||
|
)
|
||||||
|
data class LinkSectionDto(
|
||||||
|
val title: String = "",
|
||||||
|
val items: List<LinkItemDto> = emptyList(),
|
||||||
|
val id: String = "",
|
||||||
|
)
|
||||||
|
data class HomepageSectionDto(
|
||||||
|
val id: String = "",
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val key: String? = null,
|
||||||
|
val marker: String? = null,
|
||||||
|
val config: HomepageSectionConfigDto? = null,
|
||||||
|
)
|
||||||
|
data class HomepageSectionConfigDto(
|
||||||
|
val season: String? = null,
|
||||||
|
val teamName: String? = null,
|
||||||
|
val teamAgeGroup: String? = null,
|
||||||
|
)
|
||||||
|
data class HomepageDto(
|
||||||
|
val sections: List<HomepageSectionDto> = emptyList(),
|
||||||
|
)
|
||||||
|
data class HeroImageVariantDto(
|
||||||
|
val key: String = "",
|
||||||
|
val mobileWebp: String = "",
|
||||||
|
val desktopWebp: String = "",
|
||||||
|
val fallback: String = "",
|
||||||
|
)
|
||||||
|
data class HeroImagesResponse(
|
||||||
|
val variants: List<HeroImageVariantDto> = 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 mitgliedschaft: List<MembershipTierDto> = emptyList(),
|
||||||
|
val verein: VereinDto = VereinDto(),
|
||||||
|
val vorstand: VorstandDto = VorstandDto(),
|
||||||
|
val website: WebsiteDto = WebsiteDto(),
|
||||||
|
val seiten: SeitenDto = SeitenDto(),
|
||||||
|
val homepage: HomepageDto = HomepageDto(),
|
||||||
|
)
|
||||||
|
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 NewsletterCreateRequest(
|
||||||
|
val title: String,
|
||||||
|
val content: String,
|
||||||
|
val type: String,
|
||||||
|
val targetGroup: String? = null,
|
||||||
|
val sendToExternal: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NewsletterCreateResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
val newsletter: NewsletterDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NewsletterSendResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
val stats: Map<String, Any>? = null,
|
||||||
|
)
|
||||||
|
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 PasswordResetMatchingUserDto(
|
||||||
|
val id: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val active: Boolean = true,
|
||||||
|
val lastLogin: String? = null,
|
||||||
|
)
|
||||||
|
data class PasswordResetDiagnosticsResponse(
|
||||||
|
val retentionHours: Int = 0,
|
||||||
|
val searchedEmail: String? = null,
|
||||||
|
val matchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
|
||||||
|
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(
|
||||||
|
@Query("page") page: Int = 1,
|
||||||
|
@Query("perPage") perPage: Int = 60,
|
||||||
|
): Response<GalleryListResponse>
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("/api/galerie/upload")
|
||||||
|
suspend fun uploadGalleryImage(
|
||||||
|
@Part image: MultipartBody.Part,
|
||||||
|
@Part("title") title: RequestBody,
|
||||||
|
@Part("description") description: RequestBody,
|
||||||
|
@Part("isPublic") isPublic: RequestBody,
|
||||||
|
): Response<GalleryUploadResponse>
|
||||||
|
|
||||||
|
@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: String): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@GET("/api/mannschaften")
|
||||||
|
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||||
|
|
||||||
|
@GET("/api/mannschaften/seasons")
|
||||||
|
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
|
||||||
|
|
||||||
|
@GET("/api/config")
|
||||||
|
suspend fun config(): Response<ConfigResponse>
|
||||||
|
|
||||||
|
@GET("/api/hero-images")
|
||||||
|
suspend fun heroImages(): Response<HeroImagesResponse>
|
||||||
|
|
||||||
|
@PUT("/api/config")
|
||||||
|
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
|
||||||
|
|
||||||
|
@GET("/data/spielsysteme.csv")
|
||||||
|
suspend fun spielsysteme(): Response<ResponseBody>
|
||||||
|
|
||||||
|
@GET("/api/vereinsmeisterschaften")
|
||||||
|
suspend fun vereinsmeisterschaften(): Response<ResponseBody>
|
||||||
|
|
||||||
|
@POST("/api/cms/save-csv")
|
||||||
|
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
||||||
|
|
||||||
|
@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/mitgliederbereich/qttr")
|
||||||
|
suspend fun qttrValues(): Response<QttrValuesResponse>
|
||||||
|
|
||||||
|
@GET("/api/members")
|
||||||
|
suspend fun members(): Response<MembersResponse>
|
||||||
|
|
||||||
|
data class MemberSaveRequest(
|
||||||
|
val id: String? = null,
|
||||||
|
val firstName: String,
|
||||||
|
val lastName: String,
|
||||||
|
val geburtsdatum: String,
|
||||||
|
val email: String? = null,
|
||||||
|
val phone: String? = null,
|
||||||
|
val address: String? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val isMannschaftsspieler: Boolean = false,
|
||||||
|
val hasHallKey: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BulkImportRequest(val members: List<Map<String, String>>)
|
||||||
|
data class BulkImportResponse(val success: Boolean = false, val summary: Map<String, Int>? = null)
|
||||||
|
|
||||||
|
@POST("/api/members")
|
||||||
|
suspend fun saveMember(@Body request: MemberSaveRequest): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@DELETE("/api/members")
|
||||||
|
suspend fun deleteMember(@Body body: Map<String, String>): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@POST("/api/members/bulk")
|
||||||
|
suspend fun bulkImportMembers(@Body request: BulkImportRequest): Response<BulkImportResponse>
|
||||||
|
|
||||||
|
@POST("/api/members/toggle-mannschaftsspieler")
|
||||||
|
suspend fun toggleMannschaftsspieler(@Body body: Map<String, String>): Response<Map<String, Any>>
|
||||||
|
|
||||||
|
@GET("/api/cms/users/list")
|
||||||
|
suspend fun cmsUsers(): Response<CmsUsersResponse>
|
||||||
|
|
||||||
|
data class UpdateUserRolesRequest(val id: String, val roles: List<String>)
|
||||||
|
data class UpdateUserActiveRequest(val id: String, val active: Boolean)
|
||||||
|
|
||||||
|
@PUT("/api/cms/users/update-roles")
|
||||||
|
suspend fun updateUserRoles(@Body request: UpdateUserRolesRequest): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@PUT("/api/cms/users/update-active")
|
||||||
|
suspend fun updateUserActive(@Body request: UpdateUserActiveRequest): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@POST("/api/cms/users/resend-invite")
|
||||||
|
suspend fun resendInvite(@Query("id") id: String): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@GET("/api/cms/contact-requests")
|
||||||
|
suspend fun contactRequests(): Response<List<ContactRequestDto>>
|
||||||
|
|
||||||
|
data class ContactReplyRequest(val message: String)
|
||||||
|
|
||||||
|
@POST("/api/cms/contact-requests/{id}/reply")
|
||||||
|
suspend fun replyToContactRequest(@Path("id") id: String, @Body request: ContactReplyRequest): Response<de.harheimertc.data.ContactResponse>
|
||||||
|
|
||||||
|
@PATCH("/api/cms/contact-requests/{id}/toggle-status")
|
||||||
|
suspend fun toggleContactRequestStatus(@Path("id") id: String): Response<de.harheimertc.data.ContactResponse>
|
||||||
|
|
||||||
|
@GET("/api/newsletter/list")
|
||||||
|
suspend fun newsletters(): Response<NewsletterListResponse>
|
||||||
|
|
||||||
|
@GET("/api/newsletter/groups/list")
|
||||||
|
suspend fun newsletterGroups(): Response<NewsletterGroupsResponse>
|
||||||
|
|
||||||
|
@POST("/api/newsletter/groups/create")
|
||||||
|
suspend fun createNewsletterGroup(@Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@PUT("/api/newsletter/groups/{id}")
|
||||||
|
suspend fun updateNewsletterGroup(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@DELETE("/api/newsletter/groups/{id}")
|
||||||
|
suspend fun deleteNewsletterGroup(@Path("id") id: String): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@POST("/api/newsletter/create")
|
||||||
|
suspend fun createNewsletter(@Body request: NewsletterCreateRequest): Response<NewsletterCreateResponse>
|
||||||
|
|
||||||
|
@PUT("/api/newsletter/{id}")
|
||||||
|
suspend fun updateNewsletter(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<NewsletterCreateResponse>
|
||||||
|
|
||||||
|
@POST("/api/newsletter/{id}/send")
|
||||||
|
suspend fun sendNewsletter(@Path("id") id: String): Response<NewsletterSendResponse>
|
||||||
|
|
||||||
|
@DELETE("/api/newsletter/{id}")
|
||||||
|
suspend fun deleteNewsletter(@Path("id") id: String): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@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(
|
||||||
|
@Query("email") email: String? = null,
|
||||||
|
@Query("failedOnly") failedOnly: Boolean = true,
|
||||||
|
): 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,49 @@
|
|||||||
|
package de.harheimertc.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ConnectivityMonitor @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val _online = MutableStateFlow(hasInternetAccess())
|
||||||
|
val online: StateFlow<Boolean> = _online.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { poll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun poll() {
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val current = hasInternetAccess()
|
||||||
|
if (_online.value != current) {
|
||||||
|
_online.value = current
|
||||||
|
}
|
||||||
|
delay(10_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasInternetAccess(): Boolean {
|
||||||
|
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
?: return false
|
||||||
|
val network = manager.activeNetwork ?: return false
|
||||||
|
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||||
|
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,106 @@
|
|||||||
|
package de.harheimertc.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
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.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import okhttp3.Cache
|
||||||
|
import okhttp3.CacheControl
|
||||||
|
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
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object NetworkModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideMoshi(): Moshi = Moshi.Builder()
|
||||||
|
.addLast(KotlinJsonAdapterFactory())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideHttpCache(@ApplicationContext context: Context): Cache =
|
||||||
|
Cache(context.cacheDir.resolve("http_cache"), 25L * 1024L * 1024L)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideOkHttpClient(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
authInterceptor: AuthInterceptor,
|
||||||
|
accessTokenAuthenticator: AccessTokenAuthenticator,
|
||||||
|
cache: Cache,
|
||||||
|
): OkHttpClient {
|
||||||
|
val logging = HttpLoggingInterceptor()
|
||||||
|
logging.level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC
|
||||||
|
val cookies = CookieManager().apply {
|
||||||
|
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||||
|
}
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.cache(cache)
|
||||||
|
.cookieJar(JavaNetCookieJar(cookies))
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
if (request.method == "GET" && !hasNetwork(context)) {
|
||||||
|
val offlineRequest = request.newBuilder()
|
||||||
|
.cacheControl(CacheControl.Builder().onlyIfCached().maxStale(7, TimeUnit.DAYS).build())
|
||||||
|
.build()
|
||||||
|
chain.proceed(offlineRequest)
|
||||||
|
} else {
|
||||||
|
chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addNetworkInterceptor { chain ->
|
||||||
|
val response = chain.proceed(chain.request())
|
||||||
|
val request = response.request
|
||||||
|
if (request.method == "GET" && request.header("Authorization").isNullOrBlank()) {
|
||||||
|
response.newBuilder()
|
||||||
|
.header("Cache-Control", "public, max-age=300")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.authenticator(accessTokenAuthenticator)
|
||||||
|
.addInterceptor(logging)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofit(moshi: Moshi, client: OkHttpClient): Retrofit {
|
||||||
|
val runtimeBase = BuildConfig.API_BASE_URL
|
||||||
|
android.util.Log.i("NetworkModule", "Retrofit baseUrl runtime=$runtimeBase")
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(runtimeBase)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
|
||||||
|
|
||||||
|
private fun hasNetwork(context: Context): Boolean {
|
||||||
|
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
|
||||||
|
val network = manager.activeNetwork ?: return false
|
||||||
|
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||||
|
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package de.harheimertc.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.Types
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SecureOfflineCache @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val moshi: Moshi,
|
||||||
|
) {
|
||||||
|
private val tag = "SecureOfflineCache"
|
||||||
|
private companion object {
|
||||||
|
const val KEY_BIRTHDAYS = "birthdays"
|
||||||
|
const val KEY_QTTR_VALUES = "qttr_values"
|
||||||
|
const val KEY_MEMBERS = "members"
|
||||||
|
const val KEY_MEMBER_NEWS = "member_news"
|
||||||
|
const val KEY_CMS_CONFIG = "cms_config"
|
||||||
|
const val KEY_CMS_USERS = "cms_users"
|
||||||
|
const val KEY_CONTACT_REQUESTS = "contact_requests"
|
||||||
|
const val KEY_NEWSLETTERS = "newsletters"
|
||||||
|
const val KEY_NEWSLETTER_GROUPS = "newsletter_groups"
|
||||||
|
const val KEY_PASSWORD_RESET_DIAGNOSTICS = "password_reset_diagnostics"
|
||||||
|
const val TIMESTAMP_SUFFIX = "_ts"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_offline_cache",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_offline_cache")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_offline_cache",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
||||||
|
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
|
fun putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java)
|
||||||
|
fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
|
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
|
||||||
|
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
|
fun putNews(response: NewsResponse) = put(KEY_MEMBER_NEWS, response, NewsResponse::class.java)
|
||||||
|
fun getNews(maxAgeMillis: Long? = null): NewsResponse? = get(KEY_MEMBER_NEWS, NewsResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
|
fun putConfig(response: ConfigResponse) = put(KEY_CMS_CONFIG, response, ConfigResponse::class.java)
|
||||||
|
fun getConfig(maxAgeMillis: Long? = null): ConfigResponse? = get(KEY_CMS_CONFIG, ConfigResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
|
fun putCmsUsers(response: CmsUsersResponse) = put(KEY_CMS_USERS, response, CmsUsersResponse::class.java)
|
||||||
|
fun getCmsUsers(maxAgeMillis: Long? = null): CmsUsersResponse? = get(KEY_CMS_USERS, CmsUsersResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
|
fun putContactRequests(response: List<ContactRequestDto>) {
|
||||||
|
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
|
||||||
|
val json = moshi.adapter<List<ContactRequestDto>>(type).toJson(response)
|
||||||
|
preferences.edit()
|
||||||
|
.putString(KEY_CONTACT_REQUESTS, json)
|
||||||
|
.putLong(timestampKey(KEY_CONTACT_REQUESTS), System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getContactRequests(maxAgeMillis: Long? = null): List<ContactRequestDto>? {
|
||||||
|
if (isExpired(KEY_CONTACT_REQUESTS, maxAgeMillis)) return null
|
||||||
|
val json = preferences.getString(KEY_CONTACT_REQUESTS, null) ?: return null
|
||||||
|
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
|
||||||
|
return runCatching { moshi.adapter<List<ContactRequestDto>>(type).fromJson(json) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putNewsletters(response: NewsletterListResponse) = put(KEY_NEWSLETTERS, response, NewsletterListResponse::class.java)
|
||||||
|
fun getNewsletters(maxAgeMillis: Long? = null): NewsletterListResponse? =
|
||||||
|
get(KEY_NEWSLETTERS, NewsletterListResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
|
fun putNewsletterGroups(response: NewsletterGroupsResponse) = put(KEY_NEWSLETTER_GROUPS, response, NewsletterGroupsResponse::class.java)
|
||||||
|
fun getNewsletterGroups(maxAgeMillis: Long? = null): NewsletterGroupsResponse? =
|
||||||
|
get(KEY_NEWSLETTER_GROUPS, NewsletterGroupsResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
|
fun putPasswordResetDiagnostics(response: PasswordResetDiagnosticsResponse) =
|
||||||
|
put(KEY_PASSWORD_RESET_DIAGNOSTICS, response, PasswordResetDiagnosticsResponse::class.java)
|
||||||
|
fun getPasswordResetDiagnostics(maxAgeMillis: Long? = null): PasswordResetDiagnosticsResponse? =
|
||||||
|
get(KEY_PASSWORD_RESET_DIAGNOSTICS, PasswordResetDiagnosticsResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
|
fun clearCmsProtectedCaches() {
|
||||||
|
clear(
|
||||||
|
KEY_CMS_CONFIG,
|
||||||
|
KEY_CMS_USERS,
|
||||||
|
KEY_CONTACT_REQUESTS,
|
||||||
|
KEY_NEWSLETTERS,
|
||||||
|
KEY_NEWSLETTER_GROUPS,
|
||||||
|
KEY_PASSWORD_RESET_DIAGNOSTICS,
|
||||||
|
KEY_MEMBER_NEWS,
|
||||||
|
KEY_QTTR_VALUES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCmsUsersCache() = clear(KEY_CMS_USERS)
|
||||||
|
fun clearContactRequestsCache() = clear(KEY_CONTACT_REQUESTS)
|
||||||
|
fun clearNewslettersCache() = clear(KEY_NEWSLETTERS)
|
||||||
|
fun clearNewsletterGroupsCache() = clear(KEY_NEWSLETTER_GROUPS)
|
||||||
|
fun clearPasswordResetDiagnosticsCache() = clear(KEY_PASSWORD_RESET_DIAGNOSTICS)
|
||||||
|
fun clearCmsConfigCache() = clear(KEY_CMS_CONFIG)
|
||||||
|
fun clearCmsNewsCache() = clear(KEY_MEMBER_NEWS)
|
||||||
|
|
||||||
|
private fun <T> put(key: String, value: T, type: Class<T>) {
|
||||||
|
val json = moshi.adapter(type).toJson(value)
|
||||||
|
preferences.edit()
|
||||||
|
.putString(key, json)
|
||||||
|
.putLong(timestampKey(key), System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> get(key: String, type: Class<T>, maxAgeMillis: Long? = null): T? {
|
||||||
|
if (isExpired(key, maxAgeMillis)) return null
|
||||||
|
val json = preferences.getString(key, null) ?: return null
|
||||||
|
return runCatching { moshi.adapter(type).fromJson(json) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clear(vararg keys: String) {
|
||||||
|
val editor = preferences.edit()
|
||||||
|
keys.forEach { key ->
|
||||||
|
editor.remove(key)
|
||||||
|
editor.remove(timestampKey(key))
|
||||||
|
}
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isExpired(key: String, maxAgeMillis: Long?): Boolean {
|
||||||
|
if (maxAgeMillis == null) return false
|
||||||
|
val savedAt = preferences.getLong(timestampKey(key), 0L)
|
||||||
|
if (savedAt <= 0L) return true
|
||||||
|
return (System.currentTimeMillis() - savedAt) > maxAgeMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun timestampKey(key: String): String = key + TIMESTAMP_SUFFIX
|
||||||
|
}
|
||||||
@@ -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,13 @@
|
|||||||
|
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()
|
||||||
|
// Device binding via Android Keystore (optional enhancement)
|
||||||
|
fun ensureDeviceKey(): String?
|
||||||
|
fun getDevicePublicKey(): String?
|
||||||
|
fun signWithDeviceKey(data: ByteArray): ByteArray?
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.harheimertc.security.DeviceKeyManager
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AuthRepositoryImpl @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val deviceKeyManager: DeviceKeyManager,
|
||||||
|
) : AuthRepository {
|
||||||
|
private val tag = "AuthRepository"
|
||||||
|
private val tokenKey = "auth_token"
|
||||||
|
private val refreshTokenKey = "auth_refresh_token"
|
||||||
|
private val sessionIdKey = "auth_session_id"
|
||||||
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_auth")
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keystore / device binding helpers
|
||||||
|
override fun ensureDeviceKey(): String? = try {
|
||||||
|
deviceKeyManager.ensureKeyPair()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDevicePublicKey(): String? = try {
|
||||||
|
deviceKeyManager.getPublicKeyBase64()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun signWithDeviceKey(data: ByteArray): ByteArray? = try {
|
||||||
|
deviceKeyManager.sign(data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
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 de.harheimertc.data.SaveCsvRequest
|
||||||
|
import de.harheimertc.data.SaveCsvResponse
|
||||||
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CmsRepository @Inject constructor(
|
||||||
|
private val api: ApiService,
|
||||||
|
private val cache: SecureOfflineCache,
|
||||||
|
) {
|
||||||
|
private companion object {
|
||||||
|
const val CMS_CACHE_MAX_AGE_MS = 24L * 60L * 60L * 1000L
|
||||||
|
const val PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS = 6L * 60L * 60L * 1000L
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun config(): Result<ConfigResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.config()
|
||||||
|
if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putConfig,
|
||||||
|
cached = { cache.getConfig(CMS_CACHE_MAX_AGE_MS) },
|
||||||
|
fallbackMessage = "Konfiguration konnte nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun saveConfig(config: ConfigResponse): Result<ConfigResponse> = runCatching {
|
||||||
|
val response = api.updateConfig(config)
|
||||||
|
if (!response.isSuccessful) error("Konfiguration konnte nicht gespeichert werden.")
|
||||||
|
val saved = response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
cache.putConfig(saved)
|
||||||
|
saved
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun vereinsmeisterschaften(): 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) { "" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>): Result<SaveCsvResponse> = runCatching {
|
||||||
|
val csvHeader = "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2"
|
||||||
|
val csvRows = results.map { result ->
|
||||||
|
listOf(
|
||||||
|
result.year,
|
||||||
|
result.category,
|
||||||
|
result.rank,
|
||||||
|
result.playerOne,
|
||||||
|
result.playerTwo,
|
||||||
|
result.note,
|
||||||
|
result.imageOne,
|
||||||
|
result.imageTwo,
|
||||||
|
).joinToString(",") { value -> "\"${value.replace("\"", "\"\"")}\"" }
|
||||||
|
}
|
||||||
|
val response = api.saveCsv(
|
||||||
|
SaveCsvRequest(
|
||||||
|
filename = "vereinsmeisterschaften.csv",
|
||||||
|
content = listOf(csvHeader).plus(csvRows).joinToString("\n"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht gespeichert werden.")
|
||||||
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun users(): Result<CmsUsersResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.cmsUsers()
|
||||||
|
if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putCmsUsers,
|
||||||
|
cached = { cache.getCmsUsers(CMS_CACHE_MAX_AGE_MS) },
|
||||||
|
fallbackMessage = "Benutzer konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun updateUserRoles(id: String, roles: List<String>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val req = de.harheimertc.data.ApiService.UpdateUserRolesRequest(id, roles)
|
||||||
|
val response = api.updateUserRoles(req)
|
||||||
|
if (!response.isSuccessful) error("Benutzerrollen konnten nicht aktualisiert werden.")
|
||||||
|
cache.clearCmsUsersCache()
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateUserActive(id: String, active: Boolean): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val req = de.harheimertc.data.ApiService.UpdateUserActiveRequest(id, active)
|
||||||
|
val response = api.updateUserActive(req)
|
||||||
|
if (!response.isSuccessful) error("Benutzerstatus konnte nicht aktualisiert werden.")
|
||||||
|
cache.clearCmsUsersCache()
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resendInvite(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.resendInvite(id)
|
||||||
|
if (!response.isSuccessful) error("Einladung konnte nicht erneut gesendet werden.")
|
||||||
|
cache.clearCmsUsersCache()
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun contactRequests(): Result<List<ContactRequestDto>> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.contactRequests()
|
||||||
|
if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.")
|
||||||
|
response.body() ?: emptyList()
|
||||||
|
},
|
||||||
|
save = cache::putContactRequests,
|
||||||
|
cached = { cache.getContactRequests(CMS_CACHE_MAX_AGE_MS) },
|
||||||
|
fallbackMessage = "Kontaktanfragen konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun replyToContactRequest(id: String, message: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
|
||||||
|
val req = ApiService.ContactReplyRequest(message)
|
||||||
|
val response = api.replyToContactRequest(id, req)
|
||||||
|
if (!response.isSuccessful) error("Antwort konnte nicht gesendet werden.")
|
||||||
|
cache.clearContactRequestsCache()
|
||||||
|
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun toggleContactRequestStatus(id: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
|
||||||
|
val response = api.toggleContactRequestStatus(id)
|
||||||
|
if (!response.isSuccessful) error("Status konnte nicht geändert werden.")
|
||||||
|
cache.clearContactRequestsCache()
|
||||||
|
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun newsletters(): Result<NewsletterListResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.newsletters()
|
||||||
|
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putNewsletters,
|
||||||
|
cached = { cache.getNewsletters(CMS_CACHE_MAX_AGE_MS) },
|
||||||
|
fallbackMessage = "Newsletter konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun newsletterGroups(): Result<NewsletterGroupsResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.newsletterGroups()
|
||||||
|
if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putNewsletterGroups,
|
||||||
|
cached = { cache.getNewsletterGroups(CMS_CACHE_MAX_AGE_MS) },
|
||||||
|
fallbackMessage = "Newsletter-Gruppen konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun passwordResetDiagnostics(
|
||||||
|
email: String? = null,
|
||||||
|
failedOnly: Boolean = true,
|
||||||
|
): Result<PasswordResetDiagnosticsResponse> {
|
||||||
|
val normalizedEmail = email?.trim().orEmpty()
|
||||||
|
val canUseSharedCache = normalizedEmail.isBlank() && failedOnly
|
||||||
|
return fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.passwordResetDiagnostics(
|
||||||
|
email = normalizedEmail.takeIf { it.isNotBlank() },
|
||||||
|
failedOnly = failedOnly,
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = { response ->
|
||||||
|
if (canUseSharedCache) cache.putPasswordResetDiagnostics(response)
|
||||||
|
},
|
||||||
|
cached = {
|
||||||
|
if (canUseSharedCache) {
|
||||||
|
cache.getPasswordResetDiagnostics(PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fallbackMessage = "Passwort-Reset-Diagnose konnte nicht geladen werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun news(): Result<de.harheimertc.data.NewsResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.memberNews()
|
||||||
|
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.NewsResponse()
|
||||||
|
},
|
||||||
|
save = cache::putNews,
|
||||||
|
cached = { cache.getNews(CMS_CACHE_MAX_AGE_MS) },
|
||||||
|
fallbackMessage = "News konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun saveNews(request: de.harheimertc.data.NewsSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.saveNews(request)
|
||||||
|
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
|
||||||
|
cache.clearCmsNewsCache()
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createNewsletter(request: de.harheimertc.data.NewsletterCreateRequest): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
|
||||||
|
val response = api.createNewsletter(request)
|
||||||
|
if (!response.isSuccessful) error("Newsletter konnte nicht erstellt werden.")
|
||||||
|
cache.clearNewslettersCache()
|
||||||
|
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateNewsletter(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
|
||||||
|
val response = api.updateNewsletter(id, patch)
|
||||||
|
if (!response.isSuccessful) error("Newsletter konnte nicht aktualisiert werden.")
|
||||||
|
cache.clearNewslettersCache()
|
||||||
|
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendNewsletter(id: String): Result<de.harheimertc.data.NewsletterSendResponse> = runCatching {
|
||||||
|
val response = api.sendNewsletter(id)
|
||||||
|
if (!response.isSuccessful) error("Newsletter konnte nicht versendet werden.")
|
||||||
|
cache.clearNewslettersCache()
|
||||||
|
response.body() ?: de.harheimertc.data.NewsletterSendResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteNewsletter(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.deleteNewsletter(id)
|
||||||
|
if (!response.isSuccessful) error("Newsletter konnte nicht gelöscht werden.")
|
||||||
|
cache.clearNewslettersCache()
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createNewsletterGroup(payload: Map<String, @JvmSuppressWildcards Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
// use generic POST via Retrofit? build request through create endpoint
|
||||||
|
val response = api.createNewsletterGroup(payload)
|
||||||
|
if (!response.isSuccessful) error("Gruppe konnte nicht erstellt werden.")
|
||||||
|
cache.clearNewsletterGroupsCache()
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateNewsletterGroup(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.updateNewsletterGroup(id, patch)
|
||||||
|
if (!response.isSuccessful) error("Gruppe konnte nicht aktualisiert werden.")
|
||||||
|
cache.clearNewsletterGroupsCache()
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteNewsletterGroup(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.deleteNewsletterGroup(id)
|
||||||
|
if (!response.isSuccessful) error("Gruppe konnte nicht gelöscht werden.")
|
||||||
|
cache.clearNewsletterGroupsCache()
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteNews(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.deleteNews(id)
|
||||||
|
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||||
|
cache.clearCmsNewsCache()
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> fetchEncryptedFallback(
|
||||||
|
load: suspend () -> T,
|
||||||
|
save: (T) -> Unit,
|
||||||
|
cached: () -> T?,
|
||||||
|
fallbackMessage: String,
|
||||||
|
): Result<T> = runCatching {
|
||||||
|
runCatching { load() }
|
||||||
|
.onSuccess(save)
|
||||||
|
.getOrElse { original ->
|
||||||
|
cached() ?: throw IllegalStateException(fallbackMessage, original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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,124 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.GalleryImageDto
|
||||||
|
import de.harheimertc.data.GalleryPaginationDto
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class GalleryRepository @Inject constructor(
|
||||||
|
private val api: ApiService,
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.galerieList(page = 1, perPage = 1)
|
||||||
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body()?.images.orEmpty().isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
||||||
|
return runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val resp = api.galerieList(page = page, perPage = perPage)
|
||||||
|
if (resp.isSuccessful) {
|
||||||
|
val body = resp.body()
|
||||||
|
GalleryPage(
|
||||||
|
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
||||||
|
pagination = body?.pagination ?: GalleryPaginationDto(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
error("HTTP ${resp.code()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun uploadImage(uri: Uri, title: String, description: String, isPublic: Boolean): Result<Unit> = runCatching {
|
||||||
|
val titleValue = title.trim()
|
||||||
|
require(titleValue.isNotBlank()) { "Bitte einen Titel eintragen." }
|
||||||
|
|
||||||
|
val uploadFile = prepareCompressedUploadFile(uri)
|
||||||
|
val mediaType = "image/jpeg".toMediaType()
|
||||||
|
val imageBody = uploadFile.asRequestBody(mediaType)
|
||||||
|
val imagePart = MultipartBody.Part.createFormData("image", uploadFile.name, imageBody)
|
||||||
|
val textType = "text/plain".toMediaType()
|
||||||
|
|
||||||
|
val response = api.uploadGalleryImage(
|
||||||
|
image = imagePart,
|
||||||
|
title = titleValue.toRequestBody(textType),
|
||||||
|
description = description.trim().toRequestBody(textType),
|
||||||
|
isPublic = isPublic.toString().toRequestBody(textType),
|
||||||
|
)
|
||||||
|
uploadFile.delete()
|
||||||
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
val body = response.body()
|
||||||
|
if (body?.success == false) error(body.message ?: "Fehler beim Hochladen des Bildes")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun GalleryImageDto.toGalleryImage(): GalleryImage {
|
||||||
|
val base = BuildConfig.API_BASE_URL.trimEnd('/')
|
||||||
|
return GalleryImage(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
isPublic = isPublic,
|
||||||
|
uploadedAt = uploadedAt,
|
||||||
|
previewUrl = "$base/api/media/galerie/$id?preview=true",
|
||||||
|
imageUrl = "$base/api/media/galerie/$id",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareCompressedUploadFile(uri: Uri): File {
|
||||||
|
val inputBytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
?: error("Bilddatei konnte nicht gelesen werden.")
|
||||||
|
val original = BitmapFactory.decodeByteArray(inputBytes, 0, inputBytes.size)
|
||||||
|
?: error("Bilddatei konnte nicht verarbeitet werden.")
|
||||||
|
val scaled = original.scaleInside(maxSize = 2000)
|
||||||
|
val file = File(context.cacheDir, "gallery_upload_${System.currentTimeMillis()}.jpg")
|
||||||
|
FileOutputStream(file).use { out ->
|
||||||
|
scaled.compress(Bitmap.CompressFormat.JPEG, 85, out)
|
||||||
|
}
|
||||||
|
if (scaled !== original) scaled.recycle()
|
||||||
|
original.recycle()
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bitmap.scaleInside(maxSize: Int): Bitmap {
|
||||||
|
val largestSide = maxOf(width, height)
|
||||||
|
if (largestSide <= maxSize) return this
|
||||||
|
val scale = maxSize.toFloat() / largestSide.toFloat()
|
||||||
|
val nextWidth = (width * scale).toInt().coerceAtLeast(1)
|
||||||
|
val nextHeight = (height * scale).toInt().coerceAtLeast(1)
|
||||||
|
return Bitmap.createScaledBitmap(this, nextWidth, nextHeight, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class GalleryImage(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val isPublic: Boolean,
|
||||||
|
val uploadedAt: String?,
|
||||||
|
val previewUrl: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GalleryPage(
|
||||||
|
val images: List<GalleryImage>,
|
||||||
|
val pagination: GalleryPaginationDto,
|
||||||
|
)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.Types
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.harheimertc.data.HomepageSectionDto
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class HomeLayoutPreferences @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val moshi: Moshi,
|
||||||
|
) {
|
||||||
|
private val tag = "HomeLayoutPreferences"
|
||||||
|
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
|
||||||
|
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
|
||||||
|
|
||||||
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_home_layout",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_home_layout")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_home_layout",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSections(): List<HomepageSectionDto>? {
|
||||||
|
val json = preferences.getString(HOME_SECTIONS_KEY, null) ?: return null
|
||||||
|
return runCatching { sectionListAdapter.fromJson(json) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSections(sections: List<HomepageSectionDto>) {
|
||||||
|
val json = sectionListAdapter.toJson(sections)
|
||||||
|
preferences.edit().putString(HOME_SECTIONS_KEY, json).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSections() {
|
||||||
|
preferences.edit().remove(HOME_SECTIONS_KEY).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val HOME_SECTIONS_KEY = "home_sections"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.HomepageSectionDto
|
||||||
|
import de.harheimertc.data.HeroImageVariantDto
|
||||||
|
import de.harheimertc.data.NewsDto
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
|
import de.harheimertc.data.SpielDto
|
||||||
|
import de.harheimertc.data.SpielplanResponse
|
||||||
|
import de.harheimertc.data.TerminDto
|
||||||
|
import io.sentry.Sentry
|
||||||
|
import kotlin.random.Random
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
data class HomeData(
|
||||||
|
val termine: List<TerminDto>,
|
||||||
|
val spiele: List<SpielDto>,
|
||||||
|
val spielplanSeasons: List<SeasonDto>,
|
||||||
|
val selectedSpielplanSeason: String?,
|
||||||
|
val news: List<NewsDto>,
|
||||||
|
val homepageSections: List<HomepageSectionDto>,
|
||||||
|
val heroImageUrl: String? = null,
|
||||||
|
val diagnostics: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||||
|
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
||||||
|
val diagnostics = mutableListOf<String>()
|
||||||
|
|
||||||
|
val termine = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.termine()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/termine",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.termine.orEmpty()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.termine", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/termine") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/termine",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val spielplanResponse = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.spielplan()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/spielplan",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.spielplan", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/spielplan",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
val spiele = spielplanResponse?.data.orEmpty()
|
||||||
|
|
||||||
|
val news = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.publicNews()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/news-public",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.news.orEmpty()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.news", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/news-public") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/news-public",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val homepageSections = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.config()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/config",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.homepage?.sections.orEmpty()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.config", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/config") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/config",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val heroImageUrl = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.heroImages()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/hero-images",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
val variants = response.body()?.variants.orEmpty()
|
||||||
|
pickRandomHeroImage(variants)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.heroImages", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/hero-images",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
HomeData(
|
||||||
|
termine = termine,
|
||||||
|
spiele = spiele,
|
||||||
|
spielplanSeasons = spielplanResponse?.seasons.orEmpty(),
|
||||||
|
selectedSpielplanSeason = spielplanResponse?.season,
|
||||||
|
news = news,
|
||||||
|
homepageSections = homepageSections,
|
||||||
|
heroImageUrl = heroImageUrl,
|
||||||
|
diagnostics = diagnostics,
|
||||||
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "HomeRepository")
|
||||||
|
scope.setTag("operation", "fetchHomeData")
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.spielplan(season)
|
||||||
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "HomeRepository")
|
||||||
|
scope.setTag("operation", "fetchSpielplanForSeason")
|
||||||
|
scope.setExtra("season", season)
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureLoadIssue(operation: String, error: Throwable) {
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "HomeRepository")
|
||||||
|
scope.setTag("operation", operation)
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDiagnostic(
|
||||||
|
endpoint: String,
|
||||||
|
requestPayload: String,
|
||||||
|
httpCode: Int?,
|
||||||
|
responseBody: String?,
|
||||||
|
throwable: Throwable?,
|
||||||
|
): String {
|
||||||
|
val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" }
|
||||||
|
val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" }
|
||||||
|
return buildString {
|
||||||
|
append("Endpoint: ").append(endpoint).append('\n')
|
||||||
|
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n')
|
||||||
|
append("Request: ").append(requestPayload).append('\n')
|
||||||
|
append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n')
|
||||||
|
append("Response: ").append(responsePreview).append('\n')
|
||||||
|
append("Throwable: ").append(throwableInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pickRandomHeroImage(variants: List<HeroImageVariantDto>): String? {
|
||||||
|
if (variants.isEmpty()) return null
|
||||||
|
val valid = variants.filter { it.fallback.isNotBlank() }
|
||||||
|
if (valid.isEmpty()) return null
|
||||||
|
val selected = valid[Random.nextInt(valid.size)]
|
||||||
|
return toAbsoluteUrl(selected.fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toAbsoluteUrl(pathOrUrl: String): String {
|
||||||
|
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
|
||||||
|
return pathOrUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
val base = BuildConfig.API_BASE_URL.trimEnd('/')
|
||||||
|
val path = if (pathOrUrl.startsWith('/')) pathOrUrl else "/$pathOrUrl"
|
||||||
|
return "$base$path"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
|
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 io.sentry.Sentry
|
||||||
|
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 endpoint = "api/auth/login"
|
||||||
|
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
|
||||||
|
val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val body = response.errorBody()?.string().orEmpty()
|
||||||
|
val serverMessage = extractServerMessage(body)
|
||||||
|
val fallback = when (response.code()) {
|
||||||
|
401 -> "Ungueltige Anmeldedaten"
|
||||||
|
403 -> "Konto nicht freigeschaltet"
|
||||||
|
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
|
||||||
|
else -> "Anmeldung fehlgeschlagen"
|
||||||
|
}
|
||||||
|
val diagnostic = buildString {
|
||||||
|
append("\n\nDiagnose:\n")
|
||||||
|
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
|
||||||
|
append("Request: ").append(requestPreview).append('\n')
|
||||||
|
append("HTTP: ").append(response.code()).append('\n')
|
||||||
|
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
|
||||||
|
append("Server message: ").append(serverMessage ?: "none")
|
||||||
|
}
|
||||||
|
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "LoginRepository")
|
||||||
|
scope.setTag("operation", "login")
|
||||||
|
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = retryOnNetworkFailure { 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 = retryOnNetworkFailure { 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
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "LoginRepository")
|
||||||
|
scope.setTag("operation", "status")
|
||||||
|
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
|
||||||
|
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.register(request)
|
||||||
|
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractServerMessage(raw: String): String? {
|
||||||
|
if (raw.isBlank()) return null
|
||||||
|
val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"")
|
||||||
|
return msgRegex.find(raw)?.groupValues?.getOrNull(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maskEmail(rawEmail: String): String {
|
||||||
|
val email = rawEmail.trim()
|
||||||
|
if (!email.contains('@')) return "hidden"
|
||||||
|
val local = email.substringBefore('@')
|
||||||
|
val domain = email.substringAfter('@')
|
||||||
|
val localMasked = if (local.length <= 2) "**" else local.take(2) + "***"
|
||||||
|
return "$localMasked@$domain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.MannschaftenSeasonsResponse
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.mannschaften(season)
|
||||||
|
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||||
|
parseCsv(response.body()?.string().orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.mannschaftenSeasons()
|
||||||
|
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Saisons konnten nicht geladen werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,121 @@
|
|||||||
|
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 de.harheimertc.data.QttrValuesResponse
|
||||||
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class MemberAreaRepository @Inject constructor(
|
||||||
|
private val api: ApiService,
|
||||||
|
private val cache: SecureOfflineCache,
|
||||||
|
) {
|
||||||
|
suspend fun birthdays(): Result<BirthdaysResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.birthdays()
|
||||||
|
if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putBirthdays,
|
||||||
|
cached = cache::getBirthdays,
|
||||||
|
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun qttrValues(): Result<QttrValuesResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.qttrValues()
|
||||||
|
if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putQttrValues,
|
||||||
|
cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) },
|
||||||
|
fallbackMessage = "QTTR-Werte konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun members(): Result<MembersResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.members()
|
||||||
|
if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putMembers,
|
||||||
|
cached = cache::getMembers,
|
||||||
|
fallbackMessage = "Mitglieder konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun news(): Result<NewsResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.memberNews()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
try {
|
||||||
|
val body = response.errorBody()?.string()
|
||||||
|
android.util.Log.w("MemberAreaRepository", "memberNews failed: code=${response.code()} body=${body?.take(500)}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
error("News konnten nicht geladen werden.")
|
||||||
|
}
|
||||||
|
response.body() ?: run {
|
||||||
|
android.util.Log.w("MemberAreaRepository", "memberNews: successful but empty body (null)")
|
||||||
|
NewsResponse(success = false, news = emptyList())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
save = cache::putNews,
|
||||||
|
cached = cache::getNews,
|
||||||
|
fallbackMessage = "News konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun saveNews(request: NewsSaveRequest): Result<Unit> = runCatching {
|
||||||
|
val response = api.saveNews(request)
|
||||||
|
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveMember(request: de.harheimertc.data.ApiService.MemberSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.saveMember(request)
|
||||||
|
if (!response.isSuccessful) error("Mitglied konnte nicht gespeichert werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteMember(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.deleteMember(mapOf("id" to id))
|
||||||
|
if (!response.isSuccessful) error("Mitglied konnte nicht gelöscht werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun bulkImport(members: List<Map<String, String>>): Result<de.harheimertc.data.ApiService.BulkImportResponse> = runCatching {
|
||||||
|
val response = api.bulkImportMembers(de.harheimertc.data.ApiService.BulkImportRequest(members))
|
||||||
|
if (!response.isSuccessful) error("Bulk-Import fehlgeschlagen")
|
||||||
|
response.body() ?: de.harheimertc.data.ApiService.BulkImportResponse(success = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun toggleMannschaftsspieler(memberId: String): Result<Map<String, Any>> = runCatching {
|
||||||
|
val response = api.toggleMannschaftsspieler(mapOf("memberId" to memberId))
|
||||||
|
if (!response.isSuccessful) error("Status konnte nicht umgeschaltet werden.")
|
||||||
|
response.body() ?: emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteNews(id: String): Result<Unit> = runCatching {
|
||||||
|
val response = api.deleteNews(id)
|
||||||
|
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> fetchEncryptedFallback(
|
||||||
|
load: suspend () -> T,
|
||||||
|
save: (T) -> Unit,
|
||||||
|
cached: () -> T?,
|
||||||
|
fallbackMessage: String,
|
||||||
|
): Result<T> = runCatching {
|
||||||
|
runCatching { load() }
|
||||||
|
.onSuccess(save)
|
||||||
|
.getOrElse { original ->
|
||||||
|
cached() ?: throw IllegalStateException(fallbackMessage, original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 java.net.ConnectException
|
||||||
|
import java.net.NoRouteToHostException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
internal suspend fun <T> retryOnNetworkFailure(
|
||||||
|
retryDelayMillis: Long = 10_000L,
|
||||||
|
block: suspend () -> T,
|
||||||
|
): T {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
if (!error.isRetryableNetworkError()) throw error
|
||||||
|
delay(retryDelayMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Throwable.isRetryableNetworkError(): Boolean = when (this) {
|
||||||
|
is UnknownHostException,
|
||||||
|
is ConnectException,
|
||||||
|
is NoRouteToHostException,
|
||||||
|
is SocketTimeoutException,
|
||||||
|
is SSLException -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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,120 @@
|
|||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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,26 @@
|
|||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.updateProfile(request)
|
||||||
|
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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,30 @@
|
|||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
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,17 @@
|
|||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.termine()
|
||||||
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body()?.termine.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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 {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.config()
|
||||||
|
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package de.harheimertc.security
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.spec.ECGenParameterSpec
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DeviceKeyManager @Inject constructor(@param:ApplicationContext private val context: Context) {
|
||||||
|
private val alias = "harheimertc_device_key"
|
||||||
|
private val keyStore: KeyStore by lazy {
|
||||||
|
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ensureKeyPair(): String? {
|
||||||
|
try {
|
||||||
|
if (!keyStore.containsAlias(alias)) {
|
||||||
|
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
|
||||||
|
val specBuilder = KeyGenParameterSpec.Builder(
|
||||||
|
alias,
|
||||||
|
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
||||||
|
)
|
||||||
|
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||||
|
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
|
||||||
|
.setUserAuthenticationRequired(false)
|
||||||
|
|
||||||
|
// For older APIs, KeyGenParameterSpec.Builder methods exist from API 23+
|
||||||
|
kpg.initialize(specBuilder.build())
|
||||||
|
kpg.generateKeyPair()
|
||||||
|
}
|
||||||
|
val pub = keyStore.getCertificate(alias).publicKey.encoded
|
||||||
|
return Base64.encodeToString(pub, Base64.NO_WRAP)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPublicKeyBase64(): String? {
|
||||||
|
return try {
|
||||||
|
if (!keyStore.containsAlias(alias)) return null
|
||||||
|
val pub = keyStore.getCertificate(alias).publicKey.encoded
|
||||||
|
Base64.encodeToString(pub, Base64.NO_WRAP)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sign(data: ByteArray): ByteArray? {
|
||||||
|
return try {
|
||||||
|
val privateKey = keyStore.getKey(alias, null) as? java.security.PrivateKey ?: return null
|
||||||
|
val sig = Signature.getInstance("SHA256withECDSA")
|
||||||
|
sig.initSign(privateKey)
|
||||||
|
sig.update(data)
|
||||||
|
sig.sign()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteKey() {
|
||||||
|
try {
|
||||||
|
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
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.RowScope
|
||||||
|
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 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,
|
||||||
|
CMS,
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class MenuTarget(val label: String, val route: String)
|
||||||
|
private const val LOGOUT_ROUTE = "__logout__"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppNavigationHeader(
|
||||||
|
selectedRoute: String?,
|
||||||
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> 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, onLogout, navigationState)
|
||||||
|
} else {
|
||||||
|
CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompactNavigation(
|
||||||
|
selectedRoute: String?,
|
||||||
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
navigationState: NavigationUiState = NavigationUiState(),
|
||||||
|
) {
|
||||||
|
val routeSection = menuSection(selectedRoute)
|
||||||
|
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||||
|
val section = routeSection ?: sectionOverride.value
|
||||||
|
val subItems = submenu(section, navigationState)
|
||||||
|
|
||||||
|
val mainScroll = rememberScrollState()
|
||||||
|
val subScroll = rememberScrollState()
|
||||||
|
|
||||||
|
BrandRow(
|
||||||
|
loggedIn = navigationState.loggedIn,
|
||||||
|
onLogin = { onNavigate(Destinations.Login.route) },
|
||||||
|
onLogout = onLogout,
|
||||||
|
)
|
||||||
|
|
||||||
|
ScrollableMenuRow(scrollState = mainScroll) {
|
||||||
|
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
|
||||||
|
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||||
|
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
|
||||||
|
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||||
|
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
|
||||||
|
if (navigationState.showGallery) {
|
||||||
|
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
|
||||||
|
}
|
||||||
|
if (navigationState.loggedIn) {
|
||||||
|
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
|
||||||
|
if (navigationState.canAccessCms) {
|
||||||
|
CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
}
|
||||||
|
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||||
|
subItems.forEach { item ->
|
||||||
|
SubLink(item.label, item.route == selectedRoute) {
|
||||||
|
if (item.route == LOGOUT_ROUTE) {
|
||||||
|
onLogout()
|
||||||
|
} else {
|
||||||
|
onNavigate(item.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompactSectionLink(
|
||||||
|
label: String,
|
||||||
|
section: MenuSection,
|
||||||
|
activeSection: MenuSection?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = if (activeSection == section) Primary600 else Color.Transparent,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = modifier.clickable { onSelect() },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WebTabletNavigation(
|
||||||
|
selectedRoute: String?,
|
||||||
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
navigationState: NavigationUiState,
|
||||||
|
) {
|
||||||
|
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||||
|
val section = sectionOverride.value ?: menuSection(selectedRoute)
|
||||||
|
val subScroll = rememberScrollState()
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Home.route)
|
||||||
|
})
|
||||||
|
MainLink("Verein", section == MenuSection.VEREIN, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.VEREIN
|
||||||
|
onNavigate(Destinations.VereinAbout.route)
|
||||||
|
})
|
||||||
|
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.MANNSCHAFTEN
|
||||||
|
onNavigate(Destinations.Mannschaften.route)
|
||||||
|
})
|
||||||
|
MainLink("Training", section == MenuSection.TRAINING, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.TRAINING
|
||||||
|
onNavigate(Destinations.Training.route)
|
||||||
|
})
|
||||||
|
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Termine.route)
|
||||||
|
})
|
||||||
|
if (navigationState.showGallery) {
|
||||||
|
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.VEREIN
|
||||||
|
onNavigate(Destinations.Gallery.route)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.NEWSLETTER
|
||||||
|
onNavigate(Destinations.NewsletterSubscribe.route)
|
||||||
|
})
|
||||||
|
if (navigationState.loggedIn) {
|
||||||
|
MainLink("Intern", section == MenuSection.INTERN, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.INTERN
|
||||||
|
})
|
||||||
|
if (navigationState.canAccessCms) {
|
||||||
|
MainLink("CMS", section == MenuSection.CMS, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.CMS
|
||||||
|
onNavigate(Destinations.Cms.route)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Contact.route)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
if (navigationState.loggedIn) {
|
||||||
|
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Login.route)
|
||||||
|
}) { Text("Login", color = Color.White) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val subItems = submenu(section, navigationState)
|
||||||
|
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||||
|
subItems.forEach { item ->
|
||||||
|
SubLink(item.label, item.route == selectedRoute) {
|
||||||
|
if (item.route == LOGOUT_ROUTE) {
|
||||||
|
onLogout()
|
||||||
|
} else {
|
||||||
|
onNavigate(item.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BrandRow(
|
||||||
|
loggedIn: Boolean,
|
||||||
|
onLogin: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Brand()
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (loggedIn) {
|
||||||
|
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||||
|
} else {
|
||||||
|
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.94f),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
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,
|
||||||
|
beforeNavigate: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = if (route == selectedRoute) Primary600 else Color.Transparent,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = modifier.clickable {
|
||||||
|
beforeNavigate()
|
||||||
|
onNavigate(route)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScrollableMenuRow(
|
||||||
|
scrollState: ScrollState,
|
||||||
|
topPadding: Dp = 0.dp,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = topPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (scrollState.canScrollBackward) "◀" else "",
|
||||||
|
color = Color(0xFFD4D4D8),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.width(14.dp),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.horizontalScroll(scrollState),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (scrollState.canScrollForward) "▶" else "",
|
||||||
|
color = Color(0xFFD4D4D8),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.width(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.labelMedium,
|
||||||
|
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.Qttr.route,
|
||||||
|
Destinations.MemberNews.route,
|
||||||
|
Destinations.Profile.route,
|
||||||
|
Destinations.MemberApi.route -> MenuSection.INTERN
|
||||||
|
|
||||||
|
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.CMS
|
||||||
|
|
||||||
|
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("QTTR", Destinations.Qttr.route))
|
||||||
|
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||||
|
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||||
|
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
||||||
|
}
|
||||||
|
|
||||||
|
MenuSection.CMS -> buildList {
|
||||||
|
if (state.canAccessFullCms) {
|
||||||
|
add(MenuTarget("Übersicht", Destinations.Cms.route))
|
||||||
|
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||||
|
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||||
|
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||||
|
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||||
|
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||||
|
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||||
|
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||||
|
add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route))
|
||||||
|
}
|
||||||
|
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
||||||
|
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.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,86 @@
|
|||||||
|
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.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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import de.harheimertc.R
|
||||||
|
import de.harheimertc.repositories.GalleryImage
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ImageGrid(images: List<GalleryImage>, modifier: Modifier = Modifier) {
|
||||||
|
val selected = remember { mutableStateOf<GalleryImage?>(null) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = modifier.padding(8.dp)) {
|
||||||
|
items(images) { img ->
|
||||||
|
val description = stringResource(R.string.gallery_image_description, img.title.ifBlank { img.id })
|
||||||
|
Card(modifier = Modifier.padding(4.dp), elevation = CardDefaults.cardElevation(4.dp)) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(context)
|
||||||
|
.data(img.previewUrl)
|
||||||
|
.size(300, 300)
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = description,
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.semantics { contentDescription = description }
|
||||||
|
.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?.imageUrl,
|
||||||
|
contentDescription = selected.value?.title ?: stringResource(R.string.gallery_title),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { selected.value = null },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.semantics { contentDescription = context.getString(R.string.gallery_close_image) },
|
||||||
|
colors = ButtonDefaults.buttonColors(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.gallery_upload_hide))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.harheimertc.ui.theme.Accent500
|
||||||
|
import de.harheimertc.ui.theme.Primary600
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LoadingState(message: String = "Daten werden geladen...") {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = Primary600)
|
||||||
|
Text(message, color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
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.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NativeRichTextEditor(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var fieldValue by remember(value) { mutableStateOf(TextFieldValue(value, TextRange(value.length))) }
|
||||||
|
var linkDialog by remember { mutableStateOf(false) }
|
||||||
|
var imageDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun commit(next: TextFieldValue) {
|
||||||
|
fieldValue = next
|
||||||
|
onValueChange(normalizeEmptyHtml(next.text))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text(label, style = MaterialTheme.typography.titleLarge)
|
||||||
|
ToolbarRow(
|
||||||
|
onAction = { action ->
|
||||||
|
when (action) {
|
||||||
|
RichTextAction.Link -> linkDialog = true
|
||||||
|
RichTextAction.Image -> imageDialog = true
|
||||||
|
RichTextAction.Clean -> commit(fieldValue.copy(text = stripHtml(fieldValue.text), selection = TextRange(stripHtml(fieldValue.text).length)))
|
||||||
|
else -> commit(applyAction(fieldValue, action))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = fieldValue,
|
||||||
|
onValueChange = { commit(it) },
|
||||||
|
label = { Text("HTML-Inhalt") },
|
||||||
|
minLines = 12,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Surface(color = Color(0xFFF4F4F5), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Vorschau", style = MaterialTheme.typography.labelLarge)
|
||||||
|
if (fieldValue.text.isBlank()) Text("Noch kein Inhalt.", color = Color(0xFF71717A)) else RichText(fieldValue.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkDialog) {
|
||||||
|
UrlDialog(
|
||||||
|
title = "Link einfügen",
|
||||||
|
placeholder = "https://...",
|
||||||
|
onDismiss = { linkDialog = false },
|
||||||
|
onConfirm = { url ->
|
||||||
|
commit(applyLink(fieldValue, url))
|
||||||
|
linkDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (imageDialog) {
|
||||||
|
UrlDialog(
|
||||||
|
title = "Bild einfügen",
|
||||||
|
placeholder = "https://.../bild.jpg",
|
||||||
|
onDismiss = { imageDialog = false },
|
||||||
|
onConfirm = { url ->
|
||||||
|
commit(insertHtml(fieldValue, """<p><img src="${escapeHtml(url)}"></p>"""))
|
||||||
|
imageDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolbarRow(onAction: (RichTextAction) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
RichTextAction.entries.forEach { action ->
|
||||||
|
AssistChip(onClick = { onAction(action) }, label = { Text(action.label) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UrlDialog(title: String, placeholder: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||||
|
var value by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(value = value, onValueChange = { value = it }, label = { Text(placeholder) }, singleLine = true)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text("Einfügen") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Abbrechen") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class RichTextAction(val label: String) {
|
||||||
|
H1("H1"),
|
||||||
|
H2("H2"),
|
||||||
|
H3("H3"),
|
||||||
|
Bold("B"),
|
||||||
|
Italic("I"),
|
||||||
|
Underline("U"),
|
||||||
|
Strike("S"),
|
||||||
|
Color("Farbe"),
|
||||||
|
Background("Marker"),
|
||||||
|
OrderedList("1."),
|
||||||
|
BulletList("•"),
|
||||||
|
AlignCenter("Zentriert"),
|
||||||
|
Link("Link"),
|
||||||
|
Image("Bild"),
|
||||||
|
Blockquote("Zitat"),
|
||||||
|
CodeBlock("Code"),
|
||||||
|
Clean("Clean"),
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyAction(value: TextFieldValue, action: RichTextAction): TextFieldValue = when (action) {
|
||||||
|
RichTextAction.H1 -> wrapBlock(value, "h1")
|
||||||
|
RichTextAction.H2 -> wrapBlock(value, "h2")
|
||||||
|
RichTextAction.H3 -> wrapBlock(value, "h3")
|
||||||
|
RichTextAction.Bold -> wrapInline(value, "strong")
|
||||||
|
RichTextAction.Italic -> wrapInline(value, "em")
|
||||||
|
RichTextAction.Underline -> wrapInline(value, "u")
|
||||||
|
RichTextAction.Strike -> wrapInline(value, "s")
|
||||||
|
RichTextAction.Color -> wrapInline(value, "span", " style=\"color: #dc2626;\"")
|
||||||
|
RichTextAction.Background -> wrapInline(value, "span", " style=\"background-color: #fef3c7;\"")
|
||||||
|
RichTextAction.OrderedList -> wrapLines(value, "ol")
|
||||||
|
RichTextAction.BulletList -> wrapLines(value, "ul")
|
||||||
|
RichTextAction.AlignCenter -> wrapSelection(value, """<p class="ql-align-center">""", "</p>")
|
||||||
|
RichTextAction.Blockquote -> wrapBlock(value, "blockquote")
|
||||||
|
RichTextAction.CodeBlock -> wrapSelection(value, """<pre class="ql-syntax" spellcheck="false">""", "</pre>")
|
||||||
|
RichTextAction.Link,
|
||||||
|
RichTextAction.Image,
|
||||||
|
RichTextAction.Clean -> value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyLink(value: TextFieldValue, url: String): TextFieldValue {
|
||||||
|
val safeUrl = escapeHtml(url)
|
||||||
|
val label = selectedText(value).ifBlank { safeUrl }
|
||||||
|
return replaceSelection(value, """<a href="$safeUrl">$label</a>""")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wrapInline(value: TextFieldValue, tag: String, attrs: String = ""): TextFieldValue =
|
||||||
|
wrapSelection(value, "<$tag$attrs>", "</$tag>")
|
||||||
|
|
||||||
|
private fun wrapBlock(value: TextFieldValue, tag: String): TextFieldValue =
|
||||||
|
wrapSelection(value, "<$tag>", "</$tag>")
|
||||||
|
|
||||||
|
private fun wrapLines(value: TextFieldValue, listTag: String): TextFieldValue {
|
||||||
|
val lines = selectedText(value).ifBlank { "Listeneintrag" }
|
||||||
|
.lines()
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString("") { "<li>${escapeHtml(it)}</li>" }
|
||||||
|
return replaceSelection(value, "<$listTag>$lines</$listTag>")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wrapSelection(value: TextFieldValue, prefix: String, suffix: String): TextFieldValue =
|
||||||
|
replaceSelection(value, prefix + selectedText(value).ifBlank { "Text" } + suffix)
|
||||||
|
|
||||||
|
private fun insertHtml(value: TextFieldValue, html: String): TextFieldValue = replaceSelection(value, html)
|
||||||
|
|
||||||
|
private fun replaceSelection(value: TextFieldValue, replacement: String): TextFieldValue {
|
||||||
|
val start = value.selection.min.coerceIn(0, value.text.length)
|
||||||
|
val end = value.selection.max.coerceIn(0, value.text.length)
|
||||||
|
val next = value.text.replaceRange(start, end, replacement)
|
||||||
|
val cursor = start + replacement.length
|
||||||
|
return TextFieldValue(next, TextRange(cursor))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectedText(value: TextFieldValue): String {
|
||||||
|
val start = value.selection.min.coerceIn(0, value.text.length)
|
||||||
|
val end = value.selection.max.coerceIn(0, value.text.length)
|
||||||
|
return value.text.substring(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML helper functions moved to RichTextUtils.kt for reuse and testing
|
||||||
@@ -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,15 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
fun normalizeEmptyHtml(value: String): String =
|
||||||
|
if (stripHtml(value).isBlank() && !value.contains("<img", ignoreCase = true)) "" else value
|
||||||
|
|
||||||
|
fun stripHtml(value: String): String = value
|
||||||
|
.replace(Regex("<[^>]+>"), "")
|
||||||
|
.replace(" ", " ")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
fun escapeHtml(value: String): String = value
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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 Qttr : Destinations("intern/qttr")
|
||||||
|
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,362 @@
|
|||||||
|
package de.harheimertc.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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,
|
||||||
|
navigationViewModelParam: NavigationViewModel? = null,
|
||||||
|
) {
|
||||||
|
val navigationViewModel: NavigationViewModel = navigationViewModelParam ?: 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()) {
|
||||||
|
navigationState.connectionNote?.let { message ->
|
||||||
|
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = Color(0xFF7C2D12),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (persistentNavigation) {
|
||||||
|
AppNavigationHeader(
|
||||||
|
selectedRoute = currentRoute,
|
||||||
|
onNavigate = navController::navigateTopLevel,
|
||||||
|
onLogout = {
|
||||||
|
navigationViewModel.logout {
|
||||||
|
navController.navigate(Destinations.Home.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
navigationViewModel = navigationViewModel,
|
||||||
|
viewModel = hiltViewModel(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
navigationState = navigationState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Destinations.Members.route) {
|
||||||
|
de.harheimertc.ui.screens.memberarea.MembersScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Destinations.Qttr.route) {
|
||||||
|
de.harheimertc.ui.screens.memberarea.QttrScreen(
|
||||||
|
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,103 @@
|
|||||||
|
package de.harheimertc.ui.navigation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
|
import de.harheimertc.repositories.AuthRepository
|
||||||
|
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 connectionNote: String? = null,
|
||||||
|
) {
|
||||||
|
val isAdmin: Boolean get() = "admin" in roles
|
||||||
|
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
||||||
|
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 canAccessCms: Boolean get() = canAccessFullCms || canAccessNewsletter || canAccessContactRequests
|
||||||
|
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class NavigationViewModel @Inject constructor(
|
||||||
|
private val mannschaftenRepository: MannschaftenRepository,
|
||||||
|
private val galleryRepository: GalleryRepository,
|
||||||
|
private val loginRepository: LoginRepository,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(NavigationUiState())
|
||||||
|
val state: StateFlow<NavigationUiState> = _state
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadNavigationData()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
connectionNote = if (online) null else "Keine Verbindung. Die App versucht alle 10 Sekunden erneut zu laden.",
|
||||||
|
)
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||||
|
_state.value = NavigationUiState(
|
||||||
|
teams = teams.await(),
|
||||||
|
hasGalleryImages = gallery.await(),
|
||||||
|
loggedIn = hasStoredSession || status.isLoggedIn,
|
||||||
|
roles = status.navigationRoles(),
|
||||||
|
connectionNote = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshSession() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
||||||
|
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loggedIn = hasStoredSession || status.isLoggedIn,
|
||||||
|
roles = status.navigationRoles(),
|
||||||
|
connectionNote = _state.value.connectionNote,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout(onComplete: () -> Unit = {}) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
loginRepository.logout()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loggedIn = false,
|
||||||
|
roles = emptySet(),
|
||||||
|
connectionNote = _state.value.connectionNote,
|
||||||
|
)
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun de.harheimertc.data.AuthStatusResponse.navigationRoles(): Set<String> = buildSet {
|
||||||
|
addAll(roles)
|
||||||
|
role?.takeIf { it.isNotBlank() }?.let(::add)
|
||||||
|
addAll(user?.roles.orEmpty())
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
|
// Placeholder: functionality moved to CmsScreens.kt (CmsUserListPage / UserCard)
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import de.harheimertc.data.NewsDto
|
||||||
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
|
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||||
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var selection by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
|
||||||
|
val loginState by loginVm.state.collectAsState()
|
||||||
|
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showSuccessDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
androidx.compose.runtime.LaunchedEffect(state.message) {
|
||||||
|
if (!state.message.isNullOrBlank()) showSuccessDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local dialog state for create/edit + delete confirmation (hoisted)
|
||||||
|
var dialogOpen by remember { mutableStateOf(false) }
|
||||||
|
var deletingIds by remember { mutableStateOf<List<String>?>(null) }
|
||||||
|
var editing by remember { mutableStateOf<NewsDto?>(null) }
|
||||||
|
var title by remember { mutableStateOf("") }
|
||||||
|
var content by remember { mutableStateOf("") }
|
||||||
|
var isPublic by remember { mutableStateOf(false) }
|
||||||
|
var isHidden by remember { mutableStateOf(false) }
|
||||||
|
var expiresAt by remember { mutableStateOf("") } // format: yyyy-MM-dd'T'HH:mm
|
||||||
|
|
||||||
|
val dtFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
|
||||||
|
val displayFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy, HH:mm", Locale.GERMANY)
|
||||||
|
|
||||||
|
fun convertUTCToLocal(utc: String?): String {
|
||||||
|
if (utc.isNullOrBlank()) return ""
|
||||||
|
return try {
|
||||||
|
val instant = Instant.parse(utc)
|
||||||
|
LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).format(dtFormatter)
|
||||||
|
} catch (e: Exception) { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun convertLocalToUTC(local: String?): String? {
|
||||||
|
if (local.isNullOrBlank()) return null
|
||||||
|
return try {
|
||||||
|
val ldt = LocalDateTime.parse(local, dtFormatter)
|
||||||
|
ldt.atZone(ZoneId.systemDefault()).toInstant().toString()
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// open create
|
||||||
|
fun openAdd() {
|
||||||
|
editing = null
|
||||||
|
title = ""
|
||||||
|
content = ""
|
||||||
|
isPublic = false
|
||||||
|
isHidden = false
|
||||||
|
expiresAt = ""
|
||||||
|
dialogOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// open edit
|
||||||
|
fun openEdit(item: NewsDto) {
|
||||||
|
editing = item
|
||||||
|
title = item.title
|
||||||
|
content = item.content
|
||||||
|
isPublic = item.isPublic
|
||||||
|
isHidden = item.isHidden
|
||||||
|
expiresAt = convertUTCToLocal(item.expiresAt)
|
||||||
|
dialogOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
|
||||||
|
if (state.loading) item { LoadingState("News werden geladen...") }
|
||||||
|
|
||||||
|
item {
|
||||||
|
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
if (canWrite) Button(onClick = { openAdd() }, modifier = Modifier.fillMaxWidth()) { Text("News erstellen") }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
FormMessages(state.error, state.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.loading && state.news.isEmpty()) item { Text("Noch keine News vorhanden.", modifier = Modifier.padding(12.dp)) }
|
||||||
|
|
||||||
|
// selection state for bulk actions (moved to outer scope)
|
||||||
|
|
||||||
|
items(state.news) { news ->
|
||||||
|
val selected = news.id?.let { selection.contains(it) } ?: false
|
||||||
|
NewsListItem(news = news, selected = selected, onSelect = { id, sel ->
|
||||||
|
id?.let {
|
||||||
|
selection = if (sel) selection + it else selection - it
|
||||||
|
}
|
||||||
|
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// bulk action bar
|
||||||
|
if (selection.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
||||||
|
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), false) }) { Text("Als nicht-öffentlich markieren") }
|
||||||
|
Button(onClick = { viewModel.bulkSetHidden(selection.toList(), true) }) { Text("Ausblenden") }
|
||||||
|
Button(onClick = { viewModel.bulkSetHidden(selection.toList(), false) }) { Text("Einblenden") }
|
||||||
|
Button(onClick = { /* confirm then delete */ deletingIds = selection.toList() }) { Text("Löschen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (moved earlier)
|
||||||
|
|
||||||
|
// delete confirmation dialog
|
||||||
|
if (deletingIds != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deletingIds = null },
|
||||||
|
title = { Text("News löschen") },
|
||||||
|
text = { Text("Möchten Sie die ausgewählten News wirklich löschen?") },
|
||||||
|
confirmButton = { Button(onClick = {
|
||||||
|
deletingIds?.let { viewModel.bulkDelete(it) }
|
||||||
|
deletingIds = null
|
||||||
|
selection = emptySet()
|
||||||
|
}) { Text("Löschen") } },
|
||||||
|
dismissButton = { TextButton(onClick = { deletingIds = null }) { Text("Abbrechen") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialog for create/edit
|
||||||
|
if (dialogOpen) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { dialogOpen = false },
|
||||||
|
title = { Text(if (editing == null) "News erstellen" else "News bearbeiten") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Titel *") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
NativeRichTextEditor(content, { content = it }, "Inhalt *")
|
||||||
|
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = isPublic, onCheckedChange = { isPublic = it })
|
||||||
|
Text("Öffentliche News (auf Startseite anzeigen)", modifier = Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
|
// read-only datetime field that opens native pickers
|
||||||
|
OutlinedTextField(
|
||||||
|
value = expiresAt,
|
||||||
|
onValueChange = { /* no-op: controlled by pickers */ },
|
||||||
|
label = { Text("Ablaufdatum (optional)") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
// open date then time picker
|
||||||
|
val now = java.util.Calendar.getInstance()
|
||||||
|
val year = now.get(java.util.Calendar.YEAR)
|
||||||
|
val month = now.get(java.util.Calendar.MONTH)
|
||||||
|
val day = now.get(java.util.Calendar.DAY_OF_MONTH)
|
||||||
|
android.app.DatePickerDialog(context, { _, y, m, d ->
|
||||||
|
val hour = now.get(java.util.Calendar.HOUR_OF_DAY)
|
||||||
|
val minute = now.get(java.util.Calendar.MINUTE)
|
||||||
|
android.app.TimePickerDialog(context, { _, h, min ->
|
||||||
|
val ldt = LocalDateTime.of(y, m + 1, d, h, min)
|
||||||
|
expiresAt = ldt.format(dtFormatter)
|
||||||
|
}, hour, minute, true).show()
|
||||||
|
}, year, month, day).show()
|
||||||
|
},
|
||||||
|
readOnly = true,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = isHidden, onCheckedChange = { isHidden = it })
|
||||||
|
Text("News ausblenden", modifier = Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val err = state.error
|
||||||
|
if (err != null) {
|
||||||
|
Text(err, color = Color(0xFF842029))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = {
|
||||||
|
val req = NewsSaveRequest(
|
||||||
|
id = editing?.id,
|
||||||
|
title = title,
|
||||||
|
content = content,
|
||||||
|
isPublic = isPublic,
|
||||||
|
isHidden = isHidden,
|
||||||
|
expiresAt = convertLocalToUTC(expiresAt),
|
||||||
|
)
|
||||||
|
viewModel.saveNews(req)
|
||||||
|
dialogOpen = false
|
||||||
|
}, enabled = !state.saving) { Text(if (state.saving) "Speichert..." else "Speichern") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { dialogOpen = false }) { Text("Abbrechen") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSuccessDialog && !state.message.isNullOrBlank()) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showSuccessDialog = false },
|
||||||
|
title = { Text("Erfolg") },
|
||||||
|
text = { Text(state.message ?: "") },
|
||||||
|
confirmButton = { Button(onClick = { showSuccessDialog = false }) { Text("OK") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NewsListItem(
|
||||||
|
news: NewsDto,
|
||||||
|
selected: Boolean = false,
|
||||||
|
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
|
||||||
|
onEdit: (NewsDto) -> Unit,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) })
|
||||||
|
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
||||||
|
if (news.isPublic) {
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||||
|
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF0EA5A6)))
|
||||||
|
Text("Öffentlich", modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (news.isHidden) {
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||||
|
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color.Gray))
|
||||||
|
Text("Ausgeblendet", modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val expired = news.expiresAt?.let {
|
||||||
|
try { Instant.parse(it).isBefore(Instant.now()) || Instant.parse(it).equals(Instant.now()) } catch (e: Exception) { false }
|
||||||
|
} ?: false
|
||||||
|
if (expired) {
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||||
|
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFB91C1C)))
|
||||||
|
Text("Abgelaufen", modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
Text(news.author ?: "-", modifier = Modifier.padding(end = 12.dp))
|
||||||
|
Text(news.created ?: "-")
|
||||||
|
}
|
||||||
|
if (news.updated != null && news.updated != news.created) {
|
||||||
|
Text("Aktualisiert: ${news.updated}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
||||||
|
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,464 @@
|
|||||||
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
|
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.PasswordResetMatchingUserDto
|
||||||
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
|
import de.harheimertc.data.NewsDto
|
||||||
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
|
import de.harheimertc.repositories.CmsRepository
|
||||||
|
import de.harheimertc.repositories.MeisterschaftResult
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import de.harheimertc.ui.util.ErrorMapper
|
||||||
|
|
||||||
|
data class CmsUiState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val saving: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val message: 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(),
|
||||||
|
val passwordResetMatchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
|
||||||
|
val passwordResetRetentionHours: Int = 72,
|
||||||
|
val passwordResetSearchTerm: String = "",
|
||||||
|
val passwordResetFailedOnly: Boolean = true,
|
||||||
|
val news: List<NewsDto> = emptyList(),
|
||||||
|
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class CmsViewModel @Inject constructor(
|
||||||
|
private val repository: CmsRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(CmsUiState())
|
||||||
|
val state: StateFlow<CmsUiState> = _state
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(loading = true, error = null)
|
||||||
|
|
||||||
|
val configRes = async { repository.config() }
|
||||||
|
val usersRes = async { repository.users() }
|
||||||
|
val requestsRes = async { repository.contactRequests() }
|
||||||
|
val newslettersRes = async { repository.newsletters() }
|
||||||
|
val groupsRes = async { repository.newsletterGroups() }
|
||||||
|
val newsRes = async { repository.news() }
|
||||||
|
val diagnosticsRes = async {
|
||||||
|
repository.passwordResetDiagnostics(
|
||||||
|
email = _state.value.passwordResetSearchTerm.takeIf { it.isNotBlank() },
|
||||||
|
failedOnly = _state.value.passwordResetFailedOnly,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val meisterschaftenRes = async { repository.vereinsmeisterschaften() }
|
||||||
|
|
||||||
|
val configResult = configRes.await()
|
||||||
|
val usersResult = usersRes.await()
|
||||||
|
val requestsResult = requestsRes.await()
|
||||||
|
val newslettersResult = newslettersRes.await()
|
||||||
|
val groupsResult = groupsRes.await()
|
||||||
|
val newsResult = newsRes.await()
|
||||||
|
val diagnosticsResult = diagnosticsRes.await()
|
||||||
|
val meisterschaftenResult = meisterschaftenRes.await()
|
||||||
|
|
||||||
|
val errors = listOfNotNull(
|
||||||
|
ErrorMapper.mapError(configResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(usersResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(requestsResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(newslettersResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(groupsResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(newsResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(diagnosticsResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(meisterschaftenResult.exceptionOrNull()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sort users so that pending (inactive) users come first,
|
||||||
|
// followed by active users sorted by display name (case-insensitive).
|
||||||
|
val fetchedUsers = usersResult.getOrNull()?.users.orEmpty()
|
||||||
|
val pendingUsers = fetchedUsers.filter { it.active == false }
|
||||||
|
val activeUsers = fetchedUsers.filter { it.active == true }
|
||||||
|
.sortedBy { it.name.lowercase() }
|
||||||
|
val orderedUsers = pendingUsers + activeUsers
|
||||||
|
|
||||||
|
_state.value = CmsUiState(
|
||||||
|
loading = false,
|
||||||
|
error = if (errors.isNotEmpty()) errors.joinToString("; ") else null,
|
||||||
|
config = configResult.getOrNull(),
|
||||||
|
users = orderedUsers,
|
||||||
|
contactRequests = requestsResult.getOrNull().orEmpty(),
|
||||||
|
newsletters = newslettersResult.getOrNull()?.newsletters.orEmpty(),
|
||||||
|
newsletterGroups = groupsResult.getOrNull()?.groups.orEmpty(),
|
||||||
|
news = newsResult.getOrNull()?.news.orEmpty(),
|
||||||
|
passwordResetAttempts = diagnosticsResult.getOrNull()?.attempts.orEmpty(),
|
||||||
|
passwordResetMatchingUsers = diagnosticsResult.getOrNull()?.matchingUsers.orEmpty(),
|
||||||
|
passwordResetRetentionHours = diagnosticsResult.getOrNull()?.retentionHours ?: 72,
|
||||||
|
passwordResetSearchTerm = diagnosticsResult.getOrNull()?.searchedEmail.orEmpty(),
|
||||||
|
passwordResetFailedOnly = _state.value.passwordResetFailedOnly,
|
||||||
|
meisterschaften = meisterschaftenResult.getOrNull().orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPasswordResetDiagnostics(email: String, failedOnly: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loading = true,
|
||||||
|
error = null,
|
||||||
|
passwordResetSearchTerm = email.trim(),
|
||||||
|
passwordResetFailedOnly = failedOnly,
|
||||||
|
)
|
||||||
|
repository.passwordResetDiagnostics(
|
||||||
|
email = email.trim().takeIf { it.isNotBlank() },
|
||||||
|
failedOnly = failedOnly,
|
||||||
|
)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loading = false,
|
||||||
|
passwordResetRetentionHours = response.retentionHours,
|
||||||
|
passwordResetMatchingUsers = response.matchingUsers,
|
||||||
|
passwordResetAttempts = response.attempts,
|
||||||
|
passwordResetSearchTerm = response.searchedEmail.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loading = false,
|
||||||
|
passwordResetMatchingUsers = emptyList(),
|
||||||
|
passwordResetAttempts = emptyList(),
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Passwort-Reset-Diagnose konnte nicht geladen werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.saveVereinsmeisterschaften(results)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
meisterschaften = results,
|
||||||
|
message = response.message ?: "Vereinsmeisterschaften gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Vereinsmeisterschaften konnten nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveConfig(config: ConfigResponse) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.saveConfig(config)
|
||||||
|
.onSuccess { saved ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
config = saved,
|
||||||
|
message = "Inhalt gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
error = ErrorMapper.mapError(it) ?: "Inhalt konnte nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveNews(request: NewsSaveRequest) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.saveNews(request)
|
||||||
|
.onSuccess { msg ->
|
||||||
|
// refresh news list directly to preserve message
|
||||||
|
val newsRes = repository.news()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
news = newsRes.getOrNull()?.news.orEmpty(),
|
||||||
|
message = msg.message ?: "Nachricht gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gespeichert werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bulkSetPublic(ids: List<String>, makePublic: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
ids.forEach { id ->
|
||||||
|
val existing = _state.value.news.find { it.id == id }
|
||||||
|
if (existing != null) {
|
||||||
|
val req = NewsSaveRequest(
|
||||||
|
id = existing.id,
|
||||||
|
title = existing.title,
|
||||||
|
content = existing.content,
|
||||||
|
isPublic = makePublic,
|
||||||
|
isHidden = existing.isHidden,
|
||||||
|
expiresAt = existing.expiresAt,
|
||||||
|
)
|
||||||
|
repository.saveNews(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val newsRes = repository.news()
|
||||||
|
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bulkSetHidden(ids: List<String>, makeHidden: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
ids.forEach { id ->
|
||||||
|
val existing = _state.value.news.find { it.id == id }
|
||||||
|
if (existing != null) {
|
||||||
|
val req = NewsSaveRequest(
|
||||||
|
id = existing.id,
|
||||||
|
title = existing.title,
|
||||||
|
content = existing.content,
|
||||||
|
isPublic = existing.isPublic,
|
||||||
|
isHidden = makeHidden,
|
||||||
|
expiresAt = existing.expiresAt,
|
||||||
|
)
|
||||||
|
repository.saveNews(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val newsRes = repository.news()
|
||||||
|
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bulkDelete(ids: List<String>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
ids.forEach { id ->
|
||||||
|
repository.deleteNews(id)
|
||||||
|
}
|
||||||
|
val newsRes = repository.news()
|
||||||
|
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Löschung abgeschlossen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteNews(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.deleteNews(id)
|
||||||
|
.onSuccess { msg ->
|
||||||
|
val newsRes = repository.news()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
news = newsRes.getOrNull()?.news.orEmpty(),
|
||||||
|
message = msg.message ?: "Nachricht gelöscht.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gelöscht werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Newsletter (B4)
|
||||||
|
fun saveNewsletter(request: de.harheimertc.data.NewsletterCreateRequest) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.createNewsletter(request)
|
||||||
|
.onSuccess { res ->
|
||||||
|
val newslettersRes = repository.newsletters()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(),
|
||||||
|
message = res.message ?: "Newsletter gespeichert",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht gespeichert werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewsletter(id: String, patch: Map<String, Any?>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.updateNewsletter(id, patch)
|
||||||
|
.onSuccess { res ->
|
||||||
|
val newslettersRes = repository.newsletters()
|
||||||
|
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter aktualisiert")
|
||||||
|
}
|
||||||
|
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht aktualisiert werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendNewsletter(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.sendNewsletter(id)
|
||||||
|
.onSuccess { res ->
|
||||||
|
val newslettersRes = repository.newsletters()
|
||||||
|
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter versendet")
|
||||||
|
}
|
||||||
|
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht versendet werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteNewsletter(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.deleteNewsletter(id)
|
||||||
|
.onSuccess { res ->
|
||||||
|
val newslettersRes = repository.newsletters()
|
||||||
|
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter gelöscht")
|
||||||
|
}
|
||||||
|
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht gelöscht werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Newsletter Groups (B4)
|
||||||
|
fun createNewsletterGroup(payload: Map<String, Any?>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.createNewsletterGroup(payload)
|
||||||
|
.onSuccess { res ->
|
||||||
|
val groupsRes = repository.newsletterGroups()
|
||||||
|
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe erstellt")
|
||||||
|
}
|
||||||
|
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht erstellt werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewsletterGroup(id: String, patch: Map<String, Any?>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.updateNewsletterGroup(id, patch)
|
||||||
|
.onSuccess { res ->
|
||||||
|
val groupsRes = repository.newsletterGroups()
|
||||||
|
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe aktualisiert")
|
||||||
|
}
|
||||||
|
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht aktualisiert werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteNewsletterGroup(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.deleteNewsletterGroup(id)
|
||||||
|
.onSuccess { res ->
|
||||||
|
val groupsRes = repository.newsletterGroups()
|
||||||
|
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe gelöscht")
|
||||||
|
}
|
||||||
|
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht gelöscht werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User management actions (B2)
|
||||||
|
fun updateUserRoles(id: String, roles: List<String>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.updateUserRoles(id, roles)
|
||||||
|
.onSuccess { msg ->
|
||||||
|
val usersRes = repository.users()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
users = usersRes.getOrNull()?.users.orEmpty(),
|
||||||
|
message = msg.message ?: "Rollen aktualisiert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Rollen konnten nicht aktualisiert werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUserActive(id: String, active: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.updateUserActive(id, active)
|
||||||
|
.onSuccess { msg ->
|
||||||
|
val usersRes = repository.users()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
users = usersRes.getOrNull()?.users.orEmpty(),
|
||||||
|
message = msg.message ?: if (active) "Benutzer aktiviert." else "Benutzer deaktiviert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Benutzerstatus konnte nicht geändert werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resendInvite(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.resendInvite(id)
|
||||||
|
.onSuccess { msg ->
|
||||||
|
_state.value = _state.value.copy(saving = false, message = msg.message ?: "Einladung erneut gesendet.")
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Einladung konnte nicht gesendet werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Contact requests (B3)
|
||||||
|
fun replyToContactRequest(id: String, message: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.replyToContactRequest(id, message)
|
||||||
|
.onSuccess {
|
||||||
|
val reqs = repository.contactRequests()
|
||||||
|
_state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Antwort gesendet.")
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Antwort konnte nicht gesendet werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleContactRequestStatus(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.toggleContactRequestStatus(id)
|
||||||
|
.onSuccess {
|
||||||
|
val reqs = repository.contactRequests()
|
||||||
|
_state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Status aktualisiert.")
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Status konnte nicht geändert werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,146 @@
|
|||||||
|
package de.harheimertc.ui.screens.gallery
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
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.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import de.harheimertc.R
|
||||||
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
|
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()
|
||||||
|
val uploading by viewModel.uploading.collectAsState()
|
||||||
|
val message by viewModel.message.collectAsState()
|
||||||
|
val canUpload by viewModel.canUpload.collectAsState()
|
||||||
|
var selectedUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
|
var title by remember { mutableStateOf("") }
|
||||||
|
var description by remember { mutableStateOf("") }
|
||||||
|
var isPublic by remember { mutableStateOf(false) }
|
||||||
|
var showUpload by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
selectedUri = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.gallery_title),
|
||||||
|
modifier = Modifier.semantics { heading() },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
if (canUpload) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(stringResource(R.string.gallery_upload_title), modifier = Modifier.weight(1f))
|
||||||
|
OutlinedButton(onClick = { showUpload = !showUpload }) {
|
||||||
|
Text(if (showUpload) stringResource(R.string.gallery_upload_hide) else stringResource(R.string.gallery_upload_show))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showUpload) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { picker.launch("image/*") },
|
||||||
|
enabled = !uploading,
|
||||||
|
modifier = Modifier.semantics {
|
||||||
|
contentDescription = context.getString(R.string.gallery_upload_choose_file)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(selectedUri?.lastPathSegment ?: stringResource(R.string.gallery_upload_choose_file))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = title,
|
||||||
|
onValueChange = { title = it },
|
||||||
|
label = { Text(stringResource(R.string.gallery_upload_image_title)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uploading,
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = description,
|
||||||
|
onValueChange = { description = it },
|
||||||
|
label = { Text(stringResource(R.string.gallery_upload_description)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uploading,
|
||||||
|
minLines = 2,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = isPublic, onCheckedChange = { isPublic = it }, enabled = !uploading)
|
||||||
|
Text(stringResource(R.string.gallery_upload_public))
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
selectedUri?.let { uri ->
|
||||||
|
viewModel.upload(uri, title, description, isPublic)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = selectedUri != null && title.isNotBlank() && !uploading,
|
||||||
|
) {
|
||||||
|
Text(if (uploading) stringResource(R.string.gallery_uploading) else stringResource(R.string.gallery_upload_submit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
FormMessages(error = error, message = message)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
if (loading) {
|
||||||
|
LoadingState("Galerie wird geladen...")
|
||||||
|
} else if (images.isEmpty()) {
|
||||||
|
Text(text = stringResource(R.string.gallery_empty))
|
||||||
|
} else {
|
||||||
|
ImageGrid(images = images, modifier = Modifier.height(520.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
|
viewModel.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.harheimertc.ui.screens.gallery
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import android.net.Uri
|
||||||
|
import de.harheimertc.repositories.GalleryImage
|
||||||
|
import de.harheimertc.repositories.GalleryRepository
|
||||||
|
import de.harheimertc.repositories.LoginRepository
|
||||||
|
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,
|
||||||
|
private val loginRepository: LoginRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _images = MutableStateFlow<List<GalleryImage>>(emptyList())
|
||||||
|
val images: StateFlow<List<GalleryImage>> = _images
|
||||||
|
|
||||||
|
private val _loading = MutableStateFlow(false)
|
||||||
|
val loading: StateFlow<Boolean> = _loading
|
||||||
|
|
||||||
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
|
val error: StateFlow<String?> = _error
|
||||||
|
|
||||||
|
private val _uploading = MutableStateFlow(false)
|
||||||
|
val uploading: StateFlow<Boolean> = _uploading
|
||||||
|
|
||||||
|
private val _message = MutableStateFlow<String?>(null)
|
||||||
|
val message: StateFlow<String?> = _message
|
||||||
|
|
||||||
|
private val _canUpload = MutableStateFlow(false)
|
||||||
|
val canUpload: StateFlow<Boolean> = _canUpload
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_loading.value = true
|
||||||
|
_error.value = null
|
||||||
|
repo.fetchImages()
|
||||||
|
.onSuccess { _images.value = it.images }
|
||||||
|
.onFailure { _error.value = it.message ?: "Fehler" }
|
||||||
|
loginRepository.status()
|
||||||
|
.onSuccess { status ->
|
||||||
|
val roles = (status.roles + status.user?.roles.orEmpty() + listOfNotNull(status.role)).toSet()
|
||||||
|
_canUpload.value = roles.any { it in setOf("admin", "vorstand") }
|
||||||
|
}
|
||||||
|
_loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun upload(uri: Uri, title: String, description: String, isPublic: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uploading.value = true
|
||||||
|
_error.value = null
|
||||||
|
_message.value = null
|
||||||
|
repo.uploadImage(uri, title, description, isPublic)
|
||||||
|
.onSuccess {
|
||||||
|
_message.value = "Bild erfolgreich hochgeladen."
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
.onFailure { _error.value = it.message ?: "Fehler beim Hochladen des Bildes" }
|
||||||
|
_uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,832 @@
|
|||||||
|
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.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.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.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.font.FontFamily
|
||||||
|
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 de.harheimertc.ui.navigation.NavigationViewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import de.harheimertc.data.HomepageSectionDto
|
||||||
|
import de.harheimertc.data.NewsDto
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
|
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,
|
||||||
|
navigationViewModel: NavigationViewModel,
|
||||||
|
viewModel: HomeViewModel,
|
||||||
|
) {
|
||||||
|
val navigationState by navigationViewModel.state.collectAsState()
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
|
||||||
|
var editHomeSections by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
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,
|
||||||
|
onLogout = {
|
||||||
|
navigationViewModel.logout {
|
||||||
|
navController.navigate(Destinations.Home.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationState = navigationState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
HomeCustomizationSection(
|
||||||
|
sections = state.homepageSections,
|
||||||
|
spielplanSeasons = state.spielplanSeasons,
|
||||||
|
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
|
||||||
|
editEnabled = editHomeSections,
|
||||||
|
onToggleEdit = { editHomeSections = !editHomeSections },
|
||||||
|
onMoveUp = viewModel::moveSectionUp,
|
||||||
|
onMoveDown = viewModel::moveSectionDown,
|
||||||
|
onEnabledChange = viewModel::setSectionEnabled,
|
||||||
|
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
|
||||||
|
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
|
||||||
|
onReset = viewModel::resetSections,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.error) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.errorMessage ?: "Daten konnten nicht geladen werden.",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
OutlinedButton(onClick = viewModel::load) {
|
||||||
|
Text("Erneut versuchen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.debugDiagnostics.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Technische Diagnose (vorübergehend)",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = state.debugDiagnostics.joinToString("\n\n---\n\n"),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.homepageSections.forEachIndexed { index, section ->
|
||||||
|
if (!section.enabled) return@forEachIndexed
|
||||||
|
val sectionKey = homeSectionKey(section)
|
||||||
|
when (section.id) {
|
||||||
|
"banner" -> item(key = "home_section_${sectionKey}_$index") {
|
||||||
|
WebHero(imageUrl = state.heroImageUrl)
|
||||||
|
}
|
||||||
|
"termine" -> item(key = "home_section_${sectionKey}_$index") {
|
||||||
|
HomeTermineSection(
|
||||||
|
termine = state.termine,
|
||||||
|
loading = state.loading,
|
||||||
|
onAll = { navController.navigate(Destinations.Termine.route) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"spiele" -> item(key = "home_section_${sectionKey}_$index") {
|
||||||
|
HomeGamesSection(
|
||||||
|
spiele = state.spiele,
|
||||||
|
loading = state.loading,
|
||||||
|
onAll = { navController.navigate(Destinations.Spielplan.route) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"aktuelles" -> {
|
||||||
|
if (state.news.isNotEmpty()) {
|
||||||
|
item(key = "home_section_${sectionKey}_$index") {
|
||||||
|
HomeNewsSection(
|
||||||
|
news = state.news,
|
||||||
|
onOpen = { selectedNews = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"kontakt" -> item(key = "home_section_${sectionKey}_$index") {
|
||||||
|
HomeActionSection(
|
||||||
|
onMembership = { navController.navigate(Destinations.Membership.route) },
|
||||||
|
onContact = { navController.navigate(Destinations.Contact.route) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"training" -> item(key = "home_section_${sectionKey}_$index") {
|
||||||
|
HomeExtraActionSection(
|
||||||
|
title = "Training & Einstieg",
|
||||||
|
body = "Alle Infos zu Trainingszeiten, Trainern und unserem Anfängerangebot.",
|
||||||
|
action = "Zum Training",
|
||||||
|
onClick = { navController.navigate(Destinations.Training.route) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"links" -> item(key = "home_section_${sectionKey}_$index") {
|
||||||
|
HomeExtraActionSection(
|
||||||
|
title = "Nützliche Links",
|
||||||
|
body = "Direkter Zugang zu Verbänden, Ergebnisdiensten und hilfreichen Portalen.",
|
||||||
|
action = "Links öffnen",
|
||||||
|
onClick = { navController.navigate(Destinations.Links.route) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"vereinsmeisterschaften" -> item(key = "home_section_${sectionKey}_$index") {
|
||||||
|
HomeExtraActionSection(
|
||||||
|
title = "Vereinsmeisterschaften",
|
||||||
|
body = "Ergebnisse und Historie unserer Vereinsmeisterschaften.",
|
||||||
|
action = "Ergebnisse ansehen",
|
||||||
|
onClick = { navController.navigate(Destinations.Vereinsmeisterschaften.route) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"spielplan_team" -> item(key = "home_section_${sectionKey}_$index") {
|
||||||
|
HomeSpielplanTeamWidgetSection(
|
||||||
|
section = section,
|
||||||
|
spiele = state.spielplanWidgetPreviews[sectionKey].orEmpty(),
|
||||||
|
error = state.spielplanWidgetErrors[sectionKey],
|
||||||
|
loading = state.widgetsLoading,
|
||||||
|
onOpenAll = { navController.navigate(Destinations.Spielplan.route) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { HomeFooter() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HomeCustomizationSection(
|
||||||
|
sections: List<HomepageSectionDto>,
|
||||||
|
spielplanSeasons: List<SeasonDto>,
|
||||||
|
spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
|
||||||
|
editEnabled: Boolean,
|
||||||
|
onToggleEdit: () -> Unit,
|
||||||
|
onMoveUp: (String) -> Unit,
|
||||||
|
onMoveDown: (String) -> Unit,
|
||||||
|
onEnabledChange: (String, Boolean) -> Unit,
|
||||||
|
onAddSpielplanWidget: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
|
||||||
|
onUpdateSpielplanWidget: (sectionKey: String, season: String, teamName: String, teamAgeGroup: String) -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
|
) {
|
||||||
|
var addSeason by rememberSaveable { mutableStateOf("") }
|
||||||
|
var addTeamKey by rememberSaveable { mutableStateOf("") }
|
||||||
|
val addTeamOptions = spielplanTeamsBySeason[addSeason].orEmpty()
|
||||||
|
|
||||||
|
LaunchedEffect(spielplanSeasons) {
|
||||||
|
if (addSeason.isBlank() || spielplanSeasons.none { it.slug == addSeason }) {
|
||||||
|
addSeason = spielplanSeasons.firstOrNull()?.slug.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(addSeason, addTeamOptions) {
|
||||||
|
if (addTeamOptions.none { teamOptionKey(it) == addTeamKey }) {
|
||||||
|
addTeamKey = addTeamOptions.firstOrNull()?.let(::teamOptionKey).orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = Color(0xFFFAFAFA),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = onToggleEdit) {
|
||||||
|
Text(if (editEnabled) "Startseiten-Editor schließen" else "Startseite anpassen")
|
||||||
|
}
|
||||||
|
if (editEnabled) {
|
||||||
|
Text(
|
||||||
|
"Elemente ein-/ausblenden und Reihenfolge festlegen.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Accent700,
|
||||||
|
)
|
||||||
|
sections.forEachIndexed { index, section ->
|
||||||
|
val label = homeSectionLabels[section.id] ?: section.id
|
||||||
|
val sectionKey = homeSectionKey(section)
|
||||||
|
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(label, style = MaterialTheme.typography.titleMedium, color = Accent900)
|
||||||
|
Text(section.id, style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Anzeigen", color = Accent700, style = MaterialTheme.typography.labelSmall)
|
||||||
|
androidx.compose.material3.Checkbox(
|
||||||
|
checked = section.enabled,
|
||||||
|
onCheckedChange = { enabled -> onEnabledChange(sectionKey, enabled) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
OutlinedButton(onClick = { onMoveUp(sectionKey) }, enabled = index > 0) { Text("Hoch") }
|
||||||
|
OutlinedButton(onClick = { onMoveDown(sectionKey) }, enabled = index < sections.lastIndex) { Text("Runter") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section.id == "spielplan_team") {
|
||||||
|
SpielplanWidgetConfigEditor(
|
||||||
|
section = section,
|
||||||
|
seasons = spielplanSeasons,
|
||||||
|
teamsBySeason = spielplanTeamsBySeason,
|
||||||
|
onUpdate = { season, teamName, teamAgeGroup ->
|
||||||
|
onUpdateSpielplanWidget(sectionKey, season, teamName, teamAgeGroup)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text("Widget hinzufügen", style = MaterialTheme.typography.titleMedium, color = Accent900)
|
||||||
|
Text("Spielplan Mannschaft", style = MaterialTheme.typography.bodyMedium, color = Accent700)
|
||||||
|
|
||||||
|
SimpleSelector(
|
||||||
|
label = "Saison",
|
||||||
|
selected = spielplanSeasons.firstOrNull { it.slug == addSeason }?.let { formatSeasonLabel(it.slug) }
|
||||||
|
?: "Bitte wählen",
|
||||||
|
options = spielplanSeasons.map { season ->
|
||||||
|
SelectOption(
|
||||||
|
key = season.slug,
|
||||||
|
label = formatSeasonLabel(season.slug),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSelect = { option ->
|
||||||
|
addSeason = option.key
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
SimpleSelector(
|
||||||
|
label = "Mannschaft",
|
||||||
|
selected = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey }?.label ?: "Bitte wählen",
|
||||||
|
options = addTeamOptions.map { team ->
|
||||||
|
SelectOption(
|
||||||
|
key = teamOptionKey(team),
|
||||||
|
label = team.label,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSelect = { option ->
|
||||||
|
addTeamKey = option.key
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val selectedTeam = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey } ?: return@Button
|
||||||
|
onAddSpielplanWidget(addSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
|
||||||
|
},
|
||||||
|
enabled = addSeason.isNotBlank() && addTeamKey.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text("Widget hinzufügen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onReset) {
|
||||||
|
Text("Auf Server-Standard zurücksetzen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class SelectOption(
|
||||||
|
val key: String,
|
||||||
|
val label: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SimpleSelector(
|
||||||
|
label: String,
|
||||||
|
selected: String,
|
||||||
|
options: List<SelectOption>,
|
||||||
|
onSelect: (SelectOption) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||||
|
OutlinedButton(onClick = { expanded = true }, enabled = options.isNotEmpty()) {
|
||||||
|
Text(selected)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
options.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(option.label) },
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpielplanWidgetConfigEditor(
|
||||||
|
section: HomepageSectionDto,
|
||||||
|
seasons: List<SeasonDto>,
|
||||||
|
teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
|
||||||
|
onUpdate: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedSeason = section.config?.season.orEmpty()
|
||||||
|
val selectedTeamName = section.config?.teamName.orEmpty()
|
||||||
|
val selectedTeamAgeGroup = section.config?.teamAgeGroup.orEmpty()
|
||||||
|
val teamOptions = teamsBySeason[selectedSeason].orEmpty()
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SimpleSelector(
|
||||||
|
label = "Saison",
|
||||||
|
selected = seasons.firstOrNull { it.slug == selectedSeason }?.let { formatSeasonLabel(it.slug) } ?: "Bitte wählen",
|
||||||
|
options = seasons.map { season -> SelectOption(season.slug, formatSeasonLabel(season.slug)) },
|
||||||
|
onSelect = { option ->
|
||||||
|
val fallbackTeam = teamsBySeason[option.key].orEmpty().firstOrNull()
|
||||||
|
onUpdate(
|
||||||
|
option.key,
|
||||||
|
fallbackTeam?.teamName ?: "",
|
||||||
|
fallbackTeam?.teamAgeGroup ?: "",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
SimpleSelector(
|
||||||
|
label = "Mannschaft",
|
||||||
|
selected = teamOptions.firstOrNull {
|
||||||
|
it.teamName == selectedTeamName && it.teamAgeGroup == selectedTeamAgeGroup
|
||||||
|
}?.label ?: "Bitte wählen",
|
||||||
|
options = teamOptions.map { team -> SelectOption(teamOptionKey(team), team.label) },
|
||||||
|
onSelect = { option ->
|
||||||
|
val selectedTeam = teamOptions.firstOrNull { teamOptionKey(it) == option.key }
|
||||||
|
if (selectedTeam != null) {
|
||||||
|
onUpdate(selectedSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HomeSpielplanTeamWidgetSection(
|
||||||
|
section: HomepageSectionDto,
|
||||||
|
spiele: List<SpielDto>,
|
||||||
|
error: String?,
|
||||||
|
loading: Boolean,
|
||||||
|
onOpenAll: () -> Unit,
|
||||||
|
) {
|
||||||
|
val teamName = section.config?.teamName.orEmpty()
|
||||||
|
val teamAgeGroup = section.config?.teamAgeGroup.orEmpty()
|
||||||
|
val title = if (teamAgeGroup.contains("jugend", ignoreCase = true) && teamName.isNotBlank()) {
|
||||||
|
"Spielplan: (J) $teamName"
|
||||||
|
} else {
|
||||||
|
"Spielplan: ${teamName.ifBlank { "Mannschaft" }}"
|
||||||
|
}
|
||||||
|
val season = section.config?.season.orEmpty()
|
||||||
|
|
||||||
|
HomeSection(title = title, subtitle = "Saison ${formatSeasonLabel(season)}", background = Color.White) {
|
||||||
|
if (loading) {
|
||||||
|
LoadingRow("Spiele werden geladen...")
|
||||||
|
} else if (!error.isNullOrBlank()) {
|
||||||
|
EmptyRow(error)
|
||||||
|
} else if (spiele.isEmpty()) {
|
||||||
|
EmptyRow("Keine kommenden Spiele für diese Mannschaft gefunden.")
|
||||||
|
} else {
|
||||||
|
spiele.forEach { spiel -> MatchCard(spiel) }
|
||||||
|
}
|
||||||
|
PrimaryAction("Voller Spielplan", onOpenAll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WebHero(imageUrl: String?) {
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
if (!imageUrl.isNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
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 HomeExtraActionSection(title: String, body: String, action: String, onClick: () -> Unit) {
|
||||||
|
HomeSection(title = null, background = Color.White) {
|
||||||
|
ActionCard(
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
action = action,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun homeSectionKey(section: HomepageSectionDto): String =
|
||||||
|
section.key?.takeIf { it.isNotBlank() } ?: section.id
|
||||||
|
|
||||||
|
private fun teamOptionKey(option: HomeSpielplanTeamOption): String =
|
||||||
|
"${option.teamName}|${option.teamAgeGroup}"
|
||||||
|
|
||||||
|
private fun formatSeasonLabel(value: String): String {
|
||||||
|
val match = Regex("^(\\d{2})--(\\d{2})$").find(value)
|
||||||
|
if (match == null) return value.ifBlank { "-" }
|
||||||
|
return "20${match.groupValues[1]}/${match.groupValues[2]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val homeSectionLabels = mapOf(
|
||||||
|
"banner" to "Banner (Willkommen)",
|
||||||
|
"aktuelles" to "Aktuelles",
|
||||||
|
"termine" to "Kommende Termine",
|
||||||
|
"spiele" to "Nächste Spiele",
|
||||||
|
"kontakt" to "Kontakt-Boxen",
|
||||||
|
"training" to "Training-Teaser",
|
||||||
|
"links" to "Links-Teaser",
|
||||||
|
"vereinsmeisterschaften" to "Vereinsmeisterschaften-Teaser",
|
||||||
|
"spielplan_team" to "Widget: Spielplan Mannschaft",
|
||||||
|
)
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package de.harheimertc.ui.screens.home
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.HomepageSectionConfigDto
|
||||||
|
import de.harheimertc.data.HomepageSectionDto
|
||||||
|
import de.harheimertc.data.NewsDto
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
|
import de.harheimertc.data.SpielDto
|
||||||
|
import de.harheimertc.data.TerminDto
|
||||||
|
import de.harheimertc.repositories.HomeLayoutPreferences
|
||||||
|
import de.harheimertc.repositories.HomeRepository
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class HomeSpielplanTeamOption(
|
||||||
|
val teamName: String,
|
||||||
|
val teamAgeGroup: String,
|
||||||
|
) {
|
||||||
|
val label: String
|
||||||
|
get() = if (teamAgeGroup.contains("jugend", ignoreCase = true)) {
|
||||||
|
"(J) $teamName"
|
||||||
|
} else {
|
||||||
|
teamName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HomeUiState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val heroImageUrl: String? = null,
|
||||||
|
val termine: List<TerminDto> = emptyList(),
|
||||||
|
val spiele: List<SpielDto> = emptyList(),
|
||||||
|
val news: List<NewsDto> = emptyList(),
|
||||||
|
val homepageSections: List<HomepageSectionDto> = defaultHomepageSections,
|
||||||
|
val spielplanSeasons: List<SeasonDto> = emptyList(),
|
||||||
|
val spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>> = emptyMap(),
|
||||||
|
val spielplanWidgetPreviews: Map<String, List<SpielDto>> = emptyMap(),
|
||||||
|
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
|
||||||
|
val widgetsLoading: Boolean = false,
|
||||||
|
val error: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val debugDiagnostics: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class HomeViewModel @Inject constructor(
|
||||||
|
private val repository: HomeRepository,
|
||||||
|
private val layoutPreferences: HomeLayoutPreferences,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(HomeUiState())
|
||||||
|
val state: StateFlow<HomeUiState> = _state
|
||||||
|
private var serverSections: List<HomepageSectionDto> = defaultHomepageSections
|
||||||
|
private val seasonGamesCache = mutableMapOf<String, List<SpielDto>>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loading = true,
|
||||||
|
error = false,
|
||||||
|
errorMessage = null,
|
||||||
|
debugDiagnostics = emptyList(),
|
||||||
|
)
|
||||||
|
repository.fetchHomeData()
|
||||||
|
.onSuccess { data ->
|
||||||
|
serverSections = normalizedHomepageSections(data.homepageSections)
|
||||||
|
val sections = mergeWithUserSections(
|
||||||
|
server = serverSections,
|
||||||
|
user = layoutPreferences.getSections(),
|
||||||
|
)
|
||||||
|
seasonGamesCache.clear()
|
||||||
|
data.selectedSpielplanSeason?.takeIf { it.isNotBlank() }?.let { season ->
|
||||||
|
seasonGamesCache[season] = data.spiele
|
||||||
|
}
|
||||||
|
val widgetData = loadWidgetData(
|
||||||
|
sections = sections,
|
||||||
|
seasons = data.spielplanSeasons,
|
||||||
|
)
|
||||||
|
_state.value = HomeUiState(
|
||||||
|
loading = false,
|
||||||
|
heroImageUrl = data.heroImageUrl,
|
||||||
|
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),
|
||||||
|
homepageSections = sections,
|
||||||
|
spielplanSeasons = widgetData.seasons,
|
||||||
|
spielplanTeamsBySeason = widgetData.teamsBySeason,
|
||||||
|
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
|
||||||
|
spielplanWidgetErrors = widgetData.errorsBySectionKey,
|
||||||
|
debugDiagnostics = data.diagnostics,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = HomeUiState(
|
||||||
|
loading = false,
|
||||||
|
error = true,
|
||||||
|
errorMessage = err.message ?: "Daten konnten nicht geladen werden.",
|
||||||
|
debugDiagnostics = listOf(err.message ?: "Unbekannter Fehler"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveSectionUp(sectionKey: String) {
|
||||||
|
updateSections { sections ->
|
||||||
|
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
|
||||||
|
if (index <= 0) return@updateSections sections
|
||||||
|
sections.toMutableList().also { list ->
|
||||||
|
val current = list.removeAt(index)
|
||||||
|
list.add(index - 1, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveSectionDown(sectionKey: String) {
|
||||||
|
updateSections { sections ->
|
||||||
|
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
|
||||||
|
if (index < 0 || index >= sections.lastIndex) return@updateSections sections
|
||||||
|
sections.toMutableList().also { list ->
|
||||||
|
val current = list.removeAt(index)
|
||||||
|
list.add(index + 1, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSectionEnabled(sectionKey: String, enabled: Boolean) {
|
||||||
|
updateSections { sections ->
|
||||||
|
sections.map { section ->
|
||||||
|
if (sectionKey(section) == sectionKey) section.copy(enabled = enabled) else section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addSpielplanTeamWidget(season: String, teamName: String, teamAgeGroup: String) {
|
||||||
|
val normalizedSeason = season.trim()
|
||||||
|
val normalizedTeamName = teamName.trim()
|
||||||
|
if (normalizedSeason.isBlank() || normalizedTeamName.isBlank()) return
|
||||||
|
val newSection = HomepageSectionDto(
|
||||||
|
id = WIDGET_SECTION_ID,
|
||||||
|
key = "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
|
||||||
|
enabled = true,
|
||||||
|
config = HomepageSectionConfigDto(
|
||||||
|
season = normalizedSeason,
|
||||||
|
teamName = normalizedTeamName,
|
||||||
|
teamAgeGroup = teamAgeGroup.trim(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
updateSections { sections -> sections + newSection }
|
||||||
|
refreshWidgetData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSpielplanTeamWidget(
|
||||||
|
sectionKey: String,
|
||||||
|
season: String,
|
||||||
|
teamName: String,
|
||||||
|
teamAgeGroup: String,
|
||||||
|
) {
|
||||||
|
updateSections { sections ->
|
||||||
|
sections.map { section ->
|
||||||
|
if (sectionKey(section) != sectionKey) return@map section
|
||||||
|
section.copy(
|
||||||
|
config = HomepageSectionConfigDto(
|
||||||
|
season = season.trim(),
|
||||||
|
teamName = teamName.trim(),
|
||||||
|
teamAgeGroup = teamAgeGroup.trim(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshWidgetData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetSections() {
|
||||||
|
val reset = serverSections
|
||||||
|
layoutPreferences.clearSections()
|
||||||
|
_state.value = _state.value.copy(homepageSections = reset)
|
||||||
|
refreshWidgetData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSections(transform: (List<HomepageSectionDto>) -> List<HomepageSectionDto>) {
|
||||||
|
val updated = transform(_state.value.homepageSections)
|
||||||
|
if (updated == _state.value.homepageSections) return
|
||||||
|
layoutPreferences.setSections(updated)
|
||||||
|
_state.value = _state.value.copy(homepageSections = updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshWidgetData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(widgetsLoading = true)
|
||||||
|
val widgetData = loadWidgetData(
|
||||||
|
sections = _state.value.homepageSections,
|
||||||
|
seasons = _state.value.spielplanSeasons,
|
||||||
|
)
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
spielplanSeasons = widgetData.seasons,
|
||||||
|
spielplanTeamsBySeason = widgetData.teamsBySeason,
|
||||||
|
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
|
||||||
|
spielplanWidgetErrors = widgetData.errorsBySectionKey,
|
||||||
|
widgetsLoading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadWidgetData(
|
||||||
|
sections: List<HomepageSectionDto>,
|
||||||
|
seasons: List<SeasonDto>,
|
||||||
|
): HomeWidgetData {
|
||||||
|
val allSeasons = seasons
|
||||||
|
.filter { it.slug.isNotBlank() }
|
||||||
|
.distinctBy { it.slug }
|
||||||
|
.toMutableList()
|
||||||
|
seasonGamesCache.keys.forEach { slug ->
|
||||||
|
if (allSeasons.none { it.slug == slug }) {
|
||||||
|
allSeasons += SeasonDto(slug = slug, label = slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val neededWidgetSeasons = sections
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.id == WIDGET_SECTION_ID }
|
||||||
|
.mapNotNull { it.config?.season?.takeIf(String::isNotBlank) }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
allSeasons.forEach { season ->
|
||||||
|
ensureSeasonLoaded(season.slug)
|
||||||
|
}
|
||||||
|
neededWidgetSeasons.forEach { season ->
|
||||||
|
if (allSeasons.none { it.slug == season }) {
|
||||||
|
allSeasons += SeasonDto(slug = season, label = season)
|
||||||
|
}
|
||||||
|
ensureSeasonLoaded(season)
|
||||||
|
}
|
||||||
|
|
||||||
|
val teamsBySeason = buildMap {
|
||||||
|
allSeasons.forEach { season ->
|
||||||
|
val games = seasonGamesCache[season.slug] ?: return@forEach
|
||||||
|
put(season.slug, extractHarheimerTeams(games))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val previews = mutableMapOf<String, List<SpielDto>>()
|
||||||
|
val errors = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
sections.forEach { section ->
|
||||||
|
if (section.id != WIDGET_SECTION_ID) return@forEach
|
||||||
|
val key = sectionKey(section)
|
||||||
|
val config = section.config
|
||||||
|
val season = config?.season.orEmpty()
|
||||||
|
val teamName = config?.teamName.orEmpty()
|
||||||
|
if (season.isBlank() || teamName.isBlank()) {
|
||||||
|
errors[key] = "Bitte Saison und Mannschaft wählen."
|
||||||
|
previews[key] = emptyList()
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val games = seasonGamesCache[season]
|
||||||
|
if (games == null) {
|
||||||
|
errors[key] = "Spielplan konnte nicht geladen werden."
|
||||||
|
previews[key] = emptyList()
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
previews[key] = filterUpcomingTeamGames(games, teamName, config?.teamAgeGroup.orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
return HomeWidgetData(
|
||||||
|
seasons = allSeasons,
|
||||||
|
teamsBySeason = teamsBySeason,
|
||||||
|
previewGamesBySectionKey = previews,
|
||||||
|
errorsBySectionKey = errors,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureSeasonLoaded(season: String) {
|
||||||
|
if (seasonGamesCache.containsKey(season)) return
|
||||||
|
repository.fetchSpielplanForSeason(season).onSuccess { response ->
|
||||||
|
seasonGamesCache[season] = response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class HomeWidgetData(
|
||||||
|
val seasons: List<SeasonDto>,
|
||||||
|
val teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
|
||||||
|
val previewGamesBySectionKey: Map<String, List<SpielDto>>,
|
||||||
|
val errorsBySectionKey: Map<String, String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
private val defaultHomepageSections = listOf(
|
||||||
|
HomepageSectionDto(id = "banner", key = "banner", enabled = true),
|
||||||
|
HomepageSectionDto(id = "aktuelles", key = "aktuelles", enabled = true),
|
||||||
|
HomepageSectionDto(id = "termine", key = "termine", enabled = true),
|
||||||
|
HomepageSectionDto(id = "spiele", key = "spiele", enabled = true),
|
||||||
|
HomepageSectionDto(id = "kontakt", key = "kontakt", enabled = true),
|
||||||
|
HomepageSectionDto(id = "training", key = "training", enabled = false),
|
||||||
|
HomepageSectionDto(id = "links", key = "links", enabled = false),
|
||||||
|
HomepageSectionDto(id = "vereinsmeisterschaften", key = "vereinsmeisterschaften", enabled = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun normalizedHomepageSections(configuredSections: List<HomepageSectionDto>): List<HomepageSectionDto> {
|
||||||
|
val configured = configuredSections
|
||||||
|
.filter { it.id.isNotBlank() }
|
||||||
|
.mapIndexed { index, section ->
|
||||||
|
val fallback = if (section.id == WIDGET_SECTION_ID) "${section.id}_${index + 1}" else section.id
|
||||||
|
section.copy(key = section.key?.takeIf { it.isNotBlank() } ?: fallback)
|
||||||
|
}
|
||||||
|
val knownIds = configured.map { it.id }.toMutableSet()
|
||||||
|
return buildList {
|
||||||
|
addAll(configured)
|
||||||
|
defaultHomepageSections.forEach { section ->
|
||||||
|
if (knownIds.add(section.id)) add(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergeWithUserSections(
|
||||||
|
server: List<HomepageSectionDto>,
|
||||||
|
user: List<HomepageSectionDto>?,
|
||||||
|
): List<HomepageSectionDto> {
|
||||||
|
if (user.isNullOrEmpty()) return server
|
||||||
|
val serverById = server.associateBy { it.id }
|
||||||
|
val serverByKey = server.associateBy { sectionKey(it) }
|
||||||
|
val ordered = buildList<HomepageSectionDto> {
|
||||||
|
user.forEach { userSection ->
|
||||||
|
val matchedServerSection = serverByKey[sectionKey(userSection)]
|
||||||
|
?: if (userSection.id == WIDGET_SECTION_ID) null else serverById[userSection.id]
|
||||||
|
if (matchedServerSection != null) {
|
||||||
|
if (none { sectionKey(it) == sectionKey(matchedServerSection) }) {
|
||||||
|
add(
|
||||||
|
matchedServerSection.copy(
|
||||||
|
enabled = userSection.enabled,
|
||||||
|
key = sectionKey(userSection),
|
||||||
|
config = userSection.config,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (userSection.id == WIDGET_SECTION_ID && none { sectionKey(it) == sectionKey(userSection) }) {
|
||||||
|
add(
|
||||||
|
userSection.copy(
|
||||||
|
key = userSection.key?.takeIf { it.isNotBlank() }
|
||||||
|
?: "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.forEach { serverSection ->
|
||||||
|
if (none { sectionKey(it) == sectionKey(serverSection) }) add(serverSection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ordered.ifEmpty { server }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sectionKey(section: HomepageSectionDto): String =
|
||||||
|
section.key?.takeIf { it.isNotBlank() } ?: section.id
|
||||||
|
|
||||||
|
private fun extractHarheimerTeams(games: List<SpielDto>): List<HomeSpielplanTeamOption> =
|
||||||
|
games
|
||||||
|
.flatMap { game ->
|
||||||
|
listOf(
|
||||||
|
HomeSpielplanTeamOption(game.heimMannschaft.trim(), game.heimAltersklasse.trim()),
|
||||||
|
HomeSpielplanTeamOption(game.gastMannschaft.trim(), game.gastAltersklasse.trim()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.filter { option -> option.teamName.contains("Harheimer TC", ignoreCase = true) }
|
||||||
|
.filter { option -> option.teamName.isNotBlank() }
|
||||||
|
.distinctBy { "${it.teamName}|${it.teamAgeGroup}" }
|
||||||
|
.sortedBy { it.label }
|
||||||
|
|
||||||
|
private fun filterUpcomingTeamGames(
|
||||||
|
games: List<SpielDto>,
|
||||||
|
teamName: String,
|
||||||
|
teamAgeGroup: String,
|
||||||
|
): List<SpielDto> {
|
||||||
|
val normalizedTeam = teamName.trim()
|
||||||
|
val normalizedAgeGroup = teamAgeGroup.trim()
|
||||||
|
val today = LocalDate.now()
|
||||||
|
return games
|
||||||
|
.asSequence()
|
||||||
|
.filter { game ->
|
||||||
|
val homeMatch = game.heimMannschaft.trim() == normalizedTeam &&
|
||||||
|
(normalizedAgeGroup.isBlank() || game.heimAltersklasse.trim() == normalizedAgeGroup)
|
||||||
|
val awayMatch = game.gastMannschaft.trim() == normalizedTeam &&
|
||||||
|
(normalizedAgeGroup.isBlank() || game.gastAltersklasse.trim() == normalizedAgeGroup)
|
||||||
|
homeMatch || awayMatch
|
||||||
|
}
|
||||||
|
.filter { game -> game.asDate()?.let { !it.isBefore(today) } == true }
|
||||||
|
.sortedBy { it.asDate() }
|
||||||
|
.take(5)
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val WIDGET_SECTION_ID = "spielplan_team"
|
||||||
@@ -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 einen Reset-Link zu erhalten.",
|
||||||
|
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 Reset-Link, sofern ein Konto vorhanden ist. Ihr bisheriges Passwort bleibt bis zur Änderung gültig.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,443 @@
|
|||||||
|
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.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.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.text.style.TextAlign
|
||||||
|
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.components.LoadingState
|
||||||
|
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.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))
|
||||||
|
if (state.seasons.isNotEmpty()) {
|
||||||
|
SeasonSelector(
|
||||||
|
seasons = state.seasons,
|
||||||
|
selectedSeason = state.selectedSeason,
|
||||||
|
onSeasonSelected = viewModel::selectSeason,
|
||||||
|
modifier = Modifier.padding(top = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
state.seasonsLoading -> item { Loading() }
|
||||||
|
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 SeasonSelector(
|
||||||
|
seasons: List<de.harheimertc.data.SeasonDto>,
|
||||||
|
selectedSeason: String,
|
||||||
|
onSeasonSelected: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
val selectedLabel = seasons.firstOrNull { it.slug == selectedSeason }?.label ?: selectedSeason
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Saison", color = Accent700, style = MaterialTheme.typography.labelSmall)
|
||||||
|
BoxWithConstraints {
|
||||||
|
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(selectedLabel.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
|
||||||
|
Text("v")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
seasons.forEach { season ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(season.label.ifBlank { season.slug }) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onSeasonSelected(season.slug)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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() {
|
||||||
|
LoadingState("Mannschaftsdaten werden geladen...")
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,202 @@
|
|||||||
|
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.data.SeasonDto
|
||||||
|
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(),
|
||||||
|
val seasons: List<SeasonDto> = emptyList(),
|
||||||
|
val selectedSeason: String = "",
|
||||||
|
val seasonsLoading: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class MannschaftenViewModel @Inject constructor(
|
||||||
|
private val repository: MannschaftenRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(MannschaftenUiState())
|
||||||
|
val state: StateFlow<MannschaftenUiState> = _state
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadSeasonsAndMannschaften()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val season = _state.value.selectedSeason.ifBlank { null }
|
||||||
|
_state.value = _state.value.copy(loading = true, error = null)
|
||||||
|
repository.fetchMannschaften(season)
|
||||||
|
.onSuccess { teams -> _state.value = _state.value.copy(loading = false, teams = teams) }
|
||||||
|
.onFailure { _state.value = _state.value.copy(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectSeason(season: String) {
|
||||||
|
if (season == _state.value.selectedSeason) return
|
||||||
|
_state.value = _state.value.copy(selectedSeason = season)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSeasonsAndMannschaften() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(seasonsLoading = true, error = null)
|
||||||
|
repository.fetchSeasons()
|
||||||
|
.onSuccess { response ->
|
||||||
|
val currentSeason = getCurrentSeasonSlug()
|
||||||
|
val seasons = response.seasons
|
||||||
|
.map { season -> SeasonDto(slug = season, label = formatSeasonLabel(season)) }
|
||||||
|
.ifEmpty {
|
||||||
|
val fallbackSeason = response.currentSeason.ifBlank { currentSeason }
|
||||||
|
listOf(SeasonDto(slug = fallbackSeason, label = formatSeasonLabel(fallbackSeason)))
|
||||||
|
}
|
||||||
|
val serverCurrentSeason = response.currentSeason.ifBlank { currentSeason }
|
||||||
|
val selectedSeason = when {
|
||||||
|
seasons.any { it.slug == currentSeason } -> currentSeason
|
||||||
|
seasons.any { it.slug == serverCurrentSeason } -> serverCurrentSeason
|
||||||
|
response.defaultSeason.isNotBlank() -> response.defaultSeason
|
||||||
|
seasons.isNotEmpty() -> seasons.first().slug
|
||||||
|
else -> currentSeason
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
seasonsLoading = false,
|
||||||
|
seasons = seasons,
|
||||||
|
selectedSeason = selectedSeason,
|
||||||
|
)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
val currentSeason = getCurrentSeasonSlug()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
seasonsLoading = false,
|
||||||
|
seasons = listOf(SeasonDto(slug = currentSeason, label = formatSeasonLabel(currentSeason))),
|
||||||
|
selectedSeason = currentSeason,
|
||||||
|
)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCurrentSeasonSlug(): String {
|
||||||
|
val now = java.util.Calendar.getInstance()
|
||||||
|
val year = now.get(java.util.Calendar.YEAR)
|
||||||
|
val startYear = if (now.get(java.util.Calendar.MONTH) >= 6) year else year - 1
|
||||||
|
val endYear = startYear + 1
|
||||||
|
return "%02d--%02d".format(startYear % 100, endYear % 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatSeasonLabel(seasonSlug: String): String {
|
||||||
|
val match = Regex("^(\\d{2})--(\\d{2})$").matchEntire(seasonSlug.trim()) ?: return seasonSlug
|
||||||
|
return "20${match.groupValues[1]}/${match.groupValues[2]}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,495 @@
|
|||||||
|
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.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
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.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.platform.LocalUriHandler
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
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.data.QttrRowDto
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
|
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()
|
||||||
|
var sortAsc by remember { mutableStateOf(true) }
|
||||||
|
var sortField by remember { mutableStateOf("Nachname") }
|
||||||
|
|
||||||
|
val filtered = state.members.filter { member ->
|
||||||
|
query.isBlank() ||
|
||||||
|
member.name.contains(query, ignoreCase = true) ||
|
||||||
|
member.email.orEmpty().contains(query, ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
fun dayMonthKey(m: MemberDto): Int {
|
||||||
|
val src = m.geburtsdatum ?: m.birthday ?: return Int.MAX_VALUE
|
||||||
|
val s = src.trim()
|
||||||
|
try {
|
||||||
|
if (s.matches(Regex("^\\d{4}[-./].*"))) {
|
||||||
|
val ld = java.time.LocalDate.parse(s)
|
||||||
|
return ld.monthValue * 100 + ld.dayOfMonth
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
// fallback: handle ISO without year (MM-DD or M-D), or German (DD.MM(.YYYY))
|
||||||
|
val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$")
|
||||||
|
val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$")
|
||||||
|
isoNoYear.find(s)?.let {
|
||||||
|
val (mo, d) = it.destructured
|
||||||
|
return try { mo.toInt() * 100 + d.toInt() } catch (_: Exception) { Int.MAX_VALUE }
|
||||||
|
}
|
||||||
|
german.find(s)?.let {
|
||||||
|
val (d, mo, _) = it.destructured
|
||||||
|
return try { mo.toInt() * 100 + d.toInt() } catch (_: Exception) { Int.MAX_VALUE }
|
||||||
|
}
|
||||||
|
val r = Regex("(\\d{1,2})\\D+(\\d{1,2})")
|
||||||
|
val match = r.find(s) ?: return Int.MAX_VALUE
|
||||||
|
val (a, b) = match.destructured
|
||||||
|
return try {
|
||||||
|
if (a.toInt() > 12) b.toInt() * 100 + a.toInt() else a.toInt() * 100 + b.toInt()
|
||||||
|
} catch (_: Exception) { Int.MAX_VALUE }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ageKey(m: MemberDto): Int {
|
||||||
|
val src = m.geburtsdatum ?: m.birthday ?: return Int.MAX_VALUE
|
||||||
|
val s = src.trim()
|
||||||
|
try {
|
||||||
|
if (s.matches(Regex("^\\d{4}[-./].*"))) {
|
||||||
|
val ld = java.time.LocalDate.parse(s)
|
||||||
|
val current = java.time.LocalDate.now().year
|
||||||
|
return current - ld.year
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
val germanYear = Regex("^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{2,4})$")
|
||||||
|
germanYear.find(s)?.let {
|
||||||
|
val yearStr = it.groupValues[3]
|
||||||
|
return try { java.time.LocalDate.now().year - yearStr.toInt() } catch (_: Exception) { Int.MAX_VALUE }
|
||||||
|
}
|
||||||
|
return Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
|
val members = when (sortField) {
|
||||||
|
"Vorname" -> filtered.sortedWith(compareBy({ it.firstName.ifBlank { it.name } }, { it.lastName }))
|
||||||
|
"Geburtstag" -> filtered.sortedWith(compareBy({ dayMonthKey(it) }, { it.lastName }))
|
||||||
|
"Alter" -> filtered.sortedWith(compareBy({ ageKey(it) }, { it.lastName }))
|
||||||
|
else -> filtered.sortedWith(compareBy<MemberDto> { it.lastName.ifBlank { it.name } }.thenBy { it.firstName })
|
||||||
|
}.let { if (sortAsc) it else it.asReversed() }
|
||||||
|
|
||||||
|
var viewMode by remember { mutableStateOf("cards") }
|
||||||
|
var onlyHallKey by remember { mutableStateOf(false) }
|
||||||
|
val display = remember(members, onlyHallKey) { if (!onlyHallKey) members else members.filter { it.hasHallKey } }
|
||||||
|
Log.i("MembersScreen", "viewMode=$viewMode displayCount=${display.size}")
|
||||||
|
|
||||||
|
MemberAreaPage(navController, showBackNavigation, "Mitgliederliste", "Kontaktdaten der Vereinsmitglieder") {
|
||||||
|
item {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.query,
|
||||||
|
onValueChange = viewModel::updateQuery,
|
||||||
|
label = { Text("Suchen") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Sortieren nach", color = Accent700)
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
TextButton(onClick = { expanded = true }) { Text(sortField) }
|
||||||
|
androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
listOf("Nachname", "Vorname", "Geburtstag", "Alter").forEach { opt ->
|
||||||
|
androidx.compose.material3.DropdownMenuItem(text = { Text(opt) }, onClick = { sortField = opt; expanded = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(onClick = { sortAsc = !sortAsc }) { Text(if (sortAsc) "A→Z" else "Z→A") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = { viewMode = if (viewMode == "cards") "table" else "cards" }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF3F4F6))) {
|
||||||
|
Text(if (viewMode == "cards") "Tabelle" else "Karten", color = Accent900)
|
||||||
|
}
|
||||||
|
androidx.compose.material3.Checkbox(checked = onlyHallKey, onCheckedChange = { onlyHallKey = it })
|
||||||
|
Text("Nur mit Hallenschlüssel", color = Accent700)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
if (viewMode == "table") {
|
||||||
|
Text("DEBUG: TABLE", color = Color.Red, modifier = Modifier.fillMaxWidth().padding(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.loading -> item { LoadingState("Mitglieder werden geladen...") }
|
||||||
|
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||||
|
display.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) }
|
||||||
|
else -> if (viewMode == "table") {
|
||||||
|
items(display.size) { index ->
|
||||||
|
val m = display[index]
|
||||||
|
Surface(color = Color.White, shape = RoundedCornerShape(6.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
m.name,
|
||||||
|
color = Accent900,
|
||||||
|
fontFamily = FontFamily.SansSerif,
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) { Text(m.email ?: "-", color = Primary600) }
|
||||||
|
Column(Modifier.weight(1f)) { Text(m.phone ?: "-", color = Accent700) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(display.size) { index -> MemberCard(display[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 { LoadingState("News werden geladen...") }
|
||||||
|
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/mitgliederbereich/qttr", "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
|
||||||
|
fun QttrScreen(
|
||||||
|
navController: NavController,
|
||||||
|
showBackNavigation: Boolean,
|
||||||
|
viewModel: QttrViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val externalUrl = "https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021"
|
||||||
|
|
||||||
|
MemberAreaPage(navController, showBackNavigation, "QTTR-Werte", "Aus technischen Gründen sind nur die QTTR-Werte verfügbar.") {
|
||||||
|
item {
|
||||||
|
Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Für TTR bitte die myTischtennis-Rangliste verwenden.", color = Primary900)
|
||||||
|
TextButton(onClick = { uriHandler.openUri(externalUrl) }) { Text("myTischtennis öffnen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(state.title ?: "Andro-Rangliste", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||||
|
Text("${state.rows.size} Einträge · Aktualisiert ${state.importedAt ?: "unbekannt"}", color = Accent500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
state.loading -> item { LoadingState("QTTR-Werte werden geladen...") }
|
||||||
|
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||||
|
state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) }
|
||||||
|
else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.headlineMedium, color = Accent900)
|
||||||
|
Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MemberCard(member: MemberDto, onEdit: (MemberDto) -> Unit = {}, onDelete: (MemberDto) -> Unit = {}) {
|
||||||
|
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,
|
||||||
|
fontFamily = FontFamily.SansSerif,
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
color = Accent900,
|
||||||
|
)
|
||||||
|
if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600)
|
||||||
|
if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700)
|
||||||
|
if (!member.birthday.isNullOrBlank()) {
|
||||||
|
val display = formatDayMonth(member.birthday) ?: member.birthday
|
||||||
|
Text("Geburtstag: $display", 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)
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
if (member.editable) {
|
||||||
|
TextButton(onClick = { onEdit(member) }) { Text("Bearbeiten") }
|
||||||
|
TextButton(onClick = { onDelete(member) }) { Text("Löschen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 QttrRowCard(row: QttrRowDto, highlighted: Boolean) {
|
||||||
|
Surface(
|
||||||
|
color = if (highlighted) Primary100 else Color.White,
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
shadowElevation = 3.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(18.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Surface(color = if (highlighted) Primary100 else Accent100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(46.dp)) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||||
|
Text(row.rank?.toString() ?: "-", color = Accent900, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
|
Text(
|
||||||
|
row.playerName.ifBlank { "Unbekannt" },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = qttrNameColor(row.gender, isMinor(row.birthdate)),
|
||||||
|
fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(row.clubName.ifBlank { "Harheimer TC" }, color = qttrNameColor(row.gender, isMinor(row.birthdate)).copy(alpha = 0.88f))
|
||||||
|
}
|
||||||
|
Text(row.currentQttr?.toString() ?: "-", color = Primary600, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isOwnRow(playerName: String?, currentUserName: String): Boolean {
|
||||||
|
fun normalize(value: String?): String {
|
||||||
|
return java.text.Normalizer.normalize(value.orEmpty().trim().lowercase(), java.text.Normalizer.Form.NFKD)
|
||||||
|
.replace(Regex("[\\u0300-\\u036f]"), "")
|
||||||
|
.replace(Regex("[’'`]"), "")
|
||||||
|
.replace(Regex("\\s+"), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = normalize(currentUserName)
|
||||||
|
if (current.isBlank()) return false
|
||||||
|
return normalize(playerName) == current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun qttrNameColor(gender: String?, isMinor: Boolean): Color {
|
||||||
|
val value = gender.orEmpty().trim().lowercase()
|
||||||
|
return when {
|
||||||
|
value.startsWith('m') || value.contains("männ") || value.contains("maenn") -> if (isMinor) Color(0xFF60A5FA) else Color(0xFF2563EB)
|
||||||
|
value.startsWith('w') || value.contains("weib") || value.contains("frau") -> if (isMinor) Color(0xFFF9A8D4) else Color(0xFF9D174D)
|
||||||
|
else -> Accent900
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isMinor(birthdate: String?): Boolean {
|
||||||
|
val date = parseBirthdate(birthdate) ?: return false
|
||||||
|
val today = java.time.LocalDate.now()
|
||||||
|
var age = today.year - date.year
|
||||||
|
if (today.monthValue < date.monthValue || (today.monthValue == date.monthValue && today.dayOfMonth < date.dayOfMonth)) {
|
||||||
|
age -= 1
|
||||||
|
}
|
||||||
|
return age < 18
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseBirthdate(value: String?): java.time.LocalDate? {
|
||||||
|
val raw = value.orEmpty().trim()
|
||||||
|
if (raw.isBlank()) return null
|
||||||
|
return try {
|
||||||
|
if (Regex("^\\d{4}$").matches(raw)) {
|
||||||
|
java.time.LocalDate.of(raw.toInt(), 1, 1)
|
||||||
|
} else {
|
||||||
|
java.time.LocalDate.parse(raw)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeToIso(srcRaw: String?): String? {
|
||||||
|
val src = srcRaw?.trim() ?: return null
|
||||||
|
try {
|
||||||
|
if (src.matches(Regex("^\\d{4}[-./].*"))) {
|
||||||
|
val ld = java.time.LocalDate.parse(src)
|
||||||
|
return String.format("%04d-%02d-%02d", ld.year, ld.monthValue, ld.dayOfMonth)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$")
|
||||||
|
isoNoYear.find(src)?.let {
|
||||||
|
val (mo, d) = it.destructured
|
||||||
|
return String.format("%02d-%02d", mo.toInt(), d.toInt())
|
||||||
|
}
|
||||||
|
val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$")
|
||||||
|
german.find(src)?.let {
|
||||||
|
val (d, mo, y) = it.destructured
|
||||||
|
return if (y.isNullOrBlank()) String.format("%02d-%02d", mo.toInt(), d.toInt()) else String.format("%04d-%02d-%02d", y.toInt(), mo.toInt(), d.toInt())
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDayMonth(srcRaw: String?): String? {
|
||||||
|
val src = srcRaw?.trim() ?: return null
|
||||||
|
try {
|
||||||
|
if (src.matches(Regex("^\\d{4}[-./].*"))) {
|
||||||
|
val ld = java.time.LocalDate.parse(src)
|
||||||
|
return String.format("%02d.%02d.", ld.dayOfMonth, ld.monthValue)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$")
|
||||||
|
isoNoYear.find(src)?.let {
|
||||||
|
val (mo, d) = it.destructured
|
||||||
|
return try { String.format("%02d.%02d.", d.toInt(), mo.toInt()) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$")
|
||||||
|
german.find(src)?.let {
|
||||||
|
val (d, mo, _) = it.destructured
|
||||||
|
return try { String.format("%02d.%02d.", d.toInt(), mo.toInt()) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
val r = Regex("(\\d{1,2})\\D+(\\d{1,2})")
|
||||||
|
val match = r.find(src) ?: return null
|
||||||
|
val (a, b) = match.destructured
|
||||||
|
return try { String.format("%02d.%02d.", a.toInt(), b.toInt()) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package de.harheimertc.ui.screens.memberarea
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
|
import de.harheimertc.data.AuthStatusResponse
|
||||||
|
import de.harheimertc.data.MemberDto
|
||||||
|
import de.harheimertc.data.NewsDto
|
||||||
|
import de.harheimertc.data.QttrRowDto
|
||||||
|
import de.harheimertc.repositories.MemberAreaRepository
|
||||||
|
import de.harheimertc.repositories.LoginRepository
|
||||||
|
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,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(MembersUiState())
|
||||||
|
val state: StateFlow<MembersUiState> = _state
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMember(request: de.harheimertc.data.ApiService.MemberSaveRequest) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveMember(request)
|
||||||
|
.onSuccess { _ -> load() }
|
||||||
|
.onFailure { /* expose errors if needed */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMember(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteMember(id)
|
||||||
|
.onSuccess { _ -> load() }
|
||||||
|
.onFailure { /* handle error */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bulkImport(members: List<Map<String, String>>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.bulkImport(members)
|
||||||
|
.onSuccess { _ -> load() }
|
||||||
|
.onFailure { /* handle error */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleMannschaftsspieler(memberId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.toggleMannschaftsspieler(memberId)
|
||||||
|
.onSuccess { _ -> load() }
|
||||||
|
.onFailure { /* handle error */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(MemberNewsUiState())
|
||||||
|
val state: StateFlow<MemberNewsUiState> = _state
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class QttrUiState(
|
||||||
|
val rows: List<QttrRowDto> = emptyList(),
|
||||||
|
val title: String? = null,
|
||||||
|
val importedAt: String? = null,
|
||||||
|
val currentUserName: String = "",
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class QttrViewModel @Inject constructor(
|
||||||
|
private val repository: MemberAreaRepository,
|
||||||
|
private val loginRepository: LoginRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(QttrUiState())
|
||||||
|
val state: StateFlow<QttrUiState> = _state
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(loading = true, error = null)
|
||||||
|
val authStatus = loginRepository.status().getOrDefault(AuthStatusResponse())
|
||||||
|
repository.qttrValues()
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
rows = response.rows,
|
||||||
|
title = response.title,
|
||||||
|
importedAt = response.importedAt,
|
||||||
|
currentUserName = authStatus.user?.name.orEmpty(),
|
||||||
|
loading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_state.value = _state.value.copy(loading = false, error = it.message ?: "QTTR-Werte konnten nicht geladen werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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.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.BuildConfig
|
||||||
|
import de.harheimertc.data.BirthdayDto
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.ui.navigation.NavigationUiState
|
||||||
|
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,
|
||||||
|
navigationState: NavigationUiState = NavigationUiState(),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigationState.isAdmin) {
|
||||||
|
item {
|
||||||
|
ServerInfoCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
BirthdayCard(
|
||||||
|
birthdays = state.birthdays,
|
||||||
|
loading = state.loadingBirthdays,
|
||||||
|
error = state.birthdayError,
|
||||||
|
onRetry = viewModel::loadBirthdays,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerInfoCard() {
|
||||||
|
Surface(color = Primary100, shape = RoundedCornerShape(14.dp), shadowElevation = 2.dp) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text("Serververbindung", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||||
|
Text(BuildConfig.API_BASE_URL.trimEnd('/'), color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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) },
|
||||||
|
)
|
||||||
|
MemberAreaCard(
|
||||||
|
title = "QTTR",
|
||||||
|
description = "Aktuelle QTTR-Werte der Vereinsmitglieder",
|
||||||
|
marker = "Q",
|
||||||
|
onClick = { navController.navigate(Destinations.Qttr.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 -> {
|
||||||
|
LoadingState("Geburtstage werden geladen...")
|
||||||
|
}
|
||||||
|
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,59 @@
|
|||||||
|
package de.harheimertc.ui.screens.memberarea
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
|
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,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(MemberAreaUiState())
|
||||||
|
val state: StateFlow<MemberAreaUiState> = _state
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadBirthdays()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
loadBirthdays()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,246 @@
|
|||||||
|
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.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.LoadingState
|
||||||
|
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 -> {
|
||||||
|
LoadingState("Newsletter-Anmeldung wird bestätigt...")
|
||||||
|
}
|
||||||
|
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 { LoadingState("Newsletter-Daten werden geladen...") }
|
||||||
|
} 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,221 @@
|
|||||||
|
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.components.LoadingState
|
||||||
|
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 {
|
||||||
|
LoadingState("Profil wird geladen...")
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user