59 Commits

Author SHA1 Message Date
Torsten Schulz (local)
281c25b05d feat: aktualisiere die Version auf 1.8.4 und verbessere die Erkennung von Jugendmannschaften in den Filterfunktionen
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 8m54s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m41s
2026-07-17 13:46:25 +02:00
Torsten Schulz (local)
1beb5c2eee feat: verbessere die Auswahl von Spielplan-Team-IDs durch Checkboxen und aktualisiere die Logik zur Verarbeitung von Team-Kriterien
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m43s
2026-07-17 12:05:22 +02:00
Torsten Schulz (local)
461f2de8dc feat: unterstütze mehrere Spielplan-Team-IDs und verbessere die Zuordnung importierter Mannschaften
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m17s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m46s
2026-07-17 11:49:55 +02:00
Torsten Schulz (local)
75a7c409cd feat: füge Auswahl für Spielplan-Team-ID hinzu und lade Teams von der API
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m55s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m40s
2026-07-17 11:20:15 +02:00
Torsten Schulz (local)
751c302036 feat: füge Importfunktion für Spielpläne von myTischtennis hinzu und verbessere den Ladevorgang
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m55s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m43s
2026-07-17 11:03:46 +02:00
Torsten Schulz (local)
f0e1ad39b0 Verbessere die Altersklassenerkennung in der Funktion sideMatchesJugendTeam durch Verwendung eines regulären Ausdrucks zur genauen Übereinstimmung
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m4s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m55s
2026-07-17 10:33:46 +02:00
Torsten Schulz (local)
1dc84023d0 feat: füge Unterstützung für importierte Spielplan-Teams hinzu und verbessere Teamfilter-Logik
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m37s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-07-17 09:29:34 +02:00
Torsten Schulz (local)
30465c7833 Verbessere Codequalität und Sicherheit durch Refactoring, Hinzufügen von ESLint-Regeln und Aktualisierung der OSV-Scanner-Konfiguration
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m57s
2026-07-17 09:08:42 +02:00
Torsten Schulz (local)
d260f00756 Verbessere Fehlerbehandlung in der Verschlüsselung und PDF-Generierung durch Hinzufügen von Ursacheninformationen
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m34s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-07-17 08:52:16 +02:00
Torsten Schulz (local)
f0ac4b7e56 Verbessere Fehlerbehandlung beim Laden von PDF-Vorlagen und füge Ursacheninformationen hinzu
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-07-17 08:50:47 +02:00
Torsten Schulz (local)
bd45beadd5 feat(cms): add endpoint for manual spielplan import with league tables handling
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 3m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-07-17 08:41:11 +02:00
Torsten Schulz (local)
f060c9771f Füge Unterstützung für Spielplan-Team-ID in mehreren Komponenten hinzu und aktualisiere CSV-Export
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m2s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
2026-07-17 08:29:34 +02:00
Torsten Schulz (local)
0ab71166aa Refactor Spielplan-Filterlogik in separate Utility-Funktionen und verbessere die Filterung im PDF-Generierungsprozess 2026-07-15 11:30:06 +02:00
Torsten Schulz (local)
d9c5d242ec Füge Funktion zur Umwandlung von Saison-Slugs in Labels hinzu und aktualisiere PDF-Daten mit saisonalen Informationen
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m51s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m56s
2026-07-15 11:27:34 +02:00
Torsten Schulz (local)
cb1e7a2fed Aktualisiere Actions-Checkout- und Setup-Versionen in CI-Workflows
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 10m18s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m6s
2026-07-15 10:42:20 +02:00
Torsten Schulz (local)
3586704cce Update file permissions for multiple server and test files to executable mode
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 10m29s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-07-15 08:45:12 +02:00
Torsten Schulz (local)
7f3b58c5bc updated packages
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m16s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m7s
2026-06-23 16:33:29 +02:00
Torsten Schulz (local)
f29e8a4dac Bugfixing
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-23 16:21:53 +02:00
Torsten Schulz (local)
b69130c2b2 Fixed semgrep error
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m56s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m36s
2026-06-14 21:29:53 +02:00
Torsten Schulz (local)
77aabef4a9 Benachrichtigungen erweitert
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m53s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Emails korrigiert
2026-06-14 01:05:19 +02:00
Torsten Schulz (local)
4b699de853 NPM fix
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m6s
2026-06-13 00:42:00 +02:00
Torsten Schulz (local)
e3cb7282bc Android implementation of sportbetrieb, 401-fix
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m29s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-13 00:30:06 +02:00
Torsten Schulz (local)
e537839e28 Updated stuff
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m4s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m10s
2026-06-12 16:49:00 +02:00
Torsten Schulz (local)
44d441811c Fixed some problems
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m38s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m37s
2026-06-12 11:42:22 +02:00
Torsten Schulz (local)
da1efa5a74 Added notifications for actual news
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m59s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-11 09:03:16 +02:00
c7a306e8fa Merge branch 'main' into dev
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m55s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
2026-06-10 16:41:53 +02:00
Torsten Schulz (local)
14cd5f04d5 fixed deploy and navigation
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
2026-06-10 16:36:50 +02:00
Torsten Schulz (local)
7e533fae49 robustere pfad-suche
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m55s
2026-06-10 16:05:15 +02:00
Torsten Schulz (local)
8393f154e5 logging update
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-06-10 16:03:37 +02:00
Torsten Schulz (local)
c956869e8a actualized ignore file
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m46s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m56s
2026-06-10 15:39:51 +02:00
Torsten Schulz (local)
4eabb3b766 gitleaks fix
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 4m28s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-10 15:31:03 +02:00
Torsten Schulz (local)
146dedd9b4 config aktualisiert
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-06-10 15:27:53 +02:00
Torsten Schulz (local)
6076194497 added google service config
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-10 15:11:11 +02:00
Torsten Schulz (local)
9cde1ab78b google push service config
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
2026-06-10 15:02:22 +02:00
Torsten Schulz (local)
f5facaa811 FIX
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m40s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m0s
2026-06-10 14:39:17 +02:00
Torsten Schulz (local)
78015298ec package update
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 6m3s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 10s
2026-06-10 14:21:51 +02:00
Torsten Schulz (local)
b4e1c50ea3 semgrep problems fix
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m57s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-10 13:55:50 +02:00
Torsten Schulz (local)
5da11d2e4d Fix in news, first android notification service
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m50s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-10 13:47:33 +02:00
Torsten Schulz (local)
e8a50e55ca Fix Mannschaften 2026-06-10 08:03:44 +02:00
Torsten Schulz (local)
530e544542 Implemented the possibility ofa hidden user for playstore tests
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m40s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-09 11:32:00 +02:00
Torsten Schulz (local)
300dce9835 Paßwort vergessen modernisiert
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-09 10:31:32 +02:00
Torsten Schulz (local)
a98def915e bugfixing
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 8m1s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-09 09:59:32 +02:00
Torsten Schulz (local)
7aa7970f2e Android client updated
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-05 08:34:49 +02:00
Torsten Schulz (local)
e517720b03 Implement network retry mechanism across repositories and add connectivity monitoring
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Introduced `retryOnNetworkFailure` function to handle network-related exceptions and retry requests.
- Updated `GalleryRepository`, `HomeRepository`, `LoginRepository`, `MannschaftenRepository`, `NewsletterRepository`, `PasskeyRepository`, `ProfileRepository`, `PublicPagesRepository`, `SpielplanRepository`, `TermineRepository`, and `TrainingRepository` to use the new retry mechanism.
- Added `ConnectivityMonitor` to track internet connectivity status and notify UI components.
- Enhanced `NavigationViewModel`, `CmsViewModel`, `MembersViewModel`, and `MemberAreaViewModel` to reload data when connectivity is restored.
- Bumped app version to 0.9.16.
2026-06-04 22:15:44 +02:00
Torsten Schulz (local)
402913d877 feat: update navigation logic to manage section overrides in WebTabletNavigation
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m46s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-04 22:05:50 +02:00
Torsten Schulz (local)
2014abe660 Add unit tests for data file rotation utility functions
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m24s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Implement tests for writing data files with rotation, ensuring backups are created only on changes.
- Verify that old backups are rotated correctly and the maximum number of backups is maintained.
- Test restoration of backups while preserving the current state as a backup.
- Utilize Vitest for testing framework and manage temporary file storage during tests.
2026-06-01 11:21:21 +02:00
Torsten Schulz (local)
80834d8652 Add UI XML files for current and initial app layout
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m26s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
- Created `ui-current.xml` to represent the current state of the app's UI hierarchy.
- Created `ui.xml` to represent the initial state of the app's UI hierarchy.
2026-06-01 10:46:39 +02:00
45de2a576c Merge pull request 'dev' (#40) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m5s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #40
2026-05-31 15:14:03 +02:00
Torsten Schulz (local)
7bc98c03e4 feat: update Hero component styles and enhance index page layout with dynamic sections
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m0s
2026-05-31 15:04:24 +02:00
Torsten Schulz (local)
bf1caefde4 feat: update security headers and improve content security policy; enhance hero image component and loading states in public news
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m31s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
2026-05-31 14:19:15 +02:00
Torsten Schulz (local)
6983186caf feat: add hero image processing and API for serving variants
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Introduced a new script `prepare-hero-variants.mjs` to generate responsive hero image variants in WebP format.
- Added a fallback image `hero_fallback.png` for each variant.
- Created an API endpoint `hero-images.get.js` to retrieve available hero image variants and their fallback images.
- Implemented directory and file checks to ensure the existence of required images before serving.
2026-05-31 14:07:14 +02:00
e1ad5f7205 Merge pull request 'feat: add robots.txt and sitemap.xml routes for SEO optimization' (#39) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m11s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #39
2026-05-31 14:03:04 +02:00
Torsten Schulz (local)
7c93966878 feat: add robots.txt and sitemap.xml routes for SEO optimization
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
- Implemented a new route for robots.txt to control crawler access.
- Added a sitemap.xml route to provide search engines with a list of site URLs.
- Included functions for URL normalization and XML escaping to ensure proper formatting.
2026-05-31 13:36:49 +02:00
14341b7a63 Merge pull request 'dev' (#38) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m43s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #38
2026-05-30 23:54:07 +02:00
Torsten Schulz (local)
31d20f1bff feat: enhance CompactNavigation with CMS submenu and toggle functionality
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m57s
2026-05-30 23:51:41 +02:00
Torsten Schulz (local)
6507afea5f feat: add QTTR values feature to member area
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m49s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m7s
- Implemented QTTR values screen in the member area with data fetching and display.
- Added new API endpoint for QTTR values retrieval.
- Created a new view model for managing QTTR data state.
- Updated navigation to include QTTR section.
- Enhanced error handling and loading states for QTTR data.
- Adjusted server-side logic to import QTTR values from external source.
- Updated Android app version and adjusted build configurations.
- Added necessary UI components and styling for QTTR display.
2026-05-30 23:43:06 +02:00
Torsten Schulz (local)
387ce6e08e feat: update ProGuard rules and enhance typography for member area screens
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m14s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m3s
2026-05-30 01:24:39 +02:00
Torsten Schulz (local)
f822fc8a8e feat: update typography styles and enhance text appearance in navigation components
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m8s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m57s
2026-05-30 00:46:00 +02:00
Torsten Schulz (local)
67c746f18b Add script to generate Play Store screenshot sizes
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
- Introduced a Node.js script (`playstore-screenshot-sizes.mjs`) to resize images for Play Store screenshots based on predefined profiles (phone, tablet-7, tablet-10).
- The script reads images from a specified input directory, processes them, and saves the resized images in an output directory with appropriate naming conventions.
- Added a Bash wrapper script (`playstore-screenshot-sizes.sh`) to execute the Node.js script easily from the command line.
2026-05-30 00:30:50 +02:00
588 changed files with 10541 additions and 9029 deletions

0
.codex Normal file → Executable file
View File

14
.gitea/workflows/code-analysis.yml Normal file → Executable file
View File

@@ -6,10 +6,11 @@ on:
jobs: jobs:
analyze: analyze:
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with: with:
clean: true clean: true
fetch-depth: 0 fetch-depth: 0
@@ -28,7 +29,7 @@ jobs:
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
@@ -115,12 +116,11 @@ jobs:
chmod +x osv-scanner chmod +x osv-scanner
./osv-scanner --version ./osv-scanner --version
test -f ./package-lock.json test -f ./package-lock.json
./osv-scanner --lockfile ./package-lock.json ./osv-scanner scan -L ./package-lock.json --config ./.osv-scanner.toml
deploy-production: deploy-production:
needs: analyze
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- name: Prepare SSH - name: Prepare SSH
run: | run: |
@@ -146,7 +146,7 @@ jobs:
-o BatchMode=yes \ -o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \ -p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \ "${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'" "bash -lc 'cd /var/www/harheimertc && git reset --hard HEAD && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
deploy-test: deploy-test:
needs: analyze needs: analyze
@@ -177,4 +177,4 @@ jobs:
-o BatchMode=yes \ -o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \ -p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \ "${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'" "bash -lc 'cd /var/www/harheimertc.test && git reset --hard HEAD && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"

0
.gitea/workflows/version-gate.yml Normal file → Executable file
View File

6
.github/workflows/android-ci.yml vendored Normal file → Executable file
View File

@@ -10,14 +10,14 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
- name: Cache Gradle - name: Cache Gradle
uses: actions/cache@v4 uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches

1
.gitignore vendored Normal file → Executable file
View File

@@ -93,6 +93,7 @@ dist
/android-app/.kotlin/ /android-app/.kotlin/
/android-app/**/build/ /android-app/**/build/
/android-app/local.properties /android-app/local.properties
/android-app/gradle-local.properties
# Build output (but keep production data!) # Build output (but keep production data!)
.output .output

7
.gitleaks.toml Executable file
View 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
View 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

0
.nvmrc Normal file → Executable file
View File

4
.osv-scanner.toml Normal file
View File

@@ -0,0 +1,4 @@
[[IgnoredVulns]]
id = "GHSA-v3m3-f69x-jf25"
ignoreUntil = 2026-12-31
reason = "Temporary exception: Quill 2.0.3 is required by the current RichTextEditor implementation, and OSV currently reports no fixed version. Track upstream fix and remove this ignore once a patched release is available."

0
.semgrepignore Normal file → Executable file
View File

0
ANDROID_ARCHITECTURE.md Normal file → Executable file
View File

0
ANDROID_KOTLIN_PLAN.md Normal file → Executable file
View File

0
ANDROID_PORT_TODO.md Normal file → Executable file
View File

0
ANDROID_REPO_ENDPOINTS.md Normal file → Executable file
View File

0
AUTH_README.md Normal file → Executable file
View File

0
DATENSCHUTZ.md Normal file → Executable file
View File

0
DATENSCHUTZ_UEBERSICHT.md Normal file → Executable file
View File

0
DEPLOYMENT.md Normal file → Executable file
View File

36
android-app/PLAYSTORE_ASSETS.md Normal file → Executable file
View File

@@ -36,13 +36,12 @@ Ausgabe in:
## 3) Screenshots (anonymisiert) ## 3) Screenshots (anonymisiert)
### Grobe Anforderungen (Telefon) ### Zielgroessen fuer Store-Upload
- Mindestens 2 Screenshots - Telefon (Portrait): 1080 x 1920
- PNG oder JPEG - Medium 7" Tablet (Portrait): 1200 x 1920
- Seitenlaenge je Seite zwischen 320 px und 3840 px - 10" Tablet (Portrait): 1600 x 2560
Empfehlung fuer Android-Phone: Alle Dateien als PNG oder JPEG.
- 1080 x 1920 (Portrait)
### Anonymisierung ### Anonymisierung
@@ -61,10 +60,33 @@ Beispiel:
'68,118,520,72;70,706,560,98' '68,118,520,72;70,706,560,98'
``` ```
### Zielprofile erzeugen (Telefon, 7", 10")
Aus allen Dateien in `android-app/playstore-assets/anon` werden die drei Profile erzeugt:
```bash
./scripts/playstore-screenshot-sizes.sh
```
Optional mit eigenen Ordnern:
```bash
./scripts/playstore-screenshot-sizes.sh \
--input-dir android-app/playstore-assets/anon \
--output-dir android-app/playstore-assets/final
```
Output:
- android-app/playstore-assets/final/phone
- android-app/playstore-assets/final/tablet-7
- android-app/playstore-assets/final/tablet-10
## 4) Upload in Play Console ## 4) Upload in Play Console
- Datenschutzerklaerung: URL eintragen - Datenschutzerklaerung: URL eintragen
- Konto-Loeschung: URL eintragen - Konto-Loeschung: URL eintragen
- App-Icon: playstore-icon-512.png - App-Icon: playstore-icon-512.png
- Feature Graphic: playstore-feature-graphic-1024x500.png - Feature Graphic: playstore-feature-graphic-1024x500.png
- Screenshots: anonymisierte PNG/JPEG hochladen - Screenshots Telefon: Dateien aus `.../final/phone`
- Screenshots 7" Tablet: Dateien aus `.../final/tablet-7`
- Screenshots 10" Tablet: Dateien aus `.../final/tablet-10`

70
android-app/app/build.gradle.kts Normal file → Executable file
View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
@@ -5,12 +7,17 @@ plugins {
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
} }
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL") val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
.orElse("https://harheimertc.tsschulz.de/") .orElse("https://harheimertc.tsschulz.de/")
.get() .get()
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL") val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
.orElse("https://harheimertc.de/") .orElse("https://harheimertc.de/")
.get() .get()
val expectedProductionApiBaseUrl = "https://harheimertc.de/"
val sentryDsn = providers.gradleProperty("SENTRY_DSN") val sentryDsn = providers.gradleProperty("SENTRY_DSN")
.orElse("") .orElse("")
.get() .get()
@@ -25,15 +32,50 @@ val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
.orElse("true") .orElse("true")
.get() .get()
.toBoolean() .toBoolean()
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull val localSigningProperties = Properties().apply {
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull val localSigningFile = rootProject.file("gradle-local.properties")
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull if (localSigningFile.exists()) {
localSigningFile.inputStream().use { load(it) }
}
}
fun signingProperty(name: String): String? =
providers.gradleProperty(name).orNull
?: providers.environmentVariable(name).orNull
?: localSigningProperties.getProperty(name)
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() && val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
!releaseStorePassword.isNullOrBlank() && !releaseStorePassword.isNullOrBlank() &&
!releaseKeyAlias.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank() &&
!releaseKeyPassword.isNullOrBlank() !releaseKeyPassword.isNullOrBlank()
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
doFirst {
if (!hasReleaseSigning) {
throw GradleException(
"Production release signing is not configured. " +
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
)
}
}
}
val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
doFirst {
if (productionApiBaseUrl != expectedProductionApiBaseUrl) {
throw GradleException(
"Production Play Store builds must use $expectedProductionApiBaseUrl, but PRODUCTION_API_BASE_URL is $productionApiBaseUrl."
)
}
}
}
android { android {
namespace = "de.harheimertc" namespace = "de.harheimertc"
compileSdk = 35 compileSdk = 35
@@ -46,6 +88,13 @@ android {
versionName = androidVersionName versionName = androidVersionName
} }
lint {
disable += setOf(
"AutoboxingStateCreation",
"MutableCollectionMutableState",
)
}
signingConfigs { signingConfigs {
create("release") { create("release") {
if (hasReleaseSigning) { if (hasReleaseSigning) {
@@ -135,6 +184,8 @@ val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("package
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") { val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
group = "distribution" group = "distribution"
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload." description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
dependsOn(ensureReleaseSigning)
dependsOn(ensureProductionApiBaseUrl)
dependsOn(":app:bundleProductionRelease") dependsOn(":app:bundleProductionRelease")
dependsOn(packageNativeDebugSymbolsForProductionRelease) dependsOn(packageNativeDebugSymbolsForProductionRelease)
@@ -161,6 +212,13 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
} }
} }
tasks.matching {
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
}.configureEach {
dependsOn(ensureReleaseSigning)
dependsOn(ensureProductionApiBaseUrl)
}
kotlin { kotlin {
compilerOptions { compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
@@ -206,6 +264,9 @@ dependencies {
// Crash reporting // Crash reporting
implementation("io.sentry:sentry-android:8.42.0") implementation("io.sentry:sentry-android:8.42.0")
// Push notifications
implementation("com.google.firebase:firebase-messaging:25.0.2")
// Room // Room
implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-runtime:2.6.1")
ksp("androidx.room:room-compiler:2.6.1") ksp("androidx.room:room-compiler:2.6.1")
@@ -215,6 +276,7 @@ dependencies {
implementation("androidx.work:work-runtime-ktx:2.8.1") implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
// Testing (skeleton) // Testing (skeleton)
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")

View File

@@ -0,0 +1 @@
../../google-services.json

Binary file not shown.

BIN
android-app/app/local/release/app-local-release.aab Normal file → Executable file

Binary file not shown.

Binary file not shown.

37
android-app/app/proguard-rules.pro vendored Normal file → Executable file
View File

@@ -1,2 +1,37 @@
# Project-specific R8/ProGuard rules for release builds. # Project-specific R8/ProGuard rules for release builds.
# Keep this file intentionally minimal and add rules only when needed.
# Keep reflection/generic metadata used by Retrofit + Moshi.
-keepattributes Signature,InnerClasses,EnclosingMethod
-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault
-keep class kotlin.Metadata { *; }
# Keep Retrofit service interfaces and HTTP method annotations.
-keep,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
# Retrofit + R8 full mode: keep interfaces with HTTP methods and suspend continuation generics.
-if interface * { @retrofit2.http.* <methods>; }
-keep,allowobfuscation interface <1>
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# Avoid Retrofit generic signature loss on release builds for our API interface.
-keep interface de.harheimertc.data.ApiService { *; }
-keepclassmembers interface de.harheimertc.data.ApiService { *; }
# Keep app DTO/request/response models used via Moshi reflection.
-keep class de.harheimertc.data.*Dto { *; }
-keep class de.harheimertc.data.*Request { *; }
-keep class de.harheimertc.data.*Response { *; }
# Keep fields annotated with @Json names.
-keepclassmembers class * {
@com.squareup.moshi.Json <fields>;
}
# Keep WorkManager + Room generated classes used reflectively at startup.
-keep class * extends androidx.work.ListenableWorker {
<init>(android.content.Context, androidx.work.WorkerParameters);
}
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
-keep class * extends androidx.room.RoomDatabase { *; }

0
android-app/app/src/androidTest/AndroidManifest.xml Normal file → Executable file
View File

View File

0
android-app/app/src/debug/AndroidManifest.xml Normal file → Executable file
View File

11
android-app/app/src/main/AndroidManifest.xml Normal file → Executable file
View File

@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".HarheimerApplication" android:name=".HarheimerApplication"
@@ -10,12 +11,20 @@
android:theme="@style/Theme.HarheimerTC" android:theme="@style/Theme.HarheimerTC"
android:usesCleartextTraffic="${usesCleartextTraffic}"> android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity android:name="de.harheimertc.MainActivity" <activity android:name="de.harheimertc.MainActivity"
android:exported="true"> android:exported="true"
android:launchMode="singleTop">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".notifications.HarheimerMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files" android:authorities="${applicationId}.files"

View File

@@ -6,6 +6,7 @@ import coil.ImageLoaderFactory
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import de.harheimertc.notifications.HarheimerNotifications
import io.sentry.Sentry import io.sentry.Sentry
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import javax.inject.Inject import javax.inject.Inject
@@ -19,6 +20,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
Log.d("HILT", "HarheimerApplication.onCreate called") Log.d("HILT", "HarheimerApplication.onCreate called")
super.onCreate() super.onCreate()
HarheimerNotifications.createChannels(this)
if (BuildConfig.SENTRY_DSN.isNotBlank()) { if (BuildConfig.SENTRY_DSN.isNotBlank()) {
Sentry.init { options -> Sentry.init { options ->
options.dsn = BuildConfig.SENTRY_DSN options.dsn = BuildConfig.SENTRY_DSN

View File

@@ -1,13 +1,20 @@
package de.harheimertc package de.harheimertc
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import de.harheimertc.ui.navigation.NavGraph import de.harheimertc.ui.navigation.NavGraph
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.notifications.HarheimerNotifications
import de.harheimertc.ui.theme.HarheimerTheme import de.harheimertc.ui.theme.HarheimerTheme
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel import de.harheimertc.ui.navigation.NavigationViewModel
@@ -17,22 +24,67 @@ import androidx.compose.ui.platform.LocalContext
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val notificationRoute = mutableStateOf<String?>(null)
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted")
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
notificationRoute.value = extractNotificationRoute(intent)
setContent { setContent {
App() App(
notificationRoute = notificationRoute.value,
onNotificationRouteConsumed = { notificationRoute.value = null },
)
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
notificationRoute.value = extractNotificationRoute(intent)
}
private fun extractNotificationRoute(intent: Intent?): String? =
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
companion object {
const val EXTRA_NOTIFICATION_TYPE = "de.harheimertc.extra.NOTIFICATION_TYPE"
const val EXTRA_NOTIFICATION_ROUTE = "de.harheimertc.extra.NOTIFICATION_ROUTE"
const val EXTRA_NEWS_ID = "de.harheimertc.extra.NEWS_ID"
}
} }
@Composable @Composable
fun App() { fun App(
notificationRoute: String? = null,
onNotificationRouteConsumed: () -> Unit = {},
) {
HarheimerTheme { HarheimerTheme {
val navController = rememberNavController() val navController = rememberNavController()
val ctx = LocalContext.current val ctx = LocalContext.current
val activity = ctx as? ComponentActivity val activity = ctx as? ComponentActivity
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}") Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
val navigationViewModel: NavigationViewModel = hiltViewModel() val navigationViewModel: NavigationViewModel = hiltViewModel()
LaunchedEffect(notificationRoute) {
val route = notificationRoute?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
navController.navigate(route) {
launchSingleTop = true
popUpTo(Destinations.Home.route)
}
onNotificationRouteConsumed()
}
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel) NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
} }
} }

View File

@@ -21,6 +21,7 @@ import okhttp3.RequestBody
data class ContactRequest(val name: String, val email: String, val message: String) data class ContactRequest(val name: String, val email: String, val message: String)
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null) data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList()) data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
data class TermineManageResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
data class TerminDto( data class TerminDto(
val datum: String = "", val datum: String = "",
val uhrzeit: String? = null, val uhrzeit: String? = null,
@@ -37,6 +38,12 @@ data class SpielplanResponse(
val seasons: List<SeasonDto> = emptyList(), val seasons: List<SeasonDto> = emptyList(),
) )
data class SeasonDto(val slug: String = "", val label: String = "") data class SeasonDto(val slug: String = "", val label: String = "")
data class MannschaftenSeasonsResponse(
val success: Boolean = false,
val seasons: List<String> = emptyList(),
val currentSeason: String = "",
val defaultSeason: String = "",
)
data class SpielDto( data class SpielDto(
@param:Json(name = "Termin") val termin: String = "", @param:Json(name = "Termin") val termin: String = "",
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "", @param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
@@ -81,7 +88,7 @@ data class LeagueTableRowDto(
) )
data class NewsPublicResponse(val news: List<NewsDto> = emptyList()) data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
data class NewsDto( data class NewsDto(
val id: Int? = null, val id: String? = null,
val title: String = "", val title: String = "",
val content: String = "", val content: String = "",
val created: String? = null, val created: String? = null,
@@ -96,7 +103,7 @@ data class NewsResponse(
val news: List<NewsDto> = emptyList(), val news: List<NewsDto> = emptyList(),
) )
data class NewsSaveRequest( data class NewsSaveRequest(
val id: Int? = null, val id: String? = null,
val title: String, val title: String,
val content: String, val content: String,
val isPublic: Boolean = false, val isPublic: Boolean = false,
@@ -225,7 +232,7 @@ data class ProfileVisibilityDto(
val showEmail: Boolean = true, val showEmail: Boolean = true,
val showPhone: Boolean = true, val showPhone: Boolean = true,
val showAddress: Boolean = false, val showAddress: Boolean = false,
val showBirthday: Boolean = true, val showBirthday: Boolean = false,
) )
data class ProfileUserDto( data class ProfileUserDto(
val id: String? = null, val id: String? = null,
@@ -251,6 +258,30 @@ data class ProfileUpdateRequest(
val currentPassword: String? = null, val currentPassword: String? = null,
val newPassword: String? = null, val newPassword: String? = null,
) )
data class NotificationSettingsDto(
val newNews: Boolean = false,
val newEvents: Boolean = false,
val eventsToday: Boolean = false,
val eventsTomorrow: Boolean = false,
val ownTeamMatches: Boolean = false,
val allTeamMatches: Boolean = false,
val birthdays: Boolean = false,
val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false,
val 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,
)
data class BirthdayDto( data class BirthdayDto(
val name: String = "", val name: String = "",
val dayMonth: String = "", val dayMonth: String = "",
@@ -260,6 +291,28 @@ data class BirthdaysResponse(
val success: Boolean = false, val success: Boolean = false,
val birthdays: List<BirthdayDto> = emptyList(), val birthdays: List<BirthdayDto> = emptyList(),
) )
data class QttrSourceDto(
val url: String = "",
)
data class QttrRowDto(
val rank: Int? = null,
val playerNumber: Int? = null,
val gender: String? = null,
val playerName: String = "",
val clubName: String = "",
val currentQttr: Int? = null,
val previousQttr: Int? = null,
val birthdate: String? = null,
)
data class QttrValuesResponse(
val format: String = "",
val importedAt: String = "",
val source: QttrSourceDto = QttrSourceDto(),
val title: String? = null,
val headerCount: Int = 0,
val rowCount: Int = 0,
val rows: List<QttrRowDto> = emptyList(),
)
data class MemberDto( data class MemberDto(
val id: String? = null, val id: String? = null,
val name: String = "", val name: String = "",
@@ -276,6 +329,7 @@ data class MemberDto(
val editable: Boolean = false, val editable: Boolean = false,
val isMannschaftsspieler: Boolean = false, val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false, val hasHallKey: Boolean = false,
val showBirthday: Boolean = false,
val loginRoles: List<String> = emptyList(), val loginRoles: List<String> = emptyList(),
) )
data class MembersResponse( data class MembersResponse(
@@ -387,6 +441,15 @@ data class HomepageSectionConfigDto(
data class HomepageDto( data class HomepageDto(
val sections: List<HomepageSectionDto> = emptyList(), val sections: List<HomepageSectionDto> = emptyList(),
) )
data class HeroImageVariantDto(
val key: String = "",
val mobileWebp: String = "",
val desktopWebp: String = "",
val fallback: String = "",
)
data class HeroImagesResponse(
val variants: List<HeroImageVariantDto> = emptyList(),
)
data class SeitenDto( data class SeitenDto(
val ueberUns: String = "", val ueberUns: String = "",
val geschichte: String = "", val geschichte: String = "",
@@ -529,6 +592,21 @@ interface ApiService {
@GET("/api/termine") @GET("/api/termine")
suspend fun termine(): Response<TermineResponse> suspend fun termine(): Response<TermineResponse>
@GET("/api/termine-manage")
suspend fun termineManage(): Response<TermineManageResponse>
@POST("/api/termine-manage")
suspend fun saveTermin(@Body request: TerminDto): Response<AuthMessageResponse>
@DELETE("/api/termine-manage")
suspend fun deleteTermin(
@Query("datum") datum: String,
@Query("uhrzeit") uhrzeit: String = "",
@Query("titel") titel: String,
@Query("beschreibung") beschreibung: String = "",
@Query("kategorie") kategorie: String = "Sonstiges",
): Response<AuthMessageResponse>
@GET("/api/spielplan") @GET("/api/spielplan")
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse> suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
@@ -548,14 +626,20 @@ interface ApiService {
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse> suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
@DELETE("/api/news") @DELETE("/api/news")
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse> suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
@GET("/api/mannschaften") @GET("/api/mannschaften")
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody> suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
@GET("/api/mannschaften/seasons")
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
@GET("/api/config") @GET("/api/config")
suspend fun config(): Response<ConfigResponse> suspend fun config(): Response<ConfigResponse>
@GET("/api/hero-images")
suspend fun heroImages(): Response<HeroImagesResponse>
@PUT("/api/config") @PUT("/api/config")
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse> suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
@@ -568,6 +652,9 @@ interface ApiService {
@POST("/api/cms/save-csv") @POST("/api/cms/save-csv")
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse> suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
@POST("/api/cms/import-spielplan")
suspend fun importSpielplan(): Response<AuthMessageResponse>
@POST("/api/membership/generate-pdf") @POST("/api/membership/generate-pdf")
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse> suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
@@ -617,9 +704,21 @@ interface ApiService {
@retrofit2.http.PUT("/api/profile") @retrofit2.http.PUT("/api/profile")
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse> suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
@GET("/api/profile/notifications")
suspend fun notificationSettings(): Response<NotificationSettingsResponse>
@retrofit2.http.PUT("/api/profile/notifications")
suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response<NotificationSettingsResponse>
@POST("/api/profile/push-token")
suspend fun registerPushToken(@Body request: PushTokenRequest): Response<AuthMessageResponse>
@GET("/api/birthdays") @GET("/api/birthdays")
suspend fun birthdays(): Response<BirthdaysResponse> suspend fun birthdays(): Response<BirthdaysResponse>
@GET("/api/mitgliederbereich/qttr")
suspend fun qttrValues(): Response<QttrValuesResponse>
@GET("/api/members") @GET("/api/members")
suspend fun members(): Response<MembersResponse> suspend fun members(): Response<MembersResponse>
@@ -634,6 +733,7 @@ interface ApiService {
val notes: String? = null, val notes: String? = null,
val isMannschaftsspieler: Boolean = false, val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false, val hasHallKey: Boolean = false,
val showBirthday: Boolean = false,
) )
data class BulkImportRequest(val members: List<Map<String, String>>) data class BulkImportRequest(val members: List<Map<String, String>>)

View File

View 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)
}
}

View File

View File

View File

@@ -1,11 +1,13 @@
package de.harheimertc.data package de.harheimertc.data
import android.content.Context import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.GeneralSecurityException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -14,8 +16,10 @@ class SecureOfflineCache @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val moshi: Moshi, private val moshi: Moshi,
) { ) {
private val tag = "SecureOfflineCache"
private companion object { private companion object {
const val KEY_BIRTHDAYS = "birthdays" const val KEY_BIRTHDAYS = "birthdays"
const val KEY_QTTR_VALUES = "qttr_values"
const val KEY_MEMBERS = "members" const val KEY_MEMBERS = "members"
const val KEY_MEMBER_NEWS = "member_news" const val KEY_MEMBER_NEWS = "member_news"
const val KEY_CMS_CONFIG = "cms_config" const val KEY_CMS_CONFIG = "cms_config"
@@ -28,6 +32,10 @@ class SecureOfflineCache @Inject constructor(
} }
private val preferences by lazy { private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
@@ -38,11 +46,36 @@ class SecureOfflineCache @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_offline_cache")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_offline_cache",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError)
throw retryError
} }
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java) fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis) fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
fun putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java)
fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis)
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java) fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis) fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
@@ -93,6 +126,7 @@ class SecureOfflineCache @Inject constructor(
KEY_NEWSLETTER_GROUPS, KEY_NEWSLETTER_GROUPS,
KEY_PASSWORD_RESET_DIAGNOSTICS, KEY_PASSWORD_RESET_DIAGNOSTICS,
KEY_MEMBER_NEWS, KEY_MEMBER_NEWS,
KEY_QTTR_VALUES,
) )
} }

View File

View File

View 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.registerToken(token)
}
}
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")
}
}

View File

@@ -0,0 +1,87 @@
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 {
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates"
fun createChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
DEFAULT_CHANNEL_ID,
"Harheimer TC",
NotificationManager.IMPORTANCE_DEFAULT,
).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_DEFAULT)
.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
"birthdays" -> Destinations.MemberArea.route
"contact_request" -> Destinations.CmsContactRequests.route
"user_registration" -> Destinations.CmsBenutzer.route
else -> Destinations.Home.route
}
}

View File

@@ -1,10 +1,12 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import android.content.Context import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.security.DeviceKeyManager import de.harheimertc.security.DeviceKeyManager
import java.security.GeneralSecurityException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -13,10 +15,15 @@ class AuthRepositoryImpl @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val deviceKeyManager: DeviceKeyManager, private val deviceKeyManager: DeviceKeyManager,
) : AuthRepository { ) : AuthRepository {
private val tag = "AuthRepository"
private val tokenKey = "auth_token" private val tokenKey = "auth_token"
private val refreshTokenKey = "auth_refresh_token" private val refreshTokenKey = "auth_refresh_token"
private val sessionIdKey = "auth_session_id" private val sessionIdKey = "auth_session_id"
private val preferences by lazy { private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
@@ -27,6 +34,28 @@ class AuthRepositoryImpl @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_auth")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_auth",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError)
throw retryError
} }
override fun getToken(): String? = preferences.getString(tokenKey, null) override fun getToken(): String? = preferences.getString(tokenKey, null)

View File

@@ -10,6 +10,8 @@ import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.SaveCsvRequest import de.harheimertc.data.SaveCsvRequest
import de.harheimertc.data.SaveCsvResponse import de.harheimertc.data.SaveCsvResponse
import de.harheimertc.data.SecureOfflineCache import de.harheimertc.data.SecureOfflineCache
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto
import javax.inject.Inject import javax.inject.Inject
class CmsRepository @Inject constructor( class CmsRepository @Inject constructor(
@@ -83,6 +85,93 @@ class CmsRepository @Inject constructor(
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort") response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
} }
suspend fun managedTermine(): Result<List<TerminDto>> = runCatching {
val response = api.termineManage()
if (!response.isSuccessful) error("Termine konnten nicht geladen werden.")
response.body()?.termine.orEmpty()
}
suspend fun saveTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.saveTermin(request)
if (!response.isSuccessful) error("Termin konnte nicht gespeichert werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteTermin(
datum = request.datum,
uhrzeit = request.uhrzeit.orEmpty(),
titel = request.titel,
beschreibung = request.beschreibung.orEmpty(),
kategorie = request.kategorie ?: "Sonstiges",
)
if (!response.isSuccessful) error("Termin konnte nicht gelöscht werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun mannschaften(season: String? = null): Result<List<CmsMannschaftRow>> = runCatching {
val response = api.mannschaften(season)
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 10 || values[0].isBlank()) return@mapNotNull null
CmsMannschaftRow(
mannschaft = values[0],
liga = values[1],
staffelleiter = values[2],
telefon = values[3],
heimspieltag = values[4],
spielsystem = values[5],
mannschaftsfuehrer = values[6],
spieler = values[7],
informationenLink = values[8],
letzteAktualisierung = values[9],
spielplanMannschaftId = values.getOrElse(10) { "" },
)
}
}
suspend fun mannschaftenSeasons(): Result<de.harheimertc.data.MannschaftenSeasonsResponse> = runCatching {
val response = api.mannschaftenSeasons()
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
response.body() ?: de.harheimertc.data.MannschaftenSeasonsResponse()
}
suspend fun saveMannschaften(season: String?, rows: List<CmsMannschaftRow>): Result<SaveCsvResponse> = runCatching {
val response = api.saveCsv(
SaveCsvRequest(
filename = season?.takeIf { it.isNotBlank() }?.let { "mannschaften_$it.csv" } ?: "mannschaften.csv",
content = rows.toMannschaftenCsv(),
),
)
if (!response.isSuccessful) error("Mannschaften konnten nicht gespeichert werden.")
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
}
suspend fun importSpielplan(): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.importSpielplan()
if (!response.isSuccessful) error("Spielplan konnte nicht importiert werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season)
if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.")
val body = response.body() ?: error("Leere Antwort")
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
body
}
suspend fun saveSpielplan(headers: List<String>, rows: List<List<String>>): Result<SaveCsvResponse> = runCatching {
val response = api.saveCsv(
SaveCsvRequest(
filename = "spielplan.csv",
content = listOf(headers).plus(rows).joinToString("\n") { row -> row.toCsvRow(";") },
),
)
if (!response.isSuccessful) error("Spielplan konnte nicht gespeichert werden.")
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
}
suspend fun users(): Result<CmsUsersResponse> = suspend fun users(): Result<CmsUsersResponse> =
fetchEncryptedFallback( fetchEncryptedFallback(
load = { load = {
@@ -267,7 +356,7 @@ class CmsRepository @Inject constructor(
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort") response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
} }
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching { suspend fun deleteNews(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNews(id) val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
cache.clearCmsNewsCache() cache.clearCmsNewsCache()
@@ -288,6 +377,61 @@ class CmsRepository @Inject constructor(
} }
} }
data class CmsMannschaftRow(
val mannschaft: String = "",
val liga: String = "",
val staffelleiter: String = "",
val telefon: String = "",
val heimspieltag: String = "",
val spielsystem: String = "",
val mannschaftsfuehrer: String = "",
val spieler: String = "",
val informationenLink: String = "",
val letzteAktualisierung: String = "",
val spielplanMannschaftId: String = "",
)
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
val header = listOf(
"Mannschaft",
"Liga",
"Staffelleiter",
"Telefon",
"Heimspieltag",
"Spielsystem",
"Mannschaftsführer",
"Spieler",
"Weitere Informationen Link",
"Letzte Aktualisierung",
"Spielplan Mannschaft ID",
).toCsvRow()
val rows = map { row ->
listOf(
row.mannschaft,
row.liga,
row.staffelleiter,
row.telefon,
row.heimspieltag,
row.spielsystem,
row.mannschaftsfuehrer,
row.spieler,
row.informationenLink,
row.letzteAktualisierung,
row.spielplanMannschaftId,
).toCsvRow()
}
return listOf(header).plus(rows).joinToString("\n")
}
private fun List<String>.toCsvRow(delimiter: String = ","): String =
joinToString(delimiter) { value -> value.csvEscape(delimiter) }
private fun String.csvEscape(delimiter: String): String {
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
val escaped = replace("\"", "\"\"")
return if (needsQuotes) "\"$escaped\"" else escaped
}
private fun parseCsv(csv: String): List<List<String>> = private fun parseCsv(csv: String): List<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList() csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()

View File

@@ -24,27 +24,27 @@ class GalleryRepository @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
) { ) {
suspend fun hasPublicImages(): Result<Boolean> = runCatching { suspend fun hasPublicImages(): Result<Boolean> = runCatching {
retryOnNetworkFailure {
val response = api.galerieList(page = 1, perPage = 1) val response = api.galerieList(page = 1, perPage = 1)
if (!response.isSuccessful) error("HTTP ${response.code()}") if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body()?.images.orEmpty().isNotEmpty() response.body()?.images.orEmpty().isNotEmpty()
} }
}
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> { suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
return try { return runCatching {
retryOnNetworkFailure {
val resp = api.galerieList(page = page, perPage = perPage) val resp = api.galerieList(page = page, perPage = perPage)
if (resp.isSuccessful) { if (resp.isSuccessful) {
val body = resp.body() val body = resp.body()
Result.success(
GalleryPage( GalleryPage(
images = body?.images.orEmpty().map { it.toGalleryImage() }, images = body?.images.orEmpty().map { it.toGalleryImage() },
pagination = body?.pagination ?: GalleryPaginationDto(), pagination = body?.pagination ?: GalleryPaginationDto(),
),
) )
} else { } else {
Result.failure(Exception("HTTP ${resp.code()}")) error("HTTP ${resp.code()}")
}
} }
} catch (e: Exception) {
Result.failure(e)
} }
} }

View File

@@ -1,12 +1,14 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import android.content.Context import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.HomepageSectionDto import de.harheimertc.data.HomepageSectionDto
import java.security.GeneralSecurityException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -15,10 +17,15 @@ class HomeLayoutPreferences @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val moshi: Moshi, private val moshi: Moshi,
) { ) {
private val tag = "HomeLayoutPreferences"
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java) private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType) private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
private val preferences by lazy { private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
@@ -29,6 +36,28 @@ class HomeLayoutPreferences @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_home_layout")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_home_layout",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError)
throw retryError
} }
fun getSections(): List<HomepageSectionDto>? { fun getSections(): List<HomepageSectionDto>? {

View File

@@ -1,12 +1,16 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService import de.harheimertc.data.ApiService
import de.harheimertc.data.HomepageSectionDto import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.HeroImageVariantDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto import de.harheimertc.data.SpielDto
import de.harheimertc.data.SpielplanResponse import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto import de.harheimertc.data.TerminDto
import io.sentry.Sentry
import kotlin.random.Random
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -17,20 +21,162 @@ data class HomeData(
val selectedSpielplanSeason: String?, val selectedSpielplanSeason: String?,
val news: List<NewsDto>, val news: List<NewsDto>,
val homepageSections: List<HomepageSectionDto>, val homepageSections: List<HomepageSectionDto>,
val heroImageUrl: String? = null,
val diagnostics: List<String> = emptyList(),
) )
@Singleton @Singleton
class HomeRepository @Inject constructor(private val api: ApiService) { class HomeRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchHomeData(): Result<HomeData> = runCatching { suspend fun fetchHomeData(): Result<HomeData> = runCatching {
val termine = api.termine().body()?.termine.orEmpty() val diagnostics = mutableListOf<String>()
val spielplanResponse = api.spielplan().body()
val spiele = spielplanResponse?.data.orEmpty() val termine = runCatching {
val news = api.publicNews().body()?.news.orEmpty() retryOnNetworkFailure {
val homepageSections = runCatching { val response = api.termine()
val configResponse = api.config() if (!response.isSuccessful) {
if (!configResponse.isSuccessful) return@runCatching emptyList() val errorBody = response.errorBody()?.string().orEmpty()
configResponse.body()?.homepage?.sections.orEmpty() diagnostics += buildDiagnostic(
endpoint = "GET /api/termine",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.termine.orEmpty()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.termine", error)
if (diagnostics.none { it.contains("GET /api/termine") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/termine",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
val spielplanResponse = runCatching {
retryOnNetworkFailure {
val response = api.spielplan()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/spielplan",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.spielplan", error)
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/spielplan",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrNull()
val spiele = spielplanResponse?.data.orEmpty()
val news = runCatching {
retryOnNetworkFailure {
val response = api.publicNews()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/news-public",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.news.orEmpty()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.news", error)
if (diagnostics.none { it.contains("GET /api/news-public") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/news-public",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val homepageSections = runCatching {
retryOnNetworkFailure {
val response = api.config()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/config",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.homepage?.sections.orEmpty()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.config", error)
if (diagnostics.none { it.contains("GET /api/config") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/config",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val heroImageUrl = runCatching {
retryOnNetworkFailure {
val response = api.heroImages()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/hero-images",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
}
val variants = response.body()?.variants.orEmpty()
pickRandomHeroImage(variants)
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.heroImages", error)
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/hero-images",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrNull()
HomeData( HomeData(
termine = termine, termine = termine,
spiele = spiele, spiele = spiele,
@@ -38,12 +184,77 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
selectedSpielplanSeason = spielplanResponse?.season, selectedSpielplanSeason = spielplanResponse?.season,
news = news, news = news,
homepageSections = homepageSections, homepageSections = homepageSections,
heroImageUrl = heroImageUrl,
diagnostics = diagnostics,
) )
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", "fetchHomeData")
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
} }
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching { suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
retryOnNetworkFailure {
val response = api.spielplan(season) val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}") if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body() ?: error("Leere Antwort") 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"
}
} }

View File

@@ -1,5 +1,6 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService import de.harheimertc.data.ApiService
import de.harheimertc.data.LoginRequest import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse import de.harheimertc.data.LoginResponse
@@ -9,6 +10,7 @@ import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SessionRefresher import de.harheimertc.data.SessionRefresher
import io.sentry.Sentry
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -19,13 +21,41 @@ class LoginRepository @Inject constructor(
private val sessionRefresher: SessionRefresher, private val sessionRefresher: SessionRefresher,
) { ) {
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching { suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val response = api.login(LoginRequest(email.trim(), password)) val endpoint = "api/auth/login"
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.") val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
if (!response.isSuccessful) {
val body = response.errorBody()?.string().orEmpty()
val serverMessage = extractServerMessage(body)
val fallback = when (response.code()) {
401 -> "Ungueltige Anmeldedaten"
403 -> "Konto nicht freigeschaltet"
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
else -> "Anmeldung fehlgeschlagen"
}
val diagnostic = buildString {
append("\n\nDiagnose:\n")
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
append("Request: ").append(requestPreview).append('\n')
append("HTTP: ").append(response.code()).append('\n')
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
append("Server message: ").append(serverMessage ?: "none")
}
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
}
val body = response.body() ?: error("Leere Antwort") val body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank) val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.") ?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId) authRepository.setSession(token, body.refreshToken, body.sessionId)
body body
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "LoginRepository")
scope.setTag("operation", "login")
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
} }
suspend fun logout(): Result<Unit> = runCatching { suspend fun logout(): Result<Unit> = runCatching {
@@ -41,27 +71,55 @@ class LoginRepository @Inject constructor(
return@runCatching AuthStatusResponse() return@runCatching AuthStatusResponse()
} }
var response = api.authStatus() var response = retryOnNetworkFailure { api.authStatus() }
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.") if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
var status = response.body() ?: AuthStatusResponse() var status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) { if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
response = api.authStatus() response = retryOnNetworkFailure { api.authStatus() }
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.") if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
status = response.body() ?: AuthStatusResponse() status = response.body() ?: AuthStatusResponse()
} }
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession() if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
status status
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "LoginRepository")
scope.setTag("operation", "status")
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
} }
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching { suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val response = api.resetPassword(ResetPasswordRequest(email.trim())) val response = api.resetPassword(ResetPasswordRequest(email.trim()))
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.") if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
}
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching { suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val response = api.register(request) val response = api.register(request)
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.") if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
response.body() ?: error("Leere Antwort") 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"
}
}

View File

@@ -1,6 +1,8 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import de.harheimertc.data.ApiService import de.harheimertc.data.ApiService
import de.harheimertc.data.MannschaftenSeasonsResponse
import de.harheimertc.data.SeasonDto
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -24,10 +26,20 @@ data class Mannschaft(
@Singleton @Singleton
class MannschaftenRepository @Inject constructor(private val api: ApiService) { class MannschaftenRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching { suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
retryOnNetworkFailure {
val response = api.mannschaften(season) val response = api.mannschaften(season)
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.") if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()) parseCsv(response.body()?.string().orEmpty())
} }
}
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
retryOnNetworkFailure {
val response = api.mannschaftenSeasons()
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
response.body() ?: error("Saisons konnten nicht geladen werden.")
}
}
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence() private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
.filter(String::isNotBlank) .filter(String::isNotBlank)

View File

@@ -5,6 +5,7 @@ import de.harheimertc.data.BirthdaysResponse
import de.harheimertc.data.MembersResponse import de.harheimertc.data.MembersResponse
import de.harheimertc.data.NewsResponse import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsSaveRequest import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.data.QttrValuesResponse
import de.harheimertc.data.SecureOfflineCache import de.harheimertc.data.SecureOfflineCache
import javax.inject.Inject import javax.inject.Inject
@@ -24,6 +25,18 @@ class MemberAreaRepository @Inject constructor(
fallbackMessage = "Geburtstage konnten nicht geladen werden.", fallbackMessage = "Geburtstage konnten nicht geladen werden.",
) )
suspend fun qttrValues(): Result<QttrValuesResponse> =
fetchEncryptedFallback(
load = {
val response = api.qttrValues()
if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putQttrValues,
cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) },
fallbackMessage = "QTTR-Werte konnten nicht geladen werden.",
)
suspend fun members(): Result<MembersResponse> = suspend fun members(): Result<MembersResponse> =
fetchEncryptedFallback( fetchEncryptedFallback(
load = { load = {
@@ -88,7 +101,7 @@ class MemberAreaRepository @Inject constructor(
response.body() ?: emptyMap() response.body() ?: emptyMap()
} }
suspend fun deleteNews(id: Int): Result<Unit> = runCatching { suspend fun deleteNews(id: String): Result<Unit> = runCatching {
val response = api.deleteNews(id) val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
} }

View 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
}

View File

@@ -8,10 +8,12 @@ import javax.inject.Inject
class NewsletterRepository @Inject constructor(private val api: ApiService) { class NewsletterRepository @Inject constructor(private val api: ApiService) {
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching { suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
retryOnNetworkFailure {
val response = api.publicNewsletterGroups() val response = api.publicNewsletterGroups()
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.") response.body() ?: error("Leere Antwort vom Server.")
} }
}
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching { suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
val response = api.subscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim(), name?.trim().orEmpty())) val response = api.subscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim(), name?.trim().orEmpty()))
@@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) {
} }
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching { suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val response = api.confirmNewsletter(token) val response = api.confirmNewsletter(token)
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.") if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.") response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
} }
} }
}

View File

@@ -0,0 +1,135 @@
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 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),
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)
.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> {
saveLocal(settings)
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")
saveLocal(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_SELECTED_TEAM_SLUGS = "selected_team_slugs"
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
const val KEY_NOTIFICATION_TIME = "notification_time"
}
}
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
newNews = newNews,
newEvents = newEvents,
eventsToday = eventsToday,
eventsTomorrow = eventsTomorrow,
ownTeamMatches = ownTeamMatches,
allTeamMatches = allTeamMatches,
birthdays = birthdays,
newContactRequest = newContactRequest,
newUserRegistration = newUserRegistration,
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,
selectedTeamSlugs = selectedTeamSlugs.toList(),
selectedTeamSeason = selectedTeamSeason,
notificationTime = notificationTime,
)

View File

@@ -28,6 +28,7 @@ class PasskeyRepository @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
) { ) {
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching { suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
retryOnNetworkFailure {
val optionsResponse = api.passkeyAuthenticationOptions( val optionsResponse = api.passkeyAuthenticationOptions(
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)), PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
) )
@@ -59,15 +60,19 @@ class PasskeyRepository @Inject constructor(
?: error("Der Server hat kein Zugriffstoken geliefert.") ?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId) authRepository.setSession(token, body.refreshToken, body.sessionId)
body body
}
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.") }.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
suspend fun list(): Result<PasskeysResponse> = runCatching { suspend fun list(): Result<PasskeysResponse> = runCatching {
retryOnNetworkFailure {
val response = api.passkeys() val response = api.passkeys()
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.") if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
}
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching { suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest()) val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.") if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
@@ -90,6 +95,7 @@ class PasskeyRepository @Inject constructor(
) )
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.") if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
}
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.") }.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching { suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {

View File

@@ -9,14 +9,18 @@ import javax.inject.Singleton
@Singleton @Singleton
class ProfileRepository @Inject constructor(private val api: ApiService) { class ProfileRepository @Inject constructor(private val api: ApiService) {
suspend fun load(): Result<ProfileResponse> = runCatching { suspend fun load(): Result<ProfileResponse> = runCatching {
retryOnNetworkFailure {
val response = api.profile() val response = api.profile()
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.") if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
}
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching { suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
retryOnNetworkFailure {
val response = api.updateProfile(request) val response = api.updateProfile(request)
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.") if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
} }
}

View File

@@ -31,12 +31,15 @@ data class MeisterschaftResult(
@Singleton @Singleton
class PublicPagesRepository @Inject constructor(private val api: ApiService) { class PublicPagesRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching { suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
retryOnNetworkFailure {
val response = api.config() val response = api.config()
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.") if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
}
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching { suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
retryOnNetworkFailure {
val response = api.spielsysteme() val response = api.spielsysteme()
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.") if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
@@ -52,8 +55,10 @@ class PublicPagesRepository @Inject constructor(private val api: ApiService) {
) )
} }
} }
}
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching { suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
retryOnNetworkFailure {
val response = api.vereinsmeisterschaften() val response = api.vereinsmeisterschaften()
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.") if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
@@ -71,6 +76,7 @@ class PublicPagesRepository @Inject constructor(private val api: ApiService) {
} }
} }
} }
}
private fun parseCsv(csv: String): List<List<String>> = private fun parseCsv(csv: String): List<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList() csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()

View File

@@ -0,0 +1,35 @@
package de.harheimertc.repositories
import android.util.Log
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()
registerToken(token).getOrThrow()
}
suspend fun registerToken(token: String): Result<Unit> = runCatching {
if (token.isBlank()) return@runCatching
retryOnNetworkFailure {
val response = api.registerPushToken(
PushTokenRequest(
token = token,
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
),
)
if (!response.isSuccessful) error("Push-Token konnte nicht registriert werden.")
}
}.onFailure { error ->
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
}
}

View File

@@ -9,14 +9,17 @@ import javax.inject.Singleton
@Singleton @Singleton
class SpielplanRepository @Inject constructor(private val api: ApiService) { class SpielplanRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching { suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
retryOnNetworkFailure {
val response = api.spielplan(season) val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}") if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body() ?: error("Leere Antwort") val body = response.body() ?: error("Leere Antwort")
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.") if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
body body
} }
}
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching { suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
retryOnNetworkFailure {
val response = api.spielplanTable(team, season) val response = api.spielplanTable(team, season)
if (!response.isSuccessful) error("HTTP ${response.code()}") if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body() ?: error("Leere Antwort") val body = response.body() ?: error("Leere Antwort")
@@ -24,3 +27,4 @@ class SpielplanRepository @Inject constructor(private val api: ApiService) {
body body
} }
} }
}

View File

@@ -8,8 +8,10 @@ import javax.inject.Singleton
@Singleton @Singleton
class TermineRepository @Inject constructor(private val api: ApiService) { class TermineRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching { suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
retryOnNetworkFailure {
val response = api.termine() val response = api.termine()
if (!response.isSuccessful) error("HTTP ${response.code()}") if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body()?.termine.orEmpty() response.body()?.termine.orEmpty()
} }
} }
}

View File

@@ -8,8 +8,10 @@ import javax.inject.Singleton
@Singleton @Singleton
class TrainingRepository @Inject constructor(private val api: ApiService) { class TrainingRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching { suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
retryOnNetworkFailure {
val response = api.config() val response = api.config()
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.") if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
} }
}

View File

@@ -1,12 +1,14 @@
package de.harheimertc.ui.components package de.harheimertc.ui.components
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -25,6 +27,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.harheimertc.BuildConfig import de.harheimertc.BuildConfig
import de.harheimertc.R import de.harheimertc.R
@@ -40,14 +43,17 @@ private enum class MenuSection {
TRAINING, TRAINING,
NEWSLETTER, NEWSLETTER,
INTERN, INTERN,
CMS,
} }
private data class MenuTarget(val label: String, val route: String) private data class MenuTarget(val label: String, val route: String)
private const val LOGOUT_ROUTE = "__logout__"
@Composable @Composable
fun AppNavigationHeader( fun AppNavigationHeader(
selectedRoute: String?, selectedRoute: String?,
onNavigate: (String) -> Unit, onNavigate: (String) -> Unit,
onLogout: () -> Unit = {},
webTabletNavigation: Boolean = false, webTabletNavigation: Boolean = false,
navigationState: NavigationUiState = NavigationUiState(), navigationState: NavigationUiState = NavigationUiState(),
) { ) {
@@ -59,9 +65,9 @@ fun AppNavigationHeader(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (webTabletNavigation) { if (webTabletNavigation) {
WebTabletNavigation(selectedRoute, onNavigate, navigationState) WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState)
} else { } else {
CompactNavigation(selectedRoute, onNavigate, navigationState) CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
} }
} }
} }
@@ -70,18 +76,79 @@ fun AppNavigationHeader(
private fun CompactNavigation( private fun CompactNavigation(
selectedRoute: String?, selectedRoute: String?,
onNavigate: (String) -> Unit, onNavigate: (String) -> Unit,
onLogout: () -> Unit,
navigationState: NavigationUiState = NavigationUiState(), navigationState: NavigationUiState = NavigationUiState(),
) { ) {
BrandRow(onLogin = { onNavigate(Destinations.Login.route) }) val routeSection = menuSection(selectedRoute)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f)) val section = routeSection ?: sectionOverride.value
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f)) val subItems = submenu(section, navigationState)
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f)) val mainScroll = rememberScrollState()
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f)) val subScroll = rememberScrollState()
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f)) BrandRow(
loggedIn = navigationState.loggedIn,
onLogin = { onNavigate(Destinations.Login.route) },
onLogout = onLogout,
)
ScrollableMenuRow(scrollState = mainScroll) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
if (navigationState.showGallery) {
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
} }
if (navigationState.loggedIn) {
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
if (navigationState.canAccessCms) {
CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS }
}
} else {
CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
}
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
}
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
subItems.forEach { item ->
SubLink(item.label, item.route == selectedRoute) {
if (item.route == LOGOUT_ROUTE) {
onLogout()
} else {
onNavigate(item.route)
}
}
}
}
}
@Composable
private fun CompactSectionLink(
label: String,
section: MenuSection,
activeSection: MenuSection?,
modifier: Modifier = Modifier,
onSelect: () -> Unit,
) {
Surface(
color = if (activeSection == section) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onSelect() },
) {
Text(
label,
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
maxLines = 1,
)
} }
} }
@@ -89,12 +156,13 @@ private fun CompactNavigation(
private fun WebTabletNavigation( private fun WebTabletNavigation(
selectedRoute: String?, selectedRoute: String?,
onNavigate: (String) -> Unit, onNavigate: (String) -> Unit,
onLogout: () -> Unit,
navigationState: NavigationUiState, navigationState: NavigationUiState,
) { ) {
val section = menuSection(selectedRoute) val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } val section = sectionOverride.value ?: menuSection(selectedRoute)
// Helper that closes the CMS submenu when navigating away val subScroll = rememberScrollState()
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Brand() Brand()
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
@@ -103,77 +171,93 @@ private fun WebTabletNavigation(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) }) MainLink("Start", selectedRoute == Destinations.Home.route, onClick = {
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) }) sectionOverride.value = null
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) }) onNavigate(Destinations.Home.route)
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) }) })
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) }) MainLink("Verein", section == MenuSection.VEREIN, onClick = {
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) }) sectionOverride.value = MenuSection.VEREIN
onNavigate(Destinations.VereinAbout.route)
})
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = {
sectionOverride.value = MenuSection.MANNSCHAFTEN
onNavigate(Destinations.Mannschaften.route)
})
MainLink("Training", section == MenuSection.TRAINING, onClick = {
sectionOverride.value = MenuSection.TRAINING
onNavigate(Destinations.Training.route)
})
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = {
sectionOverride.value = null
onNavigate(Destinations.Termine.route)
})
if (navigationState.showGallery) { if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) }) MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = {
sectionOverride.value = MenuSection.VEREIN
onNavigate(Destinations.Gallery.route)
})
} }
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) }) MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = {
sectionOverride.value = MenuSection.NEWSLETTER
onNavigate(Destinations.NewsletterSubscribe.route)
})
if (navigationState.loggedIn) { if (navigationState.loggedIn) {
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) }) MainLink("Intern", section == MenuSection.INTERN, onClick = {
} sectionOverride.value = MenuSection.INTERN
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) }) })
TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) } if (navigationState.canAccessCms) {
MainLink("CMS", section == MenuSection.CMS, onClick = {
sectionOverride.value = MenuSection.CMS
onNavigate(Destinations.Cms.route)
})
} }
} }
val subItems = submenu(section, navigationState) MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = {
// determine CMS parent index and children sectionOverride.value = null
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" } onNavigate(Destinations.Contact.route)
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 Spacer(Modifier.width(12.dp))
Row( if (navigationState.loggedIn) {
modifier = Modifier TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
subItems.forEachIndexed { idx, item ->
if (idx == cmsIndex) {
// CMS parent toggle
SubLink(item.label, item.route == selectedRoute) {
cmsExpanded.value = !cmsExpanded.value
}
} else if (idx > cmsIndex && cmsIndex >= 0) {
// skip cms children here; they'll be rendered in the second row when expanded
} else { } else {
// normal item before CMS: close cms submenu on navigate TextButton(onClick = {
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) } sectionOverride.value = null
} onNavigate(Destinations.Login.route)
}) { Text("Login", color = Color.White) }
} }
} }
// Second row: when CMS expanded, render its children beneath val subItems = submenu(section, navigationState)
if (cmsExpanded.value && cmsChildren.isNotEmpty()) { ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
Row( subItems.forEach { item ->
modifier = Modifier SubLink(item.label, item.route == selectedRoute) {
.fillMaxWidth() if (item.route == LOGOUT_ROUTE) {
.horizontalScroll(rememberScrollState()) onLogout()
.padding(top = 6.dp, bottom = 3.dp), } else {
horizontalArrangement = Arrangement.spacedBy(8.dp), onNavigate(item.route)
) { }
cmsChildren.forEach { child ->
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
} }
} }
} }
} }
@Composable @Composable
private fun BrandRow(onLogin: () -> Unit) { private fun BrandRow(
loggedIn: Boolean,
onLogin: () -> Unit,
onLogout: () -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Brand() Brand()
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (loggedIn) {
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
} else {
TextButton(onClick = onLogin) { Text("Login", color = Color.White) } TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
} }
} }
}
@Composable @Composable
private fun Brand() { private fun Brand() {
@@ -214,8 +298,8 @@ private fun MainLink(
) { ) {
Text( Text(
label, label,
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f), color = Color.White.copy(alpha = if (selected || primary) 1f else 0.94f),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp), modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
maxLines = 1, maxLines = 1,
) )
@@ -229,23 +313,62 @@ private fun CompactLink(
selectedRoute: String?, selectedRoute: String?,
onNavigate: (String) -> Unit, onNavigate: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
beforeNavigate: () -> Unit = {},
) { ) {
Surface( Surface(
color = if (route == selectedRoute) Primary600 else Color.Transparent, color = if (route == selectedRoute) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onNavigate(route) }, modifier = modifier.clickable {
beforeNavigate()
onNavigate(route)
},
) { ) {
Text( Text(
label, label,
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8), color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp), modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
maxLines = 1, maxLines = 1,
) )
} }
} }
@Composable
private fun ScrollableMenuRow(
scrollState: ScrollState,
topPadding: Dp = 0.dp,
content: @Composable RowScope.() -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = topPadding),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (scrollState.canScrollBackward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(14.dp),
)
Row(
modifier = Modifier
.weight(1f)
.horizontalScroll(scrollState),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
Text(
if (scrollState.canScrollForward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(14.dp),
)
}
}
@Composable @Composable
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) { private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
Surface( Surface(
@@ -256,7 +379,7 @@ private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
Text( Text(
label, label,
color = if (selected) Color.White else Color(0xFFD4D4D8), color = if (selected) Color.White else Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
maxLines = 1, maxLines = 1,
) )
@@ -287,14 +410,19 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.NewsletterConfirm.route, Destinations.NewsletterConfirm.route,
Destinations.NewsletterConfirmed.route, Destinations.NewsletterConfirmed.route,
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
Destinations.MemberArea.route, Destinations.MemberArea.route,
Destinations.Members.route, Destinations.Members.route,
Destinations.Qttr.route,
Destinations.MemberNews.route, Destinations.MemberNews.route,
Destinations.Profile.route, Destinations.Profile.route,
Destinations.MemberApi.route, Destinations.NotificationSettings.route,
Destinations.MemberApi.route -> MenuSection.INTERN
Destinations.CmsStartseite.route, Destinations.CmsStartseite.route,
Destinations.CmsInhalte.route, Destinations.CmsInhalte.route,
Destinations.CmsVereinsmeisterschaften.route, Destinations.CmsVereinsmeisterschaften.route,
Destinations.CmsNews.route,
Destinations.CmsSportbetrieb.route, Destinations.CmsSportbetrieb.route,
Destinations.CmsMitgliederverwaltung.route, Destinations.CmsMitgliederverwaltung.route,
Destinations.CmsNewsletter.route, Destinations.CmsNewsletter.route,
@@ -302,7 +430,8 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.CmsEinstellungen.route, Destinations.CmsEinstellungen.route,
Destinations.CmsBenutzer.route, Destinations.CmsBenutzer.route,
Destinations.CmsPasswordResetDiagnostics.route, Destinations.CmsPasswordResetDiagnostics.route,
Destinations.Cms.route -> MenuSection.INTERN Destinations.Cms.route -> MenuSection.CMS
else -> null else -> null
}.let { section -> }.let { section ->
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
@@ -319,42 +448,52 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuTarget("Links", Destinations.Links.route), MenuTarget("Links", Destinations.Links.route),
MenuTarget("Impressum", Destinations.Impressum.route), MenuTarget("Impressum", Destinations.Impressum.route),
) )
MenuSection.MANNSCHAFTEN -> listOf( MenuSection.MANNSCHAFTEN -> listOf(
MenuTarget("Übersicht", Destinations.Mannschaften.route), MenuTarget("Übersicht", Destinations.Mannschaften.route),
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf( ) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
MenuTarget("Spielpläne", Destinations.Spielplan.route), MenuTarget("Spielpläne", Destinations.Spielplan.route),
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route), MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
) )
MenuSection.TRAINING -> listOf( MenuSection.TRAINING -> listOf(
MenuTarget("Trainingszeiten", Destinations.Training.route), MenuTarget("Trainingszeiten", Destinations.Training.route),
MenuTarget("Trainer", Destinations.Trainer.route), MenuTarget("Trainer", Destinations.Trainer.route),
MenuTarget("Anfänger", Destinations.Anfaenger.route), MenuTarget("Anfänger", Destinations.Anfaenger.route),
MenuTarget("TT-Regeln", Destinations.Regeln.route), MenuTarget("TT-Regeln", Destinations.Regeln.route),
) )
MenuSection.NEWSLETTER -> listOf( MenuSection.NEWSLETTER -> listOf(
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route), MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route), MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route), MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
) )
MenuSection.INTERN -> buildList { MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route)) add(MenuTarget("Übersicht", Destinations.MemberArea.route))
add(MenuTarget("Mitgliederliste", Destinations.Members.route)) add(MenuTarget("Mitgliederliste", Destinations.Members.route))
add(MenuTarget("QTTR", Destinations.Qttr.route))
add(MenuTarget("News", Destinations.MemberNews.route)) add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route)) add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route)) add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route)) }
// CMS child items (will be rendered when CMS parent is expanded)
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("Übersicht", Destinations.Cms.route)) MenuSection.CMS -> buildList {
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route)) if (state.canAccessFullCms) {
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route)) add(MenuTarget("Übersicht", Destinations.Cms.route))
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route)) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route)) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route)) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
add(MenuTarget("News", Destinations.CmsNews.route))
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route))
}
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route)) if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route)) if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
} }
null -> emptyList() null -> emptyList()
} }

View 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))
}
}

View File

View File

@@ -10,8 +10,12 @@ sealed class Destinations(val route: String) {
object Links : Destinations("verein/links") object Links : Destinations("verein/links")
object Impressum : Destinations("impressum") object Impressum : Destinations("impressum")
object Mannschaften : Destinations("mannschaften") object Mannschaften : Destinations("mannschaften")
object MannschaftDetail : Destinations("mannschaften/{slug}") { object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
fun create(slug: String): String = "mannschaften/$slug" fun create(slug: String, season: String? = null): String {
val encodedSlug = android.net.Uri.encode(slug)
val selectedSeason = season?.takeIf { it.isNotBlank() } ?: return "mannschaften/$encodedSlug"
return "mannschaften/$encodedSlug?season=${android.net.Uri.encode(selectedSeason)}"
}
} }
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") { object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
fun create(slug: String): String = "mannschaft/$slug" fun create(slug: String): String = "mannschaft/$slug"
@@ -36,12 +40,15 @@ sealed class Destinations(val route: String) {
object Register : Destinations("register") object Register : Destinations("register")
object MemberArea : Destinations("intern") object MemberArea : Destinations("intern")
object Members : Destinations("intern/mitglieder") object Members : Destinations("intern/mitglieder")
object Qttr : Destinations("intern/qttr")
object MemberNews : Destinations("intern/news") object MemberNews : Destinations("intern/news")
object Profile : Destinations("intern/profil") object Profile : Destinations("intern/profil")
object NotificationSettings : Destinations("intern/benachrichtigungen")
object MemberApi : Destinations("intern/api") object MemberApi : Destinations("intern/api")
object CmsStartseite : Destinations("cms/startseite") object CmsStartseite : Destinations("cms/startseite")
object CmsInhalte : Destinations("cms/inhalte") object CmsInhalte : Destinations("cms/inhalte")
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften") object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
object CmsNews : Destinations("cms/news")
object CmsSportbetrieb : Destinations("cms/sportbetrieb") object CmsSportbetrieb : Destinations("cms/sportbetrieb")
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung") object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
object CmsNewsletter : Destinations("cms/newsletter") object CmsNewsletter : Destinations("cms/newsletter")

View File

@@ -2,15 +2,25 @@ package de.harheimertc.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Button
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -26,7 +36,9 @@ fun NavGraph(
val backStackEntry = navController.currentBackStackEntryAsState().value val backStackEntry = navController.currentBackStackEntryAsState().value
val route = backStackEntry?.destination?.route val route = backStackEntry?.destination?.route
val currentRoute = if (route == Destinations.MannschaftDetail.route) { val currentRoute = if (route == Destinations.MannschaftDetail.route) {
backStackEntry.arguments?.getString("slug")?.let(Destinations.MannschaftDetail::create) backStackEntry.arguments?.getString("slug")?.let { slug ->
Destinations.MannschaftDetail.create(slug, backStackEntry.arguments?.getString("season"))
}
} else route } else route
val navigationState by navigationViewModel.state.collectAsState() val navigationState by navigationViewModel.state.collectAsState()
LaunchedEffect(currentRoute) { LaunchedEffect(currentRoute) {
@@ -35,10 +47,27 @@ fun NavGraph(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val persistentNavigation = maxWidth >= 600.dp val persistentNavigation = maxWidth >= 600.dp
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
navigationState.connectionNote?.let { message ->
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
Text(
text = message,
color = Color(0xFF7C2D12),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
}
if (persistentNavigation) { if (persistentNavigation) {
AppNavigationHeader( AppNavigationHeader(
selectedRoute = currentRoute, selectedRoute = currentRoute,
onNavigate = navController::navigateTopLevel, onNavigate = navController::navigateTopLevel,
onLogout = {
navigationViewModel.logout {
navController.navigate(Destinations.Home.route) {
launchSingleTop = true
}
}
},
webTabletNavigation = true, webTabletNavigation = true,
navigationState = navigationState, navigationState = navigationState,
) )
@@ -52,6 +81,8 @@ fun NavGraph(
de.harheimertc.ui.screens.home.HomeScreen( de.harheimertc.ui.screens.home.HomeScreen(
navController = navController, navController = navController,
showNavigationHeader = !persistentNavigation, showNavigationHeader = !persistentNavigation,
navigationViewModel = navigationViewModel,
viewModel = hiltViewModel(),
) )
} }
composable(Destinations.VereinAbout.route) { composable(Destinations.VereinAbout.route) {
@@ -111,9 +142,13 @@ fun NavGraph(
composable("mannschaften/jugend") { composable("mannschaften/jugend") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
} }
composable(Destinations.MannschaftDetail.route) { entry -> composable(
route = Destinations.MannschaftDetail.route,
arguments = listOf(navArgument("season") { nullable = true; defaultValue = null }),
) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen( de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(), slug = entry.arguments?.getString("slug").orEmpty(),
season = entry.arguments?.getString("season"),
navController = navController, navController = navController,
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
@@ -121,6 +156,7 @@ fun NavGraph(
composable(Destinations.MannschaftLegacyDetail.route) { entry -> composable(Destinations.MannschaftLegacyDetail.route) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen( de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(), slug = entry.arguments?.getString("slug").orEmpty(),
season = null,
navController = navController, navController = navController,
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
@@ -253,6 +289,7 @@ fun NavGraph(
de.harheimertc.ui.screens.memberarea.MemberAreaScreen( de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
navController = navController, navController = navController,
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
navigationState = navigationState,
) )
} }
composable(Destinations.Members.route) { composable(Destinations.Members.route) {
@@ -261,6 +298,12 @@ fun NavGraph(
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
} }
composable(Destinations.Qttr.route) {
de.harheimertc.ui.screens.memberarea.QttrScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MemberNews.route) { composable(Destinations.MemberNews.route) {
de.harheimertc.ui.screens.memberarea.MemberNewsScreen( de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
navController = navController, navController = navController,
@@ -273,6 +316,13 @@ fun NavGraph(
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
} }
composable(Destinations.NotificationSettings.route) {
de.harheimertc.ui.screens.notifications.NotificationSettingsScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
navigationState = navigationState,
)
}
composable(Destinations.MemberApi.route) { composable(Destinations.MemberApi.route) {
de.harheimertc.ui.screens.memberarea.MemberApiScreen( de.harheimertc.ui.screens.memberarea.MemberApiScreen(
navController = navController, navController = navController,
@@ -280,42 +330,140 @@ fun NavGraph(
) )
} }
composable(Destinations.CmsStartseite.route) { composable(Destinations.CmsStartseite.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
} }
}
composable(Destinations.CmsInhalte.route) { composable(Destinations.CmsInhalte.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
} }
}
composable(Destinations.CmsVereinsmeisterschaften.route) { composable(Destinations.CmsVereinsmeisterschaften.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
} }
}
composable(Destinations.CmsNews.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
}
}
composable(Destinations.CmsSportbetrieb.route) { composable(Destinations.CmsSportbetrieb.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
} }
}
composable(Destinations.CmsMitgliederverwaltung.route) { composable(Destinations.CmsMitgliederverwaltung.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
} }
}
composable(Destinations.CmsNewsletter.route) { composable(Destinations.CmsNewsletter.route) {
RequireCmsAccess(
allowed = navigationState.canAccessNewsletter,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
} }
}
composable(Destinations.CmsContactRequests.route) { composable(Destinations.CmsContactRequests.route) {
RequireCmsAccess(
allowed = navigationState.canAccessContactRequests,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
} }
}
composable(Destinations.CmsEinstellungen.route) { composable(Destinations.CmsEinstellungen.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
} }
}
composable(Destinations.CmsBenutzer.route) { composable(Destinations.CmsBenutzer.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
} }
}
composable(Destinations.CmsPasswordResetDiagnostics.route) { composable(Destinations.CmsPasswordResetDiagnostics.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
} }
}
composable(Destinations.Cms.route) { composable(Destinations.Cms.route) {
RequireCmsAccess(
allowed = navigationState.canAccessCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
} }
} }
} }
} }
} }
}
@Composable
private fun RequireCmsAccess(
allowed: Boolean,
navController: NavHostController,
content: @Composable () -> Unit,
) {
if (allowed) {
content()
return
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Kein Zugriff auf diesen Bereich.",
style = MaterialTheme.typography.headlineSmall,
color = Color(0xFF7F1D1D),
)
Text(
text = "Bitte mit einem berechtigten Konto anmelden.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF7C2D12),
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
)
Button(onClick = { navController.navigateTopLevel(Destinations.Login.route) }) {
Text("Zum Login")
}
}
}
private fun NavHostController.navigateTopLevel(route: String) { private fun NavHostController.navigateTopLevel(route: String) {
val isTeamDetail = route.startsWith("mannschaften/") && val isTeamDetail = route.startsWith("mannschaften/") &&

View File

@@ -3,10 +3,13 @@ package de.harheimertc.ui.navigation
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.GalleryRepository import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -18,10 +21,13 @@ data class NavigationUiState(
val hasGalleryImages: Boolean = false, val hasGalleryImages: Boolean = false,
val loggedIn: Boolean = false, val loggedIn: Boolean = false,
val roles: Set<String> = emptySet(), val roles: Set<String> = emptySet(),
val connectionNote: String? = null,
) { ) {
val isAdmin: Boolean get() = "admin" in roles val isAdmin: Boolean get() = "admin" in roles
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") } val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") } val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
val canAccessCms: Boolean get() = canAccessFullCms || canAccessNewsletter || canAccessContactRequests
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
} }
@@ -30,12 +36,24 @@ class NavigationViewModel @Inject constructor(
private val mannschaftenRepository: MannschaftenRepository, private val mannschaftenRepository: MannschaftenRepository,
private val galleryRepository: GalleryRepository, private val galleryRepository: GalleryRepository,
private val loginRepository: LoginRepository, private val loginRepository: LoginRepository,
private val authRepository: AuthRepository,
private val connectivityMonitor: ConnectivityMonitor,
private val pushTokenRepository: PushTokenRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState()) private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state val state: StateFlow<NavigationUiState> = _state
init { init {
loadNavigationData() loadNavigationData()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
_state.value = _state.value.copy(
connectionNote = if (online) null else "Keine Verbindung. Die App versucht alle 10 Sekunden erneut zu laden.",
)
wasOnline = online
}
}
} }
fun loadNavigationData() { fun loadNavigationData() {
@@ -44,22 +62,54 @@ class NavigationViewModel @Inject constructor(
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) } val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) } val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
val status = auth.await() val status = auth.await()
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
val loggedIn = hasStoredSession || status.isLoggedIn
_state.value = NavigationUiState( _state.value = NavigationUiState(
teams = teams.await(), teams = teams.await(),
hasGalleryImages = gallery.await(), hasGalleryImages = gallery.await(),
loggedIn = status.isLoggedIn, loggedIn = loggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(), roles = status.navigationRoles(),
connectionNote = null,
) )
if (loggedIn) registerPushToken()
} }
} }
fun refreshSession() { fun refreshSession() {
viewModelScope.launch { viewModelScope.launch {
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
val loggedIn = hasStoredSession || status.isLoggedIn
_state.value = _state.value.copy( _state.value = _state.value.copy(
loggedIn = status.isLoggedIn, loggedIn = loggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(), roles = status.navigationRoles(),
connectionNote = _state.value.connectionNote,
) )
if (loggedIn) registerPushToken()
}
}
private fun registerPushToken() {
viewModelScope.launch {
pushTokenRepository.registerCurrentDevice()
}
}
fun logout(onComplete: () -> Unit = {}) {
viewModelScope.launch {
loginRepository.logout()
_state.value = _state.value.copy(
loggedIn = false,
roles = emptySet(),
connectionNote = _state.value.connectionNote,
)
onComplete()
} }
} }
} }
private fun de.harheimertc.data.AuthStatusResponse.navigationRoles(): Set<String> = buildSet {
addAll(roles)
role?.takeIf { it.isNotBlank() }?.let(::add)
addAll(user?.roles.orEmpty())
}

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -36,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsSaveRequest import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.ui.components.FormMessages import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.NativeRichTextEditor import de.harheimertc.ui.components.NativeRichTextEditor
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -49,7 +49,7 @@ import java.util.Locale
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var selection by remember { mutableStateOf(setOf<Int>()) } var selection by remember { mutableStateOf(setOf<String>()) }
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel() val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
val loginState by loginVm.state.collectAsState() val loginState by loginVm.state.collectAsState()
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" } val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
@@ -62,7 +62,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
// Local dialog state for create/edit + delete confirmation (hoisted) // Local dialog state for create/edit + delete confirmation (hoisted)
var dialogOpen by remember { mutableStateOf(false) } var dialogOpen by remember { mutableStateOf(false) }
var deletingIds by remember { mutableStateOf<List<Int>?>(null) } var deletingIds by remember { mutableStateOf<List<String>?>(null) }
var editing by remember { mutableStateOf<NewsDto?>(null) } var editing by remember { mutableStateOf<NewsDto?>(null) }
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") } var content by remember { mutableStateOf("") }
@@ -112,7 +112,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
} }
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") { CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
if (state.loading) item { CircularProgressIndicator() } if (state.loading) item { LoadingState("News werden geladen...") }
item { item {
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") } Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
@@ -132,15 +132,22 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
items(state.news) { news -> items(state.news) { news ->
val selected = news.id?.let { selection.contains(it) } ?: false val selected = news.id?.let { selection.contains(it) } ?: false
NewsListItem(news = news, selected = selected, onSelect = { id, sel -> NewsListItem(
news = news,
canWrite = canWrite,
selected = selected,
onSelect = { id, sel ->
id?.let { id?.let {
selection = if (sel) selection + it else selection - it selection = if (sel) selection + it else selection - it
} }
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } }) },
onEdit = { openEdit(news) },
onDelete = { news.id?.let { id -> deletingIds = listOf(id) } },
)
} }
// bulk action bar // bulk action bar
if (selection.isNotEmpty()) { if (canWrite && selection.isNotEmpty()) {
item { item {
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") } Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
@@ -255,16 +262,21 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
@Composable @Composable
private fun NewsListItem( private fun NewsListItem(
news: NewsDto, news: NewsDto,
canWrite: Boolean,
selected: Boolean = false, selected: Boolean = false,
onSelect: (Int?, Boolean) -> Unit = { _, _ -> }, onSelect: (String?, Boolean) -> Unit = { _, _ -> },
onEdit: (NewsDto) -> Unit, onEdit: (NewsDto) -> Unit,
onDelete: (Int) -> Unit, onDelete: (String) -> Unit,
) { ) {
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) { androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) }) Checkbox(
checked = selected,
onCheckedChange = { onSelect(news.id, it) },
enabled = canWrite,
)
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp)) Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
if (news.isPublic) { if (news.isPublic) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
@@ -297,6 +309,7 @@ private fun NewsListItem(
Text("Aktualisiert: ${news.updated}") Text("Aktualisiert: ${news.updated}")
} }
} }
if (canWrite) {
Row { Row {
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") } TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") } TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
@@ -304,3 +317,4 @@ private fun NewsListItem(
} }
} }
} }
}

View File

@@ -1,8 +1,10 @@
package de.harheimertc.ui.screens.cms package de.harheimertc.ui.screens.cms
import android.content.Intent import android.content.Intent
import android.app.DatePickerDialog
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -16,7 +18,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -37,6 +38,8 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -49,9 +52,12 @@ import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.PasswordResetMatchingUserDto import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetStepDto import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.data.TerminDto
import de.harheimertc.ui.components.FormMessages import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.NativeRichTextEditor import de.harheimertc.ui.components.NativeRichTextEditor
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.repositories.CmsMannschaftRow
import de.harheimertc.repositories.MeisterschaftResult import de.harheimertc.repositories.MeisterschaftResult
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
@@ -61,13 +67,14 @@ import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600 import de.harheimertc.ui.theme.Primary600
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.Locale import java.util.Locale
@Composable @Composable
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") { CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) } if (state.loading) item { LoadingState("CMS-Daten werden geladen...") }
item { CmsSummaryGrid(navController, state) } item { CmsSummaryGrid(navController, state) }
} }
} }
@@ -85,7 +92,7 @@ fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolea
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") { CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
when { when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } state.loading || config == null -> item { LoadingState("Startseitenkonfiguration wird geladen...") }
else -> { else -> {
item { item {
Button( Button(
@@ -171,7 +178,7 @@ fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean,
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") { CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
when { when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } state.loading || config == null -> item { LoadingState("Inhalte werden geladen...") }
else -> { else -> {
item { item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
@@ -262,7 +269,7 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") { CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
when { when {
state.loading -> item { CircularProgressIndicator(color = Primary600) } state.loading -> item { LoadingState("Vereinsmeisterschaften werden geladen...") }
else -> { else -> {
item { item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
@@ -433,116 +440,426 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
@Composable @Composable
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val config = state.config val context = LocalContext.current
var ortName by remember { mutableStateOf("") } var activeTab by remember { mutableStateOf("termine") }
var ortStrasse by remember { mutableStateOf("") } val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() }
var ortPlz by remember { mutableStateOf("") } var spielplanCsv by remember { mutableStateOf("") }
var ortOrt by remember { mutableStateOf("") } var spielplanEditorOpen by remember { mutableStateOf(false) }
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() } var terminDialogOpen by remember { mutableStateOf(false) }
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() } var editingTermin by remember { mutableStateOf<TerminDto?>(null) }
var terminDatum by remember { mutableStateOf("") }
var terminUhrzeit by remember { mutableStateOf("") }
var terminTitel by remember { mutableStateOf("") }
var terminBeschreibung by remember { mutableStateOf("") }
var terminKategorie by remember { mutableStateOf("Sonstiges") }
var terminKategorieOpen by remember { mutableStateOf(false) }
val tabs = listOf(
"termine" to "Termine",
"mannschaften" to "Mannschaften",
"spielplaene" to "Spielpläne",
)
val spielplanTeamOptions = remember(state.sportSpielplanHeaders, state.sportSpielplanRows) {
importedSpielplanTeams(state.sportSpielplanHeaders, state.sportSpielplanRows)
}
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
LaunchedEffect(config) { LaunchedEffect(Unit) {
config?.let { viewModel.loadSportbetrieb()
ortName = it.training.ort.name }
ortStrasse = it.training.ort.strasse
ortPlz = it.training.ort.plz LaunchedEffect(state.sportMannschaften) {
ortOrt = it.training.ort.ort mannschaften.clear()
trainingTimes.clear() mannschaften.addAll(state.sportMannschaften)
trainingTimes.addAll(it.training.zeiten) }
trainers.clear()
trainers.addAll(it.trainer) 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) }
}
}
} }
} }
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") { if (!state.sportLoading) {
when { when (activeTab) {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } "termine" -> {
else -> {
item { item {
Button( Button(
onClick = { onClick = { openTerminDialog(null) },
viewModel.saveConfig( enabled = !state.sportSaving,
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(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(if (state.saving) "Speichert..." else "Speichern") 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 { item {
DataCard("Trainingsort") { if (state.sportMannschaftenSeasons.isNotEmpty()) {
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth()) DataCard("Saison") {
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth()) Row(
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { modifier = Modifier.fillMaxWidth(),
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f)) horizontalArrangement = Arrangement.spacedBy(8.dp),
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f)) ) {
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 { item {
DataCard("Trainingszeiten") { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
trainingTimes.forEachIndexed { index, zeit ->
TrainingTimeEditorCard(
zeit = zeit,
onChange = { updated -> trainingTimes[index] = updated },
onRemove = { trainingTimes.removeAt(index) },
)
}
Button( Button(
onClick = { onClick = {
trainingTimes.add( mannschaften.add(CmsMannschaftRow(letzteAktualisierung = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE)))
de.harheimertc.data.TrainingTimeDto(
id = "training-${System.currentTimeMillis()}",
tag = "Montag",
),
)
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.weight(1f),
) { ) {
Text("Trainingszeit hinzufügen") Text("Hinzufügen")
}
}
}
item {
DataCard("Trainer") {
trainers.forEachIndexed { index, trainer ->
TrainerEditorCard(
trainer = trainer,
onChange = { updated -> trainers[index] = updated },
onRemove = { trainers.removeAt(index) },
)
} }
Button( Button(
onClick = { onClick = { viewModel.saveSportMannschaften(state.sportMannschaftenSeason, mannschaften.toList()) },
trainers.add( enabled = !state.sportSaving,
de.harheimertc.data.TrainerDto( modifier = Modifier.weight(1f),
id = "trainer-${System.currentTimeMillis()}",
),
)
},
modifier = Modifier.fillMaxWidth(),
) { ) {
Text("Trainer hinzufügen") 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) } item { FormMessages(state.error, state.message) }
} }
} }
if (terminDialogOpen) {
AlertDialog(
onDismissRequest = { terminDialogOpen = false },
title = { Text(if (editingTermin == null) "Termin hinzufügen" else "Termin bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { openDatePicker() }, modifier = Modifier.fillMaxWidth()) {
Text(terminDatum.ifBlank { "Datum auswählen" })
} }
OutlinedTextField(value = terminUhrzeit, onValueChange = { terminUhrzeit = it }, label = { Text("Uhrzeit") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = terminTitel, onValueChange = { terminTitel = it }, label = { Text("Titel") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = terminBeschreibung, onValueChange = { terminBeschreibung = it }, label = { Text("Beschreibung") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { terminKategorieOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text(terminKategorie.ifBlank { "Kategorie auswählen" })
}
DropdownMenu(expanded = terminKategorieOpen, onDismissRequest = { terminKategorieOpen = false }) {
terminKategorien.forEach { kategorie ->
DropdownMenuItem(
text = { Text(kategorie) },
onClick = {
terminKategorie = kategorie
terminKategorieOpen = false
},
)
}
}
}
}
},
confirmButton = {
Button(
onClick = {
viewModel.saveSportTermin(
editingTermin,
TerminDto(
datum = terminDatum,
uhrzeit = terminUhrzeit.takeIf { it.isNotBlank() },
titel = terminTitel,
beschreibung = terminBeschreibung.takeIf { it.isNotBlank() },
kategorie = terminKategorie.ifBlank { "Sonstiges" },
),
)
terminDialogOpen = false
},
enabled = !state.sportSaving && terminDatum.isNotBlank() && terminTitel.isNotBlank(),
) {
Text(if (state.sportSaving) "Speichert..." else "Speichern")
}
},
dismissButton = {
TextButton(onClick = { terminDialogOpen = false }) { Text("Abbrechen") }
},
)
}
if (spielplanEditorOpen) {
AlertDialog(
onDismissRequest = { spielplanEditorOpen = false },
title = { Text("Spielplan CSV bearbeiten") },
text = {
OutlinedTextField(
value = spielplanCsv,
onValueChange = { spielplanCsv = it },
label = { Text("CSV mit Semikolon") },
modifier = Modifier.fillMaxWidth(),
minLines = 12,
)
},
confirmButton = {
Button(
onClick = {
val (headers, rows) = parseSportCsvText(spielplanCsv)
viewModel.saveSportSpielplan(headers, rows)
spielplanEditorOpen = false
},
enabled = !state.sportSaving && spielplanCsv.isNotBlank(),
) {
Text(if (state.sportSaving) "Speichert..." else "Speichern")
}
},
dismissButton = {
TextButton(onClick = { spielplanEditorOpen = false }) { Text("Abbrechen") }
},
)
}
}
@Composable
private fun MannschaftEditorCard(
row: CmsMannschaftRow,
spielplanTeams: List<ImportedSpielplanTeam>,
onChange: (CmsMannschaftRow) -> Unit,
onRemove: () -> Unit,
) {
var teamPickerOpen by remember(row.mannschaft, spielplanTeams) { mutableStateOf(false) }
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.spielplanMannschaftId, onValueChange = { onChange(row.copy(spielplanMannschaftId = it)) }, label = { Text("Spielplan-Team-ID") }, supportingText = { Text("Eindeutige myTischtennis-Team-ID") }, modifier = Modifier.fillMaxWidth())
if (spielplanTeams.isNotEmpty()) {
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { teamPickerOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text("Aus importierten Teams auswählen")
}
DropdownMenu(expanded = teamPickerOpen, onDismissRequest = { teamPickerOpen = false }) {
spielplanTeams.forEach { team ->
DropdownMenuItem(
text = { Text(team.label) },
onClick = {
onChange(row.copy(spielplanMannschaftId = team.id))
teamPickerOpen = false
},
)
}
}
}
}
OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.heimspieltag, onValueChange = { onChange(row.copy(heimspieltag = it)) }, label = { Text("Heimspieltag") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.spielsystem, onValueChange = { onChange(row.copy(spielsystem = it)) }, label = { Text("Spielsystem") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.mannschaftsfuehrer, onValueChange = { onChange(row.copy(mannschaftsfuehrer = it)) }, label = { Text("Mannschaftsführer") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.spieler, onValueChange = { onChange(row.copy(spieler = it)) }, label = { Text("Spieler") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
OutlinedTextField(value = row.informationenLink, onValueChange = { onChange(row.copy(informationenLink = it)) }, label = { Text("Weitere Informationen Link") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.letzteAktualisierung, onValueChange = { onChange(row.copy(letzteAktualisierung = it)) }, label = { Text("Letzte Aktualisierung") }, modifier = Modifier.fillMaxWidth())
TextButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) {
Text("Entfernen")
}
}
}
private data class ImportedSpielplanTeam(val id: String, val label: String)
private fun importedSpielplanTeams(headers: List<String>, rows: List<List<String>>): List<ImportedSpielplanTeam> {
fun index(name: String) = headers.indexOf(name)
fun value(row: List<String>, column: String): String {
val columnIndex = index(column)
return row.getOrElse(columnIndex) { "" }.trim()
}
val teams = linkedMapOf<String, ImportedSpielplanTeam>()
listOf("Heim", "Gast").forEach { side ->
rows.forEach { row ->
val isHarheimer = value(row, "${side}VereinNr") == "43030" || value(row, "${side}VereinName") == "Harheimer TC"
val id = value(row, "${side}MannschaftId")
if (!isHarheimer || id.isBlank()) return@forEach
val teamName = value(row, "${side}Mannschaft")
val ageClass = value(row, "${side}MannschaftAltersklasse")
val teamNumber = value(row, "${side}MannschaftNr")
val label = listOf(teamName, ageClass, teamNumber.takeIf { it.isNotBlank() }?.let { "Nr. $it" })
.filterNotNull()
.filter { it.isNotBlank() }
.joinToString(" · ")
teams.putIfAbsent(id, ImportedSpielplanTeam(id, "$label [$id]"))
}
}
return teams.values.sortedBy { it.label }
}
private fun sportSpielplanCsvText(headers: List<String>, rows: List<List<String>>): String {
if (headers.isEmpty()) return ""
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }
}
private fun parseSportCsvText(text: String): Pair<List<String>, List<List<String>>> {
val lines = text.lineSequence().filter { it.isNotBlank() }.toList()
if (lines.isEmpty()) return emptyList<String>() to emptyList()
val headers = parseDelimitedLine(lines.first(), ';')
val rows = lines.drop(1).map { parseDelimitedLine(it, ';') }
return headers to rows
}
private fun parseDelimitedLine(line: String, delimiter: Char): List<String> {
val values = mutableListOf<String>()
val value = StringBuilder()
var quoted = false
var index = 0
while (index < line.length) {
when (val char = line[index]) {
'"' -> {
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
value.append('"')
index++
} else {
quoted = !quoted
}
}
delimiter -> if (quoted) value.append(char) else {
values += value.toString()
value.clear()
}
else -> value.append(char)
}
index++
}
values += value.toString()
return values
}
private fun String.csvCell(delimiter: String): String {
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
val escaped = replace("\"", "\"\"")
return if (needsQuotes) "\"$escaped\"" else escaped
} }
@Composable @Composable
@@ -555,7 +872,7 @@ fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigati
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") { CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) } if (state.loading) item { LoadingState("Kontaktanfragen werden geladen...") }
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") } if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) } items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
} }
@@ -592,7 +909,7 @@ fun CmsNewsletterScreen(
var grpTargetGroup by remember { mutableStateOf("") } var grpTargetGroup by remember { mutableStateOf("") }
var grpSendToExternal by remember { mutableStateOf(true) } var grpSendToExternal by remember { mutableStateOf(true) }
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") { CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) } if (state.loading) item { LoadingState("Newsletter-Daten werden geladen...") }
item { item {
if (canWrite) Button(onClick = { if (canWrite) Button(onClick = {
editingNewsletter = null editingNewsletter = null
@@ -619,11 +936,13 @@ fun CmsNewsletterScreen(
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") } if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
items(state.newsletters.size) { index -> items(state.newsletters.size) { index ->
val item = state.newsletters[index] val item = state.newsletters[index]
NewsletterCard(item, NewsletterCard(
item,
canWrite = canWrite,
onEdit = { nl -> onEdit = { nl ->
editingNewsletter = nl editingNewsletter = nl
nlTitle = nl.title nlTitle = nl.title
nlContent = nl.title ?: "" nlContent = nl.title
nlType = "subscription" nlType = "subscription"
nlTargetGroup = "" nlTargetGroup = ""
nlSendToExternal = true nlSendToExternal = true
@@ -637,7 +956,9 @@ fun CmsNewsletterScreen(
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") } if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
items(state.newsletterGroups.size) { index -> items(state.newsletterGroups.size) { index ->
val group = state.newsletterGroups[index] val group = state.newsletterGroups[index]
NewsletterGroupCard(group, NewsletterGroupCard(
group,
canWrite = canWrite,
onEdit = { g -> onEdit = { g ->
editingGroup = g editingGroup = g
grpName = g.name grpName = g.name
@@ -753,6 +1074,12 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
var websiteVorname by remember { mutableStateOf("") } var websiteVorname by remember { mutableStateOf("") }
var websiteNachname by remember { mutableStateOf("") } var websiteNachname by remember { mutableStateOf("") }
var websiteEmail by remember { mutableStateOf("") } var websiteEmail by remember { mutableStateOf("") }
var ortName by remember { mutableStateOf("") }
var ortStrasse by remember { mutableStateOf("") }
var ortPlz by remember { mutableStateOf("") }
var ortOrt by remember { mutableStateOf("") }
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
LaunchedEffect(config) { LaunchedEffect(config) {
config?.let { config?.let {
@@ -764,12 +1091,20 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
websiteVorname = it.website.verantwortlicher.vorname websiteVorname = it.website.verantwortlicher.vorname
websiteNachname = it.website.verantwortlicher.nachname websiteNachname = it.website.verantwortlicher.nachname
websiteEmail = it.website.verantwortlicher.email websiteEmail = it.website.verantwortlicher.email
ortName = it.training.ort.name
ortStrasse = it.training.ort.strasse
ortPlz = it.training.ort.plz
ortOrt = it.training.ort.ort
trainingTimes.clear()
trainingTimes.addAll(it.training.zeiten)
trainers.clear()
trainers.addAll(it.trainer)
} }
} }
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") { CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
when { when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } state.loading || config == null -> item { LoadingState("Einstellungen werden geladen...") }
else -> { else -> {
item { item {
Button( Button(
@@ -790,6 +1125,16 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
email = websiteEmail, email = websiteEmail,
), ),
), ),
training = config.training.copy(
ort = config.training.ort.copy(
name = ortName,
strasse = ortStrasse,
plz = ortPlz,
ort = ortOrt,
),
zeiten = trainingTimes.toList(),
),
trainer = trainers.toList(),
), ),
) )
}, },
@@ -822,6 +1167,63 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
} }
} }
item {
DataCard("Trainingsort") {
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
}
}
}
item {
DataCard("Trainingszeiten") {
trainingTimes.forEachIndexed { index, zeit ->
TrainingTimeEditorCard(
zeit = zeit,
onChange = { updated -> trainingTimes[index] = updated },
onRemove = { trainingTimes.removeAt(index) },
)
}
Button(
onClick = {
trainingTimes.add(
de.harheimertc.data.TrainingTimeDto(
id = "training-${System.currentTimeMillis()}",
tag = "Montag",
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Trainingszeit hinzufügen")
}
}
}
item {
DataCard("Trainer") {
trainers.forEachIndexed { index, trainer ->
TrainerEditorCard(
trainer = trainer,
onChange = { updated -> trainers[index] = updated },
onRemove = { trainers.removeAt(index) },
)
}
Button(
onClick = {
trainers.add(
de.harheimertc.data.TrainerDto(
id = "trainer-${System.currentTimeMillis()}",
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Trainer hinzufügen")
}
}
}
item { item {
DataCard("Systemstatus") { DataCard("Systemstatus") {
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString()) InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
@@ -883,7 +1285,7 @@ fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavi
} }
if (state.loading) { if (state.loading) {
item { CircularProgressIndicator(color = Primary600) } item { LoadingState("Diagnosedaten werden geladen...") }
} }
if (state.passwordResetSearchTerm.isNotBlank()) { if (state.passwordResetSearchTerm.isNotBlank()) {
@@ -950,6 +1352,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
val cards = listOf( val cards = listOf(
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route), Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route), Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
Triple("News", "Interne und öffentliche News", Destinations.CmsNews.route),
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route), Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route), Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route), Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
@@ -1073,7 +1476,7 @@ private fun CmsConfigPage(
) { ) {
CmsPage(navController, showBackNavigation, title, subtitle) { CmsPage(navController, showBackNavigation, title, subtitle) {
if (config == null) { if (config == null) {
item { CircularProgressIndicator(color = Primary600) } item { LoadingState("Konfiguration wird geladen...") }
} else { } else {
item { item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
@@ -1229,11 +1632,18 @@ private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewMod
} }
@Composable @Composable
private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) { private fun NewsletterCard(
newsletter: NewsletterDto,
canWrite: Boolean,
onEdit: (NewsletterDto) -> Unit = {},
onDelete: (String) -> Unit = {},
onSend: (String) -> Unit = {},
) {
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) { DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf") InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
InfoRow("Erstellt", newsletter.createdAt ?: "-") InfoRow("Erstellt", newsletter.createdAt ?: "-")
InfoRow("Versendet", newsletter.sentAt ?: "-") InfoRow("Versendet", newsletter.sentAt ?: "-")
if (canWrite) {
Row { Row {
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") } TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
@@ -1243,18 +1653,26 @@ private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) ->
} }
} }
} }
}
@Composable @Composable
private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGroupDto) -> Unit = {}, onDelete: (String) -> Unit = {}) { private fun NewsletterGroupCard(
group: NewsletterGroupDto,
canWrite: Boolean,
onEdit: (NewsletterGroupDto) -> Unit = {},
onDelete: (String) -> Unit = {},
) {
DataCard(group.name.ifBlank { group.id }) { DataCard(group.name.ifBlank { group.id }) {
InfoRow("Beschreibung", group.description.ifBlank { "-" }) InfoRow("Beschreibung", group.description.ifBlank { "-" })
InfoRow("Abonnenten", group.subscribers.size.toString()) InfoRow("Abonnenten", group.subscribers.size.toString())
if (canWrite) {
Row { Row {
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") } TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
} }
} }
} }
}
@Composable @Composable
private fun PasswordAttemptCard(attempt: PasswordResetAttemptDto) { private fun PasswordAttemptCard(attempt: PasswordResetAttemptDto) {

View File

@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.cms
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.CmsUserDto import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.ConfigResponse import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto import de.harheimertc.data.ContactRequestDto
@@ -12,6 +13,10 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsSaveRequest import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.repositories.CmsMannschaftRow
import de.harheimertc.repositories.CmsRepository import de.harheimertc.repositories.CmsRepository
import de.harheimertc.repositories.MeisterschaftResult import de.harheimertc.repositories.MeisterschaftResult
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -38,17 +43,37 @@ data class CmsUiState(
val passwordResetFailedOnly: Boolean = true, val passwordResetFailedOnly: Boolean = true,
val news: List<NewsDto> = emptyList(), val news: List<NewsDto> = emptyList(),
val meisterschaften: List<MeisterschaftResult> = emptyList(), val meisterschaften: List<MeisterschaftResult> = emptyList(),
val sportLoading: Boolean = false,
val sportSaving: Boolean = false,
val sportTermine: List<TerminDto> = emptyList(),
val sportMannschaften: List<CmsMannschaftRow> = emptyList(),
val sportMannschaftenSeasons: List<String> = emptyList(),
val sportMannschaftenSeason: String = "",
val sportSpielplanHeaders: List<String> = emptyList(),
val sportSpielplanRows: List<List<String>> = emptyList(),
val sportSpielplanSeason: String = "",
val sportSpielplanSeasons: List<SeasonDto> = emptyList(),
) )
@HiltViewModel @HiltViewModel
class CmsViewModel @Inject constructor( class CmsViewModel @Inject constructor(
private val repository: CmsRepository, private val repository: CmsRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(CmsUiState()) private val _state = MutableStateFlow(CmsUiState())
val state: StateFlow<CmsUiState> = _state val state: StateFlow<CmsUiState> = _state
init { init {
load() load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
} }
fun load() { fun load() {
@@ -168,6 +193,183 @@ class CmsViewModel @Inject constructor(
} }
} }
fun loadSportbetrieb() {
viewModelScope.launch {
_state.value = _state.value.copy(sportLoading = true, error = null, message = null)
val termineRes = async { repository.managedTermine() }
val seasonsRes = async { repository.mannschaftenSeasons() }
val spielplanRes = async { repository.spielplan() }
val termineResult = termineRes.await()
val seasonsResult = seasonsRes.await()
val seasonInfo = seasonsResult.getOrNull()
val selectedSeason = _state.value.sportMannschaftenSeason.takeIf { it.isNotBlank() }
?: seasonInfo?.defaultSeason?.takeIf { it.isNotBlank() }
?: seasonInfo?.currentSeason?.takeIf { it.isNotBlank() }
?: seasonInfo?.seasons?.firstOrNull().orEmpty()
val mannschaftenResult = repository.mannschaften(selectedSeason.takeIf { it.isNotBlank() })
val spielplanResult = spielplanRes.await()
val errors = listOfNotNull(
ErrorMapper.mapError(termineResult.exceptionOrNull()),
ErrorMapper.mapError(seasonsResult.exceptionOrNull()),
ErrorMapper.mapError(mannschaftenResult.exceptionOrNull()),
ErrorMapper.mapError(spielplanResult.exceptionOrNull()),
)
val spielplan = spielplanResult.getOrNull()
val headers = spielplan?.headers.orEmpty()
_state.value = _state.value.copy(
sportLoading = false,
sportTermine = termineResult.getOrNull().orEmpty(),
sportMannschaften = mannschaftenResult.getOrNull().orEmpty(),
sportMannschaftenSeasons = seasonInfo?.seasons.orEmpty(),
sportMannschaftenSeason = selectedSeason,
sportSpielplanHeaders = headers,
sportSpielplanRows = spielplan?.data.orEmpty().map { row -> headers.map { header -> row.valueForHeader(header) } },
sportSpielplanSeason = spielplan?.season.orEmpty(),
sportSpielplanSeasons = spielplan?.seasons.orEmpty(),
error = errors.takeIf { it.isNotEmpty() }?.joinToString("; "),
)
}
}
fun loadSportMannschaftenSeason(season: String) {
viewModelScope.launch {
_state.value = _state.value.copy(sportLoading = true, error = null, message = null, sportMannschaftenSeason = season)
repository.mannschaften(season)
.onSuccess { rows ->
_state.value = _state.value.copy(sportLoading = false, sportMannschaften = rows)
}
.onFailure { err ->
_state.value = _state.value.copy(
sportLoading = false,
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht geladen werden.",
)
}
}
}
fun saveSportTermin(original: TerminDto?, termin: TerminDto) {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
if (original != null) {
repository.deleteTermin(original)
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Alter Termin konnte nicht ersetzt werden.",
)
return@launch
}
}
val saveResult = repository.saveTermin(termin)
saveResult
.onSuccess { response ->
val termine = repository.managedTermine().getOrDefault(_state.value.sportTermine)
_state.value = _state.value.copy(
sportSaving = false,
sportTermine = termine,
message = response.message ?: "Termin gespeichert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gespeichert werden.",
)
}
}
}
fun deleteSportTermin(termin: TerminDto) {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
repository.deleteTermin(termin)
.onSuccess { response ->
_state.value = _state.value.copy(
sportSaving = false,
sportTermine = _state.value.sportTermine.filterNot { it == termin },
message = response.message ?: "Termin gelöscht.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gelöscht werden.",
)
}
}
}
fun saveSportMannschaften(season: String, rows: List<CmsMannschaftRow>) {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
repository.saveMannschaften(season.takeIf { it.isNotBlank() }, rows)
.onSuccess { response ->
_state.value = _state.value.copy(
sportSaving = false,
sportMannschaften = rows,
message = response.message ?: "Mannschaften gespeichert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht gespeichert werden.",
)
}
}
}
fun importSportSpielplan() {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
repository.importSpielplan()
.onSuccess { response ->
if (!response.success) {
_state.value = _state.value.copy(
sportSaving = false,
error = response.message ?: "Spielplan konnte nicht importiert werden.",
)
return@onSuccess
}
_state.value = _state.value.copy(
sportSaving = false,
message = response.message ?: "Spielplan aktualisiert.",
)
loadSportbetrieb()
}
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht importiert werden.",
)
}
}
}
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
repository.saveSpielplan(headers, rows)
.onSuccess { response ->
_state.value = _state.value.copy(
sportSaving = false,
sportSpielplanHeaders = headers,
sportSpielplanRows = rows,
message = response.message ?: "Spielplan gespeichert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht gespeichert werden.",
)
}
}
}
fun saveConfig(config: ConfigResponse) { fun saveConfig(config: ConfigResponse) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null) _state.value = _state.value.copy(saving = true, error = null, message = null)
@@ -207,7 +409,7 @@ class CmsViewModel @Inject constructor(
} }
} }
fun bulkSetPublic(ids: List<Int>, makePublic: Boolean) { fun bulkSetPublic(ids: List<String>, makePublic: Boolean) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null) _state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id -> ids.forEach { id ->
@@ -229,7 +431,7 @@ class CmsViewModel @Inject constructor(
} }
} }
fun bulkSetHidden(ids: List<Int>, makeHidden: Boolean) { fun bulkSetHidden(ids: List<String>, makeHidden: Boolean) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null) _state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id -> ids.forEach { id ->
@@ -251,7 +453,7 @@ class CmsViewModel @Inject constructor(
} }
} }
fun bulkDelete(ids: List<Int>) { fun bulkDelete(ids: List<String>) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null) _state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id -> ids.forEach { id ->
@@ -262,7 +464,7 @@ class CmsViewModel @Inject constructor(
} }
} }
fun deleteNews(id: Int) { fun deleteNews(id: String) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null) _state.value = _state.value.copy(saving = true, error = null, message = null)
repository.deleteNews(id) repository.deleteNews(id)
@@ -451,3 +653,18 @@ class CmsViewModel @Inject constructor(
} }
} }
} }
private fun SpielDto.valueForHeader(header: String): String = when (header) {
"Termin" -> termin
"HeimMannschaft" -> heimMannschaft
"GastMannschaft" -> gastMannschaft
"HeimMannschaftAltersklasse" -> heimAltersklasse
"GastMannschaftAltersklasse" -> gastAltersklasse
"Altersklasse" -> altersklasse
"Liga" -> liga
"Staffel" -> staffel
"Runde" -> runde.orEmpty()
"SpieleHeim" -> spieleHeim
"SpieleGast" -> spieleGast
else -> ""
}

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.R import de.harheimertc.R
import de.harheimertc.ui.components.FormMessages import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.ImageGrid import de.harheimertc.ui.components.ImageGrid
@Composable @Composable
@@ -131,7 +131,7 @@ fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
if (loading) { if (loading) {
CircularProgressIndicator() LoadingState("Galerie wird geladen...")
} else if (images.isEmpty()) { } else if (images.isEmpty()) {
Text(text = stringResource(R.string.gallery_empty)) Text(text = stringResource(R.string.gallery_empty))
} else { } else {

View File

@@ -44,15 +44,14 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel import de.harheimertc.ui.navigation.NavigationViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import de.harheimertc.BuildConfig
import de.harheimertc.data.HomepageSectionDto import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto import de.harheimertc.data.SeasonDto
@@ -77,9 +76,9 @@ import java.util.Locale
fun HomeScreen( fun HomeScreen(
navController: NavController, navController: NavController,
showNavigationHeader: Boolean = true, showNavigationHeader: Boolean = true,
viewModel: HomeViewModel = hiltViewModel(), navigationViewModel: NavigationViewModel,
viewModel: HomeViewModel,
) { ) {
val navigationViewModel: NavigationViewModel = hiltViewModel()
val navigationState by navigationViewModel.state.collectAsState() val navigationState by navigationViewModel.state.collectAsState()
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var selectedNews by remember { mutableStateOf<NewsDto?>(null) } var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
@@ -107,10 +106,18 @@ fun HomeScreen(
AppNavigationHeader( AppNavigationHeader(
selectedRoute = Destinations.Home.route, selectedRoute = Destinations.Home.route,
onNavigate = navController::navigate, onNavigate = navController::navigate,
onLogout = {
navigationViewModel.logout {
navController.navigate(Destinations.Home.route) {
launchSingleTop = true
}
}
},
navigationState = navigationState, navigationState = navigationState,
) )
} }
} }
if (navigationState.canAccessFullCms) {
item { item {
HomeCustomizationSection( HomeCustomizationSection(
sections = state.homepageSections, sections = state.homepageSections,
@@ -126,11 +133,55 @@ fun HomeScreen(
onReset = viewModel::resetSections, onReset = viewModel::resetSections,
) )
} }
}
if (state.error) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = state.errorMessage ?: "Daten konnten nicht geladen werden.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(onClick = viewModel::load) {
Text("Erneut versuchen")
}
}
}
}
if (state.debugDiagnostics.isNotEmpty()) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Technische Diagnose (vorübergehend)",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = state.debugDiagnostics.joinToString("\n\n---\n\n"),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.error,
)
}
}
}
state.homepageSections.forEachIndexed { index, section -> state.homepageSections.forEachIndexed { index, section ->
if (!section.enabled) return@forEachIndexed if (!section.enabled) return@forEachIndexed
val sectionKey = homeSectionKey(section) val sectionKey = homeSectionKey(section)
when (section.id) { when (section.id) {
"banner" -> item(key = "home_section_${sectionKey}_$index") { WebHero() } "banner" -> item(key = "home_section_${sectionKey}_$index") {
WebHero(imageUrl = state.heroImageUrl)
}
"termine" -> item(key = "home_section_${sectionKey}_$index") { "termine" -> item(key = "home_section_${sectionKey}_$index") {
HomeTermineSection( HomeTermineSection(
termine = state.termine, termine = state.termine,
@@ -454,7 +505,7 @@ private fun HomeSpielplanTeamWidgetSection(
} }
@Composable @Composable
private fun WebHero() { private fun WebHero(imageUrl: String?) {
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954 val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
Box( Box(
modifier = Modifier modifier = Modifier
@@ -463,12 +514,14 @@ private fun WebHero() {
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))), .background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (!imageUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = "${BuildConfig.API_BASE_URL}images/club_about_us.png", model = imageUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier.matchParentSize().alpha(0.10f), modifier = Modifier.matchParentSize().alpha(0.10f),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
}
Column( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,

View File

@@ -34,6 +34,7 @@ data class HomeSpielplanTeamOption(
data class HomeUiState( data class HomeUiState(
val loading: Boolean = true, val loading: Boolean = true,
val heroImageUrl: String? = null,
val termine: List<TerminDto> = emptyList(), val termine: List<TerminDto> = emptyList(),
val spiele: List<SpielDto> = emptyList(), val spiele: List<SpielDto> = emptyList(),
val news: List<NewsDto> = emptyList(), val news: List<NewsDto> = emptyList(),
@@ -44,6 +45,8 @@ data class HomeUiState(
val spielplanWidgetErrors: Map<String, String> = emptyMap(), val spielplanWidgetErrors: Map<String, String> = emptyMap(),
val widgetsLoading: Boolean = false, val widgetsLoading: Boolean = false,
val error: Boolean = false, val error: Boolean = false,
val errorMessage: String? = null,
val debugDiagnostics: List<String> = emptyList(),
) )
@HiltViewModel @HiltViewModel
@@ -62,7 +65,12 @@ class HomeViewModel @Inject constructor(
fun load() { fun load() {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = false) _state.value = _state.value.copy(
loading = true,
error = false,
errorMessage = null,
debugDiagnostics = emptyList(),
)
repository.fetchHomeData() repository.fetchHomeData()
.onSuccess { data -> .onSuccess { data ->
serverSections = normalizedHomepageSections(data.homepageSections) serverSections = normalizedHomepageSections(data.homepageSections)
@@ -80,8 +88,9 @@ class HomeViewModel @Inject constructor(
) )
_state.value = HomeUiState( _state.value = HomeUiState(
loading = false, loading = false,
heroImageUrl = data.heroImageUrl,
termine = data.termine termine = data.termine
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true } .filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
.sortedBy { it.asDateTime() } .sortedBy { it.asDateTime() }
.take(3), .take(3),
spiele = data.spiele spiele = data.spiele
@@ -99,10 +108,16 @@ class HomeViewModel @Inject constructor(
spielplanTeamsBySeason = widgetData.teamsBySeason, spielplanTeamsBySeason = widgetData.teamsBySeason,
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey, spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
spielplanWidgetErrors = widgetData.errorsBySectionKey, spielplanWidgetErrors = widgetData.errorsBySectionKey,
debugDiagnostics = data.diagnostics,
) )
} }
.onFailure { .onFailure { err ->
_state.value = HomeUiState(loading = false, error = true) _state.value = HomeUiState(
loading = false,
error = true,
errorMessage = err.message ?: "Daten konnten nicht geladen werden.",
debugDiagnostics = listOf(err.message ?: "Unbekannter Fehler"),
)
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More