Compare commits
109 Commits
21b4e8bc9f
...
test/fix-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,14 +1,12 @@
|
|||||||
name: Code Analysis and Production Deploy
|
name: Code Analysis and Production Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
push:
|
push:
|
||||||
branches: [ main, dev ]
|
branches: [ main, dev ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -32,6 +30,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
|
||||||
@@ -91,8 +98,9 @@ jobs:
|
|||||||
./osv-scanner --lockfile ./package-lock.json
|
./osv-scanner --lockfile ./package-lock.json
|
||||||
|
|
||||||
deploy-production:
|
deploy-production:
|
||||||
|
needs: analyze
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare SSH
|
- name: Prepare SSH
|
||||||
run: |
|
run: |
|
||||||
@@ -121,8 +129,9 @@ jobs:
|
|||||||
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
"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:
|
deploy-test:
|
||||||
|
needs: analyze
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare SSH
|
- name: Prepare SSH
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
name: Require Package Version Change
|
name: Require Package Version Change (disabled)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
workflow_dispatch: {}
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
noop:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Disabled
|
||||||
uses: actions/checkout@v4
|
run: echo "version-gate workflow disabled — gating is handled in code-analysis.yml"
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Check package.json version changed
|
|
||||||
run: scripts/check-package-version-changed.sh origin/main
|
|
||||||
|
|||||||
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
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -88,6 +88,12 @@ 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
|
||||||
|
|
||||||
# Build output (but keep production data!)
|
# Build output (but keep production data!)
|
||||||
.output
|
.output
|
||||||
!.output/.gitkeep
|
!.output/.gitkeep
|
||||||
@@ -143,6 +149,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
|
||||||
|
|||||||
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).
|
||||||
70
android-app/PLAYSTORE_ASSETS.md
Normal file
70
android-app/PLAYSTORE_ASSETS.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# 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)
|
||||||
|
|
||||||
|
### Grobe Anforderungen (Telefon)
|
||||||
|
- Mindestens 2 Screenshots
|
||||||
|
- PNG oder JPEG
|
||||||
|
- Seitenlaenge je Seite zwischen 320 px und 3840 px
|
||||||
|
|
||||||
|
Empfehlung fuer Android-Phone:
|
||||||
|
- 1080 x 1920 (Portrait)
|
||||||
|
|
||||||
|
### 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'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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: anonymisierte PNG/JPEG hochladen
|
||||||
234
android-app/app/build.gradle.kts
Normal file
234
android-app/app/build.gradle.kts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
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 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 releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
|
||||||
|
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull
|
||||||
|
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull
|
||||||
|
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull
|
||||||
|
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
||||||
|
!releaseStorePassword.isNullOrBlank() &&
|
||||||
|
!releaseKeyAlias.isNullOrBlank() &&
|
||||||
|
!releaseKeyPassword.isNullOrBlank()
|
||||||
|
|
||||||
|
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(":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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
2
android-app/app/proguard-rules.pro
vendored
Normal file
2
android-app/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Project-specific R8/ProGuard rules for release builds.
|
||||||
|
# Keep this file intentionally minimal and add rules only when needed.
|
||||||
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))
|
||||||
|
< | ||||||