Compare commits
143 Commits
test/fix-v
...
552068da89
| Author | SHA1 | Date | |
|---|---|---|---|
| 552068da89 | |||
|
|
c550fe8581 | ||
|
|
472bec4519 | ||
|
|
10a37180de | ||
|
|
4ecc2cc90c | ||
|
|
7a991a50c7 | ||
| d26737e38c | |||
|
|
bf74d41773 | ||
|
|
6369249f40 | ||
|
|
6b9198ac41 | ||
| 032b1a531c | |||
|
|
3dc293517f | ||
|
|
b86555744f | ||
|
|
5c964592a8 | ||
| 3b9d8a1623 | |||
|
|
91335422aa | ||
|
|
87e08df93a | ||
| 8d7f7d7d66 | |||
|
|
835a31a72e | ||
|
|
9eacbc77ea | ||
| 8fe3c56a9e | |||
|
|
1fc28a6438 | ||
|
|
d3e6fbf49b | ||
|
|
a9ce55699e | ||
|
|
3996e8d68c | ||
|
|
1a7ba583d8 | ||
|
|
645e4b9655 | ||
|
|
93b76d4446 | ||
|
|
2842c89bef | ||
|
|
dc08ae98ea | ||
| 7ac2fd39da | |||
|
|
5e8006323b | ||
|
|
e02de6cbb2 | ||
|
|
3e981de770 | ||
| 4705d411d5 | |||
|
|
65e7563e79 | ||
|
|
9bc9501219 | ||
| 64bd7d56e5 | |||
|
|
5ca4253b44 | ||
|
|
eb75f86d45 | ||
|
|
395e2a84bd | ||
|
|
3fb60227df | ||
|
|
9a4eb77c56 | ||
|
|
02c1765214 | ||
|
|
ee93e15af8 | ||
|
|
d31026a606 | ||
|
|
4dca2af645 | ||
|
|
63b5786e43 | ||
|
|
497d804244 | ||
|
|
9ca1464899 | ||
|
|
8f6fda2e61 | ||
| 446ec752b4 | |||
|
|
3314aa06b4 | ||
| aa3d065294 | |||
|
|
12c909d6a4 | ||
|
|
c2594fd330 | ||
|
|
00b08e2f92 | ||
|
|
93da9fbcbe | ||
|
|
82dcb1da40 | ||
| ffc4b66601 | |||
|
|
e82c1d9b0a | ||
|
|
c1c648de74 | ||
|
|
7523023be8 | ||
|
|
2103e9c1e8 | ||
| f1fc5eafc3 | |||
|
|
7aadb5e215 | ||
|
|
76d7000735 | ||
|
|
51e59e85ef | ||
|
|
0475e3084d | ||
|
|
9889430109 | ||
|
|
cb89fdd911 | ||
| 42398dad21 | |||
|
|
281c25b05d | ||
|
|
1beb5c2eee | ||
|
|
461f2de8dc | ||
|
|
75a7c409cd | ||
|
|
751c302036 | ||
|
|
f0e1ad39b0 | ||
|
|
1dc84023d0 | ||
|
|
30465c7833 | ||
|
|
d260f00756 | ||
|
|
f0ac4b7e56 | ||
|
|
bd45beadd5 | ||
|
|
f060c9771f | ||
| d563d81584 | |||
|
|
0ab71166aa | ||
|
|
d9c5d242ec | ||
| 380d0a8332 | |||
|
|
cb1e7a2fed | ||
|
|
3586704cce | ||
| 301bd7acbd | |||
|
|
7f3b58c5bc | ||
|
|
f29e8a4dac | ||
| 05e25aa3d1 | |||
|
|
b69130c2b2 | ||
|
|
77aabef4a9 | ||
|
|
4b699de853 | ||
|
|
e3cb7282bc | ||
|
|
e537839e28 | ||
|
|
44d441811c | ||
|
|
da1efa5a74 | ||
| ecae88af78 | |||
| c7a306e8fa | |||
|
|
14cd5f04d5 | ||
|
|
7e533fae49 | ||
|
|
8393f154e5 | ||
|
|
c956869e8a | ||
|
|
4eabb3b766 | ||
|
|
146dedd9b4 | ||
|
|
6076194497 | ||
|
|
9cde1ab78b | ||
|
|
f5facaa811 | ||
|
|
78015298ec | ||
|
|
b4e1c50ea3 | ||
|
|
5da11d2e4d | ||
|
|
e8a50e55ca | ||
|
|
530e544542 | ||
|
|
300dce9835 | ||
|
|
a98def915e | ||
|
|
7aa7970f2e | ||
|
|
e517720b03 | ||
|
|
402913d877 | ||
|
|
2014abe660 | ||
|
|
80834d8652 | ||
| 45de2a576c | |||
|
|
7bc98c03e4 | ||
|
|
bf1caefde4 | ||
|
|
6983186caf | ||
| e1ad5f7205 | |||
|
|
7c93966878 | ||
| 14341b7a63 | |||
|
|
31d20f1bff | ||
|
|
6507afea5f | ||
|
|
387ce6e08e | ||
|
|
f822fc8a8e | ||
|
|
67c746f18b | ||
| 803481ca8e | |||
|
|
1e65cb47da | ||
|
|
ec96e21517 | ||
|
|
46f80df165 | ||
| 46a8d5a77a | |||
| 96c1d0260b | |||
| 6e00a1b829 |
53
.gitea/workflows/code-analysis.yml
Normal file → Executable file
53
.gitea/workflows/code-analysis.yml
Normal file → Executable file
@@ -6,13 +6,39 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
clean: true
|
||||||
|
# Der Analyse-Workflow benötigt keine Historie. Ein flacher Checkout
|
||||||
|
# verhindert, dass der Runner das große Repository-Pack vollständig lädt.
|
||||||
|
fetch-depth: 1
|
||||||
|
# Der Web-Workflow braucht keine Android-Artefakte. Insbesondere liegt
|
||||||
|
# dort ein großer Heap-Dump im aktuellen Commit, der auch bei einem
|
||||||
|
# flachen Checkout übertragen würde und den Runner-Checkout abbricht.
|
||||||
|
sparse-checkout: |
|
||||||
|
/*
|
||||||
|
!/android-app/
|
||||||
|
sparse-checkout-cone-mode: false
|
||||||
|
|
||||||
|
- name: Ensure clean workspace
|
||||||
|
run: |
|
||||||
|
git reset --hard HEAD
|
||||||
|
git clean -fdx
|
||||||
|
|
||||||
|
- name: Debug dependency files
|
||||||
|
run: |
|
||||||
|
echo "commit: $(git rev-parse HEAD)"
|
||||||
|
echo "branch ref: ${GITHUB_REF:-unknown}"
|
||||||
|
echo "package.json checksum:" && sha256sum package.json
|
||||||
|
echo "package-lock.json checksum:" && sha256sum package-lock.json
|
||||||
|
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -72,7 +98,11 @@ jobs:
|
|||||||
rm -f gitleaks.tar.gz
|
rm -f gitleaks.tar.gz
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: |
|
||||||
|
if ! npm ci; then
|
||||||
|
echo "WARNING: npm ci fehlgeschlagen (Lockfile-Drift?). Fallback auf npm install."
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
@@ -86,21 +116,22 @@ jobs:
|
|||||||
- name: Semgrep (SAST)
|
- name: Semgrep (SAST)
|
||||||
run: semgrep --config p/default --error .
|
run: semgrep --config p/default --error .
|
||||||
|
|
||||||
- name: npm audit (high+)
|
- name: npm audit (critical)
|
||||||
run: npm audit --audit-level=high
|
run: npm audit --audit-level=critical
|
||||||
|
|
||||||
- name: OSV-Scanner (SCA)
|
- name: OSV-Scanner (SCA)
|
||||||
run: |
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
|
curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
|
||||||
chmod +x osv-scanner
|
chmod +x osv-scanner
|
||||||
./osv-scanner --version
|
./osv-scanner --version
|
||||||
test -f ./package-lock.json
|
test -f "$GITHUB_WORKSPACE/package-lock.json"
|
||||||
./osv-scanner --lockfile ./package-lock.json
|
./osv-scanner scan -L "$GITHUB_WORKSPACE/package-lock.json" --config "$GITHUB_WORKSPACE/.osv-scanner.toml" --format json --output-file osv-report.json || true
|
||||||
|
node scripts/check-osv-severity.mjs osv-report.json
|
||||||
|
|
||||||
deploy-production:
|
deploy-production:
|
||||||
needs: analyze
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare SSH
|
- name: Prepare SSH
|
||||||
run: |
|
run: |
|
||||||
@@ -126,7 +157,7 @@ jobs:
|
|||||||
-o BatchMode=yes \
|
-o BatchMode=yes \
|
||||||
-p "${{ vars.PROD_PORT }}" \
|
-p "${{ vars.PROD_PORT }}" \
|
||||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||||
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
"bash -lc 'cd /var/www/harheimertc && git reset --hard HEAD && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
||||||
|
|
||||||
deploy-test:
|
deploy-test:
|
||||||
needs: analyze
|
needs: analyze
|
||||||
@@ -157,4 +188,4 @@ jobs:
|
|||||||
-o BatchMode=yes \
|
-o BatchMode=yes \
|
||||||
-p "${{ vars.PROD_PORT }}" \
|
-p "${{ vars.PROD_PORT }}" \
|
||||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||||
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
"bash -lc 'cd /var/www/harheimertc.test && git reset --hard HEAD && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
||||||
|
|||||||
0
.gitea/workflows/version-gate.yml
Normal file → Executable file
0
.gitea/workflows/version-gate.yml
Normal file → Executable file
6
.github/workflows/android-ci.yml
vendored
Normal file → Executable file
6
.github/workflows/android-ci.yml
vendored
Normal file → Executable file
@@ -10,14 +10,14 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
|
|||||||
5
.gitignore
vendored
Normal file → Executable file
5
.gitignore
vendored
Normal file → Executable file
@@ -93,6 +93,11 @@ dist
|
|||||||
/android-app/.kotlin/
|
/android-app/.kotlin/
|
||||||
/android-app/**/build/
|
/android-app/**/build/
|
||||||
/android-app/local.properties
|
/android-app/local.properties
|
||||||
|
/android-app/gradle-local.properties
|
||||||
|
# Android Play Store / release artifacts are generated locally or in CI.
|
||||||
|
/android-app/**/*.aab
|
||||||
|
# JVM Heap-Dumps sind lokale Diagnoseartefakte und dürfen nie ins Repository.
|
||||||
|
*.hprof
|
||||||
|
|
||||||
# Build output (but keep production data!)
|
# Build output (but keep production data!)
|
||||||
.output
|
.output
|
||||||
|
|||||||
7
.gitleaks.toml
Executable file
7
.gitleaks.toml
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
[[allowlists]]
|
||||||
|
description = "generated/imported non-secret data"
|
||||||
|
paths = [
|
||||||
|
'''server/data/spielplan-import/harheimer_tc_spielplan\.(html|json)$''',
|
||||||
|
'''android-app/app/build/.*''',
|
||||||
|
'''android-app/\.idea/planningMode\.xml$''',
|
||||||
|
]
|
||||||
3
.gitleaksignore
Executable file
3
.gitleaksignore
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
google-services.json:gcp-api-key:18
|
||||||
|
google-services.json:gcp-api-key:37
|
||||||
|
google-services.json:gcp-api-key:56
|
||||||
2
.osv-scanner.toml
Normal file
2
.osv-scanner.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Retained because the CI scanner is invoked with --config. Findings are
|
||||||
|
# evaluated transparently by scripts/check-osv-severity.mjs.
|
||||||
0
.semgrepignore
Normal file → Executable file
0
.semgrepignore
Normal file → Executable file
0
ANDROID_ARCHITECTURE.md
Normal file → Executable file
0
ANDROID_ARCHITECTURE.md
Normal file → Executable file
0
ANDROID_KOTLIN_PLAN.md
Normal file → Executable file
0
ANDROID_KOTLIN_PLAN.md
Normal file → Executable file
0
ANDROID_PORT_TODO.md
Normal file → Executable file
0
ANDROID_PORT_TODO.md
Normal file → Executable file
0
ANDROID_REPO_ENDPOINTS.md
Normal file → Executable file
0
ANDROID_REPO_ENDPOINTS.md
Normal file → Executable file
0
AUTH_README.md
Normal file → Executable file
0
AUTH_README.md
Normal file → Executable file
0
DATENSCHUTZ.md
Normal file → Executable file
0
DATENSCHUTZ.md
Normal file → Executable file
0
DATENSCHUTZ_UEBERSICHT.md
Normal file → Executable file
0
DATENSCHUTZ_UEBERSICHT.md
Normal file → Executable file
0
DEPLOYMENT.md
Normal file → Executable file
0
DEPLOYMENT.md
Normal file → Executable file
32
PLAYSTORE_REVIEW.md
Normal file
32
PLAYSTORE_REVIEW.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Google Play: Zugang für die App-Prüfung
|
||||||
|
|
||||||
|
In der Play Console unter **Richtlinien und Programme → App-Inhalte → App-Zugriff / Anmeldedaten** angeben, dass einige Funktionen eine Anmeldung erfordern. Eine neue Anleitung mit Titel `Harheimer TC review account` anlegen.
|
||||||
|
|
||||||
|
## Konto auf dem Backend der eingereichten App einrichten
|
||||||
|
|
||||||
|
Nach Deployment dieser Änderungen im Projektverzeichnis des entsprechenden Servers ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/setup-playstore-review.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript liest die dortige `.env` einschließlich `ENCRYPTION_KEY`, sichert die bisherige Benutzerdatei und erstellt ein aktives, ausgeblendetes Mitgliedskonto. Standardadresse: `playstore-review@harheimertc.invalid`. Dies ist bewusst kein echtes Postfach. Das zufällig erzeugte Passwort wird einmal ausgegeben und muss sicher abgelegt werden. Bei erneutem Aufruf bleibt es unverändert. Optional können `PLAYSTORE_REVIEW_EMAIL` und `PLAYSTORE_REVIEW_PASSWORD` gesetzt werden; ein explizites Passwort aktualisiert das bestehende Review-Konto. Kein reguläres Konto wird umgewandelt.
|
||||||
|
|
||||||
|
In der Play Console die ausgegebene E-Mail-Adresse und das Passwort eintragen. Zusätzliche Anweisungen auf Englisch:
|
||||||
|
|
||||||
|
> Open the app and go to Login. Sign in with the email address and password provided above. This account is already activated and provides access to the member features. No registration, email verification, one-time code or approval is required. Please stay signed in while testing forms. Outgoing emails triggered by this account are suppressed by the server. When a form asks for an email address, use the review account email provided above, including when testing while signed out.
|
||||||
|
|
||||||
|
Die Zugangsdaten dauerhaft gültig halten. Das Konto ist ein Mitglied, kein CMS-Administrator.
|
||||||
|
|
||||||
|
## Versandsperre
|
||||||
|
|
||||||
|
Alle SMTP-Versandstellen verwenden dieselbe serverseitige Sperre:
|
||||||
|
|
||||||
|
- Für authentifizierte Konten mit `accountType: playstore_review` oder der konfigurierten `PLAYSTORE_REVIEW_EMAIL` wird kein SMTP-Versand ausgeführt, auch bei abweichender Formularadresse.
|
||||||
|
- Mails an die Review-Adresse sowie Kontaktmails mit dieser Antwortadresse werden auch ohne Anmeldung unterdrückt. Registrierung und Mitgliedsanträge prüfen zusätzlich die Formularadresse. Gemischte Empfängerlisten mit einem Review-Konto werden vollständig unterdrückt.
|
||||||
|
- `MAIL_DELIVERY_DISABLED=true` unterdrückt sämtliche SMTP-Mails einer Installation. Die PM2-Testkonfiguration setzt dies standardmäßig; Produktion bleibt ohne diese Einstellung aktiv. Nach Konfigurationsänderungen den Backend-Prozess mit aktualisierter Umgebung neu starten.
|
||||||
|
- `DEBUG=true` allein verhindert keinen Mailversand.
|
||||||
|
|
||||||
|
Die Versandsperre ist keine isolierte Demo-Datenbank: Formulare können weiterhin Daten speichern und Push-Benachrichtigungen auslösen. Ohne Anmeldung und mit einer fremden Formularadresse lässt sich eine Anfrage keinem Review-Konto zuordnen. Für vollständig mailfreie Tests daher eine separate Testinstanz mit `MAIL_DELIVERY_DISABLED=true` verwenden und sicherstellen, dass der eingereichte App-Build dieses Backend nutzt.
|
||||||
|
|
||||||
|
Google-Anforderungen: https://support.google.com/googleplay/android-developer/answer/15748846
|
||||||
36
android-app/PLAYSTORE_ASSETS.md
Normal file → Executable file
36
android-app/PLAYSTORE_ASSETS.md
Normal file → Executable file
@@ -36,13 +36,12 @@ Ausgabe in:
|
|||||||
|
|
||||||
## 3) Screenshots (anonymisiert)
|
## 3) Screenshots (anonymisiert)
|
||||||
|
|
||||||
### Grobe Anforderungen (Telefon)
|
### Zielgroessen fuer Store-Upload
|
||||||
- Mindestens 2 Screenshots
|
- Telefon (Portrait): 1080 x 1920
|
||||||
- PNG oder JPEG
|
- Medium 7" Tablet (Portrait): 1200 x 1920
|
||||||
- Seitenlaenge je Seite zwischen 320 px und 3840 px
|
- 10" Tablet (Portrait): 1600 x 2560
|
||||||
|
|
||||||
Empfehlung fuer Android-Phone:
|
Alle Dateien als PNG oder JPEG.
|
||||||
- 1080 x 1920 (Portrait)
|
|
||||||
|
|
||||||
### Anonymisierung
|
### Anonymisierung
|
||||||
|
|
||||||
@@ -61,10 +60,33 @@ Beispiel:
|
|||||||
'68,118,520,72;70,706,560,98'
|
'68,118,520,72;70,706,560,98'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Zielprofile erzeugen (Telefon, 7", 10")
|
||||||
|
|
||||||
|
Aus allen Dateien in `android-app/playstore-assets/anon` werden die drei Profile erzeugt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/playstore-screenshot-sizes.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional mit eigenen Ordnern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/playstore-screenshot-sizes.sh \
|
||||||
|
--input-dir android-app/playstore-assets/anon \
|
||||||
|
--output-dir android-app/playstore-assets/final
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- android-app/playstore-assets/final/phone
|
||||||
|
- android-app/playstore-assets/final/tablet-7
|
||||||
|
- android-app/playstore-assets/final/tablet-10
|
||||||
|
|
||||||
## 4) Upload in Play Console
|
## 4) Upload in Play Console
|
||||||
|
|
||||||
- Datenschutzerklaerung: URL eintragen
|
- Datenschutzerklaerung: URL eintragen
|
||||||
- Konto-Loeschung: URL eintragen
|
- Konto-Loeschung: URL eintragen
|
||||||
- App-Icon: playstore-icon-512.png
|
- App-Icon: playstore-icon-512.png
|
||||||
- Feature Graphic: playstore-feature-graphic-1024x500.png
|
- Feature Graphic: playstore-feature-graphic-1024x500.png
|
||||||
- Screenshots: anonymisierte PNG/JPEG hochladen
|
- Screenshots Telefon: Dateien aus `.../final/phone`
|
||||||
|
- Screenshots 7" Tablet: Dateien aus `.../final/tablet-7`
|
||||||
|
- Screenshots 10" Tablet: Dateien aus `.../final/tablet-10`
|
||||||
|
|||||||
74
android-app/app/build.gradle.kts
Normal file → Executable file
74
android-app/app/build.gradle.kts
Normal file → Executable file
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("com.google.devtools.ksp")
|
id("com.google.devtools.ksp")
|
||||||
@@ -5,12 +7,17 @@ plugins {
|
|||||||
id("com.google.dagger.hilt.android")
|
id("com.google.dagger.hilt.android")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (file("google-services.json").exists()) {
|
||||||
|
apply(plugin = "com.google.gms.google-services")
|
||||||
|
}
|
||||||
|
|
||||||
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
||||||
.orElse("https://harheimertc.tsschulz.de/")
|
.orElse("https://harheimertc.tsschulz.de/")
|
||||||
.get()
|
.get()
|
||||||
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
||||||
.orElse("https://harheimertc.de/")
|
.orElse("https://harheimertc.de/")
|
||||||
.get()
|
.get()
|
||||||
|
val expectedProductionApiBaseUrl = "https://harheimertc.de/"
|
||||||
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||||
.orElse("")
|
.orElse("")
|
||||||
.get()
|
.get()
|
||||||
@@ -25,27 +32,69 @@ val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
|
|||||||
.orElse("true")
|
.orElse("true")
|
||||||
.get()
|
.get()
|
||||||
.toBoolean()
|
.toBoolean()
|
||||||
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
|
|
||||||
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull
|
val localSigningProperties = Properties().apply {
|
||||||
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull
|
val localSigningFile = rootProject.file("gradle-local.properties")
|
||||||
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull
|
if (localSigningFile.exists()) {
|
||||||
|
localSigningFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signingProperty(name: String): String? =
|
||||||
|
providers.gradleProperty(name).orNull
|
||||||
|
?: providers.environmentVariable(name).orNull
|
||||||
|
?: localSigningProperties.getProperty(name)
|
||||||
|
|
||||||
|
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
|
||||||
|
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
|
||||||
|
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
|
||||||
|
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
|
||||||
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
||||||
!releaseStorePassword.isNullOrBlank() &&
|
!releaseStorePassword.isNullOrBlank() &&
|
||||||
!releaseKeyAlias.isNullOrBlank() &&
|
!releaseKeyAlias.isNullOrBlank() &&
|
||||||
!releaseKeyPassword.isNullOrBlank()
|
!releaseKeyPassword.isNullOrBlank()
|
||||||
|
|
||||||
|
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
|
||||||
|
doFirst {
|
||||||
|
if (!hasReleaseSigning) {
|
||||||
|
throw GradleException(
|
||||||
|
"Production release signing is not configured. " +
|
||||||
|
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
|
||||||
|
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
|
||||||
|
doFirst {
|
||||||
|
if (productionApiBaseUrl != expectedProductionApiBaseUrl) {
|
||||||
|
throw GradleException(
|
||||||
|
"Production Play Store builds must use $expectedProductionApiBaseUrl, but PRODUCTION_API_BASE_URL is $productionApiBaseUrl."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "de.harheimertc"
|
namespace = "de.harheimertc"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "de.harheimertc"
|
applicationId = "de.harheimertc"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = androidVersionCode
|
versionCode = androidVersionCode
|
||||||
versionName = androidVersionName
|
versionName = androidVersionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
disable += setOf(
|
||||||
|
"AutoboxingStateCreation",
|
||||||
|
"MutableCollectionMutableState",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
if (hasReleaseSigning) {
|
if (hasReleaseSigning) {
|
||||||
@@ -135,6 +184,8 @@ val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("package
|
|||||||
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||||
group = "distribution"
|
group = "distribution"
|
||||||
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
||||||
|
dependsOn(ensureReleaseSigning)
|
||||||
|
dependsOn(ensureProductionApiBaseUrl)
|
||||||
dependsOn(":app:bundleProductionRelease")
|
dependsOn(":app:bundleProductionRelease")
|
||||||
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
||||||
|
|
||||||
@@ -161,6 +212,13 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.matching {
|
||||||
|
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
|
||||||
|
}.configureEach {
|
||||||
|
dependsOn(ensureReleaseSigning)
|
||||||
|
dependsOn(ensureProductionApiBaseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
@@ -206,6 +264,9 @@ dependencies {
|
|||||||
// Crash reporting
|
// Crash reporting
|
||||||
implementation("io.sentry:sentry-android:8.42.0")
|
implementation("io.sentry:sentry-android:8.42.0")
|
||||||
|
|
||||||
|
// Push notifications
|
||||||
|
implementation("com.google.firebase:firebase-messaging:25.0.2")
|
||||||
|
|
||||||
// Room
|
// Room
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
ksp("androidx.room:room-compiler:2.6.1")
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
@@ -215,6 +276,7 @@ dependencies {
|
|||||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||||
|
|
||||||
// Testing (skeleton)
|
// Testing (skeleton)
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|||||||
1
android-app/app/google-services.json
Symbolic link
1
android-app/app/google-services.json
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../google-services.json
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
37
android-app/app/proguard-rules.pro
vendored
Normal file → Executable file
37
android-app/app/proguard-rules.pro
vendored
Normal file → Executable file
@@ -1,2 +1,37 @@
|
|||||||
# Project-specific R8/ProGuard rules for release builds.
|
# Project-specific R8/ProGuard rules for release builds.
|
||||||
# Keep this file intentionally minimal and add rules only when needed.
|
|
||||||
|
# Keep reflection/generic metadata used by Retrofit + Moshi.
|
||||||
|
-keepattributes Signature,InnerClasses,EnclosingMethod
|
||||||
|
-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault
|
||||||
|
-keep class kotlin.Metadata { *; }
|
||||||
|
|
||||||
|
# Keep Retrofit service interfaces and HTTP method annotations.
|
||||||
|
-keep,allowobfuscation interface * {
|
||||||
|
@retrofit2.http.* <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retrofit + R8 full mode: keep interfaces with HTTP methods and suspend continuation generics.
|
||||||
|
-if interface * { @retrofit2.http.* <methods>; }
|
||||||
|
-keep,allowobfuscation interface <1>
|
||||||
|
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||||
|
|
||||||
|
# Avoid Retrofit generic signature loss on release builds for our API interface.
|
||||||
|
-keep interface de.harheimertc.data.ApiService { *; }
|
||||||
|
-keepclassmembers interface de.harheimertc.data.ApiService { *; }
|
||||||
|
|
||||||
|
# Keep app DTO/request/response models used via Moshi reflection.
|
||||||
|
-keep class de.harheimertc.data.*Dto { *; }
|
||||||
|
-keep class de.harheimertc.data.*Request { *; }
|
||||||
|
-keep class de.harheimertc.data.*Response { *; }
|
||||||
|
|
||||||
|
# Keep fields annotated with @Json names.
|
||||||
|
-keepclassmembers class * {
|
||||||
|
@com.squareup.moshi.Json <fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep WorkManager + Room generated classes used reflectively at startup.
|
||||||
|
-keep class * extends androidx.work.ListenableWorker {
|
||||||
|
<init>(android.content.Context, androidx.work.WorkerParameters);
|
||||||
|
}
|
||||||
|
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
|
|||||||
0
android-app/app/src/androidTest/AndroidManifest.xml
Normal file → Executable file
0
android-app/app/src/androidTest/AndroidManifest.xml
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestBindingsModule.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestBindingsModule.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/TestActivity.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/TestActivity.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsActivateResendTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsActivateResendTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsExistingScreensSmokeTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsExistingScreensSmokeTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsPasswordResetDiagnosticsScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsPasswordResetDiagnosticsScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsRolesDialogTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsRolesDialogTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsStartseiteSmokeTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsStartseiteSmokeTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsUiAutomatorClickTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsUiAutomatorClickTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/gallery/GalleryScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/gallery/GalleryScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/home/HomeScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/home/HomeScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/login/LoginScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/login/LoginScreenTest.kt
Normal file → Executable file
0
android-app/app/src/debug/AndroidManifest.xml
Normal file → Executable file
0
android-app/app/src/debug/AndroidManifest.xml
Normal file → Executable file
11
android-app/app/src/main/AndroidManifest.xml
Normal file → Executable file
11
android-app/app/src/main/AndroidManifest.xml
Normal file → Executable file
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".HarheimerApplication"
|
android:name=".HarheimerApplication"
|
||||||
@@ -10,12 +11,20 @@
|
|||||||
android:theme="@style/Theme.HarheimerTC"
|
android:theme="@style/Theme.HarheimerTC"
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||||
<activity android:name="de.harheimertc.MainActivity"
|
<activity android:name="de.harheimertc.MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".notifications.HarheimerMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.files"
|
android:authorities="${applicationId}.files"
|
||||||
|
|||||||
2
android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt
Normal file → Executable file
2
android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt
Normal file → Executable file
@@ -6,6 +6,7 @@ import coil.ImageLoaderFactory
|
|||||||
import coil.disk.DiskCache
|
import coil.disk.DiskCache
|
||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import de.harheimertc.notifications.HarheimerNotifications
|
||||||
import io.sentry.Sentry
|
import io.sentry.Sentry
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -19,6 +20,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
Log.d("HILT", "HarheimerApplication.onCreate called")
|
Log.d("HILT", "HarheimerApplication.onCreate called")
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
HarheimerNotifications.createChannels(this)
|
||||||
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||||
Sentry.init { options ->
|
Sentry.init { options ->
|
||||||
options.dsn = BuildConfig.SENTRY_DSN
|
options.dsn = BuildConfig.SENTRY_DSN
|
||||||
|
|||||||
84
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file → Executable file
84
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file → Executable file
@@ -1,38 +1,118 @@
|
|||||||
package de.harheimertc
|
package de.harheimertc
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import de.harheimertc.ui.navigation.NavGraph
|
import de.harheimertc.ui.navigation.NavGraph
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.notifications.HarheimerNotifications
|
||||||
import de.harheimertc.ui.theme.HarheimerTheme
|
import de.harheimertc.ui.theme.HarheimerTheme
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import de.harheimertc.repositories.AuthRepository
|
||||||
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
@Inject
|
||||||
|
lateinit var authRepository: AuthRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var pushTokenRepository: PushTokenRepository
|
||||||
|
|
||||||
|
private val notificationRoute = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
private val notificationPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
requestNotificationPermissionIfNeeded()
|
||||||
|
notificationRoute.value = extractNotificationRoute(intent)
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App(
|
||||||
|
notificationRoute = notificationRoute.value,
|
||||||
|
onNotificationRouteConsumed = { notificationRoute.value = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
notificationRoute.value = extractNotificationRoute(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
syncPushTokenWhenSignedIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side tokens are persistent, but re-registering the current FCM
|
||||||
|
* token is cheap and idempotent. It restores a token if server data was
|
||||||
|
* ever recovered from a backup or manually repaired.
|
||||||
|
*/
|
||||||
|
private fun syncPushTokenWhenSignedIn() {
|
||||||
|
if (authRepository.getToken().isNullOrBlank() && authRepository.getRefreshToken().isNullOrBlank()) return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
pushTokenRepository.registerCurrentDevice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractNotificationRoute(intent: Intent?): String? =
|
||||||
|
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
private fun requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_NOTIFICATION_TYPE = "de.harheimertc.extra.NOTIFICATION_TYPE"
|
||||||
|
const val EXTRA_NOTIFICATION_ROUTE = "de.harheimertc.extra.NOTIFICATION_ROUTE"
|
||||||
|
const val EXTRA_NEWS_ID = "de.harheimertc.extra.NEWS_ID"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App(
|
||||||
|
notificationRoute: String? = null,
|
||||||
|
onNotificationRouteConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
HarheimerTheme {
|
HarheimerTheme {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val ctx = LocalContext.current
|
val ctx = LocalContext.current
|
||||||
val activity = ctx as? ComponentActivity
|
val activity = ctx as? ComponentActivity
|
||||||
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
||||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||||
|
LaunchedEffect(notificationRoute) {
|
||||||
|
val route = notificationRoute?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
|
||||||
|
navController.navigate(route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
popUpTo(Destinations.Home.route)
|
||||||
|
}
|
||||||
|
onNotificationRouteConsumed()
|
||||||
|
}
|
||||||
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
|
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/data/AccessTokenAuthenticator.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/AccessTokenAuthenticator.kt
Normal file → Executable file
139
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file → Executable file
139
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file → Executable file
@@ -21,6 +21,7 @@ import okhttp3.RequestBody
|
|||||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||||
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
||||||
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||||
|
data class TermineManageResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||||
data class TerminDto(
|
data class TerminDto(
|
||||||
val datum: String = "",
|
val datum: String = "",
|
||||||
val uhrzeit: String? = null,
|
val uhrzeit: String? = null,
|
||||||
@@ -36,7 +37,29 @@ data class SpielplanResponse(
|
|||||||
val season: String? = null,
|
val season: String? = null,
|
||||||
val seasons: List<SeasonDto> = emptyList(),
|
val seasons: List<SeasonDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
data class LiveTickerResponse(
|
||||||
|
val matches: List<LiveTickerMatchDto> = emptyList(),
|
||||||
|
val updatedAt: String? = null,
|
||||||
|
)
|
||||||
|
data class LiveTickerMatchDto(
|
||||||
|
val meetingId: String = "",
|
||||||
|
val homeTeam: String = "",
|
||||||
|
val awayTeam: String = "",
|
||||||
|
val score: String = "",
|
||||||
|
val startsAt: Long = 0,
|
||||||
|
val hasLiveData: Boolean = false,
|
||||||
|
val completed: Boolean = false,
|
||||||
|
val completedAt: Long? = null,
|
||||||
|
val updatedAt: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||||
|
data class MannschaftenSeasonsResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val seasons: List<String> = emptyList(),
|
||||||
|
val currentSeason: String = "",
|
||||||
|
val defaultSeason: String = "",
|
||||||
|
)
|
||||||
data class SpielDto(
|
data class SpielDto(
|
||||||
@param:Json(name = "Termin") val termin: String = "",
|
@param:Json(name = "Termin") val termin: String = "",
|
||||||
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
||||||
@@ -81,7 +104,7 @@ data class LeagueTableRowDto(
|
|||||||
)
|
)
|
||||||
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
|
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
|
||||||
data class NewsDto(
|
data class NewsDto(
|
||||||
val id: Int? = null,
|
val id: String? = null,
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val content: String = "",
|
val content: String = "",
|
||||||
val created: String? = null,
|
val created: String? = null,
|
||||||
@@ -96,7 +119,7 @@ data class NewsResponse(
|
|||||||
val news: List<NewsDto> = emptyList(),
|
val news: List<NewsDto> = emptyList(),
|
||||||
)
|
)
|
||||||
data class NewsSaveRequest(
|
data class NewsSaveRequest(
|
||||||
val id: Int? = null,
|
val id: String? = null,
|
||||||
val title: String,
|
val title: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val isPublic: Boolean = false,
|
val isPublic: Boolean = false,
|
||||||
@@ -159,6 +182,7 @@ data class MembershipResponse(
|
|||||||
val success: Boolean = false,
|
val success: Boolean = false,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val downloadUrl: String? = null,
|
val downloadUrl: String? = null,
|
||||||
|
val downloadToken: String? = null,
|
||||||
)
|
)
|
||||||
data class LoginRequest(
|
data class LoginRequest(
|
||||||
val email: String,
|
val email: String,
|
||||||
@@ -225,7 +249,7 @@ data class ProfileVisibilityDto(
|
|||||||
val showEmail: Boolean = true,
|
val showEmail: Boolean = true,
|
||||||
val showPhone: Boolean = true,
|
val showPhone: Boolean = true,
|
||||||
val showAddress: Boolean = false,
|
val showAddress: Boolean = false,
|
||||||
val showBirthday: Boolean = true,
|
val showBirthday: Boolean = false,
|
||||||
)
|
)
|
||||||
data class ProfileUserDto(
|
data class ProfileUserDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
@@ -251,6 +275,35 @@ data class ProfileUpdateRequest(
|
|||||||
val currentPassword: String? = null,
|
val currentPassword: String? = null,
|
||||||
val newPassword: String? = null,
|
val newPassword: String? = null,
|
||||||
)
|
)
|
||||||
|
data class NotificationSettingsDto(
|
||||||
|
val newNews: Boolean = false,
|
||||||
|
val newEvents: Boolean = false,
|
||||||
|
val eventsToday: Boolean = false,
|
||||||
|
val eventsTomorrow: Boolean = false,
|
||||||
|
val ownTeamMatches: Boolean = false,
|
||||||
|
val allTeamMatches: Boolean = false,
|
||||||
|
val birthdays: Boolean = false,
|
||||||
|
val newContactRequest: Boolean = false,
|
||||||
|
val newUserRegistration: Boolean = false,
|
||||||
|
val ownTtrChanges: Boolean = false,
|
||||||
|
val liveTickerStarts: Boolean = false,
|
||||||
|
val liveTickerScoreChanges: Boolean = false,
|
||||||
|
val liveTickerFinished: Boolean = false,
|
||||||
|
val selectedTeamSlugs: List<String> = emptyList(),
|
||||||
|
val selectedTeamSeason: String? = null,
|
||||||
|
val notificationTime: String = "09:00",
|
||||||
|
)
|
||||||
|
data class NotificationSettingsResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
val settings: NotificationSettingsDto = NotificationSettingsDto(),
|
||||||
|
)
|
||||||
|
data class PushTokenRequest(
|
||||||
|
val token: String,
|
||||||
|
val platform: String = "android",
|
||||||
|
val appVersion: String? = null,
|
||||||
|
val installationId: String? = null,
|
||||||
|
)
|
||||||
data class BirthdayDto(
|
data class BirthdayDto(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val dayMonth: String = "",
|
val dayMonth: String = "",
|
||||||
@@ -260,6 +313,29 @@ data class BirthdaysResponse(
|
|||||||
val success: Boolean = false,
|
val success: Boolean = false,
|
||||||
val birthdays: List<BirthdayDto> = emptyList(),
|
val birthdays: List<BirthdayDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
data class QttrSourceDto(
|
||||||
|
val url: String = "",
|
||||||
|
)
|
||||||
|
data class QttrRowDto(
|
||||||
|
val rank: Int? = null,
|
||||||
|
val playerNumber: Int? = null,
|
||||||
|
val gender: String? = null,
|
||||||
|
val playerName: String = "",
|
||||||
|
val clubName: String = "",
|
||||||
|
val currentQttr: Int? = null,
|
||||||
|
val currentTtr: Int? = null,
|
||||||
|
val previousQttr: Int? = null,
|
||||||
|
val birthdate: String? = null,
|
||||||
|
)
|
||||||
|
data class QttrValuesResponse(
|
||||||
|
val format: String = "",
|
||||||
|
val importedAt: String = "",
|
||||||
|
val source: QttrSourceDto = QttrSourceDto(),
|
||||||
|
val title: String? = null,
|
||||||
|
val headerCount: Int = 0,
|
||||||
|
val rowCount: Int = 0,
|
||||||
|
val rows: List<QttrRowDto> = emptyList(),
|
||||||
|
)
|
||||||
data class MemberDto(
|
data class MemberDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@@ -276,6 +352,7 @@ data class MemberDto(
|
|||||||
val editable: Boolean = false,
|
val editable: Boolean = false,
|
||||||
val isMannschaftsspieler: Boolean = false,
|
val isMannschaftsspieler: Boolean = false,
|
||||||
val hasHallKey: Boolean = false,
|
val hasHallKey: Boolean = false,
|
||||||
|
val showBirthday: Boolean = false,
|
||||||
val loginRoles: List<String> = emptyList(),
|
val loginRoles: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
data class MembersResponse(
|
data class MembersResponse(
|
||||||
@@ -387,6 +464,15 @@ data class HomepageSectionConfigDto(
|
|||||||
data class HomepageDto(
|
data class HomepageDto(
|
||||||
val sections: List<HomepageSectionDto> = emptyList(),
|
val sections: List<HomepageSectionDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
data class HeroImageVariantDto(
|
||||||
|
val key: String = "",
|
||||||
|
val mobileWebp: String = "",
|
||||||
|
val desktopWebp: String = "",
|
||||||
|
val fallback: String = "",
|
||||||
|
)
|
||||||
|
data class HeroImagesResponse(
|
||||||
|
val variants: List<HeroImageVariantDto> = emptyList(),
|
||||||
|
)
|
||||||
data class SeitenDto(
|
data class SeitenDto(
|
||||||
val ueberUns: String = "",
|
val ueberUns: String = "",
|
||||||
val geschichte: String = "",
|
val geschichte: String = "",
|
||||||
@@ -529,6 +615,21 @@ interface ApiService {
|
|||||||
@GET("/api/termine")
|
@GET("/api/termine")
|
||||||
suspend fun termine(): Response<TermineResponse>
|
suspend fun termine(): Response<TermineResponse>
|
||||||
|
|
||||||
|
@GET("/api/termine-manage")
|
||||||
|
suspend fun termineManage(): Response<TermineManageResponse>
|
||||||
|
|
||||||
|
@POST("/api/termine-manage")
|
||||||
|
suspend fun saveTermin(@Body request: TerminDto): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@DELETE("/api/termine-manage")
|
||||||
|
suspend fun deleteTermin(
|
||||||
|
@Query("datum") datum: String,
|
||||||
|
@Query("uhrzeit") uhrzeit: String = "",
|
||||||
|
@Query("titel") titel: String,
|
||||||
|
@Query("beschreibung") beschreibung: String = "",
|
||||||
|
@Query("kategorie") kategorie: String = "Sonstiges",
|
||||||
|
): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@GET("/api/spielplan")
|
@GET("/api/spielplan")
|
||||||
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
||||||
|
|
||||||
@@ -548,14 +649,20 @@ interface ApiService {
|
|||||||
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
|
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@DELETE("/api/news")
|
@DELETE("/api/news")
|
||||||
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
|
suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@GET("/api/mannschaften")
|
@GET("/api/mannschaften")
|
||||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||||
|
|
||||||
|
@GET("/api/mannschaften/seasons")
|
||||||
|
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
|
||||||
|
|
||||||
@GET("/api/config")
|
@GET("/api/config")
|
||||||
suspend fun config(): Response<ConfigResponse>
|
suspend fun config(): Response<ConfigResponse>
|
||||||
|
|
||||||
|
@GET("/api/hero-images")
|
||||||
|
suspend fun heroImages(): Response<HeroImagesResponse>
|
||||||
|
|
||||||
@PUT("/api/config")
|
@PUT("/api/config")
|
||||||
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
|
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
|
||||||
|
|
||||||
@@ -568,12 +675,18 @@ interface ApiService {
|
|||||||
@POST("/api/cms/save-csv")
|
@POST("/api/cms/save-csv")
|
||||||
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
||||||
|
|
||||||
|
@POST("/api/cms/import-spielplan")
|
||||||
|
suspend fun importSpielplan(): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@POST("/api/membership/generate-pdf")
|
@POST("/api/membership/generate-pdf")
|
||||||
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
||||||
|
|
||||||
@Streaming
|
@Streaming
|
||||||
@GET
|
@GET
|
||||||
suspend fun downloadMembershipPdf(@Url downloadUrl: String): Response<ResponseBody>
|
suspend fun downloadMembershipPdf(
|
||||||
|
@Url downloadUrl: String,
|
||||||
|
@retrofit2.http.Header("X-Membership-Download-Token") downloadToken: String? = null,
|
||||||
|
): Response<ResponseBody>
|
||||||
|
|
||||||
@POST("/api/auth/login")
|
@POST("/api/auth/login")
|
||||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||||
@@ -617,9 +730,24 @@ interface ApiService {
|
|||||||
@retrofit2.http.PUT("/api/profile")
|
@retrofit2.http.PUT("/api/profile")
|
||||||
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
||||||
|
|
||||||
|
@GET("/api/profile/notifications")
|
||||||
|
suspend fun notificationSettings(): Response<NotificationSettingsResponse>
|
||||||
|
|
||||||
|
@retrofit2.http.PUT("/api/profile/notifications")
|
||||||
|
suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response<NotificationSettingsResponse>
|
||||||
|
|
||||||
|
@POST("/api/profile/push-token")
|
||||||
|
suspend fun registerPushToken(@Body request: PushTokenRequest): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@GET("/api/birthdays")
|
@GET("/api/birthdays")
|
||||||
suspend fun birthdays(): Response<BirthdaysResponse>
|
suspend fun birthdays(): Response<BirthdaysResponse>
|
||||||
|
|
||||||
|
@GET("/api/mitgliederbereich/qttr")
|
||||||
|
suspend fun qttrValues(): Response<QttrValuesResponse>
|
||||||
|
|
||||||
|
@GET("/api/mitgliederbereich/live-ticker")
|
||||||
|
suspend fun liveTicker(): Response<LiveTickerResponse>
|
||||||
|
|
||||||
@GET("/api/members")
|
@GET("/api/members")
|
||||||
suspend fun members(): Response<MembersResponse>
|
suspend fun members(): Response<MembersResponse>
|
||||||
|
|
||||||
@@ -634,6 +762,7 @@ interface ApiService {
|
|||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
val isMannschaftsspieler: Boolean = false,
|
val isMannschaftsspieler: Boolean = false,
|
||||||
val hasHallKey: Boolean = false,
|
val hasHallKey: Boolean = false,
|
||||||
|
val showBirthday: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BulkImportRequest(val members: List<Map<String, String>>)
|
data class BulkImportRequest(val members: List<Map<String, String>>)
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/data/AuthInterceptor.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/AuthInterceptor.kt
Normal file → Executable file
49
android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt
Executable file
49
android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
package de.harheimertc.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ConnectivityMonitor @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val _online = MutableStateFlow(hasInternetAccess())
|
||||||
|
val online: StateFlow<Boolean> = _online.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { poll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun poll() {
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val current = hasInternetAccess()
|
||||||
|
if (_online.value != current) {
|
||||||
|
_online.value = current
|
||||||
|
}
|
||||||
|
delay(10_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasInternetAccess(): Boolean {
|
||||||
|
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
?: return false
|
||||||
|
val network = manager.activeNetwork ?: return false
|
||||||
|
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||||
|
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
}
|
||||||
|
}
|
||||||
0
android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt
Normal file → Executable file
34
android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt
Normal file → Executable file
34
android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt
Normal file → Executable file
@@ -1,11 +1,13 @@
|
|||||||
package de.harheimertc.data
|
package de.harheimertc.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.Types
|
import com.squareup.moshi.Types
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -14,8 +16,10 @@ class SecureOfflineCache @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val moshi: Moshi,
|
private val moshi: Moshi,
|
||||||
) {
|
) {
|
||||||
|
private val tag = "SecureOfflineCache"
|
||||||
private companion object {
|
private companion object {
|
||||||
const val KEY_BIRTHDAYS = "birthdays"
|
const val KEY_BIRTHDAYS = "birthdays"
|
||||||
|
const val KEY_QTTR_VALUES = "qttr_values"
|
||||||
const val KEY_MEMBERS = "members"
|
const val KEY_MEMBERS = "members"
|
||||||
const val KEY_MEMBER_NEWS = "member_news"
|
const val KEY_MEMBER_NEWS = "member_news"
|
||||||
const val KEY_CMS_CONFIG = "cms_config"
|
const val KEY_CMS_CONFIG = "cms_config"
|
||||||
@@ -28,6 +32,10 @@ class SecureOfflineCache @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val preferences by lazy {
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
@@ -38,11 +46,36 @@ class SecureOfflineCache @Inject constructor(
|
|||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
)
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_offline_cache")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_offline_cache",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
}
|
}
|
||||||
|
|
||||||
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
||||||
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
|
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
|
fun putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java)
|
||||||
|
fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
|
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
|
||||||
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
|
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
@@ -93,6 +126,7 @@ class SecureOfflineCache @Inject constructor(
|
|||||||
KEY_NEWSLETTER_GROUPS,
|
KEY_NEWSLETTER_GROUPS,
|
||||||
KEY_PASSWORD_RESET_DIAGNOSTICS,
|
KEY_PASSWORD_RESET_DIAGNOSTICS,
|
||||||
KEY_MEMBER_NEWS,
|
KEY_MEMBER_NEWS,
|
||||||
|
KEY_QTTR_VALUES,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/di/RepositoryModule.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/di/RepositoryModule.kt
Normal file → Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
package de.harheimertc.notifications
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class HarheimerMessagingService : FirebaseMessagingService() {
|
||||||
|
@Inject
|
||||||
|
lateinit var pushTokenRepository: PushTokenRepository
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
super.onNewToken(token)
|
||||||
|
serviceScope.launch {
|
||||||
|
pushTokenRepository.registerCurrentDevice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
super.onMessageReceived(message)
|
||||||
|
val title = message.notification?.title
|
||||||
|
?: message.data["title"]
|
||||||
|
?: "Harheimer TC"
|
||||||
|
val body = message.notification?.body
|
||||||
|
?: message.data["body"]
|
||||||
|
?: message.data["message"]
|
||||||
|
?: return
|
||||||
|
val notificationId = message.data["notificationId"]?.toIntOrNull()
|
||||||
|
?: message.messageId?.hashCode()
|
||||||
|
?: System.currentTimeMillis().toInt()
|
||||||
|
val shown = HarheimerNotifications.showBasicNotification(this, notificationId, title, body, message.data)
|
||||||
|
Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package de.harheimertc.notifications
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.harheimertc.MainActivity
|
||||||
|
import de.harheimertc.R
|
||||||
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
|
||||||
|
object HarheimerNotifications {
|
||||||
|
// A new ID is intentional: Android does not allow an app update to raise
|
||||||
|
// the importance of an already-created notification channel.
|
||||||
|
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates_v2"
|
||||||
|
|
||||||
|
fun createChannels(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
DEFAULT_CHANNEL_ID,
|
||||||
|
"Harheimer TC",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply {
|
||||||
|
description = "Benachrichtigungen des Harheimer TC"
|
||||||
|
}
|
||||||
|
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNotificationPermission(context: Context): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
fun showBasicNotification(
|
||||||
|
context: Context,
|
||||||
|
notificationId: Int,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
data: Map<String, String> = emptyMap(),
|
||||||
|
): Boolean {
|
||||||
|
if (!hasNotificationPermission(context)) return false
|
||||||
|
val notification = NotificationCompat.Builder(context, DEFAULT_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(message)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setContentIntent(createContentIntent(context, notificationId, data))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
return try {
|
||||||
|
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||||
|
true
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
|
||||||
|
val route = destinationRoute(payload)
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
putExtra(MainActivity.EXTRA_NOTIFICATION_TYPE, payload["type"])
|
||||||
|
putExtra(MainActivity.EXTRA_NOTIFICATION_ROUTE, route)
|
||||||
|
payload["newsId"]?.let { putExtra(MainActivity.EXTRA_NEWS_ID, it) }
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
notificationId,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
||||||
|
"news", "news_expiring" -> Destinations.MemberNews.route
|
||||||
|
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
|
||||||
|
"team_matches" -> Destinations.Spielplan.route
|
||||||
|
"live_ticker_started", "live_ticker_score_changed", "live_ticker_finished" -> Destinations.MemberArea.route
|
||||||
|
"qttr_list_updated", "ttr_change" -> Destinations.Qttr.route
|
||||||
|
"birthdays" -> Destinations.MemberArea.route
|
||||||
|
"contact_request" -> Destinations.CmsContactRequests.route
|
||||||
|
"user_registration" -> Destinations.CmsBenutzer.route
|
||||||
|
else -> Destinations.Home.route
|
||||||
|
}
|
||||||
|
}
|
||||||
0
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt
Normal file → Executable file
29
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt
Normal file → Executable file
29
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt
Normal file → Executable file
@@ -1,10 +1,12 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.harheimertc.security.DeviceKeyManager
|
import de.harheimertc.security.DeviceKeyManager
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -13,10 +15,15 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val deviceKeyManager: DeviceKeyManager,
|
private val deviceKeyManager: DeviceKeyManager,
|
||||||
) : AuthRepository {
|
) : AuthRepository {
|
||||||
|
private val tag = "AuthRepository"
|
||||||
private val tokenKey = "auth_token"
|
private val tokenKey = "auth_token"
|
||||||
private val refreshTokenKey = "auth_refresh_token"
|
private val refreshTokenKey = "auth_refresh_token"
|
||||||
private val sessionIdKey = "auth_session_id"
|
private val sessionIdKey = "auth_session_id"
|
||||||
private val preferences by lazy {
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
@@ -27,6 +34,28 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
)
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_auth")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_auth",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
||||||
|
|||||||
146
android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt
Normal file → Executable file
146
android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt
Normal file → Executable file
@@ -10,6 +10,8 @@ import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
|||||||
import de.harheimertc.data.SaveCsvRequest
|
import de.harheimertc.data.SaveCsvRequest
|
||||||
import de.harheimertc.data.SaveCsvResponse
|
import de.harheimertc.data.SaveCsvResponse
|
||||||
import de.harheimertc.data.SecureOfflineCache
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
|
import de.harheimertc.data.SpielplanResponse
|
||||||
|
import de.harheimertc.data.TerminDto
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class CmsRepository @Inject constructor(
|
class CmsRepository @Inject constructor(
|
||||||
@@ -83,6 +85,93 @@ class CmsRepository @Inject constructor(
|
|||||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun managedTermine(): Result<List<TerminDto>> = runCatching {
|
||||||
|
val response = api.termineManage()
|
||||||
|
if (!response.isSuccessful) error("Termine konnten nicht geladen werden.")
|
||||||
|
response.body()?.termine.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.saveTermin(request)
|
||||||
|
if (!response.isSuccessful) error("Termin konnte nicht gespeichert werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.deleteTermin(
|
||||||
|
datum = request.datum,
|
||||||
|
uhrzeit = request.uhrzeit.orEmpty(),
|
||||||
|
titel = request.titel,
|
||||||
|
beschreibung = request.beschreibung.orEmpty(),
|
||||||
|
kategorie = request.kategorie ?: "Sonstiges",
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Termin konnte nicht gelöscht werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mannschaften(season: String? = null): Result<List<CmsMannschaftRow>> = runCatching {
|
||||||
|
val response = api.mannschaften(season)
|
||||||
|
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||||
|
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||||
|
if (values.size < 10 || values[0].isBlank()) return@mapNotNull null
|
||||||
|
CmsMannschaftRow(
|
||||||
|
mannschaft = values[0],
|
||||||
|
liga = values[1],
|
||||||
|
staffelleiter = values[2],
|
||||||
|
telefon = values[3],
|
||||||
|
heimspieltag = values[4],
|
||||||
|
spielsystem = values[5],
|
||||||
|
mannschaftsfuehrer = values[6],
|
||||||
|
spieler = values[7],
|
||||||
|
informationenLink = values[8],
|
||||||
|
letzteAktualisierung = values[9],
|
||||||
|
spielplanMannschaftId = values.getOrElse(10) { "" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mannschaftenSeasons(): Result<de.harheimertc.data.MannschaftenSeasonsResponse> = runCatching {
|
||||||
|
val response = api.mannschaftenSeasons()
|
||||||
|
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.MannschaftenSeasonsResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveMannschaften(season: String?, rows: List<CmsMannschaftRow>): Result<SaveCsvResponse> = runCatching {
|
||||||
|
val response = api.saveCsv(
|
||||||
|
SaveCsvRequest(
|
||||||
|
filename = season?.takeIf { it.isNotBlank() }?.let { "mannschaften_$it.csv" } ?: "mannschaften.csv",
|
||||||
|
content = rows.toMannschaftenCsv(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Mannschaften konnten nicht gespeichert werden.")
|
||||||
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importSpielplan(): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.importSpielplan()
|
||||||
|
if (!response.isSuccessful) error("Spielplan konnte nicht importiert werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||||
|
val response = api.spielplan(season)
|
||||||
|
if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.")
|
||||||
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
|
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveSpielplan(headers: List<String>, rows: List<List<String>>): Result<SaveCsvResponse> = runCatching {
|
||||||
|
val response = api.saveCsv(
|
||||||
|
SaveCsvRequest(
|
||||||
|
filename = "spielplan.csv",
|
||||||
|
content = listOf(headers).plus(rows).joinToString("\n") { row -> row.toCsvRow(";") },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Spielplan konnte nicht gespeichert werden.")
|
||||||
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun users(): Result<CmsUsersResponse> =
|
suspend fun users(): Result<CmsUsersResponse> =
|
||||||
fetchEncryptedFallback(
|
fetchEncryptedFallback(
|
||||||
load = {
|
load = {
|
||||||
@@ -267,7 +356,7 @@ class CmsRepository @Inject constructor(
|
|||||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
suspend fun deleteNews(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
val response = api.deleteNews(id)
|
val response = api.deleteNews(id)
|
||||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||||
cache.clearCmsNewsCache()
|
cache.clearCmsNewsCache()
|
||||||
@@ -288,6 +377,61 @@ class CmsRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class CmsMannschaftRow(
|
||||||
|
val mannschaft: String = "",
|
||||||
|
val liga: String = "",
|
||||||
|
val staffelleiter: String = "",
|
||||||
|
val telefon: String = "",
|
||||||
|
val heimspieltag: String = "",
|
||||||
|
val spielsystem: String = "",
|
||||||
|
val mannschaftsfuehrer: String = "",
|
||||||
|
val spieler: String = "",
|
||||||
|
val informationenLink: String = "",
|
||||||
|
val letzteAktualisierung: String = "",
|
||||||
|
val spielplanMannschaftId: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
|
||||||
|
val header = listOf(
|
||||||
|
"Mannschaft",
|
||||||
|
"Liga",
|
||||||
|
"Staffelleiter",
|
||||||
|
"Telefon",
|
||||||
|
"Heimspieltag",
|
||||||
|
"Spielsystem",
|
||||||
|
"Mannschaftsführer",
|
||||||
|
"Spieler",
|
||||||
|
"Weitere Informationen Link",
|
||||||
|
"Letzte Aktualisierung",
|
||||||
|
"Spielplan Mannschaft ID",
|
||||||
|
).toCsvRow()
|
||||||
|
val rows = map { row ->
|
||||||
|
listOf(
|
||||||
|
row.mannschaft,
|
||||||
|
row.liga,
|
||||||
|
row.staffelleiter,
|
||||||
|
row.telefon,
|
||||||
|
row.heimspieltag,
|
||||||
|
row.spielsystem,
|
||||||
|
row.mannschaftsfuehrer,
|
||||||
|
row.spieler,
|
||||||
|
row.informationenLink,
|
||||||
|
row.letzteAktualisierung,
|
||||||
|
row.spielplanMannschaftId,
|
||||||
|
).toCsvRow()
|
||||||
|
}
|
||||||
|
return listOf(header).plus(rows).joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.toCsvRow(delimiter: String = ","): String =
|
||||||
|
joinToString(delimiter) { value -> value.csvEscape(delimiter) }
|
||||||
|
|
||||||
|
private fun String.csvEscape(delimiter: String): String {
|
||||||
|
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
|
||||||
|
val escaped = replace("\"", "\"\"")
|
||||||
|
return if (needsQuotes) "\"$escaped\"" else escaped
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseCsv(csv: String): List<List<String>> =
|
private fun parseCsv(csv: String): List<List<String>> =
|
||||||
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
||||||
|
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/repositories/ContactRepository.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/repositories/ContactRepository.kt
Normal file → Executable file
28
android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt
Normal file → Executable file
28
android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt
Normal file → Executable file
@@ -24,27 +24,27 @@ class GalleryRepository @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||||
val response = api.galerieList(page = 1, perPage = 1)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.galerieList(page = 1, perPage = 1)
|
||||||
response.body()?.images.orEmpty().isNotEmpty()
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body()?.images.orEmpty().isNotEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
||||||
return try {
|
return runCatching {
|
||||||
val resp = api.galerieList(page = page, perPage = perPage)
|
retryOnNetworkFailure {
|
||||||
if (resp.isSuccessful) {
|
val resp = api.galerieList(page = page, perPage = perPage)
|
||||||
val body = resp.body()
|
if (resp.isSuccessful) {
|
||||||
Result.success(
|
val body = resp.body()
|
||||||
GalleryPage(
|
GalleryPage(
|
||||||
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
||||||
pagination = body?.pagination ?: GalleryPaginationDto(),
|
pagination = body?.pagination ?: GalleryPaginationDto(),
|
||||||
),
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
error("HTTP ${resp.code()}")
|
||||||
Result.failure(Exception("HTTP ${resp.code()}"))
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt
Normal file → Executable file
29
android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt
Normal file → Executable file
@@ -1,12 +1,14 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.Types
|
import com.squareup.moshi.Types
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.harheimertc.data.HomepageSectionDto
|
import de.harheimertc.data.HomepageSectionDto
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -15,10 +17,15 @@ class HomeLayoutPreferences @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val moshi: Moshi,
|
private val moshi: Moshi,
|
||||||
) {
|
) {
|
||||||
|
private val tag = "HomeLayoutPreferences"
|
||||||
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
|
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
|
||||||
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
|
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
|
||||||
|
|
||||||
private val preferences by lazy {
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
@@ -29,6 +36,28 @@ class HomeLayoutPreferences @Inject constructor(
|
|||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
)
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_home_layout")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_home_layout",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSections(): List<HomepageSectionDto>? {
|
fun getSections(): List<HomepageSectionDto>? {
|
||||||
|
|||||||
233
android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt
Normal file → Executable file
233
android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt
Normal file → Executable file
@@ -1,12 +1,16 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.data.ApiService
|
import de.harheimertc.data.ApiService
|
||||||
import de.harheimertc.data.HomepageSectionDto
|
import de.harheimertc.data.HomepageSectionDto
|
||||||
|
import de.harheimertc.data.HeroImageVariantDto
|
||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
import de.harheimertc.data.SeasonDto
|
import de.harheimertc.data.SeasonDto
|
||||||
import de.harheimertc.data.SpielDto
|
import de.harheimertc.data.SpielDto
|
||||||
import de.harheimertc.data.SpielplanResponse
|
import de.harheimertc.data.SpielplanResponse
|
||||||
import de.harheimertc.data.TerminDto
|
import de.harheimertc.data.TerminDto
|
||||||
|
import io.sentry.Sentry
|
||||||
|
import kotlin.random.Random
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -17,20 +21,162 @@ data class HomeData(
|
|||||||
val selectedSpielplanSeason: String?,
|
val selectedSpielplanSeason: String?,
|
||||||
val news: List<NewsDto>,
|
val news: List<NewsDto>,
|
||||||
val homepageSections: List<HomepageSectionDto>,
|
val homepageSections: List<HomepageSectionDto>,
|
||||||
|
val heroImageUrl: String? = null,
|
||||||
|
val diagnostics: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class HomeRepository @Inject constructor(private val api: ApiService) {
|
class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
||||||
val termine = api.termine().body()?.termine.orEmpty()
|
val diagnostics = mutableListOf<String>()
|
||||||
val spielplanResponse = api.spielplan().body()
|
|
||||||
val spiele = spielplanResponse?.data.orEmpty()
|
val termine = runCatching {
|
||||||
val news = api.publicNews().body()?.news.orEmpty()
|
retryOnNetworkFailure {
|
||||||
val homepageSections = runCatching {
|
val response = api.termine()
|
||||||
val configResponse = api.config()
|
if (!response.isSuccessful) {
|
||||||
if (!configResponse.isSuccessful) return@runCatching emptyList()
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
configResponse.body()?.homepage?.sections.orEmpty()
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/termine",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.termine.orEmpty()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.termine", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/termine") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/termine",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val spielplanResponse = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.spielplan()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/spielplan",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.spielplan", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/spielplan",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
val spiele = spielplanResponse?.data.orEmpty()
|
||||||
|
|
||||||
|
val news = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.publicNews()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/news-public",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.news.orEmpty()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.news", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/news-public") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/news-public",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val homepageSections = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.config()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/config",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.homepage?.sections.orEmpty()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.config", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/config") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/config",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val heroImageUrl = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.heroImages()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/hero-images",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
val variants = response.body()?.variants.orEmpty()
|
||||||
|
pickRandomHeroImage(variants)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.heroImages", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/hero-images",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
HomeData(
|
HomeData(
|
||||||
termine = termine,
|
termine = termine,
|
||||||
spiele = spiele,
|
spiele = spiele,
|
||||||
@@ -38,12 +184,77 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
|||||||
selectedSpielplanSeason = spielplanResponse?.season,
|
selectedSpielplanSeason = spielplanResponse?.season,
|
||||||
news = news,
|
news = news,
|
||||||
homepageSections = homepageSections,
|
homepageSections = homepageSections,
|
||||||
|
heroImageUrl = heroImageUrl,
|
||||||
|
diagnostics = diagnostics,
|
||||||
)
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "HomeRepository")
|
||||||
|
scope.setTag("operation", "fetchHomeData")
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
||||||
val response = api.spielplan(season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.spielplan(season)
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "HomeRepository")
|
||||||
|
scope.setTag("operation", "fetchSpielplanForSeason")
|
||||||
|
scope.setExtra("season", season)
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureLoadIssue(operation: String, error: Throwable) {
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "HomeRepository")
|
||||||
|
scope.setTag("operation", operation)
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDiagnostic(
|
||||||
|
endpoint: String,
|
||||||
|
requestPayload: String,
|
||||||
|
httpCode: Int?,
|
||||||
|
responseBody: String?,
|
||||||
|
throwable: Throwable?,
|
||||||
|
): String {
|
||||||
|
val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" }
|
||||||
|
val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" }
|
||||||
|
return buildString {
|
||||||
|
append("Endpoint: ").append(endpoint).append('\n')
|
||||||
|
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n')
|
||||||
|
append("Request: ").append(requestPayload).append('\n')
|
||||||
|
append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n')
|
||||||
|
append("Response: ").append(responsePreview).append('\n')
|
||||||
|
append("Throwable: ").append(throwableInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pickRandomHeroImage(variants: List<HeroImageVariantDto>): String? {
|
||||||
|
if (variants.isEmpty()) return null
|
||||||
|
val valid = variants.filter { it.fallback.isNotBlank() }
|
||||||
|
if (valid.isEmpty()) return null
|
||||||
|
val selected = valid[Random.nextInt(valid.size)]
|
||||||
|
return toAbsoluteUrl(selected.fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toAbsoluteUrl(pathOrUrl: String): String {
|
||||||
|
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
|
||||||
|
return pathOrUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
val base = BuildConfig.API_BASE_URL.trimEnd('/')
|
||||||
|
val path = if (pathOrUrl.startsWith('/')) pathOrUrl else "/$pathOrUrl"
|
||||||
|
return "$base$path"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.LiveTickerMatchDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class LiveTickerRepository @Inject constructor(private val api: ApiService) {
|
||||||
|
suspend fun fetch(): Result<List<LiveTickerMatchDto>> = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.liveTicker()
|
||||||
|
if (!response.isSuccessful) error("Live-Ergebnisse konnten nicht geladen werden.")
|
||||||
|
response.body()?.matches ?: emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt
Normal file → Executable file
78
android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt
Normal file → Executable file
@@ -1,5 +1,6 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.data.ApiService
|
import de.harheimertc.data.ApiService
|
||||||
import de.harheimertc.data.LoginRequest
|
import de.harheimertc.data.LoginRequest
|
||||||
import de.harheimertc.data.LoginResponse
|
import de.harheimertc.data.LoginResponse
|
||||||
@@ -9,6 +10,7 @@ import de.harheimertc.data.LogoutRequest
|
|||||||
import de.harheimertc.data.RegistrationRequest
|
import de.harheimertc.data.RegistrationRequest
|
||||||
import de.harheimertc.data.ResetPasswordRequest
|
import de.harheimertc.data.ResetPasswordRequest
|
||||||
import de.harheimertc.data.SessionRefresher
|
import de.harheimertc.data.SessionRefresher
|
||||||
|
import io.sentry.Sentry
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -19,13 +21,41 @@ class LoginRepository @Inject constructor(
|
|||||||
private val sessionRefresher: SessionRefresher,
|
private val sessionRefresher: SessionRefresher,
|
||||||
) {
|
) {
|
||||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||||
val response = api.login(LoginRequest(email.trim(), password))
|
val endpoint = "api/auth/login"
|
||||||
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
|
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
|
||||||
|
val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val body = response.errorBody()?.string().orEmpty()
|
||||||
|
val serverMessage = extractServerMessage(body)
|
||||||
|
val fallback = when (response.code()) {
|
||||||
|
401 -> "Ungueltige Anmeldedaten"
|
||||||
|
403 -> "Konto nicht freigeschaltet"
|
||||||
|
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
|
||||||
|
else -> "Anmeldung fehlgeschlagen"
|
||||||
|
}
|
||||||
|
val diagnostic = buildString {
|
||||||
|
append("\n\nDiagnose:\n")
|
||||||
|
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
|
||||||
|
append("Request: ").append(requestPreview).append('\n')
|
||||||
|
append("HTTP: ").append(response.code()).append('\n')
|
||||||
|
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
|
||||||
|
append("Server message: ").append(serverMessage ?: "none")
|
||||||
|
}
|
||||||
|
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
|
||||||
|
}
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||||
body
|
body
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "LoginRepository")
|
||||||
|
scope.setTag("operation", "login")
|
||||||
|
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun logout(): Result<Unit> = runCatching {
|
suspend fun logout(): Result<Unit> = runCatching {
|
||||||
@@ -41,27 +71,55 @@ class LoginRepository @Inject constructor(
|
|||||||
return@runCatching AuthStatusResponse()
|
return@runCatching AuthStatusResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = api.authStatus()
|
var response = retryOnNetworkFailure { api.authStatus() }
|
||||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||||
var status = response.body() ?: AuthStatusResponse()
|
var status = response.body() ?: AuthStatusResponse()
|
||||||
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
||||||
response = api.authStatus()
|
response = retryOnNetworkFailure { api.authStatus() }
|
||||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||||
status = response.body() ?: AuthStatusResponse()
|
status = response.body() ?: AuthStatusResponse()
|
||||||
}
|
}
|
||||||
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
||||||
status
|
status
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "LoginRepository")
|
||||||
|
scope.setTag("operation", "status")
|
||||||
|
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
|
||||||
|
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
||||||
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
|
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
|
||||||
val response = api.register(request)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
val response = api.register(request)
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractServerMessage(raw: String): String? {
|
||||||
|
if (raw.isBlank()) return null
|
||||||
|
val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"")
|
||||||
|
return msgRegex.find(raw)?.groupValues?.getOrNull(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maskEmail(rawEmail: String): String {
|
||||||
|
val email = rawEmail.trim()
|
||||||
|
if (!email.contains('@')) return "hidden"
|
||||||
|
val local = email.substringBefore('@')
|
||||||
|
val domain = email.substringAfter('@')
|
||||||
|
val localMasked = if (local.length <= 2) "**" else local.take(2) + "***"
|
||||||
|
return "$localMasked@$domain"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt
Normal file → Executable file
18
android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt
Normal file → Executable file
@@ -1,6 +1,8 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import de.harheimertc.data.ApiService
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.MannschaftenSeasonsResponse
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -24,9 +26,19 @@ data class Mannschaft(
|
|||||||
@Singleton
|
@Singleton
|
||||||
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
||||||
val response = api.mannschaften(season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
val response = api.mannschaften(season)
|
||||||
parseCsv(response.body()?.string().orEmpty())
|
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||||
|
parseCsv(response.body()?.string().orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.mannschaftenSeasons()
|
||||||
|
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Saisons konnten nicht geladen werden.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
||||||
|
|||||||
15
android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt
Normal file → Executable file
15
android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt
Normal file → Executable file
@@ -5,6 +5,7 @@ import de.harheimertc.data.BirthdaysResponse
|
|||||||
import de.harheimertc.data.MembersResponse
|
import de.harheimertc.data.MembersResponse
|
||||||
import de.harheimertc.data.NewsResponse
|
import de.harheimertc.data.NewsResponse
|
||||||
import de.harheimertc.data.NewsSaveRequest
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
|
import de.harheimertc.data.QttrValuesResponse
|
||||||
import de.harheimertc.data.SecureOfflineCache
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -24,6 +25,18 @@ class MemberAreaRepository @Inject constructor(
|
|||||||
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
|
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun qttrValues(): Result<QttrValuesResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.qttrValues()
|
||||||
|
if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putQttrValues,
|
||||||
|
cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) },
|
||||||
|
fallbackMessage = "QTTR-Werte konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun members(): Result<MembersResponse> =
|
suspend fun members(): Result<MembersResponse> =
|
||||||
fetchEncryptedFallback(
|
fetchEncryptedFallback(
|
||||||
load = {
|
load = {
|
||||||
@@ -88,7 +101,7 @@ class MemberAreaRepository @Inject constructor(
|
|||||||
response.body() ?: emptyMap()
|
response.body() ?: emptyMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
|
suspend fun deleteNews(id: String): Result<Unit> = runCatching {
|
||||||
val response = api.deleteNews(id)
|
val response = api.deleteNews(id)
|
||||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||||
}
|
}
|
||||||
|
|||||||
38
android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt
Normal file → Executable file
38
android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt
Normal file → Executable file
@@ -9,7 +9,7 @@ import java.io.File
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
data class MembershipDocument(val message: String, val uri: String)
|
data class MembershipDocument(val message: String, val uri: String? = null)
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class MembershipRepository @Inject constructor(
|
class MembershipRepository @Inject constructor(
|
||||||
@@ -18,21 +18,33 @@ class MembershipRepository @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
|
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
|
||||||
val response = api.generateMembershipPdf(request)
|
val response = api.generateMembershipPdf(request)
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
if (!response.isSuccessful) {
|
||||||
|
val serverMessage = response.errorBody()?.string()
|
||||||
|
?.let { Regex("\\\"(?:statusMessage|message)\\\"\\s*:\\s*\\\"([^\\\"]+)\\\"").find(it)?.groupValues?.getOrNull(1) ?: it }
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
error(serverMessage ?: "Der Antrag konnte nicht übermittelt werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
|
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
|
||||||
val downloadUrl = body.downloadUrl ?: error("PDF-Download fehlt.")
|
val uri = body.downloadUrl?.let { downloadUrl ->
|
||||||
val documentResponse = api.downloadMembershipPdf(downloadUrl)
|
runCatching {
|
||||||
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
|
val documentResponse = api.downloadMembershipPdf(downloadUrl, body.downloadToken)
|
||||||
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
|
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
|
||||||
val file = File(directory, "beitrittserklaerung.pdf")
|
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
|
||||||
documentResponse.body()?.byteStream()?.use { input ->
|
val file = File(directory, "beitrittserklaerung.pdf")
|
||||||
file.outputStream().use { output -> input.copyTo(output) }
|
documentResponse.body()?.byteStream()?.use { input ->
|
||||||
} ?: error("Leere PDF-Antwort")
|
file.outputStream().use { output -> input.copyTo(output) }
|
||||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
} ?: error("Leere PDF-Antwort")
|
||||||
|
FileProvider.getUriForFile(context, "${context.packageName}.files", file).toString()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
MembershipDocument(
|
MembershipDocument(
|
||||||
message = body.message ?: "Beitrittsformular erfolgreich erstellt.",
|
message = if (uri == null) {
|
||||||
uri = uri.toString(),
|
"${body.message ?: "Mitgliedschaftsantrag erfolgreich übermittelt."} Das PDF konnte nicht auf diesem Gerät gespeichert werden."
|
||||||
|
} else {
|
||||||
|
body.message ?: "Beitrittsformular erfolgreich erstellt."
|
||||||
|
},
|
||||||
|
uri = uri,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt
Executable file
33
android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.NoRouteToHostException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
internal suspend fun <T> retryOnNetworkFailure(
|
||||||
|
retryDelayMillis: Long = 10_000L,
|
||||||
|
block: suspend () -> T,
|
||||||
|
): T {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
if (!error.isRetryableNetworkError()) throw error
|
||||||
|
delay(retryDelayMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Throwable.isRetryableNetworkError(): Boolean = when (this) {
|
||||||
|
is UnknownHostException,
|
||||||
|
is ConnectException,
|
||||||
|
is NoRouteToHostException,
|
||||||
|
is SocketTimeoutException,
|
||||||
|
is SSLException -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
16
android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt
Normal file → Executable file
16
android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt
Normal file → Executable file
@@ -8,9 +8,11 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
|
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
|
||||||
val response = api.publicNewsletterGroups()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
val response = api.publicNewsletterGroups()
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
|
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
|
||||||
@@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
|
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
|
||||||
val response = api.confirmNewsletter(token)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
val response = api.confirmNewsletter(token)
|
||||||
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
||||||
|
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.NotificationSettingsDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private const val DEFAULT_NOTIFICATION_TIME = "09:00"
|
||||||
|
|
||||||
|
data class NotificationPreferences(
|
||||||
|
val newNews: Boolean = false,
|
||||||
|
val newEvents: Boolean = false,
|
||||||
|
val eventsToday: Boolean = false,
|
||||||
|
val eventsTomorrow: Boolean = false,
|
||||||
|
val ownTeamMatches: Boolean = false,
|
||||||
|
val allTeamMatches: Boolean = false,
|
||||||
|
val birthdays: Boolean = false,
|
||||||
|
val newContactRequest: Boolean = false,
|
||||||
|
val newUserRegistration: Boolean = false,
|
||||||
|
val ownTtrChanges: Boolean = false,
|
||||||
|
val liveTickerStarts: Boolean = false,
|
||||||
|
val liveTickerScoreChanges: Boolean = false,
|
||||||
|
val liveTickerFinished: Boolean = false,
|
||||||
|
val selectedTeamSlugs: Set<String> = emptySet(),
|
||||||
|
val selectedTeamSeason: String? = null,
|
||||||
|
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class NotificationPreferencesRepository @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val api: ApiService,
|
||||||
|
) {
|
||||||
|
private val preferences by lazy {
|
||||||
|
context.getSharedPreferences("harheimertc_notification_preferences", Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadLocal(): NotificationPreferences = NotificationPreferences(
|
||||||
|
newNews = preferences.getBoolean(KEY_NEW_NEWS, false),
|
||||||
|
newEvents = preferences.getBoolean(KEY_NEW_EVENTS, false),
|
||||||
|
eventsToday = preferences.getBoolean(KEY_EVENTS_TODAY, false),
|
||||||
|
eventsTomorrow = preferences.getBoolean(KEY_EVENTS_TOMORROW, false),
|
||||||
|
ownTeamMatches = preferences.getBoolean(KEY_OWN_TEAM_MATCHES, false),
|
||||||
|
allTeamMatches = preferences.getBoolean(KEY_ALL_TEAM_MATCHES, false),
|
||||||
|
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
|
||||||
|
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
||||||
|
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false),
|
||||||
|
ownTtrChanges = preferences.getBoolean(KEY_OWN_TTR_CHANGES, false),
|
||||||
|
liveTickerStarts = preferences.getBoolean(KEY_LIVE_TICKER_STARTS, false),
|
||||||
|
liveTickerScoreChanges = preferences.getBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, false),
|
||||||
|
liveTickerFinished = preferences.getBoolean(KEY_LIVE_TICKER_FINISHED, false),
|
||||||
|
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
|
||||||
|
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
||||||
|
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun loadRemote(): Result<NotificationPreferences> = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.notificationSettings()
|
||||||
|
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht geladen werden.")
|
||||||
|
val settings = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||||
|
saveLocal(settings)
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLocal(settings: NotificationPreferences) {
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(KEY_NEW_NEWS, settings.newNews)
|
||||||
|
.putBoolean(KEY_NEW_EVENTS, settings.newEvents)
|
||||||
|
.putBoolean(KEY_EVENTS_TODAY, settings.eventsToday)
|
||||||
|
.putBoolean(KEY_EVENTS_TOMORROW, settings.eventsTomorrow)
|
||||||
|
.putBoolean(KEY_OWN_TEAM_MATCHES, settings.ownTeamMatches)
|
||||||
|
.putBoolean(KEY_ALL_TEAM_MATCHES, settings.allTeamMatches)
|
||||||
|
.putBoolean(KEY_BIRTHDAYS, settings.birthdays)
|
||||||
|
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
|
||||||
|
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
|
||||||
|
.putBoolean(KEY_OWN_TTR_CHANGES, settings.ownTtrChanges)
|
||||||
|
.putBoolean(KEY_LIVE_TICKER_STARTS, settings.liveTickerStarts)
|
||||||
|
.putBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, settings.liveTickerScoreChanges)
|
||||||
|
.putBoolean(KEY_LIVE_TICKER_FINISHED, settings.liveTickerFinished)
|
||||||
|
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
|
||||||
|
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
||||||
|
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
||||||
|
return runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.updateNotificationSettings(settings.toDto())
|
||||||
|
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||||
|
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||||
|
requireLiveNotificationSettingsSaved(settings, saved)
|
||||||
|
saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val KEY_NEW_NEWS = "new_news"
|
||||||
|
const val KEY_NEW_EVENTS = "new_events"
|
||||||
|
const val KEY_EVENTS_TODAY = "events_today"
|
||||||
|
const val KEY_EVENTS_TOMORROW = "events_tomorrow"
|
||||||
|
const val KEY_OWN_TEAM_MATCHES = "own_team_matches"
|
||||||
|
const val KEY_ALL_TEAM_MATCHES = "all_team_matches"
|
||||||
|
const val KEY_BIRTHDAYS = "birthdays"
|
||||||
|
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
|
||||||
|
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
|
||||||
|
const val KEY_OWN_TTR_CHANGES = "own_ttr_changes"
|
||||||
|
const val KEY_LIVE_TICKER_STARTS = "live_ticker_starts"
|
||||||
|
const val KEY_LIVE_TICKER_SCORE_CHANGES = "live_ticker_score_changes"
|
||||||
|
const val KEY_LIVE_TICKER_FINISHED = "live_ticker_finished"
|
||||||
|
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
|
||||||
|
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
|
||||||
|
const val KEY_NOTIFICATION_TIME = "notification_time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun requireLiveNotificationSettingsSaved(requested: NotificationPreferences, saved: NotificationPreferences) {
|
||||||
|
if (requested.liveTickerStarts != saved.liveTickerStarts ||
|
||||||
|
requested.liveTickerScoreChanges != saved.liveTickerScoreChanges ||
|
||||||
|
requested.liveTickerFinished != saved.liveTickerFinished
|
||||||
|
) {
|
||||||
|
error("Der Server hat die Live-Benachrichtigungen nicht übernommen. Bitte das Backend aktualisieren und erneut versuchen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
|
||||||
|
newNews = newNews,
|
||||||
|
newEvents = newEvents,
|
||||||
|
eventsToday = eventsToday,
|
||||||
|
eventsTomorrow = eventsTomorrow,
|
||||||
|
ownTeamMatches = ownTeamMatches,
|
||||||
|
allTeamMatches = allTeamMatches,
|
||||||
|
birthdays = birthdays,
|
||||||
|
newContactRequest = newContactRequest,
|
||||||
|
newUserRegistration = newUserRegistration,
|
||||||
|
ownTtrChanges = ownTtrChanges,
|
||||||
|
liveTickerStarts = liveTickerStarts,
|
||||||
|
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||||
|
liveTickerFinished = liveTickerFinished,
|
||||||
|
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
||||||
|
selectedTeamSeason = selectedTeamSeason,
|
||||||
|
notificationTime = notificationTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun NotificationPreferences.toDto(): NotificationSettingsDto = NotificationSettingsDto(
|
||||||
|
newNews = newNews,
|
||||||
|
newEvents = newEvents,
|
||||||
|
eventsToday = eventsToday,
|
||||||
|
eventsTomorrow = eventsTomorrow,
|
||||||
|
ownTeamMatches = ownTeamMatches,
|
||||||
|
allTeamMatches = allTeamMatches,
|
||||||
|
birthdays = birthdays,
|
||||||
|
newContactRequest = newContactRequest,
|
||||||
|
newUserRegistration = newUserRegistration,
|
||||||
|
ownTtrChanges = ownTtrChanges,
|
||||||
|
liveTickerStarts = liveTickerStarts,
|
||||||
|
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||||
|
liveTickerFinished = liveTickerFinished,
|
||||||
|
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
||||||
|
selectedTeamSeason = selectedTeamSeason,
|
||||||
|
notificationTime = notificationTime,
|
||||||
|
)
|
||||||
110
android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt
Normal file → Executable file
110
android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt
Normal file → Executable file
@@ -28,68 +28,74 @@ class PasskeyRepository @Inject constructor(
|
|||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
) {
|
) {
|
||||||
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
|
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
|
||||||
val optionsResponse = api.passkeyAuthenticationOptions(
|
retryOnNetworkFailure {
|
||||||
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
val optionsResponse = api.passkeyAuthenticationOptions(
|
||||||
)
|
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
||||||
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
)
|
||||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
||||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||||
|
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||||
|
|
||||||
val credentialManager = CredentialManager.create(context)
|
val credentialManager = CredentialManager.create(context)
|
||||||
val credentialResponse = credentialManager.getCredential(
|
val credentialResponse = credentialManager.getCredential(
|
||||||
context = context,
|
context = context,
|
||||||
request = GetCredentialRequest(
|
request = GetCredentialRequest(
|
||||||
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val credential = credentialResponse.credential as? PublicKeyCredential
|
val credential = credentialResponse.credential as? PublicKeyCredential
|
||||||
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
||||||
|
|
||||||
val response = api.passkeyLogin(
|
val response = api.passkeyLogin(
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("credential", JSONObject(credential.authenticationResponseJson))
|
.put("credential", JSONObject(credential.authenticationResponseJson))
|
||||||
.put("client", "android")
|
.put("client", "android")
|
||||||
.put("deviceName", "Harheimer TC Android-App")
|
.put("deviceName", "Harheimer TC Android-App")
|
||||||
.toString()
|
.toString()
|
||||||
.toRequestBody(MediaTypes.json),
|
.toRequestBody(MediaTypes.json),
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||||
body
|
body
|
||||||
|
}
|
||||||
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
|
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
|
||||||
|
|
||||||
suspend fun list(): Result<PasskeysResponse> = runCatching {
|
suspend fun list(): Result<PasskeysResponse> = runCatching {
|
||||||
val response = api.passkeys()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
val response = api.passkeys()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
|
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
|
||||||
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
retryOnNetworkFailure {
|
||||||
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
||||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
||||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||||
|
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||||
|
|
||||||
val credentialManager = CredentialManager.create(context)
|
val credentialManager = CredentialManager.create(context)
|
||||||
val credentialResponse = credentialManager.createCredential(
|
val credentialResponse = credentialManager.createCredential(
|
||||||
context = context,
|
context = context,
|
||||||
request = CreatePublicKeyCredentialRequest(optionsJson),
|
request = CreatePublicKeyCredentialRequest(optionsJson),
|
||||||
) as? CreatePublicKeyCredentialResponse
|
) as? CreatePublicKeyCredentialResponse
|
||||||
?: error("Der erstellte Zugang ist kein Passkey.")
|
?: error("Der erstellte Zugang ist kein Passkey.")
|
||||||
|
|
||||||
val response = api.registerPasskey(
|
val response = api.registerPasskey(
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
||||||
.put("name", name)
|
.put("name", name)
|
||||||
.put("client", "android")
|
.put("client", "android")
|
||||||
.toString()
|
.toString()
|
||||||
.toRequestBody(MediaTypes.json),
|
.toRequestBody(MediaTypes.json),
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
||||||
response.body() ?: error("Leere Antwort")
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
|
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
|
||||||
|
|
||||||
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
|
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
|
||||||
|
|||||||
16
android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt
Normal file → Executable file
16
android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt
Normal file → Executable file
@@ -9,14 +9,18 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class ProfileRepository @Inject constructor(private val api: ApiService) {
|
class ProfileRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun load(): Result<ProfileResponse> = runCatching {
|
suspend fun load(): Result<ProfileResponse> = runCatching {
|
||||||
val response = api.profile()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
val response = api.profile()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
|
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
|
||||||
val response = api.updateProfile(request)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
val response = api.updateProfile(request)
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
66
android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt
Normal file → Executable file
66
android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt
Normal file → Executable file
@@ -31,43 +31,49 @@ data class MeisterschaftResult(
|
|||||||
@Singleton
|
@Singleton
|
||||||
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||||
val response = api.config()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
val response = api.config()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
|
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
|
||||||
val response = api.spielsysteme()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
val response = api.spielsysteme()
|
||||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
||||||
if (values.size < 8) return@mapNotNull null
|
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||||
Spielsystem(
|
if (values.size < 8) return@mapNotNull null
|
||||||
name = values[0],
|
Spielsystem(
|
||||||
description = values[1],
|
name = values[0],
|
||||||
teamSize = values[2],
|
description = values[1],
|
||||||
category = values[3],
|
teamSize = values[2],
|
||||||
sequence = values[5],
|
category = values[3],
|
||||||
gameCount = values[6],
|
sequence = values[5],
|
||||||
features = values[7],
|
gameCount = values[6],
|
||||||
)
|
features = values[7],
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
||||||
val response = api.vereinsmeisterschaften()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
val response = api.vereinsmeisterschaften()
|
||||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||||
if (values.size < 6) return@mapNotNull null
|
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||||
MeisterschaftResult(
|
if (values.size < 6) return@mapNotNull null
|
||||||
year = values[0],
|
MeisterschaftResult(
|
||||||
category = values[1],
|
year = values[0],
|
||||||
rank = values[2],
|
category = values[1],
|
||||||
playerOne = values[3],
|
rank = values[2],
|
||||||
playerTwo = values[4],
|
playerOne = values[3],
|
||||||
note = values[5],
|
playerTwo = values[4],
|
||||||
imageOne = values.getOrElse(6) { "" },
|
note = values[5],
|
||||||
imageTwo = values.getOrElse(7) { "" },
|
imageOne = values.getOrElse(6) { "" },
|
||||||
)
|
imageTwo = values.getOrElse(7) { "" },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.installations.FirebaseInstallations
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.PushTokenRequest
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class PushTokenRepository @Inject constructor(
|
||||||
|
private val api: ApiService,
|
||||||
|
) {
|
||||||
|
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
||||||
|
val token = FirebaseMessaging.getInstance().token.await()
|
||||||
|
val installationId = FirebaseInstallations.getInstance().id.await()
|
||||||
|
registerToken(token, installationId).getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun registerToken(token: String, installationId: String? = null): Result<Unit> = runCatching {
|
||||||
|
if (token.isBlank()) return@runCatching
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.registerPushToken(
|
||||||
|
PushTokenRequest(
|
||||||
|
token = token,
|
||||||
|
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
||||||
|
installationId = installationId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val detail = response.errorBody()?.string().orEmpty().replace(Regex("\\s+"), " ").take(180)
|
||||||
|
error("Push-Token konnte nicht registriert werden (HTTP ${response.code()})${if (detail.isBlank()) "" else ": $detail"}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt
Normal file → Executable file
24
android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt
Normal file → Executable file
@@ -9,18 +9,22 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||||
val response = api.spielplan(season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.spielplan(season)
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
body
|
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||||
|
body
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
|
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
|
||||||
val response = api.spielplanTable(team, season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.spielplanTable(team, season)
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
body
|
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
||||||
|
body
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt
Normal file → Executable file
8
android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt
Normal file → Executable file
@@ -8,8 +8,10 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class TermineRepository @Inject constructor(private val api: ApiService) {
|
class TermineRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
|
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
|
||||||
val response = api.termine()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.termine()
|
||||||
response.body()?.termine.orEmpty()
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body()?.termine.orEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt
Normal file → Executable file
8
android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt
Normal file → Executable file
@@ -8,8 +8,10 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||||
val response = api.config()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
val response = api.config()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/security/DeviceKeyManager.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/security/DeviceKeyManager.kt
Normal file → Executable file
309
android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt
Normal file → Executable file
309
android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt
Normal file → Executable file
@@ -1,12 +1,14 @@
|
|||||||
package de.harheimertc.ui.components
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -25,6 +27,7 @@ import androidx.compose.ui.graphics.Brush
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.harheimertc.BuildConfig
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.R
|
import de.harheimertc.R
|
||||||
@@ -40,14 +43,17 @@ private enum class MenuSection {
|
|||||||
TRAINING,
|
TRAINING,
|
||||||
NEWSLETTER,
|
NEWSLETTER,
|
||||||
INTERN,
|
INTERN,
|
||||||
|
CMS,
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class MenuTarget(val label: String, val route: String)
|
private data class MenuTarget(val label: String, val route: String)
|
||||||
|
private const val LOGOUT_ROUTE = "__logout__"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavigationHeader(
|
fun AppNavigationHeader(
|
||||||
selectedRoute: String?,
|
selectedRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit = {},
|
||||||
webTabletNavigation: Boolean = false,
|
webTabletNavigation: Boolean = false,
|
||||||
navigationState: NavigationUiState = NavigationUiState(),
|
navigationState: NavigationUiState = NavigationUiState(),
|
||||||
) {
|
) {
|
||||||
@@ -59,9 +65,9 @@ fun AppNavigationHeader(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
if (webTabletNavigation) {
|
if (webTabletNavigation) {
|
||||||
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
|
WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||||
} else {
|
} else {
|
||||||
CompactNavigation(selectedRoute, onNavigate, navigationState)
|
CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,18 +76,79 @@ fun AppNavigationHeader(
|
|||||||
private fun CompactNavigation(
|
private fun CompactNavigation(
|
||||||
selectedRoute: String?,
|
selectedRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
navigationState: NavigationUiState = NavigationUiState(),
|
navigationState: NavigationUiState = NavigationUiState(),
|
||||||
) {
|
) {
|
||||||
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
|
val routeSection = menuSection(selectedRoute)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
val sectionOverride = androidx.compose.runtime.remember(selectedRoute) { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
val section = sectionOverride.value ?: routeSection
|
||||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
val subItems = submenu(section, navigationState)
|
||||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
|
||||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
val mainScroll = rememberScrollState()
|
||||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
val subScroll = rememberScrollState()
|
||||||
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
|
|
||||||
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
BrandRow(
|
||||||
|
loggedIn = navigationState.loggedIn,
|
||||||
|
onLogin = { onNavigate(Destinations.Login.route) },
|
||||||
|
onLogout = onLogout,
|
||||||
|
)
|
||||||
|
|
||||||
|
ScrollableMenuRow(scrollState = mainScroll) {
|
||||||
|
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
|
||||||
|
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||||
|
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
|
||||||
|
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||||
|
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
|
||||||
|
if (navigationState.showGallery) {
|
||||||
|
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
|
||||||
}
|
}
|
||||||
|
if (navigationState.loggedIn) {
|
||||||
|
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
|
||||||
|
if (navigationState.canAccessCms) {
|
||||||
|
CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
}
|
||||||
|
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||||
|
subItems.forEach { item ->
|
||||||
|
SubLink(item.label, item.route == selectedRoute) {
|
||||||
|
if (item.route == LOGOUT_ROUTE) {
|
||||||
|
onLogout()
|
||||||
|
} else {
|
||||||
|
onNavigate(item.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompactSectionLink(
|
||||||
|
label: String,
|
||||||
|
section: MenuSection,
|
||||||
|
activeSection: MenuSection?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = if (activeSection == section) Primary600 else Color.Transparent,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = modifier.clickable { onSelect() },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +156,13 @@ private fun CompactNavigation(
|
|||||||
private fun WebTabletNavigation(
|
private fun WebTabletNavigation(
|
||||||
selectedRoute: String?,
|
selectedRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
navigationState: NavigationUiState,
|
navigationState: NavigationUiState,
|
||||||
) {
|
) {
|
||||||
val section = menuSection(selectedRoute)
|
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||||
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
val section = sectionOverride.value ?: menuSection(selectedRoute)
|
||||||
// Helper that closes the CMS submenu when navigating away
|
val subScroll = rememberScrollState()
|
||||||
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Brand()
|
Brand()
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
@@ -103,75 +171,91 @@ private fun WebTabletNavigation(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) })
|
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = {
|
||||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
|
sectionOverride.value = null
|
||||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
|
onNavigate(Destinations.Home.route)
|
||||||
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
|
})
|
||||||
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) })
|
MainLink("Verein", section == MenuSection.VEREIN, onClick = {
|
||||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
|
sectionOverride.value = MenuSection.VEREIN
|
||||||
|
onNavigate(Destinations.VereinAbout.route)
|
||||||
|
})
|
||||||
|
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.MANNSCHAFTEN
|
||||||
|
onNavigate(Destinations.Mannschaften.route)
|
||||||
|
})
|
||||||
|
MainLink("Training", section == MenuSection.TRAINING, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.TRAINING
|
||||||
|
onNavigate(Destinations.Training.route)
|
||||||
|
})
|
||||||
|
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Termine.route)
|
||||||
|
})
|
||||||
if (navigationState.showGallery) {
|
if (navigationState.showGallery) {
|
||||||
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
|
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.VEREIN
|
||||||
|
onNavigate(Destinations.Gallery.route)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) })
|
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.NEWSLETTER
|
||||||
|
onNavigate(Destinations.NewsletterSubscribe.route)
|
||||||
|
})
|
||||||
if (navigationState.loggedIn) {
|
if (navigationState.loggedIn) {
|
||||||
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
|
MainLink("Intern", section == MenuSection.INTERN, onClick = {
|
||||||
}
|
sectionOverride.value = MenuSection.INTERN
|
||||||
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) })
|
})
|
||||||
TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) }
|
if (navigationState.canAccessCms) {
|
||||||
}
|
MainLink("CMS", section == MenuSection.CMS, onClick = {
|
||||||
}
|
sectionOverride.value = MenuSection.CMS
|
||||||
val subItems = submenu(section, navigationState)
|
onNavigate(Destinations.Cms.route)
|
||||||
// determine CMS parent index and children
|
})
|
||||||
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
|
|
||||||
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
|
|
||||||
if (cmsChildren.any { it.route == selectedRoute }) {
|
|
||||||
cmsExpanded.value = true
|
|
||||||
}
|
|
||||||
// First row: render all subitems but do NOT render CMS children here
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.horizontalScroll(rememberScrollState())
|
|
||||||
.padding(top = 3.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
subItems.forEachIndexed { idx, item ->
|
|
||||||
if (idx == cmsIndex) {
|
|
||||||
// CMS parent toggle
|
|
||||||
SubLink(item.label, item.route == selectedRoute) {
|
|
||||||
cmsExpanded.value = !cmsExpanded.value
|
|
||||||
}
|
}
|
||||||
} else if (idx > cmsIndex && cmsIndex >= 0) {
|
|
||||||
// skip cms children here; they'll be rendered in the second row when expanded
|
|
||||||
} else {
|
|
||||||
// normal item before CMS: close cms submenu on navigate
|
|
||||||
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
|
|
||||||
}
|
}
|
||||||
|
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Contact.route)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
if (navigationState.loggedIn) {
|
||||||
|
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Login.route)
|
||||||
|
}) { Text("Login", color = Color.White) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second row: when CMS expanded, render its children beneath
|
val subItems = submenu(section, navigationState)
|
||||||
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
|
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||||
Row(
|
subItems.forEach { item ->
|
||||||
modifier = Modifier
|
SubLink(item.label, item.route == selectedRoute) {
|
||||||
.fillMaxWidth()
|
if (item.route == LOGOUT_ROUTE) {
|
||||||
.horizontalScroll(rememberScrollState())
|
onLogout()
|
||||||
.padding(top = 6.dp, bottom = 3.dp),
|
} else {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
onNavigate(item.route)
|
||||||
) {
|
}
|
||||||
cmsChildren.forEach { child ->
|
|
||||||
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BrandRow(onLogin: () -> Unit) {
|
private fun BrandRow(
|
||||||
|
loggedIn: Boolean,
|
||||||
|
onLogin: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Brand()
|
Brand()
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
if (loggedIn) {
|
||||||
|
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +298,8 @@ private fun MainLink(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f),
|
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.94f),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
@@ -229,23 +313,62 @@ private fun CompactLink(
|
|||||||
selectedRoute: String?,
|
selectedRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
beforeNavigate: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = if (route == selectedRoute) Primary600 else Color.Transparent,
|
color = if (route == selectedRoute) Primary600 else Color.Transparent,
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
modifier = modifier.clickable { onNavigate(route) },
|
modifier = modifier.clickable {
|
||||||
|
beforeNavigate()
|
||||||
|
onNavigate(route)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
|
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScrollableMenuRow(
|
||||||
|
scrollState: ScrollState,
|
||||||
|
topPadding: Dp = 0.dp,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = topPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (scrollState.canScrollBackward) "◀" else "",
|
||||||
|
color = Color(0xFFD4D4D8),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.width(14.dp),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.horizontalScroll(scrollState),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (scrollState.canScrollForward) "▶" else "",
|
||||||
|
color = Color(0xFFD4D4D8),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.width(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -256,7 +379,7 @@ private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
|||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
color = if (selected) Color.White else Color(0xFFD4D4D8),
|
color = if (selected) Color.White else Color(0xFFD4D4D8),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
@@ -287,14 +410,19 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
|||||||
Destinations.NewsletterConfirm.route,
|
Destinations.NewsletterConfirm.route,
|
||||||
Destinations.NewsletterConfirmed.route,
|
Destinations.NewsletterConfirmed.route,
|
||||||
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
||||||
|
|
||||||
Destinations.MemberArea.route,
|
Destinations.MemberArea.route,
|
||||||
Destinations.Members.route,
|
Destinations.Members.route,
|
||||||
|
Destinations.Qttr.route,
|
||||||
Destinations.MemberNews.route,
|
Destinations.MemberNews.route,
|
||||||
Destinations.Profile.route,
|
Destinations.Profile.route,
|
||||||
Destinations.MemberApi.route,
|
Destinations.NotificationSettings.route,
|
||||||
|
Destinations.MemberApi.route -> MenuSection.INTERN
|
||||||
|
|
||||||
Destinations.CmsStartseite.route,
|
Destinations.CmsStartseite.route,
|
||||||
Destinations.CmsInhalte.route,
|
Destinations.CmsInhalte.route,
|
||||||
Destinations.CmsVereinsmeisterschaften.route,
|
Destinations.CmsVereinsmeisterschaften.route,
|
||||||
|
Destinations.CmsNews.route,
|
||||||
Destinations.CmsSportbetrieb.route,
|
Destinations.CmsSportbetrieb.route,
|
||||||
Destinations.CmsMitgliederverwaltung.route,
|
Destinations.CmsMitgliederverwaltung.route,
|
||||||
Destinations.CmsNewsletter.route,
|
Destinations.CmsNewsletter.route,
|
||||||
@@ -302,7 +430,8 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
|||||||
Destinations.CmsEinstellungen.route,
|
Destinations.CmsEinstellungen.route,
|
||||||
Destinations.CmsBenutzer.route,
|
Destinations.CmsBenutzer.route,
|
||||||
Destinations.CmsPasswordResetDiagnostics.route,
|
Destinations.CmsPasswordResetDiagnostics.route,
|
||||||
Destinations.Cms.route -> MenuSection.INTERN
|
Destinations.Cms.route -> MenuSection.CMS
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}.let { section ->
|
}.let { section ->
|
||||||
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
|
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
|
||||||
@@ -319,42 +448,52 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
|||||||
MenuTarget("Links", Destinations.Links.route),
|
MenuTarget("Links", Destinations.Links.route),
|
||||||
MenuTarget("Impressum", Destinations.Impressum.route),
|
MenuTarget("Impressum", Destinations.Impressum.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.MANNSCHAFTEN -> listOf(
|
MenuSection.MANNSCHAFTEN -> listOf(
|
||||||
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
||||||
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
|
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
|
||||||
MenuTarget("Spielpläne", Destinations.Spielplan.route),
|
MenuTarget("Spielpläne", Destinations.Spielplan.route),
|
||||||
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
|
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.TRAINING -> listOf(
|
MenuSection.TRAINING -> listOf(
|
||||||
MenuTarget("Trainingszeiten", Destinations.Training.route),
|
MenuTarget("Trainingszeiten", Destinations.Training.route),
|
||||||
MenuTarget("Trainer", Destinations.Trainer.route),
|
MenuTarget("Trainer", Destinations.Trainer.route),
|
||||||
MenuTarget("Anfänger", Destinations.Anfaenger.route),
|
MenuTarget("Anfänger", Destinations.Anfaenger.route),
|
||||||
MenuTarget("TT-Regeln", Destinations.Regeln.route),
|
MenuTarget("TT-Regeln", Destinations.Regeln.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.NEWSLETTER -> listOf(
|
MenuSection.NEWSLETTER -> listOf(
|
||||||
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
||||||
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
||||||
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
|
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.INTERN -> buildList {
|
MenuSection.INTERN -> buildList {
|
||||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||||
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
||||||
|
add(MenuTarget("TTR / QTTR", Destinations.Qttr.route))
|
||||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||||
|
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
|
||||||
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
||||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
|
}
|
||||||
// CMS child items (will be rendered when CMS parent is expanded)
|
|
||||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("Übersicht", Destinations.Cms.route))
|
MenuSection.CMS -> buildList {
|
||||||
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
if (state.canAccessFullCms) {
|
||||||
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
add(MenuTarget("Übersicht", Destinations.Cms.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||||
|
add(MenuTarget("News", Destinations.CmsNews.route))
|
||||||
|
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||||
|
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||||
|
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||||
|
add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route))
|
||||||
|
}
|
||||||
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
||||||
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
|
||||||
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
|
|
||||||
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
null -> emptyList()
|
null -> emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/HeroComponent.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/HeroComponent.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt
Normal file → Executable file
@@ -0,0 +1,137 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.harheimertc.data.LiveTickerMatchDto
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
|
||||||
|
var observedLoadedState by remember { mutableStateOf(false) }
|
||||||
|
var previousStates by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||||
|
|
||||||
|
LaunchedEffect(matches, loaded) {
|
||||||
|
if (!loaded) {
|
||||||
|
observedLoadedState = false
|
||||||
|
previousStates = emptyMap()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
val currentStates = matches.associate { match ->
|
||||||
|
(match.meetingId.ifBlank { "${match.homeTeam}-${match.awayTeam}-${match.startsAt}" }) to
|
||||||
|
"${match.hasLiveData}|${match.score}|${match.completed}"
|
||||||
|
}
|
||||||
|
if (observedLoadedState && currentStates.any { (id, state) -> previousStates[id] != state }) {
|
||||||
|
LiveTickerSound.play()
|
||||||
|
}
|
||||||
|
previousStates = currentStates
|
||||||
|
observedLoadedState = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.isEmpty()) return
|
||||||
|
|
||||||
|
var paused by remember { mutableStateOf(false) }
|
||||||
|
Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { paused = !paused },
|
||||||
|
modifier = Modifier.semantics {
|
||||||
|
contentDescription = if (paused) "Laufschrift fortsetzen" else "Laufschrift anhalten"
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(if (paused) "▶" else "Ⅱ", color = Color(0xFF334155))
|
||||||
|
}
|
||||||
|
BoxWithConstraints(modifier = Modifier.weight(1f).clipToBounds()) {
|
||||||
|
val viewportWidth = maxWidth
|
||||||
|
var groupWidth by remember { mutableStateOf(0) }
|
||||||
|
val offset = remember { Animatable(0f) }
|
||||||
|
val pixelsPerSecond = with(LocalDensity.current) { 36.dp.toPx() }
|
||||||
|
LaunchedEffect(groupWidth, pixelsPerSecond, paused) {
|
||||||
|
if (groupWidth <= 0 || paused) return@LaunchedEffect
|
||||||
|
offset.snapTo(offset.value % groupWidth)
|
||||||
|
while (isActive) {
|
||||||
|
val duration = ((groupWidth - offset.value) / pixelsPerSecond * 1000).toInt().coerceAtLeast(1)
|
||||||
|
offset.animateTo(groupWidth.toFloat(), tween(duration, easing = LinearEasing))
|
||||||
|
offset.snapTo(0f)
|
||||||
|
// Also yield when system animations are disabled.
|
||||||
|
withFrameNanos { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentWidth(Alignment.Start, unbounded = true)
|
||||||
|
.graphicsLayer { translationX = -offset.value },
|
||||||
|
) {
|
||||||
|
repeat(2) { copy ->
|
||||||
|
LiveTickerGroup(
|
||||||
|
matches = matches,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = viewportWidth)
|
||||||
|
.then(if (copy == 0) Modifier.onSizeChanged { groupWidth = it.width } else Modifier.clearAndSetSemantics {}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LiveTickerGroup(matches: List<LiveTickerMatchDto>, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.padding(start = 16.dp, end = 32.dp, top = 9.dp, bottom = 9.dp),
|
||||||
|
) {
|
||||||
|
matches.forEachIndexed { index, match ->
|
||||||
|
if (index > 0) {
|
||||||
|
Text(" • ", color = Color(0xFF64748B))
|
||||||
|
}
|
||||||
|
val status = when {
|
||||||
|
match.completed -> "✓ beendet"
|
||||||
|
match.hasLiveData -> "● LIVE"
|
||||||
|
else -> "◌ Keine Live-Daten"
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "$status ${match.homeTeam} – ${match.awayTeam} ${match.score}",
|
||||||
|
color = when {
|
||||||
|
match.completed -> Color(0xFF64748B)
|
||||||
|
match.hasLiveData -> Color(0xFFB91C1C)
|
||||||
|
else -> Color(0xFF334155)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = if (match.completed) FontWeight.Normal else FontWeight.SemiBold,
|
||||||
|
fontStyle = if (match.completed) FontStyle.Italic else FontStyle.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.ToneGenerator
|
||||||
|
|
||||||
|
/** A generated system tone; no audio asset or third-party licence is required. */
|
||||||
|
object LiveTickerSound {
|
||||||
|
private val toneGenerator by lazy { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 65) }
|
||||||
|
|
||||||
|
fun play() {
|
||||||
|
runCatching {
|
||||||
|
toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP2, 170)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
android-app/app/src/main/java/de/harheimertc/ui/components/LoadingState.kt
Executable file
24
android-app/app/src/main/java/de/harheimertc/ui/components/LoadingState.kt
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.harheimertc.ui.theme.Accent500
|
||||||
|
import de.harheimertc.ui.theme.Primary600
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LoadingState(message: String = "Daten werden geladen...") {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = Primary600)
|
||||||
|
Text(message, color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
0
android-app/app/src/main/java/de/harheimertc/ui/components/NativeRichTextEditor.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/NativeRichTextEditor.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/PendingPage.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/PendingPage.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichTextUtils.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichTextUtils.kt
Normal file → Executable file
11
android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt
Normal file → Executable file
11
android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt
Normal file → Executable file
@@ -10,8 +10,12 @@ sealed class Destinations(val route: String) {
|
|||||||
object Links : Destinations("verein/links")
|
object Links : Destinations("verein/links")
|
||||||
object Impressum : Destinations("impressum")
|
object Impressum : Destinations("impressum")
|
||||||
object Mannschaften : Destinations("mannschaften")
|
object Mannschaften : Destinations("mannschaften")
|
||||||
object MannschaftDetail : Destinations("mannschaften/{slug}") {
|
object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
|
||||||
fun create(slug: String): String = "mannschaften/$slug"
|
fun create(slug: String, season: String? = null): String {
|
||||||
|
val encodedSlug = android.net.Uri.encode(slug)
|
||||||
|
val selectedSeason = season?.takeIf { it.isNotBlank() } ?: return "mannschaften/$encodedSlug"
|
||||||
|
return "mannschaften/$encodedSlug?season=${android.net.Uri.encode(selectedSeason)}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
|
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
|
||||||
fun create(slug: String): String = "mannschaft/$slug"
|
fun create(slug: String): String = "mannschaft/$slug"
|
||||||
@@ -36,12 +40,15 @@ sealed class Destinations(val route: String) {
|
|||||||
object Register : Destinations("register")
|
object Register : Destinations("register")
|
||||||
object MemberArea : Destinations("intern")
|
object MemberArea : Destinations("intern")
|
||||||
object Members : Destinations("intern/mitglieder")
|
object Members : Destinations("intern/mitglieder")
|
||||||
|
object Qttr : Destinations("intern/qttr")
|
||||||
object MemberNews : Destinations("intern/news")
|
object MemberNews : Destinations("intern/news")
|
||||||
object Profile : Destinations("intern/profil")
|
object Profile : Destinations("intern/profil")
|
||||||
|
object NotificationSettings : Destinations("intern/benachrichtigungen")
|
||||||
object MemberApi : Destinations("intern/api")
|
object MemberApi : Destinations("intern/api")
|
||||||
object CmsStartseite : Destinations("cms/startseite")
|
object CmsStartseite : Destinations("cms/startseite")
|
||||||
object CmsInhalte : Destinations("cms/inhalte")
|
object CmsInhalte : Destinations("cms/inhalte")
|
||||||
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
||||||
|
object CmsNews : Destinations("cms/news")
|
||||||
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
||||||
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
||||||
object CmsNewsletter : Destinations("cms/newsletter")
|
object CmsNewsletter : Destinations("cms/newsletter")
|
||||||
|
|||||||
193
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt
Normal file → Executable file
193
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt
Normal file → Executable file
@@ -2,19 +2,31 @@ package de.harheimertc.ui.navigation
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import de.harheimertc.ui.components.AppNavigationHeader
|
import de.harheimertc.ui.components.AppNavigationHeader
|
||||||
|
import de.harheimertc.ui.components.LiveTickerBanner
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavGraph(
|
fun NavGraph(
|
||||||
@@ -26,23 +38,53 @@ fun NavGraph(
|
|||||||
val backStackEntry = navController.currentBackStackEntryAsState().value
|
val backStackEntry = navController.currentBackStackEntryAsState().value
|
||||||
val route = backStackEntry?.destination?.route
|
val route = backStackEntry?.destination?.route
|
||||||
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
||||||
backStackEntry.arguments?.getString("slug")?.let(Destinations.MannschaftDetail::create)
|
backStackEntry.arguments?.getString("slug")?.let { slug ->
|
||||||
|
Destinations.MannschaftDetail.create(slug, backStackEntry.arguments?.getString("season"))
|
||||||
|
}
|
||||||
} else route
|
} else route
|
||||||
val navigationState by navigationViewModel.state.collectAsState()
|
val navigationState by navigationViewModel.state.collectAsState()
|
||||||
LaunchedEffect(currentRoute) {
|
LaunchedEffect(currentRoute) {
|
||||||
navigationViewModel.refreshSession()
|
navigationViewModel.refreshSession()
|
||||||
}
|
}
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
LaunchedEffect(navigationState.loggedIn) {
|
||||||
val persistentNavigation = maxWidth >= 600.dp
|
navigationViewModel.setLiveTickerActive(navigationState.loggedIn)
|
||||||
|
}
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
|
||||||
|
val tabletNavigation = maxWidth >= 600.dp
|
||||||
|
// Signed-in phones share the same menu and ticker above every screen.
|
||||||
|
val persistentNavigation = tabletNavigation || navigationState.loggedIn
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
navigationState.connectionNote?.let { message ->
|
||||||
|
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = Color(0xFF7C2D12),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (persistentNavigation) {
|
if (persistentNavigation) {
|
||||||
AppNavigationHeader(
|
AppNavigationHeader(
|
||||||
selectedRoute = currentRoute,
|
selectedRoute = currentRoute,
|
||||||
onNavigate = navController::navigateTopLevel,
|
onNavigate = navController::navigateTopLevel,
|
||||||
webTabletNavigation = true,
|
onLogout = {
|
||||||
|
navigationViewModel.logout {
|
||||||
|
navController.navigate(Destinations.Home.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
webTabletNavigation = tabletNavigation,
|
||||||
navigationState = navigationState,
|
navigationState = navigationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (navigationState.loggedIn) {
|
||||||
|
LiveTickerBanner(
|
||||||
|
matches = navigationState.liveTickerMatches,
|
||||||
|
loaded = navigationState.liveTickerLoaded,
|
||||||
|
)
|
||||||
|
}
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination,
|
startDestination = startDestination,
|
||||||
@@ -52,6 +94,8 @@ fun NavGraph(
|
|||||||
de.harheimertc.ui.screens.home.HomeScreen(
|
de.harheimertc.ui.screens.home.HomeScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showNavigationHeader = !persistentNavigation,
|
showNavigationHeader = !persistentNavigation,
|
||||||
|
navigationViewModel = navigationViewModel,
|
||||||
|
viewModel = hiltViewModel(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Destinations.VereinAbout.route) {
|
composable(Destinations.VereinAbout.route) {
|
||||||
@@ -111,9 +155,13 @@ fun NavGraph(
|
|||||||
composable("mannschaften/jugend") {
|
composable("mannschaften/jugend") {
|
||||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||||
}
|
}
|
||||||
composable(Destinations.MannschaftDetail.route) { entry ->
|
composable(
|
||||||
|
route = Destinations.MannschaftDetail.route,
|
||||||
|
arguments = listOf(navArgument("season") { nullable = true; defaultValue = null }),
|
||||||
|
) { entry ->
|
||||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||||
|
season = entry.arguments?.getString("season"),
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
@@ -121,6 +169,7 @@ fun NavGraph(
|
|||||||
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
||||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||||
|
season = null,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
@@ -253,6 +302,7 @@ fun NavGraph(
|
|||||||
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
|
navigationState = navigationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Destinations.Members.route) {
|
composable(Destinations.Members.route) {
|
||||||
@@ -261,6 +311,12 @@ fun NavGraph(
|
|||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Destinations.Qttr.route) {
|
||||||
|
de.harheimertc.ui.screens.memberarea.QttrScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Destinations.MemberNews.route) {
|
composable(Destinations.MemberNews.route) {
|
||||||
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
|
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -273,6 +329,13 @@ fun NavGraph(
|
|||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Destinations.NotificationSettings.route) {
|
||||||
|
de.harheimertc.ui.screens.notifications.NotificationSettingsScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
navigationState = navigationState,
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Destinations.MemberApi.route) {
|
composable(Destinations.MemberApi.route) {
|
||||||
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
|
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -280,43 +343,141 @@ fun NavGraph(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsStartseite.route) {
|
composable(Destinations.CmsStartseite.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsInhalte.route) {
|
composable(Destinations.CmsInhalte.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(Destinations.CmsNews.route) {
|
||||||
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsSportbetrieb.route) {
|
composable(Destinations.CmsSportbetrieb.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsMitgliederverwaltung.route) {
|
composable(Destinations.CmsMitgliederverwaltung.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsNewsletter.route) {
|
composable(Destinations.CmsNewsletter.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessNewsletter,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsContactRequests.route) {
|
composable(Destinations.CmsContactRequests.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessContactRequests,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsEinstellungen.route) {
|
composable(Destinations.CmsEinstellungen.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsBenutzer.route) {
|
composable(Destinations.CmsBenutzer.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsPasswordResetDiagnostics.route) {
|
composable(Destinations.CmsPasswordResetDiagnostics.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.Cms.route) {
|
composable(Destinations.Cms.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RequireCmsAccess(
|
||||||
|
allowed: Boolean,
|
||||||
|
navController: NavHostController,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
if (allowed) {
|
||||||
|
content()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Kein Zugriff auf diesen Bereich.",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = Color(0xFF7F1D1D),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Bitte mit einem berechtigten Konto anmelden.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(0xFF7C2D12),
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
|
||||||
|
)
|
||||||
|
Button(onClick = { navController.navigateTopLevel(Destinations.Login.route) }) {
|
||||||
|
Text("Zum Login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun NavHostController.navigateTopLevel(route: String) {
|
private fun NavHostController.navigateTopLevel(route: String) {
|
||||||
val isTeamDetail = route.startsWith("mannschaften/") &&
|
val isTeamDetail = route.startsWith("mannschaften/") &&
|
||||||
route != Destinations.Spielsysteme.route
|
route != Destinations.Spielsysteme.route
|
||||||
|
|||||||
89
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt
Normal file → Executable file
89
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt
Normal file → Executable file
@@ -3,13 +3,21 @@ package de.harheimertc.ui.navigation
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
|
import de.harheimertc.data.LiveTickerMatchDto
|
||||||
|
import de.harheimertc.repositories.AuthRepository
|
||||||
import de.harheimertc.repositories.GalleryRepository
|
import de.harheimertc.repositories.GalleryRepository
|
||||||
import de.harheimertc.repositories.LoginRepository
|
import de.harheimertc.repositories.LoginRepository
|
||||||
|
import de.harheimertc.repositories.LiveTickerRepository
|
||||||
import de.harheimertc.repositories.Mannschaft
|
import de.harheimertc.repositories.Mannschaft
|
||||||
import de.harheimertc.repositories.MannschaftenRepository
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -18,10 +26,15 @@ data class NavigationUiState(
|
|||||||
val hasGalleryImages: Boolean = false,
|
val hasGalleryImages: Boolean = false,
|
||||||
val loggedIn: Boolean = false,
|
val loggedIn: Boolean = false,
|
||||||
val roles: Set<String> = emptySet(),
|
val roles: Set<String> = emptySet(),
|
||||||
|
val connectionNote: String? = null,
|
||||||
|
val liveTickerMatches: List<LiveTickerMatchDto> = emptyList(),
|
||||||
|
val liveTickerLoaded: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val isAdmin: Boolean get() = "admin" in roles
|
val isAdmin: Boolean get() = "admin" in roles
|
||||||
|
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
||||||
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
|
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
|
||||||
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
|
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
|
||||||
|
val canAccessCms: Boolean get() = canAccessFullCms || canAccessNewsletter || canAccessContactRequests
|
||||||
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,12 +43,27 @@ class NavigationViewModel @Inject constructor(
|
|||||||
private val mannschaftenRepository: MannschaftenRepository,
|
private val mannschaftenRepository: MannschaftenRepository,
|
||||||
private val galleryRepository: GalleryRepository,
|
private val galleryRepository: GalleryRepository,
|
||||||
private val loginRepository: LoginRepository,
|
private val loginRepository: LoginRepository,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
|
private val pushTokenRepository: PushTokenRepository,
|
||||||
|
private val liveTickerRepository: LiveTickerRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(NavigationUiState())
|
private val _state = MutableStateFlow(NavigationUiState())
|
||||||
val state: StateFlow<NavigationUiState> = _state
|
val state: StateFlow<NavigationUiState> = _state
|
||||||
|
private var tickerJob: Job? = null
|
||||||
|
private var tickerActive = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadNavigationData()
|
loadNavigationData()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
connectionNote = if (online) null else "Keine Verbindung. Die App versucht alle 10 Sekunden erneut zu laden.",
|
||||||
|
)
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadNavigationData() {
|
fun loadNavigationData() {
|
||||||
@@ -44,22 +72,75 @@ class NavigationViewModel @Inject constructor(
|
|||||||
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
|
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
|
||||||
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
||||||
val status = auth.await()
|
val status = auth.await()
|
||||||
|
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||||
|
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||||
_state.value = NavigationUiState(
|
_state.value = NavigationUiState(
|
||||||
teams = teams.await(),
|
teams = teams.await(),
|
||||||
hasGalleryImages = gallery.await(),
|
hasGalleryImages = gallery.await(),
|
||||||
loggedIn = status.isLoggedIn,
|
loggedIn = loggedIn,
|
||||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
roles = status.navigationRoles(),
|
||||||
|
connectionNote = null,
|
||||||
|
liveTickerMatches = _state.value.liveTickerMatches,
|
||||||
|
liveTickerLoaded = _state.value.liveTickerLoaded,
|
||||||
)
|
)
|
||||||
|
if (loggedIn) registerPushToken()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshSession() {
|
fun refreshSession() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
||||||
|
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||||
|
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
loggedIn = status.isLoggedIn,
|
loggedIn = loggedIn,
|
||||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
roles = status.navigationRoles(),
|
||||||
|
connectionNote = _state.value.connectionNote,
|
||||||
)
|
)
|
||||||
|
if (loggedIn) registerPushToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLiveTickerActive(active: Boolean) {
|
||||||
|
if (tickerActive == active) return
|
||||||
|
tickerActive = active
|
||||||
|
tickerJob?.cancel()
|
||||||
|
if (!active) {
|
||||||
|
_state.value = _state.value.copy(liveTickerMatches = emptyList(), liveTickerLoaded = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tickerJob = viewModelScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
liveTickerRepository.fetch().onSuccess { matches ->
|
||||||
|
_state.value = _state.value.copy(liveTickerMatches = matches, liveTickerLoaded = true)
|
||||||
|
}
|
||||||
|
delay(30_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerPushToken() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
pushTokenRepository.registerCurrentDevice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout(onComplete: () -> Unit = {}) {
|
||||||
|
setLiveTickerActive(false)
|
||||||
|
viewModelScope.launch {
|
||||||
|
loginRepository.logout()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loggedIn = false,
|
||||||
|
roles = emptySet(),
|
||||||
|
connectionNote = _state.value.connectionNote,
|
||||||
|
)
|
||||||
|
onComplete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun de.harheimertc.data.AuthStatusResponse.navigationRoles(): Set<String> = buildSet {
|
||||||
|
addAll(roles)
|
||||||
|
role?.takeIf { it.isNotBlank() }?.let(::add)
|
||||||
|
addAll(user?.roles.orEmpty())
|
||||||
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsBenutzerScreens.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsBenutzerScreens.kt
Normal file → Executable file
46
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt
Normal file → Executable file
46
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt
Normal file → Executable file
@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -36,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
import de.harheimertc.data.NewsSaveRequest
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
import de.harheimertc.ui.components.FormMessages
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -49,7 +49,7 @@ import java.util.Locale
|
|||||||
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var selection by remember { mutableStateOf(setOf<Int>()) }
|
var selection by remember { mutableStateOf(setOf<String>()) }
|
||||||
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
|
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
|
||||||
val loginState by loginVm.state.collectAsState()
|
val loginState by loginVm.state.collectAsState()
|
||||||
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
|
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
|
||||||
@@ -62,7 +62,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
|||||||
|
|
||||||
// Local dialog state for create/edit + delete confirmation (hoisted)
|
// Local dialog state for create/edit + delete confirmation (hoisted)
|
||||||
var dialogOpen by remember { mutableStateOf(false) }
|
var dialogOpen by remember { mutableStateOf(false) }
|
||||||
var deletingIds by remember { mutableStateOf<List<Int>?>(null) }
|
var deletingIds by remember { mutableStateOf<List<String>?>(null) }
|
||||||
var editing by remember { mutableStateOf<NewsDto?>(null) }
|
var editing by remember { mutableStateOf<NewsDto?>(null) }
|
||||||
var title by remember { mutableStateOf("") }
|
var title by remember { mutableStateOf("") }
|
||||||
var content by remember { mutableStateOf("") }
|
var content by remember { mutableStateOf("") }
|
||||||
@@ -112,7 +112,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
|||||||
}
|
}
|
||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
|
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
|
||||||
if (state.loading) item { CircularProgressIndicator() }
|
if (state.loading) item { LoadingState("News werden geladen...") }
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
|
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
|
||||||
@@ -132,15 +132,22 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
|||||||
|
|
||||||
items(state.news) { news ->
|
items(state.news) { news ->
|
||||||
val selected = news.id?.let { selection.contains(it) } ?: false
|
val selected = news.id?.let { selection.contains(it) } ?: false
|
||||||
NewsListItem(news = news, selected = selected, onSelect = { id, sel ->
|
NewsListItem(
|
||||||
id?.let {
|
news = news,
|
||||||
selection = if (sel) selection + it else selection - it
|
canWrite = canWrite,
|
||||||
}
|
selected = selected,
|
||||||
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } })
|
onSelect = { id, sel ->
|
||||||
|
id?.let {
|
||||||
|
selection = if (sel) selection + it else selection - it
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEdit = { openEdit(news) },
|
||||||
|
onDelete = { news.id?.let { id -> deletingIds = listOf(id) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bulk action bar
|
// bulk action bar
|
||||||
if (selection.isNotEmpty()) {
|
if (canWrite && selection.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
||||||
@@ -255,16 +262,21 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
|||||||
@Composable
|
@Composable
|
||||||
private fun NewsListItem(
|
private fun NewsListItem(
|
||||||
news: NewsDto,
|
news: NewsDto,
|
||||||
|
canWrite: Boolean,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
onSelect: (Int?, Boolean) -> Unit = { _, _ -> },
|
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
|
||||||
onEdit: (NewsDto) -> Unit,
|
onEdit: (NewsDto) -> Unit,
|
||||||
onDelete: (Int) -> Unit,
|
onDelete: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) })
|
Checkbox(
|
||||||
|
checked = selected,
|
||||||
|
onCheckedChange = { onSelect(news.id, it) },
|
||||||
|
enabled = canWrite,
|
||||||
|
)
|
||||||
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
||||||
if (news.isPublic) {
|
if (news.isPublic) {
|
||||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||||
@@ -297,9 +309,11 @@ private fun NewsListItem(
|
|||||||
Text("Aktualisiert: ${news.updated}")
|
Text("Aktualisiert: ${news.updated}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row {
|
if (canWrite) {
|
||||||
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
Row {
|
||||||
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
||||||
|
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
672
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt
Normal file → Executable file
672
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt
Normal file → Executable file
@@ -1,8 +1,10 @@
|
|||||||
package de.harheimertc.ui.screens.cms
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.app.DatePickerDialog
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -16,7 +18,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -37,6 +38,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
@@ -49,9 +52,12 @@ import de.harheimertc.data.NewsletterGroupDto
|
|||||||
import de.harheimertc.data.PasswordResetMatchingUserDto
|
import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||||
import de.harheimertc.data.PasswordResetAttemptDto
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
import de.harheimertc.data.PasswordResetStepDto
|
import de.harheimertc.data.PasswordResetStepDto
|
||||||
|
import de.harheimertc.data.TerminDto
|
||||||
import de.harheimertc.ui.components.FormMessages
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.repositories.CmsMannschaftRow
|
||||||
import de.harheimertc.repositories.MeisterschaftResult
|
import de.harheimertc.repositories.MeisterschaftResult
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
@@ -61,13 +67,14 @@ import de.harheimertc.ui.theme.Primary100
|
|||||||
import de.harheimertc.ui.theme.Primary600
|
import de.harheimertc.ui.theme.Primary600
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
|
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
|
||||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
if (state.loading) item { LoadingState("CMS-Daten werden geladen...") }
|
||||||
item { CmsSummaryGrid(navController, state) }
|
item { CmsSummaryGrid(navController, state) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +92,7 @@ fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolea
|
|||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
|
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
|
||||||
when {
|
when {
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading || config == null -> item { LoadingState("Startseitenkonfiguration wird geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Button(
|
Button(
|
||||||
@@ -171,7 +178,7 @@ fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean,
|
|||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
|
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
|
||||||
when {
|
when {
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading || config == null -> item { LoadingState("Inhalte werden geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||||
@@ -262,7 +269,7 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
|||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
|
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
|
||||||
when {
|
when {
|
||||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading -> item { LoadingState("Vereinsmeisterschaften werden geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -433,116 +440,426 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
|||||||
@Composable
|
@Composable
|
||||||
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val config = state.config
|
val context = LocalContext.current
|
||||||
var ortName by remember { mutableStateOf("") }
|
var activeTab by remember { mutableStateOf("termine") }
|
||||||
var ortStrasse by remember { mutableStateOf("") }
|
val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() }
|
||||||
var ortPlz by remember { mutableStateOf("") }
|
var spielplanCsv by remember { mutableStateOf("") }
|
||||||
var ortOrt by remember { mutableStateOf("") }
|
var spielplanEditorOpen by remember { mutableStateOf(false) }
|
||||||
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
|
var terminDialogOpen by remember { mutableStateOf(false) }
|
||||||
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
|
var editingTermin by remember { mutableStateOf<TerminDto?>(null) }
|
||||||
|
var terminDatum by remember { mutableStateOf("") }
|
||||||
|
var terminUhrzeit by remember { mutableStateOf("") }
|
||||||
|
var terminTitel by remember { mutableStateOf("") }
|
||||||
|
var terminBeschreibung by remember { mutableStateOf("") }
|
||||||
|
var terminKategorie by remember { mutableStateOf("Sonstiges") }
|
||||||
|
var terminKategorieOpen by remember { mutableStateOf(false) }
|
||||||
|
val tabs = listOf(
|
||||||
|
"termine" to "Termine",
|
||||||
|
"mannschaften" to "Mannschaften",
|
||||||
|
"spielplaene" to "Spielpläne",
|
||||||
|
)
|
||||||
|
val spielplanTeamOptions = remember(state.sportSpielplanHeaders, state.sportSpielplanRows) {
|
||||||
|
importedSpielplanTeams(state.sportSpielplanHeaders, state.sportSpielplanRows)
|
||||||
|
}
|
||||||
|
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
|
||||||
|
|
||||||
LaunchedEffect(config) {
|
LaunchedEffect(Unit) {
|
||||||
config?.let {
|
viewModel.loadSportbetrieb()
|
||||||
ortName = it.training.ort.name
|
|
||||||
ortStrasse = it.training.ort.strasse
|
|
||||||
ortPlz = it.training.ort.plz
|
|
||||||
ortOrt = it.training.ort.ort
|
|
||||||
trainingTimes.clear()
|
|
||||||
trainingTimes.addAll(it.training.zeiten)
|
|
||||||
trainers.clear()
|
|
||||||
trainers.addAll(it.trainer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
LaunchedEffect(state.sportMannschaften) {
|
||||||
when {
|
mannschaften.clear()
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
mannschaften.addAll(state.sportMannschaften)
|
||||||
else -> {
|
}
|
||||||
item {
|
|
||||||
Button(
|
LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) {
|
||||||
onClick = {
|
spielplanCsv = sportSpielplanCsvText(state.sportSpielplanHeaders, state.sportSpielplanRows)
|
||||||
viewModel.saveConfig(
|
}
|
||||||
config.copy(
|
|
||||||
training = config.training.copy(
|
fun openTerminDialog(termin: TerminDto?) {
|
||||||
ort = config.training.ort.copy(
|
editingTermin = termin
|
||||||
name = ortName,
|
terminDatum = termin?.datum.orEmpty()
|
||||||
strasse = ortStrasse,
|
terminUhrzeit = termin?.uhrzeit.orEmpty()
|
||||||
plz = ortPlz,
|
terminTitel = termin?.titel.orEmpty()
|
||||||
ort = ortOrt,
|
terminBeschreibung = termin?.beschreibung.orEmpty()
|
||||||
),
|
terminKategorie = termin?.kategorie ?: "Sonstiges"
|
||||||
zeiten = trainingTimes.toList(),
|
terminDialogOpen = true
|
||||||
),
|
}
|
||||||
trainer = trainers.toList(),
|
|
||||||
),
|
fun openDatePicker() {
|
||||||
)
|
val calendar = Calendar.getInstance()
|
||||||
},
|
runCatching {
|
||||||
enabled = !state.saving,
|
val parts = terminDatum.split("-")
|
||||||
modifier = Modifier.fillMaxWidth(),
|
if (parts.size == 3) {
|
||||||
) {
|
calendar.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
|
||||||
Text(if (state.saving) "Speichert..." else "Speichern")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DataCard("Trainingsort") {
|
|
||||||
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
|
||||||
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
|
||||||
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DataCard("Trainingszeiten") {
|
|
||||||
trainingTimes.forEachIndexed { index, zeit ->
|
|
||||||
TrainingTimeEditorCard(
|
|
||||||
zeit = zeit,
|
|
||||||
onChange = { updated -> trainingTimes[index] = updated },
|
|
||||||
onRemove = { trainingTimes.removeAt(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
trainingTimes.add(
|
|
||||||
de.harheimertc.data.TrainingTimeDto(
|
|
||||||
id = "training-${System.currentTimeMillis()}",
|
|
||||||
tag = "Montag",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Trainingszeit hinzufügen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DataCard("Trainer") {
|
|
||||||
trainers.forEachIndexed { index, trainer ->
|
|
||||||
TrainerEditorCard(
|
|
||||||
trainer = trainer,
|
|
||||||
onChange = { updated -> trainers[index] = updated },
|
|
||||||
onRemove = { trainers.removeAt(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
trainers.add(
|
|
||||||
de.harheimertc.data.TrainerDto(
|
|
||||||
id = "trainer-${System.currentTimeMillis()}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Trainer hinzufügen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { FormMessages(state.error, state.message) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DatePickerDialog(
|
||||||
|
context,
|
||||||
|
{ _, year, month, day ->
|
||||||
|
terminDatum = "%04d-%02d-%02d".format(Locale.ROOT, year, month + 1, day)
|
||||||
|
},
|
||||||
|
calendar.get(Calendar.YEAR),
|
||||||
|
calendar.get(Calendar.MONTH),
|
||||||
|
calendar.get(Calendar.DAY_OF_MONTH),
|
||||||
|
).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne") {
|
||||||
|
if (state.sportLoading) item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
tabs.forEach { (id, label) ->
|
||||||
|
if (activeTab == id) {
|
||||||
|
Button(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.sportLoading) {
|
||||||
|
when (activeTab) {
|
||||||
|
"termine" -> {
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = { openTerminDialog(null) },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Termin hinzufügen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.sportTermine.isEmpty()) {
|
||||||
|
item { EmptyCard("Keine Termine gefunden.") }
|
||||||
|
}
|
||||||
|
items(state.sportTermine.size) { index ->
|
||||||
|
val termin = state.sportTermine[index]
|
||||||
|
DataCard(termin.titel.ifBlank { "Termin" }) {
|
||||||
|
InfoRow("Datum", listOf(termin.datum, termin.uhrzeit.orEmpty()).filter(String::isNotBlank).joinToString(" "))
|
||||||
|
InfoRow("Kategorie", termin.kategorie ?: "Sonstiges")
|
||||||
|
if (!termin.beschreibung.isNullOrBlank()) {
|
||||||
|
Text(termin.beschreibung, color = Accent700)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(onClick = { openTerminDialog(termin) }, modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Bearbeiten")
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.deleteSportTermin(termin) },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Löschen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"mannschaften" -> {
|
||||||
|
item {
|
||||||
|
if (state.sportMannschaftenSeasons.isNotEmpty()) {
|
||||||
|
DataCard("Saison") {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
state.sportMannschaftenSeasons.forEach { season ->
|
||||||
|
if (season == state.sportMannschaftenSeason) {
|
||||||
|
Button(onClick = { }, modifier = Modifier.weight(1f)) { Text(season) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.loadSportMannschaftenSeason(season) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(season)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
mannschaften.add(CmsMannschaftRow(letzteAktualisierung = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE)))
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Hinzufügen")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.saveSportMannschaften(state.sportMannschaftenSeason, mannschaften.toList()) },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mannschaften.isEmpty()) {
|
||||||
|
item { EmptyCard("Keine Mannschaften gefunden.") }
|
||||||
|
}
|
||||||
|
items(mannschaften.size) { index ->
|
||||||
|
MannschaftEditorCard(
|
||||||
|
row = mannschaften[index],
|
||||||
|
spielplanTeams = spielplanTeamOptions,
|
||||||
|
onChange = { updated -> mannschaften[index] = updated },
|
||||||
|
onRemove = { mannschaften.removeAt(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"spielplaene" -> {
|
||||||
|
item {
|
||||||
|
DataCard("Vereins-Spielplan (CSV)") {
|
||||||
|
val seasonLabel = state.sportSpielplanSeason.ifBlank { "aktuelle Saison" }
|
||||||
|
val fileName = state.sportSpielplanSeason.takeIf { it.isNotBlank() }?.let { "spielplan-$it.json" } ?: "spielplan.csv"
|
||||||
|
InfoRow("Datei", fileName)
|
||||||
|
InfoRow("Saison", seasonLabel)
|
||||||
|
InfoRow("Einträge", state.sportSpielplanRows.size.toString())
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.importSportSpielplan() },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Importiert..." else "Von myTischtennis importieren")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.loadSportbetrieb() },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Neu laden")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { spielplanEditorOpen = true },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("CSV bearbeiten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { FormMessages(state.error, state.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminDialogOpen) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { terminDialogOpen = false },
|
||||||
|
title = { Text(if (editingTermin == null) "Termin hinzufügen" else "Termin bearbeiten") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = { openDatePicker() }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(terminDatum.ifBlank { "Datum auswählen" })
|
||||||
|
}
|
||||||
|
OutlinedTextField(value = terminUhrzeit, onValueChange = { terminUhrzeit = it }, label = { Text("Uhrzeit") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = terminTitel, onValueChange = { terminTitel = it }, label = { Text("Titel") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = terminBeschreibung, onValueChange = { terminBeschreibung = it }, label = { Text("Beschreibung") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(onClick = { terminKategorieOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(terminKategorie.ifBlank { "Kategorie auswählen" })
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = terminKategorieOpen, onDismissRequest = { terminKategorieOpen = false }) {
|
||||||
|
terminKategorien.forEach { kategorie ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(kategorie) },
|
||||||
|
onClick = {
|
||||||
|
terminKategorie = kategorie
|
||||||
|
terminKategorieOpen = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.saveSportTermin(
|
||||||
|
editingTermin,
|
||||||
|
TerminDto(
|
||||||
|
datum = terminDatum,
|
||||||
|
uhrzeit = terminUhrzeit.takeIf { it.isNotBlank() },
|
||||||
|
titel = terminTitel,
|
||||||
|
beschreibung = terminBeschreibung.takeIf { it.isNotBlank() },
|
||||||
|
kategorie = terminKategorie.ifBlank { "Sonstiges" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
terminDialogOpen = false
|
||||||
|
},
|
||||||
|
enabled = !state.sportSaving && terminDatum.isNotBlank() && terminTitel.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { terminDialogOpen = false }) { Text("Abbrechen") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spielplanEditorOpen) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { spielplanEditorOpen = false },
|
||||||
|
title = { Text("Spielplan CSV bearbeiten") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = spielplanCsv,
|
||||||
|
onValueChange = { spielplanCsv = it },
|
||||||
|
label = { Text("CSV mit Semikolon") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 12,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val (headers, rows) = parseSportCsvText(spielplanCsv)
|
||||||
|
viewModel.saveSportSpielplan(headers, rows)
|
||||||
|
spielplanEditorOpen = false
|
||||||
|
},
|
||||||
|
enabled = !state.sportSaving && spielplanCsv.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { spielplanEditorOpen = false }) { Text("Abbrechen") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MannschaftEditorCard(
|
||||||
|
row: CmsMannschaftRow,
|
||||||
|
spielplanTeams: List<ImportedSpielplanTeam>,
|
||||||
|
onChange: (CmsMannschaftRow) -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
var teamPickerOpen by remember(row.mannschaft, spielplanTeams) { mutableStateOf(false) }
|
||||||
|
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
|
||||||
|
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.spielplanMannschaftId, onValueChange = { onChange(row.copy(spielplanMannschaftId = it)) }, label = { Text("Spielplan-Team-ID") }, supportingText = { Text("Eindeutige myTischtennis-Team-ID") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
if (spielplanTeams.isNotEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(onClick = { teamPickerOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Aus importierten Teams auswählen")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = teamPickerOpen, onDismissRequest = { teamPickerOpen = false }) {
|
||||||
|
spielplanTeams.forEach { team ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(team.label) },
|
||||||
|
onClick = {
|
||||||
|
onChange(row.copy(spielplanMannschaftId = team.id))
|
||||||
|
teamPickerOpen = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.heimspieltag, onValueChange = { onChange(row.copy(heimspieltag = it)) }, label = { Text("Heimspieltag") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.spielsystem, onValueChange = { onChange(row.copy(spielsystem = it)) }, label = { Text("Spielsystem") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.mannschaftsfuehrer, onValueChange = { onChange(row.copy(mannschaftsfuehrer = it)) }, label = { Text("Mannschaftsführer") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.spieler, onValueChange = { onChange(row.copy(spieler = it)) }, label = { Text("Spieler") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
|
||||||
|
OutlinedTextField(value = row.informationenLink, onValueChange = { onChange(row.copy(informationenLink = it)) }, label = { Text("Weitere Informationen Link") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.letzteAktualisierung, onValueChange = { onChange(row.copy(letzteAktualisierung = it)) }, label = { Text("Letzte Aktualisierung") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
TextButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Entfernen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ImportedSpielplanTeam(val id: String, val label: String)
|
||||||
|
|
||||||
|
private fun importedSpielplanTeams(headers: List<String>, rows: List<List<String>>): List<ImportedSpielplanTeam> {
|
||||||
|
fun index(name: String) = headers.indexOf(name)
|
||||||
|
fun value(row: List<String>, column: String): String {
|
||||||
|
val columnIndex = index(column)
|
||||||
|
return row.getOrElse(columnIndex) { "" }.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
val teams = linkedMapOf<String, ImportedSpielplanTeam>()
|
||||||
|
listOf("Heim", "Gast").forEach { side ->
|
||||||
|
rows.forEach { row ->
|
||||||
|
val isHarheimer = value(row, "${side}VereinNr") == "43030" || value(row, "${side}VereinName") == "Harheimer TC"
|
||||||
|
val id = value(row, "${side}MannschaftId")
|
||||||
|
if (!isHarheimer || id.isBlank()) return@forEach
|
||||||
|
|
||||||
|
val teamName = value(row, "${side}Mannschaft")
|
||||||
|
val ageClass = value(row, "${side}MannschaftAltersklasse")
|
||||||
|
val teamNumber = value(row, "${side}MannschaftNr")
|
||||||
|
val label = listOf(teamName, ageClass, teamNumber.takeIf { it.isNotBlank() }?.let { "Nr. $it" })
|
||||||
|
.filterNotNull()
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(" · ")
|
||||||
|
teams.putIfAbsent(id, ImportedSpielplanTeam(id, "$label [$id]"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return teams.values.sortedBy { it.label }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sportSpielplanCsvText(headers: List<String>, rows: List<List<String>>): String {
|
||||||
|
if (headers.isEmpty()) return ""
|
||||||
|
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSportCsvText(text: String): Pair<List<String>, List<List<String>>> {
|
||||||
|
val lines = text.lineSequence().filter { it.isNotBlank() }.toList()
|
||||||
|
if (lines.isEmpty()) return emptyList<String>() to emptyList()
|
||||||
|
val headers = parseDelimitedLine(lines.first(), ';')
|
||||||
|
val rows = lines.drop(1).map { parseDelimitedLine(it, ';') }
|
||||||
|
return headers to rows
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDelimitedLine(line: String, delimiter: Char): List<String> {
|
||||||
|
val values = mutableListOf<String>()
|
||||||
|
val value = StringBuilder()
|
||||||
|
var quoted = false
|
||||||
|
var index = 0
|
||||||
|
while (index < line.length) {
|
||||||
|
when (val char = line[index]) {
|
||||||
|
'"' -> {
|
||||||
|
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
|
||||||
|
value.append('"')
|
||||||
|
index++
|
||||||
|
} else {
|
||||||
|
quoted = !quoted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delimiter -> if (quoted) value.append(char) else {
|
||||||
|
values += value.toString()
|
||||||
|
value.clear()
|
||||||
|
}
|
||||||
|
else -> value.append(char)
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
values += value.toString()
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.csvCell(delimiter: String): String {
|
||||||
|
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
|
||||||
|
val escaped = replace("\"", "\"\"")
|
||||||
|
return if (needsQuotes) "\"$escaped\"" else escaped
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -555,7 +872,7 @@ fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigati
|
|||||||
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
|
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
|
||||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
if (state.loading) item { LoadingState("Kontaktanfragen werden geladen...") }
|
||||||
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
|
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
|
||||||
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
|
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
|
||||||
}
|
}
|
||||||
@@ -592,7 +909,7 @@ fun CmsNewsletterScreen(
|
|||||||
var grpTargetGroup by remember { mutableStateOf("") }
|
var grpTargetGroup by remember { mutableStateOf("") }
|
||||||
var grpSendToExternal by remember { mutableStateOf(true) }
|
var grpSendToExternal by remember { mutableStateOf(true) }
|
||||||
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
|
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
|
||||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
if (state.loading) item { LoadingState("Newsletter-Daten werden geladen...") }
|
||||||
item {
|
item {
|
||||||
if (canWrite) Button(onClick = {
|
if (canWrite) Button(onClick = {
|
||||||
editingNewsletter = null
|
editingNewsletter = null
|
||||||
@@ -619,11 +936,13 @@ fun CmsNewsletterScreen(
|
|||||||
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
|
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
|
||||||
items(state.newsletters.size) { index ->
|
items(state.newsletters.size) { index ->
|
||||||
val item = state.newsletters[index]
|
val item = state.newsletters[index]
|
||||||
NewsletterCard(item,
|
NewsletterCard(
|
||||||
|
item,
|
||||||
|
canWrite = canWrite,
|
||||||
onEdit = { nl ->
|
onEdit = { nl ->
|
||||||
editingNewsletter = nl
|
editingNewsletter = nl
|
||||||
nlTitle = nl.title
|
nlTitle = nl.title
|
||||||
nlContent = nl.title ?: ""
|
nlContent = nl.title
|
||||||
nlType = "subscription"
|
nlType = "subscription"
|
||||||
nlTargetGroup = ""
|
nlTargetGroup = ""
|
||||||
nlSendToExternal = true
|
nlSendToExternal = true
|
||||||
@@ -637,7 +956,9 @@ fun CmsNewsletterScreen(
|
|||||||
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
|
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
|
||||||
items(state.newsletterGroups.size) { index ->
|
items(state.newsletterGroups.size) { index ->
|
||||||
val group = state.newsletterGroups[index]
|
val group = state.newsletterGroups[index]
|
||||||
NewsletterGroupCard(group,
|
NewsletterGroupCard(
|
||||||
|
group,
|
||||||
|
canWrite = canWrite,
|
||||||
onEdit = { g ->
|
onEdit = { g ->
|
||||||
editingGroup = g
|
editingGroup = g
|
||||||
grpName = g.name
|
grpName = g.name
|
||||||
@@ -753,6 +1074,12 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
var websiteVorname by remember { mutableStateOf("") }
|
var websiteVorname by remember { mutableStateOf("") }
|
||||||
var websiteNachname by remember { mutableStateOf("") }
|
var websiteNachname by remember { mutableStateOf("") }
|
||||||
var websiteEmail by remember { mutableStateOf("") }
|
var websiteEmail by remember { mutableStateOf("") }
|
||||||
|
var ortName by remember { mutableStateOf("") }
|
||||||
|
var ortStrasse by remember { mutableStateOf("") }
|
||||||
|
var ortPlz by remember { mutableStateOf("") }
|
||||||
|
var ortOrt by remember { mutableStateOf("") }
|
||||||
|
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
|
||||||
|
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
|
||||||
|
|
||||||
LaunchedEffect(config) {
|
LaunchedEffect(config) {
|
||||||
config?.let {
|
config?.let {
|
||||||
@@ -764,12 +1091,20 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
websiteVorname = it.website.verantwortlicher.vorname
|
websiteVorname = it.website.verantwortlicher.vorname
|
||||||
websiteNachname = it.website.verantwortlicher.nachname
|
websiteNachname = it.website.verantwortlicher.nachname
|
||||||
websiteEmail = it.website.verantwortlicher.email
|
websiteEmail = it.website.verantwortlicher.email
|
||||||
|
ortName = it.training.ort.name
|
||||||
|
ortStrasse = it.training.ort.strasse
|
||||||
|
ortPlz = it.training.ort.plz
|
||||||
|
ortOrt = it.training.ort.ort
|
||||||
|
trainingTimes.clear()
|
||||||
|
trainingTimes.addAll(it.training.zeiten)
|
||||||
|
trainers.clear()
|
||||||
|
trainers.addAll(it.trainer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
|
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
|
||||||
when {
|
when {
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading || config == null -> item { LoadingState("Einstellungen werden geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Button(
|
Button(
|
||||||
@@ -790,6 +1125,16 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
email = websiteEmail,
|
email = websiteEmail,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
training = config.training.copy(
|
||||||
|
ort = config.training.ort.copy(
|
||||||
|
name = ortName,
|
||||||
|
strasse = ortStrasse,
|
||||||
|
plz = ortPlz,
|
||||||
|
ort = ortOrt,
|
||||||
|
),
|
||||||
|
zeiten = trainingTimes.toList(),
|
||||||
|
),
|
||||||
|
trainer = trainers.toList(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -822,6 +1167,63 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
DataCard("Trainingsort") {
|
||||||
|
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
||||||
|
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
DataCard("Trainingszeiten") {
|
||||||
|
trainingTimes.forEachIndexed { index, zeit ->
|
||||||
|
TrainingTimeEditorCard(
|
||||||
|
zeit = zeit,
|
||||||
|
onChange = { updated -> trainingTimes[index] = updated },
|
||||||
|
onRemove = { trainingTimes.removeAt(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
trainingTimes.add(
|
||||||
|
de.harheimertc.data.TrainingTimeDto(
|
||||||
|
id = "training-${System.currentTimeMillis()}",
|
||||||
|
tag = "Montag",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Trainingszeit hinzufügen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
DataCard("Trainer") {
|
||||||
|
trainers.forEachIndexed { index, trainer ->
|
||||||
|
TrainerEditorCard(
|
||||||
|
trainer = trainer,
|
||||||
|
onChange = { updated -> trainers[index] = updated },
|
||||||
|
onRemove = { trainers.removeAt(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
trainers.add(
|
||||||
|
de.harheimertc.data.TrainerDto(
|
||||||
|
id = "trainer-${System.currentTimeMillis()}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Trainer hinzufügen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
DataCard("Systemstatus") {
|
DataCard("Systemstatus") {
|
||||||
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
|
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
|
||||||
@@ -883,7 +1285,7 @@ fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
item { CircularProgressIndicator(color = Primary600) }
|
item { LoadingState("Diagnosedaten werden geladen...") }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.passwordResetSearchTerm.isNotBlank()) {
|
if (state.passwordResetSearchTerm.isNotBlank()) {
|
||||||
@@ -950,6 +1352,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
|
|||||||
val cards = listOf(
|
val cards = listOf(
|
||||||
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
||||||
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
|
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
|
||||||
|
Triple("News", "Interne und öffentliche News", Destinations.CmsNews.route),
|
||||||
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
||||||
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
||||||
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
||||||
@@ -1073,7 +1476,7 @@ private fun CmsConfigPage(
|
|||||||
) {
|
) {
|
||||||
CmsPage(navController, showBackNavigation, title, subtitle) {
|
CmsPage(navController, showBackNavigation, title, subtitle) {
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
item { CircularProgressIndicator(color = Primary600) }
|
item { LoadingState("Konfiguration wird geladen...") }
|
||||||
} else {
|
} else {
|
||||||
item {
|
item {
|
||||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||||
@@ -1229,29 +1632,44 @@ private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewMod
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) {
|
private fun NewsletterCard(
|
||||||
|
newsletter: NewsletterDto,
|
||||||
|
canWrite: Boolean,
|
||||||
|
onEdit: (NewsletterDto) -> Unit = {},
|
||||||
|
onDelete: (String) -> Unit = {},
|
||||||
|
onSend: (String) -> Unit = {},
|
||||||
|
) {
|
||||||
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
|
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
|
||||||
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
|
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
|
||||||
InfoRow("Erstellt", newsletter.createdAt ?: "-")
|
InfoRow("Erstellt", newsletter.createdAt ?: "-")
|
||||||
InfoRow("Versendet", newsletter.sentAt ?: "-")
|
InfoRow("Versendet", newsletter.sentAt ?: "-")
|
||||||
Row {
|
if (canWrite) {
|
||||||
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
|
Row {
|
||||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
|
||||||
if (newsletter.status != "sent") {
|
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
|
if (newsletter.status != "sent") {
|
||||||
|
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGroupDto) -> Unit = {}, onDelete: (String) -> Unit = {}) {
|
private fun NewsletterGroupCard(
|
||||||
|
group: NewsletterGroupDto,
|
||||||
|
canWrite: Boolean,
|
||||||
|
onEdit: (NewsletterGroupDto) -> Unit = {},
|
||||||
|
onDelete: (String) -> Unit = {},
|
||||||
|
) {
|
||||||
DataCard(group.name.ifBlank { group.id }) {
|
DataCard(group.name.ifBlank { group.id }) {
|
||||||
InfoRow("Beschreibung", group.description.ifBlank { "-" })
|
InfoRow("Beschreibung", group.description.ifBlank { "-" })
|
||||||
InfoRow("Abonnenten", group.subscribers.size.toString())
|
InfoRow("Abonnenten", group.subscribers.size.toString())
|
||||||
Row {
|
if (canWrite) {
|
||||||
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
|
Row {
|
||||||
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
|
||||||
|
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
225
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt
Normal file → Executable file
225
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt
Normal file → Executable file
@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.cms
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
import de.harheimertc.data.CmsUserDto
|
import de.harheimertc.data.CmsUserDto
|
||||||
import de.harheimertc.data.ConfigResponse
|
import de.harheimertc.data.ConfigResponse
|
||||||
import de.harheimertc.data.ContactRequestDto
|
import de.harheimertc.data.ContactRequestDto
|
||||||
@@ -12,6 +13,10 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
|
|||||||
import de.harheimertc.data.PasswordResetAttemptDto
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
import de.harheimertc.data.NewsSaveRequest
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
|
import de.harheimertc.data.SpielDto
|
||||||
|
import de.harheimertc.data.TerminDto
|
||||||
|
import de.harheimertc.repositories.CmsMannschaftRow
|
||||||
import de.harheimertc.repositories.CmsRepository
|
import de.harheimertc.repositories.CmsRepository
|
||||||
import de.harheimertc.repositories.MeisterschaftResult
|
import de.harheimertc.repositories.MeisterschaftResult
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -38,17 +43,37 @@ data class CmsUiState(
|
|||||||
val passwordResetFailedOnly: Boolean = true,
|
val passwordResetFailedOnly: Boolean = true,
|
||||||
val news: List<NewsDto> = emptyList(),
|
val news: List<NewsDto> = emptyList(),
|
||||||
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
||||||
|
val sportLoading: Boolean = false,
|
||||||
|
val sportSaving: Boolean = false,
|
||||||
|
val sportTermine: List<TerminDto> = emptyList(),
|
||||||
|
val sportMannschaften: List<CmsMannschaftRow> = emptyList(),
|
||||||
|
val sportMannschaftenSeasons: List<String> = emptyList(),
|
||||||
|
val sportMannschaftenSeason: String = "",
|
||||||
|
val sportSpielplanHeaders: List<String> = emptyList(),
|
||||||
|
val sportSpielplanRows: List<List<String>> = emptyList(),
|
||||||
|
val sportSpielplanSeason: String = "",
|
||||||
|
val sportSpielplanSeasons: List<SeasonDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CmsViewModel @Inject constructor(
|
class CmsViewModel @Inject constructor(
|
||||||
private val repository: CmsRepository,
|
private val repository: CmsRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(CmsUiState())
|
private val _state = MutableStateFlow(CmsUiState())
|
||||||
val state: StateFlow<CmsUiState> = _state
|
val state: StateFlow<CmsUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
@@ -168,6 +193,183 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadSportbetrieb() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportLoading = true, error = null, message = null)
|
||||||
|
|
||||||
|
val termineRes = async { repository.managedTermine() }
|
||||||
|
val seasonsRes = async { repository.mannschaftenSeasons() }
|
||||||
|
val spielplanRes = async { repository.spielplan() }
|
||||||
|
|
||||||
|
val termineResult = termineRes.await()
|
||||||
|
val seasonsResult = seasonsRes.await()
|
||||||
|
val seasonInfo = seasonsResult.getOrNull()
|
||||||
|
val selectedSeason = _state.value.sportMannschaftenSeason.takeIf { it.isNotBlank() }
|
||||||
|
?: seasonInfo?.defaultSeason?.takeIf { it.isNotBlank() }
|
||||||
|
?: seasonInfo?.currentSeason?.takeIf { it.isNotBlank() }
|
||||||
|
?: seasonInfo?.seasons?.firstOrNull().orEmpty()
|
||||||
|
val mannschaftenResult = repository.mannschaften(selectedSeason.takeIf { it.isNotBlank() })
|
||||||
|
val spielplanResult = spielplanRes.await()
|
||||||
|
val errors = listOfNotNull(
|
||||||
|
ErrorMapper.mapError(termineResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(seasonsResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(mannschaftenResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(spielplanResult.exceptionOrNull()),
|
||||||
|
)
|
||||||
|
val spielplan = spielplanResult.getOrNull()
|
||||||
|
val headers = spielplan?.headers.orEmpty()
|
||||||
|
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportLoading = false,
|
||||||
|
sportTermine = termineResult.getOrNull().orEmpty(),
|
||||||
|
sportMannschaften = mannschaftenResult.getOrNull().orEmpty(),
|
||||||
|
sportMannschaftenSeasons = seasonInfo?.seasons.orEmpty(),
|
||||||
|
sportMannschaftenSeason = selectedSeason,
|
||||||
|
sportSpielplanHeaders = headers,
|
||||||
|
sportSpielplanRows = spielplan?.data.orEmpty().map { row -> headers.map { header -> row.valueForHeader(header) } },
|
||||||
|
sportSpielplanSeason = spielplan?.season.orEmpty(),
|
||||||
|
sportSpielplanSeasons = spielplan?.seasons.orEmpty(),
|
||||||
|
error = errors.takeIf { it.isNotEmpty() }?.joinToString("; "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSportMannschaftenSeason(season: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportLoading = true, error = null, message = null, sportMannschaftenSeason = season)
|
||||||
|
repository.mannschaften(season)
|
||||||
|
.onSuccess { rows ->
|
||||||
|
_state.value = _state.value.copy(sportLoading = false, sportMannschaften = rows)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportLoading = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSportTermin(original: TerminDto?, termin: TerminDto) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
if (original != null) {
|
||||||
|
repository.deleteTermin(original)
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Alter Termin konnte nicht ersetzt werden.",
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val saveResult = repository.saveTermin(termin)
|
||||||
|
saveResult
|
||||||
|
.onSuccess { response ->
|
||||||
|
val termine = repository.managedTermine().getOrDefault(_state.value.sportTermine)
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportTermine = termine,
|
||||||
|
message = response.message ?: "Termin gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSportTermin(termin: TerminDto) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.deleteTermin(termin)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportTermine = _state.value.sportTermine.filterNot { it == termin },
|
||||||
|
message = response.message ?: "Termin gelöscht.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gelöscht werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSportMannschaften(season: String, rows: List<CmsMannschaftRow>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.saveMannschaften(season.takeIf { it.isNotBlank() }, rows)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportMannschaften = rows,
|
||||||
|
message = response.message ?: "Mannschaften gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importSportSpielplan() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.importSpielplan()
|
||||||
|
.onSuccess { response ->
|
||||||
|
if (!response.success) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = response.message ?: "Spielplan konnte nicht importiert werden.",
|
||||||
|
)
|
||||||
|
return@onSuccess
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
message = response.message ?: "Spielplan aktualisiert.",
|
||||||
|
)
|
||||||
|
loadSportbetrieb()
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht importiert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.saveSpielplan(headers, rows)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportSpielplanHeaders = headers,
|
||||||
|
sportSpielplanRows = rows,
|
||||||
|
message = response.message ?: "Spielplan gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun saveConfig(config: ConfigResponse) {
|
fun saveConfig(config: ConfigResponse) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
@@ -207,7 +409,7 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bulkSetPublic(ids: List<Int>, makePublic: Boolean) {
|
fun bulkSetPublic(ids: List<String>, makePublic: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
ids.forEach { id ->
|
ids.forEach { id ->
|
||||||
@@ -229,7 +431,7 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bulkSetHidden(ids: List<Int>, makeHidden: Boolean) {
|
fun bulkSetHidden(ids: List<String>, makeHidden: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
ids.forEach { id ->
|
ids.forEach { id ->
|
||||||
@@ -251,7 +453,7 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bulkDelete(ids: List<Int>) {
|
fun bulkDelete(ids: List<String>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
ids.forEach { id ->
|
ids.forEach { id ->
|
||||||
@@ -262,7 +464,7 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteNews(id: Int) {
|
fun deleteNews(id: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
repository.deleteNews(id)
|
repository.deleteNews(id)
|
||||||
@@ -451,3 +653,18 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun SpielDto.valueForHeader(header: String): String = when (header) {
|
||||||
|
"Termin" -> termin
|
||||||
|
"HeimMannschaft" -> heimMannschaft
|
||||||
|
"GastMannschaft" -> gastMannschaft
|
||||||
|
"HeimMannschaftAltersklasse" -> heimAltersklasse
|
||||||
|
"GastMannschaftAltersklasse" -> gastAltersklasse
|
||||||
|
"Altersklasse" -> altersklasse
|
||||||
|
"Liga" -> liga
|
||||||
|
"Staffel" -> staffel
|
||||||
|
"Runde" -> runde.orEmpty()
|
||||||
|
"SpieleHeim" -> spieleHeim
|
||||||
|
"SpieleGast" -> spieleGast
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt
Normal file → Executable file
4
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt
Normal file → Executable file
4
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt
Normal file → Executable file
@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import de.harheimertc.R
|
import de.harheimertc.R
|
||||||
import de.harheimertc.ui.components.FormMessages
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.ImageGrid
|
import de.harheimertc.ui.components.ImageGrid
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -131,7 +131,7 @@ fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
|
|||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
CircularProgressIndicator()
|
LoadingState("Galerie wird geladen...")
|
||||||
} else if (images.isEmpty()) {
|
} else if (images.isEmpty()) {
|
||||||
Text(text = stringResource(R.string.gallery_empty))
|
Text(text = stringResource(R.string.gallery_empty))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt
Normal file → Executable file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user