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:
|
||||
analyze:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -72,7 +98,11 @@ jobs:
|
||||
rm -f gitleaks.tar.gz
|
||||
|
||||
- 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
|
||||
run: npm run lint
|
||||
@@ -86,21 +116,22 @@ jobs:
|
||||
- name: Semgrep (SAST)
|
||||
run: semgrep --config p/default --error .
|
||||
|
||||
- name: npm audit (high+)
|
||||
run: npm audit --audit-level=high
|
||||
- name: npm audit (critical)
|
||||
run: npm audit --audit-level=critical
|
||||
|
||||
- name: OSV-Scanner (SCA)
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
|
||||
chmod +x osv-scanner
|
||||
./osv-scanner --version
|
||||
test -f ./package-lock.json
|
||||
./osv-scanner --lockfile ./package-lock.json
|
||||
test -f "$GITHUB_WORKSPACE/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:
|
||||
needs: analyze
|
||||
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:
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
@@ -126,7 +157,7 @@ jobs:
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
||||
"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:
|
||||
needs: analyze
|
||||
@@ -157,4 +188,4 @@ jobs:
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
||||
"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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
|
||||
with:
|
||||
path: |
|
||||
~/.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/**/build/
|
||||
/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!)
|
||||
.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)
|
||||
|
||||
### Grobe Anforderungen (Telefon)
|
||||
- Mindestens 2 Screenshots
|
||||
- PNG oder JPEG
|
||||
- Seitenlaenge je Seite zwischen 320 px und 3840 px
|
||||
### Zielgroessen fuer Store-Upload
|
||||
- Telefon (Portrait): 1080 x 1920
|
||||
- Medium 7" Tablet (Portrait): 1200 x 1920
|
||||
- 10" Tablet (Portrait): 1600 x 2560
|
||||
|
||||
Empfehlung fuer Android-Phone:
|
||||
- 1080 x 1920 (Portrait)
|
||||
Alle Dateien als PNG oder JPEG.
|
||||
|
||||
### Anonymisierung
|
||||
|
||||
@@ -61,10 +60,33 @@ Beispiel:
|
||||
'68,118,520,72;70,706,560,98'
|
||||
```
|
||||
|
||||
### Zielprofile erzeugen (Telefon, 7", 10")
|
||||
|
||||
Aus allen Dateien in `android-app/playstore-assets/anon` werden die drei Profile erzeugt:
|
||||
|
||||
```bash
|
||||
./scripts/playstore-screenshot-sizes.sh
|
||||
```
|
||||
|
||||
Optional mit eigenen Ordnern:
|
||||
|
||||
```bash
|
||||
./scripts/playstore-screenshot-sizes.sh \
|
||||
--input-dir android-app/playstore-assets/anon \
|
||||
--output-dir android-app/playstore-assets/final
|
||||
```
|
||||
|
||||
Output:
|
||||
- android-app/playstore-assets/final/phone
|
||||
- android-app/playstore-assets/final/tablet-7
|
||||
- android-app/playstore-assets/final/tablet-10
|
||||
|
||||
## 4) Upload in Play Console
|
||||
|
||||
- Datenschutzerklaerung: URL eintragen
|
||||
- Konto-Loeschung: URL eintragen
|
||||
- App-Icon: playstore-icon-512.png
|
||||
- Feature Graphic: playstore-feature-graphic-1024x500.png
|
||||
- Screenshots: 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 {
|
||||
id("com.android.application")
|
||||
id("com.google.devtools.ksp")
|
||||
@@ -5,12 +7,17 @@ plugins {
|
||||
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")
|
||||
.orElse("https://harheimertc.tsschulz.de/")
|
||||
.get()
|
||||
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
||||
.orElse("https://harheimertc.de/")
|
||||
.get()
|
||||
val expectedProductionApiBaseUrl = "https://harheimertc.de/"
|
||||
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||
.orElse("")
|
||||
.get()
|
||||
@@ -25,27 +32,69 @@ val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
|
||||
.orElse("true")
|
||||
.get()
|
||||
.toBoolean()
|
||||
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
|
||||
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull
|
||||
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull
|
||||
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull
|
||||
|
||||
val localSigningProperties = Properties().apply {
|
||||
val localSigningFile = rootProject.file("gradle-local.properties")
|
||||
if (localSigningFile.exists()) {
|
||||
localSigningFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun signingProperty(name: String): String? =
|
||||
providers.gradleProperty(name).orNull
|
||||
?: providers.environmentVariable(name).orNull
|
||||
?: localSigningProperties.getProperty(name)
|
||||
|
||||
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
|
||||
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
|
||||
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
|
||||
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
|
||||
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
||||
!releaseStorePassword.isNullOrBlank() &&
|
||||
!releaseKeyAlias.isNullOrBlank() &&
|
||||
!releaseKeyPassword.isNullOrBlank()
|
||||
|
||||
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
|
||||
doFirst {
|
||||
if (!hasReleaseSigning) {
|
||||
throw GradleException(
|
||||
"Production release signing is not configured. " +
|
||||
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
|
||||
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
|
||||
doFirst {
|
||||
if (productionApiBaseUrl != expectedProductionApiBaseUrl) {
|
||||
throw GradleException(
|
||||
"Production Play Store builds must use $expectedProductionApiBaseUrl, but PRODUCTION_API_BASE_URL is $productionApiBaseUrl."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.harheimertc"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.harheimertc"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
targetSdk = 36
|
||||
versionCode = androidVersionCode
|
||||
versionName = androidVersionName
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf(
|
||||
"AutoboxingStateCreation",
|
||||
"MutableCollectionMutableState",
|
||||
)
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (hasReleaseSigning) {
|
||||
@@ -135,6 +184,8 @@ val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("package
|
||||
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||
group = "distribution"
|
||||
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
||||
dependsOn(ensureReleaseSigning)
|
||||
dependsOn(ensureProductionApiBaseUrl)
|
||||
dependsOn(":app:bundleProductionRelease")
|
||||
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
||||
|
||||
@@ -161,6 +212,13 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching {
|
||||
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
|
||||
}.configureEach {
|
||||
dependsOn(ensureReleaseSigning)
|
||||
dependsOn(ensureProductionApiBaseUrl)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
@@ -206,6 +264,9 @@ dependencies {
|
||||
// Crash reporting
|
||||
implementation("io.sentry:sentry-android:8.42.0")
|
||||
|
||||
// Push notifications
|
||||
implementation("com.google.firebase:firebase-messaging:25.0.2")
|
||||
|
||||
// Room
|
||||
implementation("androidx.room:room-runtime: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.datastore:datastore-preferences:1.0.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||
|
||||
// Testing (skeleton)
|
||||
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.
|
||||
# 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.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".HarheimerApplication"
|
||||
@@ -10,12 +11,20 @@
|
||||
android:theme="@style/Theme.HarheimerTC"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity android:name="de.harheimertc.MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".notifications.HarheimerMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
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.memory.MemoryCache
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import io.sentry.Sentry
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
@@ -19,6 +20,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
Log.d("HILT", "HarheimerApplication.onCreate called")
|
||||
super.onCreate()
|
||||
HarheimerNotifications.createChannels(this)
|
||||
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||
Sentry.init { options ->
|
||||
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
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.harheimertc.ui.navigation.NavGraph
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import de.harheimertc.ui.theme.HarheimerTheme
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestNotificationPermissionIfNeeded()
|
||||
notificationRoute.value = extractNotificationRoute(intent)
|
||||
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
|
||||
fun App() {
|
||||
fun App(
|
||||
notificationRoute: String? = null,
|
||||
onNotificationRouteConsumed: () -> Unit = {},
|
||||
) {
|
||||
HarheimerTheme {
|
||||
val navController = rememberNavController()
|
||||
val ctx = LocalContext.current
|
||||
val activity = ctx as? ComponentActivity
|
||||
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 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 TermineManageResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||
data class TerminDto(
|
||||
val datum: String = "",
|
||||
val uhrzeit: String? = null,
|
||||
@@ -36,7 +37,29 @@ data class SpielplanResponse(
|
||||
val season: String? = null,
|
||||
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 MannschaftenSeasonsResponse(
|
||||
val success: Boolean = false,
|
||||
val seasons: List<String> = emptyList(),
|
||||
val currentSeason: String = "",
|
||||
val defaultSeason: String = "",
|
||||
)
|
||||
data class SpielDto(
|
||||
@param:Json(name = "Termin") val termin: String = "",
|
||||
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
||||
@@ -81,7 +104,7 @@ data class LeagueTableRowDto(
|
||||
)
|
||||
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
|
||||
data class NewsDto(
|
||||
val id: Int? = null,
|
||||
val id: String? = null,
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val created: String? = null,
|
||||
@@ -96,7 +119,7 @@ data class NewsResponse(
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
)
|
||||
data class NewsSaveRequest(
|
||||
val id: Int? = null,
|
||||
val id: String? = null,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val isPublic: Boolean = false,
|
||||
@@ -159,6 +182,7 @@ data class MembershipResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val downloadUrl: String? = null,
|
||||
val downloadToken: String? = null,
|
||||
)
|
||||
data class LoginRequest(
|
||||
val email: String,
|
||||
@@ -225,7 +249,7 @@ data class ProfileVisibilityDto(
|
||||
val showEmail: Boolean = true,
|
||||
val showPhone: Boolean = true,
|
||||
val showAddress: Boolean = false,
|
||||
val showBirthday: Boolean = true,
|
||||
val showBirthday: Boolean = false,
|
||||
)
|
||||
data class ProfileUserDto(
|
||||
val id: String? = null,
|
||||
@@ -251,6 +275,35 @@ data class ProfileUpdateRequest(
|
||||
val currentPassword: 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(
|
||||
val name: String = "",
|
||||
val dayMonth: String = "",
|
||||
@@ -260,6 +313,29 @@ data class BirthdaysResponse(
|
||||
val success: Boolean = false,
|
||||
val birthdays: List<BirthdayDto> = emptyList(),
|
||||
)
|
||||
data class QttrSourceDto(
|
||||
val url: String = "",
|
||||
)
|
||||
data class QttrRowDto(
|
||||
val rank: Int? = null,
|
||||
val playerNumber: Int? = null,
|
||||
val gender: String? = null,
|
||||
val playerName: String = "",
|
||||
val clubName: String = "",
|
||||
val currentQttr: Int? = null,
|
||||
val 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(
|
||||
val id: String? = null,
|
||||
val name: String = "",
|
||||
@@ -276,6 +352,7 @@ data class MemberDto(
|
||||
val editable: Boolean = false,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
val showBirthday: Boolean = false,
|
||||
val loginRoles: List<String> = emptyList(),
|
||||
)
|
||||
data class MembersResponse(
|
||||
@@ -387,6 +464,15 @@ data class HomepageSectionConfigDto(
|
||||
data class HomepageDto(
|
||||
val sections: List<HomepageSectionDto> = emptyList(),
|
||||
)
|
||||
data class HeroImageVariantDto(
|
||||
val key: String = "",
|
||||
val mobileWebp: String = "",
|
||||
val desktopWebp: String = "",
|
||||
val fallback: String = "",
|
||||
)
|
||||
data class HeroImagesResponse(
|
||||
val variants: List<HeroImageVariantDto> = emptyList(),
|
||||
)
|
||||
data class SeitenDto(
|
||||
val ueberUns: String = "",
|
||||
val geschichte: String = "",
|
||||
@@ -529,6 +615,21 @@ interface ApiService {
|
||||
@GET("/api/termine")
|
||||
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")
|
||||
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
||||
|
||||
@@ -548,14 +649,20 @@ interface ApiService {
|
||||
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/news")
|
||||
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
|
||||
suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/mannschaften")
|
||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||
|
||||
@GET("/api/mannschaften/seasons")
|
||||
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
|
||||
|
||||
@GET("/api/config")
|
||||
suspend fun config(): Response<ConfigResponse>
|
||||
|
||||
@GET("/api/hero-images")
|
||||
suspend fun heroImages(): Response<HeroImagesResponse>
|
||||
|
||||
@PUT("/api/config")
|
||||
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
|
||||
|
||||
@@ -568,12 +675,18 @@ interface ApiService {
|
||||
@POST("/api/cms/save-csv")
|
||||
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
||||
|
||||
@POST("/api/cms/import-spielplan")
|
||||
suspend fun importSpielplan(): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/membership/generate-pdf")
|
||||
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
||||
|
||||
@Streaming
|
||||
@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")
|
||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||
@@ -617,9 +730,24 @@ interface ApiService {
|
||||
@retrofit2.http.PUT("/api/profile")
|
||||
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")
|
||||
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")
|
||||
suspend fun members(): Response<MembersResponse>
|
||||
|
||||
@@ -634,6 +762,7 @@ interface ApiService {
|
||||
val notes: String? = null,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
val showBirthday: Boolean = false,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -14,8 +16,10 @@ class SecureOfflineCache @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
private val tag = "SecureOfflineCache"
|
||||
private companion object {
|
||||
const val KEY_BIRTHDAYS = "birthdays"
|
||||
const val KEY_QTTR_VALUES = "qttr_values"
|
||||
const val KEY_MEMBERS = "members"
|
||||
const val KEY_MEMBER_NEWS = "member_news"
|
||||
const val KEY_CMS_CONFIG = "cms_config"
|
||||
@@ -28,6 +32,10 @@ class SecureOfflineCache @Inject constructor(
|
||||
}
|
||||
|
||||
private val preferences by lazy {
|
||||
buildEncryptedPreferences()
|
||||
}
|
||||
|
||||
private fun buildEncryptedPreferences() = try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
@@ -38,11 +46,36 @@ class SecureOfflineCache @Inject constructor(
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (error: GeneralSecurityException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
} catch (error: RuntimeException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
}
|
||||
|
||||
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||
Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error)
|
||||
context.deleteSharedPreferences("harheimertc_offline_cache")
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_offline_cache",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (retryError: Throwable) {
|
||||
Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError)
|
||||
throw retryError
|
||||
}
|
||||
|
||||
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
||||
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java)
|
||||
fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
|
||||
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
|
||||
|
||||
@@ -93,6 +126,7 @@ class SecureOfflineCache @Inject constructor(
|
||||
KEY_NEWSLETTER_GROUPS,
|
||||
KEY_PASSWORD_RESET_DIAGNOSTICS,
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.security.DeviceKeyManager
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -13,10 +15,15 @@ class AuthRepositoryImpl @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val deviceKeyManager: DeviceKeyManager,
|
||||
) : AuthRepository {
|
||||
private val tag = "AuthRepository"
|
||||
private val tokenKey = "auth_token"
|
||||
private val refreshTokenKey = "auth_refresh_token"
|
||||
private val sessionIdKey = "auth_session_id"
|
||||
private val preferences by lazy {
|
||||
buildEncryptedPreferences()
|
||||
}
|
||||
|
||||
private fun buildEncryptedPreferences() = try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
@@ -27,6 +34,28 @@ class AuthRepositoryImpl @Inject constructor(
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (error: GeneralSecurityException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
} catch (error: RuntimeException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
}
|
||||
|
||||
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||
Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error)
|
||||
context.deleteSharedPreferences("harheimertc_auth")
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_auth",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (retryError: Throwable) {
|
||||
Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError)
|
||||
throw retryError
|
||||
}
|
||||
|
||||
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
||||
|
||||
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.SaveCsvResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TerminDto
|
||||
import javax.inject.Inject
|
||||
|
||||
class CmsRepository @Inject constructor(
|
||||
@@ -83,6 +85,93 @@ class CmsRepository @Inject constructor(
|
||||
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> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
@@ -267,7 +356,7 @@ class CmsRepository @Inject constructor(
|
||||
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)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
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>> =
|
||||
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,
|
||||
) {
|
||||
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||
val response = api.galerieList(page = 1, perPage = 1)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body()?.images.orEmpty().isNotEmpty()
|
||||
retryOnNetworkFailure {
|
||||
val response = api.galerieList(page = 1, perPage = 1)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body()?.images.orEmpty().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
||||
return try {
|
||||
val resp = api.galerieList(page = page, perPage = perPage)
|
||||
if (resp.isSuccessful) {
|
||||
val body = resp.body()
|
||||
Result.success(
|
||||
return runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val resp = api.galerieList(page = page, perPage = perPage)
|
||||
if (resp.isSuccessful) {
|
||||
val body = resp.body()
|
||||
GalleryPage(
|
||||
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
||||
pagination = body?.pagination ?: GalleryPaginationDto(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Result.failure(Exception("HTTP ${resp.code()}"))
|
||||
)
|
||||
} else {
|
||||
error("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
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -15,10 +17,15 @@ class HomeLayoutPreferences @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
private val tag = "HomeLayoutPreferences"
|
||||
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
|
||||
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
|
||||
|
||||
private val preferences by lazy {
|
||||
buildEncryptedPreferences()
|
||||
}
|
||||
|
||||
private fun buildEncryptedPreferences() = try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
@@ -29,6 +36,28 @@ class HomeLayoutPreferences @Inject constructor(
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (error: GeneralSecurityException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
} catch (error: RuntimeException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
}
|
||||
|
||||
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||
Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error)
|
||||
context.deleteSharedPreferences("harheimertc_home_layout")
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_home_layout",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (retryError: Throwable) {
|
||||
Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError)
|
||||
throw retryError
|
||||
}
|
||||
|
||||
fun getSections(): List<HomepageSectionDto>? {
|
||||
|
||||
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
|
||||
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import de.harheimertc.data.HeroImageVariantDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TerminDto
|
||||
import io.sentry.Sentry
|
||||
import kotlin.random.Random
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -17,20 +21,162 @@ data class HomeData(
|
||||
val selectedSpielplanSeason: String?,
|
||||
val news: List<NewsDto>,
|
||||
val homepageSections: List<HomepageSectionDto>,
|
||||
val heroImageUrl: String? = null,
|
||||
val diagnostics: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
||||
val termine = api.termine().body()?.termine.orEmpty()
|
||||
val spielplanResponse = api.spielplan().body()
|
||||
val spiele = spielplanResponse?.data.orEmpty()
|
||||
val news = api.publicNews().body()?.news.orEmpty()
|
||||
val homepageSections = runCatching {
|
||||
val configResponse = api.config()
|
||||
if (!configResponse.isSuccessful) return@runCatching emptyList()
|
||||
configResponse.body()?.homepage?.sections.orEmpty()
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val termine = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.termine()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/termine",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.termine.orEmpty()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.termine", error)
|
||||
if (diagnostics.none { it.contains("GET /api/termine") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/termine",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val spielplanResponse = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplan()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/spielplan",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.spielplan", error)
|
||||
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/spielplan",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
val spiele = spielplanResponse?.data.orEmpty()
|
||||
|
||||
val news = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.publicNews()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/news-public",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.news.orEmpty()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.news", error)
|
||||
if (diagnostics.none { it.contains("GET /api/news-public") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/news-public",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val homepageSections = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/config",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.homepage?.sections.orEmpty()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.config", error)
|
||||
if (diagnostics.none { it.contains("GET /api/config") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/config",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val heroImageUrl = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.heroImages()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/hero-images",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
val variants = response.body()?.variants.orEmpty()
|
||||
pickRandomHeroImage(variants)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.heroImages", error)
|
||||
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/hero-images",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
HomeData(
|
||||
termine = termine,
|
||||
spiele = spiele,
|
||||
@@ -38,12 +184,77 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||
selectedSpielplanSeason = spielplanResponse?.season,
|
||||
news = news,
|
||||
homepageSections = homepageSections,
|
||||
heroImageUrl = heroImageUrl,
|
||||
diagnostics = diagnostics,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", "fetchHomeData")
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", "fetchSpielplanForSeason")
|
||||
scope.setExtra("season", season)
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureLoadIssue(operation: String, error: Throwable) {
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", operation)
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDiagnostic(
|
||||
endpoint: String,
|
||||
requestPayload: String,
|
||||
httpCode: Int?,
|
||||
responseBody: String?,
|
||||
throwable: Throwable?,
|
||||
): String {
|
||||
val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" }
|
||||
val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" }
|
||||
return buildString {
|
||||
append("Endpoint: ").append(endpoint).append('\n')
|
||||
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n')
|
||||
append("Request: ").append(requestPayload).append('\n')
|
||||
append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n')
|
||||
append("Response: ").append(responsePreview).append('\n')
|
||||
append("Throwable: ").append(throwableInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickRandomHeroImage(variants: List<HeroImageVariantDto>): String? {
|
||||
if (variants.isEmpty()) return null
|
||||
val valid = variants.filter { it.fallback.isNotBlank() }
|
||||
if (valid.isEmpty()) return null
|
||||
val selected = valid[Random.nextInt(valid.size)]
|
||||
return toAbsoluteUrl(selected.fallback)
|
||||
}
|
||||
|
||||
private fun toAbsoluteUrl(pathOrUrl: String): String {
|
||||
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
|
||||
return pathOrUrl
|
||||
}
|
||||
|
||||
val base = BuildConfig.API_BASE_URL.trimEnd('/')
|
||||
val path = if (pathOrUrl.startsWith('/')) pathOrUrl else "/$pathOrUrl"
|
||||
return "$base$path"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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
|
||||
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
@@ -9,6 +10,7 @@ import de.harheimertc.data.LogoutRequest
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.ResetPasswordRequest
|
||||
import de.harheimertc.data.SessionRefresher
|
||||
import io.sentry.Sentry
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -19,13 +21,41 @@ class LoginRepository @Inject constructor(
|
||||
private val sessionRefresher: SessionRefresher,
|
||||
) {
|
||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||
val response = api.login(LoginRequest(email.trim(), password))
|
||||
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
|
||||
val endpoint = "api/auth/login"
|
||||
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
|
||||
val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
|
||||
if (!response.isSuccessful) {
|
||||
val body = response.errorBody()?.string().orEmpty()
|
||||
val serverMessage = extractServerMessage(body)
|
||||
val fallback = when (response.code()) {
|
||||
401 -> "Ungueltige Anmeldedaten"
|
||||
403 -> "Konto nicht freigeschaltet"
|
||||
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
|
||||
else -> "Anmeldung fehlgeschlagen"
|
||||
}
|
||||
val diagnostic = buildString {
|
||||
append("\n\nDiagnose:\n")
|
||||
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
|
||||
append("Request: ").append(requestPreview).append('\n')
|
||||
append("HTTP: ").append(response.code()).append('\n')
|
||||
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
|
||||
append("Server message: ").append(serverMessage ?: "none")
|
||||
}
|
||||
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
|
||||
}
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "LoginRepository")
|
||||
scope.setTag("operation", "login")
|
||||
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout(): Result<Unit> = runCatching {
|
||||
@@ -41,27 +71,55 @@ class LoginRepository @Inject constructor(
|
||||
return@runCatching AuthStatusResponse()
|
||||
}
|
||||
|
||||
var response = api.authStatus()
|
||||
var response = retryOnNetworkFailure { api.authStatus() }
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
var status = response.body() ?: AuthStatusResponse()
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
||||
response = api.authStatus()
|
||||
response = retryOnNetworkFailure { api.authStatus() }
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
status = response.body() ?: AuthStatusResponse()
|
||||
}
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
||||
status
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "LoginRepository")
|
||||
scope.setTag("operation", "status")
|
||||
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
|
||||
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
||||
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
||||
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.register(request)
|
||||
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.register(request)
|
||||
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractServerMessage(raw: String): String? {
|
||||
if (raw.isBlank()) return null
|
||||
val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"")
|
||||
return msgRegex.find(raw)?.groupValues?.getOrNull(1)
|
||||
}
|
||||
|
||||
private fun maskEmail(rawEmail: String): String {
|
||||
val email = rawEmail.trim()
|
||||
if (!email.contains('@')) return "hidden"
|
||||
val local = email.substringBefore('@')
|
||||
val domain = email.substringAfter('@')
|
||||
val localMasked = if (local.length <= 2) "**" else local.take(2) + "***"
|
||||
return "$localMasked@$domain"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.MannschaftenSeasonsResponse
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -24,9 +26,19 @@ data class Mannschaft(
|
||||
@Singleton
|
||||
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
||||
val response = api.mannschaften(season)
|
||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty())
|
||||
retryOnNetworkFailure {
|
||||
val response = api.mannschaften(season)
|
||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.mannschaftenSeasons()
|
||||
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||
response.body() ?: error("Saisons konnten nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
||||
|
||||
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.NewsResponse
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.data.QttrValuesResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -24,6 +25,18 @@ class MemberAreaRepository @Inject constructor(
|
||||
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun qttrValues(): Result<QttrValuesResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.qttrValues()
|
||||
if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putQttrValues,
|
||||
cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) },
|
||||
fallbackMessage = "QTTR-Werte konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun members(): Result<MembersResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
@@ -88,7 +101,7 @@ class MemberAreaRepository @Inject constructor(
|
||||
response.body() ?: emptyMap()
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
|
||||
suspend fun deleteNews(id: String): Result<Unit> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
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.Singleton
|
||||
|
||||
data class MembershipDocument(val message: String, val uri: String)
|
||||
data class MembershipDocument(val message: String, val uri: String? = null)
|
||||
|
||||
@Singleton
|
||||
class MembershipRepository @Inject constructor(
|
||||
@@ -18,21 +18,33 @@ class MembershipRepository @Inject constructor(
|
||||
) {
|
||||
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
|
||||
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")
|
||||
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
|
||||
val downloadUrl = body.downloadUrl ?: error("PDF-Download fehlt.")
|
||||
val documentResponse = api.downloadMembershipPdf(downloadUrl)
|
||||
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
|
||||
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
|
||||
val file = File(directory, "beitrittserklaerung.pdf")
|
||||
documentResponse.body()?.byteStream()?.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: error("Leere PDF-Antwort")
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||
val uri = body.downloadUrl?.let { downloadUrl ->
|
||||
runCatching {
|
||||
val documentResponse = api.downloadMembershipPdf(downloadUrl, body.downloadToken)
|
||||
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
|
||||
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
|
||||
val file = File(directory, "beitrittserklaerung.pdf")
|
||||
documentResponse.body()?.byteStream()?.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: error("Leere PDF-Antwort")
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.files", file).toString()
|
||||
}.getOrNull()
|
||||
}
|
||||
MembershipDocument(
|
||||
message = body.message ?: "Beitrittsformular erfolgreich erstellt.",
|
||||
uri = uri.toString(),
|
||||
message = if (uri == null) {
|
||||
"${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) {
|
||||
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
|
||||
val response = api.publicNewsletterGroups()
|
||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.publicNewsletterGroups()
|
||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
|
||||
@@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||
}
|
||||
|
||||
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.confirmNewsletter(token)
|
||||
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
||||
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.confirmNewsletter(token)
|
||||
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
||||
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
) {
|
||||
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
|
||||
val optionsResponse = api.passkeyAuthenticationOptions(
|
||||
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
||||
)
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
retryOnNetworkFailure {
|
||||
val optionsResponse = api.passkeyAuthenticationOptions(
|
||||
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
||||
)
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.getCredential(
|
||||
context = context,
|
||||
request = GetCredentialRequest(
|
||||
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
||||
),
|
||||
)
|
||||
val credential = credentialResponse.credential as? PublicKeyCredential
|
||||
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.getCredential(
|
||||
context = context,
|
||||
request = GetCredentialRequest(
|
||||
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
||||
),
|
||||
)
|
||||
val credential = credentialResponse.credential as? PublicKeyCredential
|
||||
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
||||
|
||||
val response = api.passkeyLogin(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credential.authenticationResponseJson))
|
||||
.put("client", "android")
|
||||
.put("deviceName", "Harheimer TC Android-App")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
val response = api.passkeyLogin(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credential.authenticationResponseJson))
|
||||
.put("client", "android")
|
||||
.put("deviceName", "Harheimer TC Android-App")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
}
|
||||
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
|
||||
|
||||
suspend fun list(): Result<PasskeysResponse> = runCatching {
|
||||
val response = api.passkeys()
|
||||
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.passkeys()
|
||||
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
|
||||
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
retryOnNetworkFailure {
|
||||
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.createCredential(
|
||||
context = context,
|
||||
request = CreatePublicKeyCredentialRequest(optionsJson),
|
||||
) as? CreatePublicKeyCredentialResponse
|
||||
?: error("Der erstellte Zugang ist kein Passkey.")
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.createCredential(
|
||||
context = context,
|
||||
request = CreatePublicKeyCredentialRequest(optionsJson),
|
||||
) as? CreatePublicKeyCredentialResponse
|
||||
?: error("Der erstellte Zugang ist kein Passkey.")
|
||||
|
||||
val response = api.registerPasskey(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
||||
.put("name", name)
|
||||
.put("client", "android")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
val response = api.registerPasskey(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
||||
.put("name", name)
|
||||
.put("client", "android")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
|
||||
|
||||
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
|
||||
|
||||
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
|
||||
class ProfileRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun load(): Result<ProfileResponse> = runCatching {
|
||||
val response = api.profile()
|
||||
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.profile()
|
||||
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
|
||||
val response = api.updateProfile(request)
|
||||
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.updateProfile(request)
|
||||
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
|
||||
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
|
||||
val response = api.spielsysteme()
|
||||
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 8) return@mapNotNull null
|
||||
Spielsystem(
|
||||
name = values[0],
|
||||
description = values[1],
|
||||
teamSize = values[2],
|
||||
category = values[3],
|
||||
sequence = values[5],
|
||||
gameCount = values[6],
|
||||
features = values[7],
|
||||
)
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielsysteme()
|
||||
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 8) return@mapNotNull null
|
||||
Spielsystem(
|
||||
name = values[0],
|
||||
description = values[1],
|
||||
teamSize = values[2],
|
||||
category = values[3],
|
||||
sequence = values[5],
|
||||
gameCount = values[6],
|
||||
features = values[7],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
||||
val response = api.vereinsmeisterschaften()
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 6) return@mapNotNull null
|
||||
MeisterschaftResult(
|
||||
year = values[0],
|
||||
category = values[1],
|
||||
rank = values[2],
|
||||
playerOne = values[3],
|
||||
playerTwo = values[4],
|
||||
note = values[5],
|
||||
imageOne = values.getOrElse(6) { "" },
|
||||
imageTwo = values.getOrElse(7) { "" },
|
||||
)
|
||||
retryOnNetworkFailure {
|
||||
val response = api.vereinsmeisterschaften()
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 6) return@mapNotNull null
|
||||
MeisterschaftResult(
|
||||
year = values[0],
|
||||
category = values[1],
|
||||
rank = values[2],
|
||||
playerOne = values[3],
|
||||
playerTwo = values[4],
|
||||
note = values[5],
|
||||
imageOne = values.getOrElse(6) { "" },
|
||||
imageTwo = values.getOrElse(7) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||
body
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
|
||||
val response = api.spielplanTable(team, season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
||||
body
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplanTable(team, season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
class TermineRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
|
||||
val response = api.termine()
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body()?.termine.orEmpty()
|
||||
retryOnNetworkFailure {
|
||||
val response = api.termine()
|
||||
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
|
||||
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.config()
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.R
|
||||
@@ -40,14 +43,17 @@ private enum class MenuSection {
|
||||
TRAINING,
|
||||
NEWSLETTER,
|
||||
INTERN,
|
||||
CMS,
|
||||
}
|
||||
|
||||
private data class MenuTarget(val label: String, val route: String)
|
||||
private const val LOGOUT_ROUTE = "__logout__"
|
||||
|
||||
@Composable
|
||||
fun AppNavigationHeader(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
onLogout: () -> Unit = {},
|
||||
webTabletNavigation: Boolean = false,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
@@ -59,9 +65,9 @@ fun AppNavigationHeader(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (webTabletNavigation) {
|
||||
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
|
||||
WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||
} else {
|
||||
CompactNavigation(selectedRoute, onNavigate, navigationState)
|
||||
CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,18 +76,79 @@ fun AppNavigationHeader(
|
||||
private fun CompactNavigation(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
|
||||
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
val routeSection = menuSection(selectedRoute)
|
||||
val sectionOverride = androidx.compose.runtime.remember(selectedRoute) { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||
val section = sectionOverride.value ?: routeSection
|
||||
val subItems = submenu(section, navigationState)
|
||||
|
||||
val mainScroll = rememberScrollState()
|
||||
val subScroll = rememberScrollState()
|
||||
|
||||
BrandRow(
|
||||
loggedIn = navigationState.loggedIn,
|
||||
onLogin = { onNavigate(Destinations.Login.route) },
|
||||
onLogout = onLogout,
|
||||
)
|
||||
|
||||
ScrollableMenuRow(scrollState = mainScroll) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
|
||||
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
|
||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
|
||||
if (navigationState.showGallery) {
|
||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
|
||||
}
|
||||
if (navigationState.loggedIn) {
|
||||
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
|
||||
if (navigationState.canAccessCms) {
|
||||
CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS }
|
||||
}
|
||||
} else {
|
||||
CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
}
|
||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
}
|
||||
|
||||
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||
subItems.forEach { item ->
|
||||
SubLink(item.label, item.route == selectedRoute) {
|
||||
if (item.route == LOGOUT_ROUTE) {
|
||||
onLogout()
|
||||
} else {
|
||||
onNavigate(item.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactSectionLink(
|
||||
label: String,
|
||||
section: MenuSection,
|
||||
activeSection: MenuSection?,
|
||||
modifier: Modifier = Modifier,
|
||||
onSelect: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = if (activeSection == section) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.clickable { onSelect() },
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,12 +156,13 @@ private fun CompactNavigation(
|
||||
private fun WebTabletNavigation(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
navigationState: NavigationUiState,
|
||||
) {
|
||||
val section = menuSection(selectedRoute)
|
||||
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
// Helper that closes the CMS submenu when navigating away
|
||||
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
|
||||
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||
val section = sectionOverride.value ?: menuSection(selectedRoute)
|
||||
val subScroll = rememberScrollState()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Brand()
|
||||
Spacer(Modifier.width(16.dp))
|
||||
@@ -103,75 +171,91 @@ private fun WebTabletNavigation(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) })
|
||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
|
||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
|
||||
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
|
||||
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) })
|
||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
|
||||
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = {
|
||||
sectionOverride.value = null
|
||||
onNavigate(Destinations.Home.route)
|
||||
})
|
||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = {
|
||||
sectionOverride.value = MenuSection.VEREIN
|
||||
onNavigate(Destinations.VereinAbout.route)
|
||||
})
|
||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = {
|
||||
sectionOverride.value = MenuSection.MANNSCHAFTEN
|
||||
onNavigate(Destinations.Mannschaften.route)
|
||||
})
|
||||
MainLink("Training", section == MenuSection.TRAINING, onClick = {
|
||||
sectionOverride.value = MenuSection.TRAINING
|
||||
onNavigate(Destinations.Training.route)
|
||||
})
|
||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = {
|
||||
sectionOverride.value = null
|
||||
onNavigate(Destinations.Termine.route)
|
||||
})
|
||||
if (navigationState.showGallery) {
|
||||
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { 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) {
|
||||
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
|
||||
}
|
||||
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) })
|
||||
TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
val subItems = submenu(section, navigationState)
|
||||
// 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
|
||||
MainLink("Intern", section == MenuSection.INTERN, onClick = {
|
||||
sectionOverride.value = MenuSection.INTERN
|
||||
})
|
||||
if (navigationState.canAccessCms) {
|
||||
MainLink("CMS", section == MenuSection.CMS, onClick = {
|
||||
sectionOverride.value = MenuSection.CMS
|
||||
onNavigate(Destinations.Cms.route)
|
||||
})
|
||||
}
|
||||
} 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
|
||||
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(top = 6.dp, bottom = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
cmsChildren.forEach { child ->
|
||||
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
|
||||
val subItems = submenu(section, navigationState)
|
||||
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||
subItems.forEach { item ->
|
||||
SubLink(item.label, item.route == selectedRoute) {
|
||||
if (item.route == LOGOUT_ROUTE) {
|
||||
onLogout()
|
||||
} else {
|
||||
onNavigate(item.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrandRow(onLogin: () -> Unit) {
|
||||
private fun BrandRow(
|
||||
loggedIn: Boolean,
|
||||
onLogin: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Brand()
|
||||
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(
|
||||
label,
|
||||
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.94f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -229,23 +313,62 @@ private fun CompactLink(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
beforeNavigate: () -> Unit = {},
|
||||
) {
|
||||
Surface(
|
||||
color = if (route == selectedRoute) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.clickable { onNavigate(route) },
|
||||
modifier = modifier.clickable {
|
||||
beforeNavigate()
|
||||
onNavigate(route)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScrollableMenuRow(
|
||||
scrollState: ScrollState,
|
||||
topPadding: Dp = 0.dp,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = topPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
if (scrollState.canScrollBackward) "◀" else "",
|
||||
color = Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.width(14.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.horizontalScroll(scrollState),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content,
|
||||
)
|
||||
Text(
|
||||
if (scrollState.canScrollForward) "▶" else "",
|
||||
color = Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.width(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
@@ -256,7 +379,7 @@ private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Text(
|
||||
label,
|
||||
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),
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -287,14 +410,19 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
||||
Destinations.NewsletterConfirm.route,
|
||||
Destinations.NewsletterConfirmed.route,
|
||||
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
||||
|
||||
Destinations.MemberArea.route,
|
||||
Destinations.Members.route,
|
||||
Destinations.Qttr.route,
|
||||
Destinations.MemberNews.route,
|
||||
Destinations.Profile.route,
|
||||
Destinations.MemberApi.route,
|
||||
Destinations.NotificationSettings.route,
|
||||
Destinations.MemberApi.route -> MenuSection.INTERN
|
||||
|
||||
Destinations.CmsStartseite.route,
|
||||
Destinations.CmsInhalte.route,
|
||||
Destinations.CmsVereinsmeisterschaften.route,
|
||||
Destinations.CmsNews.route,
|
||||
Destinations.CmsSportbetrieb.route,
|
||||
Destinations.CmsMitgliederverwaltung.route,
|
||||
Destinations.CmsNewsletter.route,
|
||||
@@ -302,7 +430,8 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
||||
Destinations.CmsEinstellungen.route,
|
||||
Destinations.CmsBenutzer.route,
|
||||
Destinations.CmsPasswordResetDiagnostics.route,
|
||||
Destinations.Cms.route -> MenuSection.INTERN
|
||||
Destinations.Cms.route -> MenuSection.CMS
|
||||
|
||||
else -> null
|
||||
}.let { 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("Impressum", Destinations.Impressum.route),
|
||||
)
|
||||
|
||||
MenuSection.MANNSCHAFTEN -> listOf(
|
||||
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
||||
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
|
||||
MenuTarget("Spielpläne", Destinations.Spielplan.route),
|
||||
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
|
||||
)
|
||||
|
||||
MenuSection.TRAINING -> listOf(
|
||||
MenuTarget("Trainingszeiten", Destinations.Training.route),
|
||||
MenuTarget("Trainer", Destinations.Trainer.route),
|
||||
MenuTarget("Anfänger", Destinations.Anfaenger.route),
|
||||
MenuTarget("TT-Regeln", Destinations.Regeln.route),
|
||||
)
|
||||
|
||||
MenuSection.NEWSLETTER -> listOf(
|
||||
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
||||
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
||||
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
|
||||
)
|
||||
|
||||
MenuSection.INTERN -> buildList {
|
||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
||||
add(MenuTarget("TTR / QTTR", Destinations.Qttr.route))
|
||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.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))
|
||||
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||
}
|
||||
|
||||
MenuSection.CMS -> buildList {
|
||||
if (state.canAccessFullCms) {
|
||||
add(MenuTarget("Übersicht", Destinations.Cms.route))
|
||||
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||
add(MenuTarget("News", Destinations.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.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
||||
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
|
||||
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
|
||||
}
|
||||
|
||||
null -> emptyList()
|
||||
}
|
||||
|
||||
0
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 Impressum : Destinations("impressum")
|
||||
object Mannschaften : Destinations("mannschaften")
|
||||
object MannschaftDetail : Destinations("mannschaften/{slug}") {
|
||||
fun create(slug: String): String = "mannschaften/$slug"
|
||||
object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
|
||||
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}") {
|
||||
fun create(slug: String): String = "mannschaft/$slug"
|
||||
@@ -36,12 +40,15 @@ sealed class Destinations(val route: String) {
|
||||
object Register : Destinations("register")
|
||||
object MemberArea : Destinations("intern")
|
||||
object Members : Destinations("intern/mitglieder")
|
||||
object Qttr : Destinations("intern/qttr")
|
||||
object MemberNews : Destinations("intern/news")
|
||||
object Profile : Destinations("intern/profil")
|
||||
object NotificationSettings : Destinations("intern/benachrichtigungen")
|
||||
object MemberApi : Destinations("intern/api")
|
||||
object CmsStartseite : Destinations("cms/startseite")
|
||||
object CmsInhalte : Destinations("cms/inhalte")
|
||||
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
||||
object CmsNews : Destinations("cms/news")
|
||||
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
||||
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
||||
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.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.Column
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
import de.harheimertc.ui.components.LiveTickerBanner
|
||||
|
||||
@Composable
|
||||
fun NavGraph(
|
||||
@@ -26,23 +38,53 @@ fun NavGraph(
|
||||
val backStackEntry = navController.currentBackStackEntryAsState().value
|
||||
val route = backStackEntry?.destination?.route
|
||||
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
||||
backStackEntry.arguments?.getString("slug")?.let(Destinations.MannschaftDetail::create)
|
||||
backStackEntry.arguments?.getString("slug")?.let { slug ->
|
||||
Destinations.MannschaftDetail.create(slug, backStackEntry.arguments?.getString("season"))
|
||||
}
|
||||
} else route
|
||||
val navigationState by navigationViewModel.state.collectAsState()
|
||||
LaunchedEffect(currentRoute) {
|
||||
navigationViewModel.refreshSession()
|
||||
}
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val persistentNavigation = maxWidth >= 600.dp
|
||||
LaunchedEffect(navigationState.loggedIn) {
|
||||
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()) {
|
||||
navigationState.connectionNote?.let { message ->
|
||||
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = message,
|
||||
color = Color(0xFF7C2D12),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (persistentNavigation) {
|
||||
AppNavigationHeader(
|
||||
selectedRoute = currentRoute,
|
||||
onNavigate = navController::navigateTopLevel,
|
||||
webTabletNavigation = true,
|
||||
onLogout = {
|
||||
navigationViewModel.logout {
|
||||
navController.navigate(Destinations.Home.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
},
|
||||
webTabletNavigation = tabletNavigation,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
if (navigationState.loggedIn) {
|
||||
LiveTickerBanner(
|
||||
matches = navigationState.liveTickerMatches,
|
||||
loaded = navigationState.liveTickerLoaded,
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
@@ -52,6 +94,8 @@ fun NavGraph(
|
||||
de.harheimertc.ui.screens.home.HomeScreen(
|
||||
navController = navController,
|
||||
showNavigationHeader = !persistentNavigation,
|
||||
navigationViewModel = navigationViewModel,
|
||||
viewModel = hiltViewModel(),
|
||||
)
|
||||
}
|
||||
composable(Destinations.VereinAbout.route) {
|
||||
@@ -111,9 +155,13 @@ fun NavGraph(
|
||||
composable("mannschaften/jugend") {
|
||||
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(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
season = entry.arguments?.getString("season"),
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
@@ -121,6 +169,7 @@ fun NavGraph(
|
||||
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
season = null,
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
@@ -253,6 +302,7 @@ fun NavGraph(
|
||||
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Members.route) {
|
||||
@@ -261,6 +311,12 @@ fun NavGraph(
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Qttr.route) {
|
||||
de.harheimertc.ui.screens.memberarea.QttrScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MemberNews.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
|
||||
navController = navController,
|
||||
@@ -273,6 +329,13 @@ fun NavGraph(
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NotificationSettings.route) {
|
||||
de.harheimertc.ui.screens.notifications.NotificationSettingsScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MemberApi.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
|
||||
navController = navController,
|
||||
@@ -280,43 +343,141 @@ fun NavGraph(
|
||||
)
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
val isTeamDetail = route.startsWith("mannschaften/") &&
|
||||
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.viewModelScope
|
||||
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.LoginRepository
|
||||
import de.harheimertc.repositories.LiveTickerRepository
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -18,10 +26,15 @@ data class NavigationUiState(
|
||||
val hasGalleryImages: Boolean = false,
|
||||
val loggedIn: Boolean = false,
|
||||
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 canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
||||
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
|
||||
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
|
||||
val canAccessCms: Boolean get() = canAccessFullCms || canAccessNewsletter || canAccessContactRequests
|
||||
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
||||
}
|
||||
|
||||
@@ -30,12 +43,27 @@ class NavigationViewModel @Inject constructor(
|
||||
private val mannschaftenRepository: MannschaftenRepository,
|
||||
private val galleryRepository: GalleryRepository,
|
||||
private val loginRepository: LoginRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
private val pushTokenRepository: PushTokenRepository,
|
||||
private val liveTickerRepository: LiveTickerRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NavigationUiState())
|
||||
val state: StateFlow<NavigationUiState> = _state
|
||||
private var tickerJob: Job? = null
|
||||
private var tickerActive = false
|
||||
|
||||
init {
|
||||
loadNavigationData()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
_state.value = _state.value.copy(
|
||||
connectionNote = if (online) null else "Keine Verbindung. Die App versucht alle 10 Sekunden erneut zu laden.",
|
||||
)
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadNavigationData() {
|
||||
@@ -44,22 +72,75 @@ class NavigationViewModel @Inject constructor(
|
||||
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
|
||||
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
||||
val status = auth.await()
|
||||
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||
_state.value = NavigationUiState(
|
||||
teams = teams.await(),
|
||||
hasGalleryImages = gallery.await(),
|
||||
loggedIn = status.isLoggedIn,
|
||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
||||
loggedIn = loggedIn,
|
||||
roles = status.navigationRoles(),
|
||||
connectionNote = null,
|
||||
liveTickerMatches = _state.value.liveTickerMatches,
|
||||
liveTickerLoaded = _state.value.liveTickerLoaded,
|
||||
)
|
||||
if (loggedIn) registerPushToken()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSession() {
|
||||
viewModelScope.launch {
|
||||
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
||||
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||
_state.value = _state.value.copy(
|
||||
loggedIn = status.isLoggedIn,
|
||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
||||
loggedIn = loggedIn,
|
||||
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.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
@@ -36,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -49,7 +49,7 @@ import java.util.Locale
|
||||
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
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 loginState by loginVm.state.collectAsState()
|
||||
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)
|
||||
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 title 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") {
|
||||
if (state.loading) item { CircularProgressIndicator() }
|
||||
if (state.loading) item { LoadingState("News werden geladen...") }
|
||||
|
||||
item {
|
||||
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 ->
|
||||
val selected = news.id?.let { selection.contains(it) } ?: false
|
||||
NewsListItem(news = news, selected = selected, onSelect = { id, sel ->
|
||||
id?.let {
|
||||
selection = if (sel) selection + it else selection - it
|
||||
}
|
||||
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } })
|
||||
NewsListItem(
|
||||
news = news,
|
||||
canWrite = canWrite,
|
||||
selected = selected,
|
||||
onSelect = { id, sel ->
|
||||
id?.let {
|
||||
selection = if (sel) selection + it else selection - it
|
||||
}
|
||||
},
|
||||
onEdit = { openEdit(news) },
|
||||
onDelete = { news.id?.let { id -> deletingIds = listOf(id) } },
|
||||
)
|
||||
}
|
||||
|
||||
// bulk action bar
|
||||
if (selection.isNotEmpty()) {
|
||||
if (canWrite && selection.isNotEmpty()) {
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
||||
@@ -255,16 +262,21 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
||||
@Composable
|
||||
private fun NewsListItem(
|
||||
news: NewsDto,
|
||||
canWrite: Boolean,
|
||||
selected: Boolean = false,
|
||||
onSelect: (Int?, Boolean) -> Unit = { _, _ -> },
|
||||
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
|
||||
onEdit: (NewsDto) -> Unit,
|
||||
onDelete: (Int) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) })
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onSelect(news.id, it) },
|
||||
enabled = canWrite,
|
||||
)
|
||||
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
||||
if (news.isPublic) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||
@@ -297,9 +309,11 @@ private fun NewsListItem(
|
||||
Text("Aktualisiert: ${news.updated}")
|
||||
}
|
||||
}
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
if (canWrite) {
|
||||
Row {
|
||||
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
|
||||
|
||||
import android.content.Intent
|
||||
import android.app.DatePickerDialog
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -37,6 +38,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.navigation.NavController
|
||||
@@ -49,9 +52,12 @@ import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.PasswordResetStepDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.repositories.CmsMannschaftRow
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
@@ -61,13 +67,14 @@ import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
if (state.loading) item { LoadingState("CMS-Daten werden geladen...") }
|
||||
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.") {
|
||||
when {
|
||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.loading || config == null -> item { LoadingState("Startseitenkonfiguration wird geladen...") }
|
||||
else -> {
|
||||
item {
|
||||
Button(
|
||||
@@ -171,7 +178,7 @@ fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean,
|
||||
|
||||
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
|
||||
when {
|
||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.loading || config == null -> item { LoadingState("Inhalte werden geladen...") }
|
||||
else -> {
|
||||
item {
|
||||
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") {
|
||||
when {
|
||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.loading -> item { LoadingState("Vereinsmeisterschaften werden geladen...") }
|
||||
else -> {
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -433,116 +440,426 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
||||
@Composable
|
||||
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val config = state.config
|
||||
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>() }
|
||||
val context = LocalContext.current
|
||||
var activeTab by remember { mutableStateOf("termine") }
|
||||
val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() }
|
||||
var spielplanCsv by remember { mutableStateOf("") }
|
||||
var spielplanEditorOpen by remember { mutableStateOf(false) }
|
||||
var terminDialogOpen by remember { mutableStateOf(false) }
|
||||
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) {
|
||||
config?.let {
|
||||
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)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadSportbetrieb()
|
||||
}
|
||||
|
||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
||||
when {
|
||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
||||
else -> {
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.saveConfig(
|
||||
config.copy(
|
||||
training = config.training.copy(
|
||||
ort = config.training.ort.copy(
|
||||
name = ortName,
|
||||
strasse = ortStrasse,
|
||||
plz = ortPlz,
|
||||
ort = ortOrt,
|
||||
),
|
||||
zeiten = trainingTimes.toList(),
|
||||
),
|
||||
trainer = trainers.toList(),
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = !state.saving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
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) }
|
||||
LaunchedEffect(state.sportMannschaften) {
|
||||
mannschaften.clear()
|
||||
mannschaften.addAll(state.sportMannschaften)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) {
|
||||
spielplanCsv = sportSpielplanCsvText(state.sportSpielplanHeaders, state.sportSpielplanRows)
|
||||
}
|
||||
|
||||
fun openTerminDialog(termin: TerminDto?) {
|
||||
editingTermin = termin
|
||||
terminDatum = termin?.datum.orEmpty()
|
||||
terminUhrzeit = termin?.uhrzeit.orEmpty()
|
||||
terminTitel = termin?.titel.orEmpty()
|
||||
terminBeschreibung = termin?.beschreibung.orEmpty()
|
||||
terminKategorie = termin?.kategorie ?: "Sonstiges"
|
||||
terminDialogOpen = true
|
||||
}
|
||||
|
||||
fun openDatePicker() {
|
||||
val calendar = Calendar.getInstance()
|
||||
runCatching {
|
||||
val parts = terminDatum.split("-")
|
||||
if (parts.size == 3) {
|
||||
calendar.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -555,7 +872,7 @@ fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigati
|
||||
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
if (state.loading) item { LoadingState("Kontaktanfragen werden geladen...") }
|
||||
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
|
||||
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
|
||||
}
|
||||
@@ -592,7 +909,7 @@ fun CmsNewsletterScreen(
|
||||
var grpTargetGroup by remember { mutableStateOf("") }
|
||||
var grpSendToExternal by remember { mutableStateOf(true) }
|
||||
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
if (state.loading) item { LoadingState("Newsletter-Daten werden geladen...") }
|
||||
item {
|
||||
if (canWrite) Button(onClick = {
|
||||
editingNewsletter = null
|
||||
@@ -619,11 +936,13 @@ fun CmsNewsletterScreen(
|
||||
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
|
||||
items(state.newsletters.size) { index ->
|
||||
val item = state.newsletters[index]
|
||||
NewsletterCard(item,
|
||||
NewsletterCard(
|
||||
item,
|
||||
canWrite = canWrite,
|
||||
onEdit = { nl ->
|
||||
editingNewsletter = nl
|
||||
nlTitle = nl.title
|
||||
nlContent = nl.title ?: ""
|
||||
nlContent = nl.title
|
||||
nlType = "subscription"
|
||||
nlTargetGroup = ""
|
||||
nlSendToExternal = true
|
||||
@@ -637,7 +956,9 @@ fun CmsNewsletterScreen(
|
||||
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
|
||||
items(state.newsletterGroups.size) { index ->
|
||||
val group = state.newsletterGroups[index]
|
||||
NewsletterGroupCard(group,
|
||||
NewsletterGroupCard(
|
||||
group,
|
||||
canWrite = canWrite,
|
||||
onEdit = { g ->
|
||||
editingGroup = g
|
||||
grpName = g.name
|
||||
@@ -753,6 +1074,12 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
||||
var websiteVorname by remember { mutableStateOf("") }
|
||||
var websiteNachname 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) {
|
||||
config?.let {
|
||||
@@ -764,12 +1091,20 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
||||
websiteVorname = it.website.verantwortlicher.vorname
|
||||
websiteNachname = it.website.verantwortlicher.nachname
|
||||
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") {
|
||||
when {
|
||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.loading || config == null -> item { LoadingState("Einstellungen werden geladen...") }
|
||||
else -> {
|
||||
item {
|
||||
Button(
|
||||
@@ -790,6 +1125,16 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
||||
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())
|
||||
}
|
||||
}
|
||||
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 {
|
||||
DataCard("Systemstatus") {
|
||||
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
|
||||
@@ -883,7 +1285,7 @@ fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavi
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
item { CircularProgressIndicator(color = Primary600) }
|
||||
item { LoadingState("Diagnosedaten werden geladen...") }
|
||||
}
|
||||
|
||||
if (state.passwordResetSearchTerm.isNotBlank()) {
|
||||
@@ -950,6 +1352,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
|
||||
val cards = listOf(
|
||||
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.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("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
||||
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
||||
@@ -1073,7 +1476,7 @@ private fun CmsConfigPage(
|
||||
) {
|
||||
CmsPage(navController, showBackNavigation, title, subtitle) {
|
||||
if (config == null) {
|
||||
item { CircularProgressIndicator(color = Primary600) }
|
||||
item { LoadingState("Konfiguration wird geladen...") }
|
||||
} else {
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
@@ -1229,29 +1632,44 @@ private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewMod
|
||||
}
|
||||
|
||||
@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 } }) {
|
||||
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
|
||||
InfoRow("Erstellt", newsletter.createdAt ?: "-")
|
||||
InfoRow("Versendet", newsletter.sentAt ?: "-")
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
if (newsletter.status != "sent") {
|
||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
|
||||
if (canWrite) {
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
if (newsletter.status != "sent") {
|
||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 }) {
|
||||
InfoRow("Beschreibung", group.description.ifBlank { "-" })
|
||||
InfoRow("Abonnenten", group.subscribers.size.toString())
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
if (canWrite) {
|
||||
Row {
|
||||
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.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConnectivityMonitor
|
||||
import de.harheimertc.data.CmsUserDto
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
@@ -12,6 +13,10 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
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.MeisterschaftResult
|
||||
import kotlinx.coroutines.async
|
||||
@@ -38,17 +43,37 @@ data class CmsUiState(
|
||||
val passwordResetFailedOnly: Boolean = true,
|
||||
val news: List<NewsDto> = 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
|
||||
class CmsViewModel @Inject constructor(
|
||||
private val repository: CmsRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(CmsUiState())
|
||||
val state: StateFlow<CmsUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
if (online && wasOnline == false) {
|
||||
load()
|
||||
}
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
@@ -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) {
|
||||
viewModelScope.launch {
|
||||
_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 {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
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 {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
@@ -251,7 +453,7 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkDelete(ids: List<Int>) {
|
||||
fun bulkDelete(ids: List<String>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
@@ -262,7 +464,7 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNews(id: Int) {
|
||||
fun deleteNews(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.ImageGrid
|
||||
|
||||
@Composable
|
||||
@@ -131,7 +131,7 @@ fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator()
|
||||
LoadingState("Galerie wird geladen...")
|
||||
} else if (images.isEmpty()) {
|
||||
Text(text = stringResource(R.string.gallery_empty))
|
||||
} 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